12 KiB
Project Context System
Projects maintain state in .context/<project>/ folders across sessions. Tasks are optional parallel workstreams within a project.
Reading context (session start)
When the user mentions a project or task name and .context/<name>/ exists:
- Read project STATUS.md first (20-30 line "resume card")
- If the project has
tasks/, list active tasks - If the user mentions a specific task, read
tasks/<task>/STATUS.md - Read the most recent session summary if more detail is needed
- Do NOT read everything upfront. Use progressive disclosure.
Updating context (at breakpoints, NOT continuously)
Update at these moments ONLY:
- Phase or milestone completed
- Architectural decision made (append to DECISIONS.md)
- User says they're done or switching tasks
- Blocker discovered or resolved
- Task created, completed, or handed off
Do NOT update context during normal coding work. Work first, compact at breakpoints.
Compaction quality
STATUS.md must be:
- Correct (reflects actual current state, not stale)
- Complete (no critical information missing)
- Concise (project < 30 lines, task < 20 lines)
Always REWRITE STATUS.md completely rather than append.
File structure
.context/<project>/
STATUS.md -- project resume card (includes Active Tasks section)
PLAN.md -- phases and architecture
DECISIONS.md -- append-only decision log
sessions/ -- dated session summaries
tasks/ -- optional parallel workstreams
<task>/
STATUS.md -- task resume card
DECISIONS.md
Task handoff
When a task is being handed to another agent, ensure the task's STATUS.md is up to date -- it's the handoff artifact.
scripts-v2 Conventions
File naming
- Always kebab-case:
standardize-stripe-state.ts, notstandardizeStripeState.ts - Scripts go in
runs/{org-name}/: e.g.runs/mintlify/fix-prices.ts - Never use
index.tsfor business logic — name files after what the function does
Imports
script/step: from"../lib"or"../../lib"(relative to script)- Types/enums: from
"@autumn/shared"(AppEnv, FullProduct, Customer, etc.) - Server services: from
"@autumn/server/src/internal/..."(CusService, ProductService, etc.) - Lib utilities: from
"@autumn-cloud/lib/..."(createScriptContext, search helpers)
Function signatures
- Always use named object params:
fn({ db, orgId })notfn(db, orgId) - This applies to all functions, even single-argument ones
Three categories of functions
1. Steps (step())
Named phases of work. Declare their own id and typed input/output. Called via s.run().
import { step, type ScriptContext } from "../../../lib";
export const standardizeBasePrices = step({
id: "standardize-base-prices",
run: async ({ ctx, group, priceCache }: {
ctx: ScriptContext;
group: SubscriptionGroup;
priceCache: StripePriceCache;
}): Promise<BasePriceMismatch[]> => {
// business logic — returns typed data
return mismatches;
},
});
2. Orchestrators (steps that compose other steps)
Their run function takes s in addition to ctx + payload. They call s.run() to compose sub-steps.
export const processCustomer = step({
id: "process-customer",
run: async ({ s, ctx, customerId, priceCache }: {
s: ScriptUtils;
ctx: ScriptContext;
customerId: string;
priceCache: StripePriceCache;
}) => {
const groups = await s.run(loadSubscriptionGroups, { customerId });
const result = await s.run(standardizeBasePrices, { group, priceCache });
return { customerId, status: "ok", ...result };
},
});
3. Utilities (plain functions)
Helpers that aren't meaningful workflow phases. Regular async functions, no step().
Examples: formatSubscriptionUpdate, listStripeSubscriptions, buildCacheKey.
Params rules
dryRunlives onctx.dryRun— never pass as a separate paramdatais accessed vias.datain orchestrators only — never threaded to leaf steps- If a step needs more than
ctx+ 3 business params, it's doing too much
Result convention (Trigger.dev-style ok pattern)
- Every function returns typed data — never mutates caller's objects
- Fallible steps return a discriminated union:
{ ok: true; ... } | { ok: false; reason: string } - The caller checks
result.ok— just like Trigger.dev'striggerAndWait()result - Infallible steps return their typed data directly (e.g.
BasePriceMismatch[])
Script structure
dryRun: trueby default. Flip tofalseonly when ready to mutate- All tunables go in the
paramsblock — never use CLI args or scattered top-of-file constants - Use
s.step()for inline phases,s.run(step, payload)for named step functions - Use
s.batch()for iterating over items — return objects from fn to collect results - Batch fn receives
{ item, ctx, s }— uses.run()inside batch for sub-steps - Use
output: "csv"on batch to auto-write results - Use
checkpoint: trueon batch for resume across restarts - Always use
ctx.logger.info()— neverconsole.log()
Large scripts — folder organization
For non-trivial work, create a folder under runs/{org-name}/ and split into three subfolders:
runs/mintlify/standardize-stripe/
standardize-stripe-state.ts # entrypoint (script)
config.ts # constants
orchestrators/ # steps that compose other steps via s.run()
process-customer.ts
process-subscription-group.ts
steps/ # leaf steps (pure business logic, no s)
load-customer-ids.ts
load-exclude-list.ts
load-subscription-groups.ts
guards.ts
check-customer-flags.ts
standardize-base-prices.ts
standardize-usage-prices.ts
update-stripe-subscription.ts
cancel-monthly-companion.ts
utils/ # plain functions (no step(), not logged)
find-or-create-matching-stripe-price.ts
format-subscription-update.ts
format-customer-result.ts
Folder rules:
orchestrators/— takes{ s, ctx, ... }, callss.run()to compose stepssteps/— takes{ ctx, ... }, returns typed data, nosutils/— plain functions, nostep(), no logging- Root: only the entrypoint and config
Entrypoint pattern:
run: async ({ s, ctx }) => {
const customerIds = await s.step({
id: "load customers",
fn: () => loadCustomerIds({ ctx }),
});
await s.batch({
id: "standardize",
items: customerIds,
output: "csv",
fn: async ({ item: customerId, s: batchS }) => {
return batchS.run(processCustomer, { customerId, priceCache });
},
});
},
Export pattern (for Trigger.dev compatibility)
const myScript = await script({ ... });
export default myScript;
if (import.meta.main) await myScript.run();
scripts-v2 API
script()
Self-executing entry point. The file IS the execution — hit ctrl+enter to run via ./run.sh.
import { script } from "../lib";
import { AppEnv } from "@autumn/shared";
const myScript = await script({
id: "org-name/script-name",
org: "autumn_org_id",
env: AppEnv.Sandbox,
dryRun: true,
loadProducts: true,
description: "What this does",
params: {
concurrency: 5,
limit: 1 as number | null,
only: null as string[] | null,
},
run: async ({ s, ctx }) => {
// s = script utilities (step, batch, run, log, data)
// ctx = data and services (extends AutumnContext)
},
});
export default myScript;
if (import.meta.main) await myScript.run();
step()
Define a named step with typed input/output. Steps declare their own id — the caller just provides the business payload.
import { step, type ScriptContext } from "../lib";
export const loadCustomerGroups = step({
id: "load-customer-groups",
run: async ({ ctx, customerId }: {
ctx: ScriptContext;
customerId: string;
}) => {
// business logic
return { fullCustomer, groups };
},
});
Orchestrator steps (compose sub-steps) take s in addition to ctx:
export const processCustomer = step({
id: "process-customer",
run: async ({ s, ctx, customerId }: {
s: ScriptUtils;
ctx: ScriptContext;
customerId: string;
}) => {
const { groups } = await s.run(loadCustomerGroups, { customerId });
// ... compose more steps
},
});
s.run()
Execute a step, auto-injecting ctx and s. The caller only passes business-specific payload.
// step defined elsewhere with id and typed run function
const result = await s.run(standardizeBasePrices, { group, priceCache });
// ^^ knows its own id ^^ only business payload
TypeScript strips ctx and s from the call-site — you get autocomplete on just the business params and a typed return value.
ctx (ScriptContext)
Extends AutumnContext — pass directly to any server function.
| Field | Type | Description |
|---|---|---|
ctx.db |
DrizzleCli | Postgres database client |
ctx.stripe |
Stripe | Stripe client (env/Connect-aware) |
ctx.org |
Organization | Loaded org object |
ctx.env |
AppEnv | Live or Sandbox |
ctx.features |
Feature[] | All features for this org |
ctx.products |
FullProduct[] | All products (empty if loadProducts: false) |
ctx.dryRun |
boolean | Whether this is a dry run |
ctx.params |
P | Typed params from your params block |
ctx.logger |
Logger | Pino-based logger (info/warn/error/debug/child) |
s (ScriptUtils)
| Field | Type | Description |
|---|---|---|
s.step |
function | Inline named section with auto-logging |
s.batch |
function | Batch processor with output, checkpoint, buffered logging |
s.run |
function | Execute a step() with auto-injected ctx/s |
s.log |
ScriptLog | Logger with table/json/section formatting |
s.data |
ScriptData | Data layer: store, list, write, read |
s.step()
For inline phases (not defined as separate step() functions):
const data = await s.step({
id: "load customers",
fn: async () => CusService.getByOrg({ db: ctx.db, orgId: ctx.org.id, env: ctx.env }),
});
s.batch()
Batch fn receives { item, ctx, s } — use s.run() inside for sub-steps:
await s.batch({
id: "process-customers",
items: customerIds,
output: "csv",
checkpoint: true,
fn: async ({ item: customerId, ctx, s }) => {
return s.run(processCustomer, { customerId, priceCache });
},
concurrency: 5,
limit: 10,
onError: "continue",
});
BatchResult
const result = await s.batch({ ... });
result.processed // number of items successfully processed
result.skipped // number skipped (checkpoint or skipIf)
result.errors // number of errors (when onError: "continue")
result.rows // collected return values from fn
result.duration // total ms
s.data (ScriptData)
Data dir lives at runs/<script-id>/data/ with four subdirs:
inputs/— curated inputs (git-committed)outputs/— script results (git-committed)state/— checkpoints, resume tracking (gitignored)logs/— append-only audit trail (gitignored)
const finished = s.data.store<boolean>("state/finished");
finished.has("cus_123");
finished.set("cus_123", true);
finished.flush();
const results = s.data.list<AuditRow>("outputs/audit-results");
results.push({ customerId: "cus_123", status: "ok" });
s.data.write("outputs/report.csv", csvContent);
const config = s.data.read("inputs/exclude-orgs.csv");
params pattern
All tunables in one typed block. Edit and re-run — no CLI args.
params: {
concurrency: 5,
limit: 1 as number | null,
only: null as string[] | null,
},
Batch automatically inherits concurrency, limit, only from params.
Running
./run.sh runs/org-name/my-script.ts
LOCAL_OVERRIDE=true ./run.sh runs/org-name/my-script.ts