pi-extensible-workflows 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,11 @@ export interface WorktreeReference {
47
47
  cwd: string;
48
48
  base: string;
49
49
  }
50
+ export interface BorrowedWorktreeBinding {
51
+ name: string;
52
+ sourceRunId: string;
53
+ owner: string;
54
+ }
50
55
  export declare function projectStorageKey(cwd: string): string;
51
56
  export declare function runsDirectory(cwd: string, sessionId: string, home?: string): string;
52
57
  export declare class SessionLease {
@@ -66,10 +71,12 @@ export declare class RunStore {
66
71
  readonly cwd: string;
67
72
  readonly sessionId: string;
68
73
  readonly runId: string;
74
+ readonly home: string;
69
75
  readonly directory: string;
70
76
  private journalWrite;
71
77
  private stateWrite;
72
78
  private worktreeWrite;
79
+ private borrowedWorktreeWrite;
73
80
  private snapshotWrite;
74
81
  private launchSnapshotWrite;
75
82
  private systemPromptWrite;
@@ -104,11 +111,30 @@ export declare class RunStore {
104
111
  answerCheckpoint(name: string, approved: boolean): Promise<AwaitingCheckpoint | undefined>;
105
112
  private expectedWorktree;
106
113
  private markerPath;
114
+ private namedWorktreeOwner;
115
+ private worktreeName;
107
116
  private structuralWorktree;
117
+ private borrowedWorktreeRecords;
118
+ borrowedWorktrees(): Promise<readonly BorrowedWorktreeBinding[]>;
119
+ private borrowedWorktree;
120
+ private sourceRun;
121
+ validateParentRun(parentRunId: string): Promise<void>;
122
+ private ownedWorktree;
123
+ private resolveBorrowedWorktree;
124
+ private findNamedWorktree;
125
+ resolveNamedWorktree(name: string, seen?: Set<string>): Promise<{
126
+ reference: WorktreeReference;
127
+ sourceRunId: string;
128
+ owner: string;
129
+ }>;
130
+ validateBorrowedWorktrees(): Promise<void>;
131
+ ownsWorktree(owner: string): Promise<boolean>;
108
132
  private cleanupMarker;
109
133
  private cleanupOrphanWorktrees;
110
134
  validateWorktree(owner: string, cwd?: string): Promise<WorktreeReference>;
111
135
  worktree(owner: string): Promise<WorktreeReference>;
136
+ private resolveNamedWorktreeFromParent;
137
+ private bindBorrowedWorktree;
112
138
  snapshotWorktree(owner: string): Promise<string>;
113
139
  worktrees(): Promise<readonly WorktreeReference[]>;
114
140
  changedWorktrees(): Promise<readonly WorktreeReference[]>;
@@ -215,11 +215,13 @@ export class RunStore {
215
215
  cwd;
216
216
  sessionId;
217
217
  runId;
218
+ home;
218
219
  directory;
219
220
  journalWrite = Promise.resolve();
220
221
  // ponytail: serializes one RunStore instance; cross-process run sharing remains unsupported.
221
222
  stateWrite = Promise.resolve();
222
223
  worktreeWrite = Promise.resolve();
224
+ borrowedWorktreeWrite = Promise.resolve();
223
225
  snapshotWrite = Promise.resolve();
224
226
  launchSnapshotWrite = Promise.resolve();
225
227
  // ponytail: the session lease prevents concurrent RunStore writers for one run.
@@ -229,6 +231,7 @@ export class RunStore {
229
231
  this.cwd = cwd;
230
232
  this.sessionId = sessionId;
231
233
  this.runId = runId;
234
+ this.home = home;
232
235
  this.cwd = resolve(cwd);
233
236
  this.directory = join(runsDirectory(this.cwd, sessionId, home), safePart(runId));
234
237
  }
@@ -244,6 +247,7 @@ export class RunStore {
244
247
  await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {}, decisions: {} });
245
248
  await atomicJson(join(temporary, "ownership.json"), []);
246
249
  await atomicJson(join(temporary, "worktrees.json"), []);
250
+ await atomicJson(join(temporary, "borrowed-worktrees.json"), []);
247
251
  await atomicJson(join(temporary, "state.json"), run);
248
252
  await atomicJson(join(temporary, "system-prompts.json"), { version: 1, entries: [] });
249
253
  await atomicJson(join(temporary, "conversations.json"), { version: 1, conversations: {} });
@@ -447,6 +451,26 @@ export class RunStore {
447
451
  const key = createHash("sha256").update(`${this.sessionId}\0${this.runId}\0${owner}`).digest("hex").slice(0, 16);
448
452
  return join(this.directory, `worktree-${key}.creating`);
449
453
  }
454
+ namedWorktreeOwner(name) {
455
+ if (!name.trim())
456
+ throw new WorkflowError("WORKTREE_FAILED", "Named worktree names must be non-empty");
457
+ return structuralPath("worktree", "named", name.trim());
458
+ }
459
+ worktreeName(owner) {
460
+ const prefix = `${structuralPath("worktree", "named")}/`;
461
+ if (!owner.startsWith(prefix))
462
+ return undefined;
463
+ const encoded = owner.slice(prefix.length);
464
+ if (!encoded || encoded.includes("/"))
465
+ return undefined;
466
+ try {
467
+ const name = decodeURIComponent(encoded);
468
+ return name.trim() ? name : undefined;
469
+ }
470
+ catch {
471
+ return undefined;
472
+ }
473
+ }
450
474
  structuralWorktree(owner, record) {
451
475
  if (!record || typeof record !== "object")
452
476
  throw new Error(`Invalid worktree record for ${owner}`);
@@ -458,6 +482,123 @@ export class RunStore {
458
482
  throw new Error(`Invalid worktree record for ${owner}`);
459
483
  return candidate;
460
484
  }
485
+ async borrowedWorktreeRecords(wait = true) {
486
+ if (wait)
487
+ await this.borrowedWorktreeWrite;
488
+ const records = await json(join(this.directory, "borrowed-worktrees.json")).catch((error) => { if (error.code === "ENOENT")
489
+ return []; throw error; });
490
+ if (!Array.isArray(records))
491
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings are invalid");
492
+ const seen = new Set();
493
+ return records.map((record) => {
494
+ if (!record || typeof record !== "object")
495
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree binding is invalid");
496
+ const candidate = record;
497
+ if (typeof candidate.name !== "string" || !candidate.name.trim() || candidate.name !== candidate.name.trim() || typeof candidate.sourceRunId !== "string" || !candidate.sourceRunId || typeof candidate.owner !== "string" || candidate.owner !== this.namedWorktreeOwner(candidate.name))
498
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree binding is invalid");
499
+ if (seen.has(candidate.name))
500
+ throw new WorkflowError("WORKTREE_FAILED", `Duplicate borrowed worktree binding for ${candidate.name}`);
501
+ seen.add(candidate.name);
502
+ return { name: candidate.name, sourceRunId: candidate.sourceRunId, owner: candidate.owner };
503
+ });
504
+ }
505
+ async borrowedWorktrees() { return this.borrowedWorktreeRecords(); }
506
+ async borrowedWorktree(name) {
507
+ return (await this.borrowedWorktreeRecords()).find((binding) => binding.name === name);
508
+ }
509
+ async sourceRun(sourceRunId) {
510
+ if (!sourceRunId || sourceRunId === this.runId)
511
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree source run is invalid");
512
+ const source = new RunStore(this.cwd, this.sessionId, sourceRunId, this.home);
513
+ try {
514
+ const loaded = await source.load();
515
+ if (!["completed", "failed", "stopped"].includes(loaded.run.state))
516
+ throw new Error(`Source run ${sourceRunId} is not terminal`);
517
+ return source;
518
+ }
519
+ catch (error) {
520
+ if (error instanceof WorkflowError && error.code === "WORKTREE_FAILED")
521
+ throw error;
522
+ throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
523
+ }
524
+ }
525
+ async validateParentRun(parentRunId) { await this.sourceRun(parentRunId); }
526
+ async ownedWorktree(owner, cwd) {
527
+ const records = await json(join(this.directory, "worktrees.json"));
528
+ const matches = records.filter((candidate) => candidate && typeof candidate === "object" && candidate.owner === owner);
529
+ if (matches.length !== 1)
530
+ throw new Error(`Missing or duplicate worktree record for ${owner}`);
531
+ const record = this.structuralWorktree(owner, matches[0]);
532
+ if (cwd !== undefined && resolve(cwd) !== resolve(record.cwd))
533
+ throw new Error(`Invalid worktree record for ${owner}`);
534
+ await access(record.cwd);
535
+ return record;
536
+ }
537
+ async resolveBorrowedWorktree(binding, seen) {
538
+ try {
539
+ const source = await this.sourceRun(binding.sourceRunId);
540
+ const resolved = await source.findNamedWorktree(binding.name, seen);
541
+ if (!resolved)
542
+ throw new Error(`Missing named worktree ${binding.name} in source run ${binding.sourceRunId}`);
543
+ if (resolved.owner !== binding.owner)
544
+ throw new Error(`Borrowed worktree binding does not match source owner for ${binding.name}`);
545
+ return resolved;
546
+ }
547
+ catch (error) {
548
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
549
+ }
550
+ }
551
+ async findNamedWorktree(name, seen = new Set()) {
552
+ const owner = this.namedWorktreeOwner(name);
553
+ if (seen.has(this.runId))
554
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings contain a cycle");
555
+ const nextSeen = new Set(seen);
556
+ nextSeen.add(this.runId);
557
+ const binding = await this.borrowedWorktree(name);
558
+ if (binding) {
559
+ const loaded = await this.load();
560
+ if (loaded.run.parentRunId === undefined)
561
+ throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree ${name} has no parent run`);
562
+ const parent = await this.sourceRun(loaded.run.parentRunId);
563
+ const resolved = await parent.findNamedWorktree(name, nextSeen);
564
+ if (!resolved || resolved.sourceRunId !== binding.sourceRunId || resolved.owner !== binding.owner)
565
+ throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${name} is not inherited from its parent run`);
566
+ return resolved;
567
+ }
568
+ const records = await json(join(this.directory, "worktrees.json"));
569
+ const matches = records.filter((candidate) => candidate && typeof candidate === "object" && candidate.owner === owner);
570
+ if (matches.length === 0)
571
+ return undefined;
572
+ try {
573
+ const reference = await this.ownedWorktree(owner);
574
+ return { reference, sourceRunId: this.runId, owner };
575
+ }
576
+ catch (error) {
577
+ throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
578
+ }
579
+ }
580
+ async resolveNamedWorktree(name, seen = new Set()) {
581
+ const resolved = await this.findNamedWorktree(name, seen);
582
+ if (!resolved)
583
+ throw new WorkflowError("WORKTREE_FAILED", `Missing named worktree ${name}`);
584
+ return resolved;
585
+ }
586
+ async validateBorrowedWorktrees() {
587
+ try {
588
+ const loaded = await this.load();
589
+ if (loaded.run.parentRunId !== undefined)
590
+ await this.validateParentRun(loaded.run.parentRunId);
591
+ for (const binding of await this.borrowedWorktreeRecords())
592
+ await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
593
+ }
594
+ catch (error) {
595
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
596
+ }
597
+ }
598
+ async ownsWorktree(owner) {
599
+ const records = await json(join(this.directory, "worktrees.json"));
600
+ return records.filter((candidate) => candidate && typeof candidate === "object" && candidate.owner === owner).length === 1;
601
+ }
461
602
  async cleanupMarker(markerPath) {
462
603
  let marker;
463
604
  try {
@@ -490,15 +631,15 @@ export class RunStore {
490
631
  async validateWorktree(owner, cwd) {
491
632
  try {
492
633
  await this.load();
493
- const records = await json(join(this.directory, "worktrees.json"));
494
- const matches = records.filter((candidate) => candidate && typeof candidate === "object" && candidate.owner === owner);
495
- if (matches.length !== 1)
496
- throw new Error(`Missing or duplicate worktree record for ${owner}`);
497
- const record = this.structuralWorktree(owner, matches[0]);
498
- if (cwd !== undefined && resolve(cwd) !== resolve(record.cwd))
499
- throw new Error(`Invalid worktree record for ${owner}`);
500
- await access(record.cwd);
501
- return record;
634
+ const name = this.worktreeName(owner);
635
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
636
+ if (binding) {
637
+ const resolved = await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
638
+ if (cwd !== undefined && resolve(cwd) !== resolve(resolved.reference.cwd))
639
+ throw new Error(`Invalid worktree record for ${owner}`);
640
+ return resolved.reference;
641
+ }
642
+ return await this.ownedWorktree(owner, cwd);
502
643
  }
503
644
  catch (error) {
504
645
  throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
@@ -506,10 +647,21 @@ export class RunStore {
506
647
  }
507
648
  async worktree(owner) {
508
649
  const write = this.worktreeWrite.then(async () => {
509
- await this.load();
650
+ const loaded = await this.load();
510
651
  const recordsPath = join(this.directory, "worktrees.json");
511
652
  let records = await json(recordsPath).catch((error) => { if (error.code === "ENOENT")
512
653
  return []; throw error; });
654
+ const name = this.worktreeName(owner);
655
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
656
+ if (binding)
657
+ return (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference;
658
+ if (name && loaded.run.parentRunId !== undefined) {
659
+ const resolved = await this.resolveNamedWorktreeFromParent(name, loaded.run.parentRunId);
660
+ if (resolved) {
661
+ await this.bindBorrowedWorktree({ name, sourceRunId: resolved.sourceRunId, owner: resolved.owner });
662
+ return resolved.reference;
663
+ }
664
+ }
513
665
  const existing = records.find((record) => record.owner === owner);
514
666
  if (existing)
515
667
  return this.validateWorktree(owner);
@@ -564,6 +716,25 @@ export class RunStore {
564
716
  this.worktreeWrite = write.then(() => undefined, () => undefined);
565
717
  return write;
566
718
  }
719
+ async resolveNamedWorktreeFromParent(name, parentRunId) {
720
+ const source = await this.sourceRun(parentRunId);
721
+ return source.findNamedWorktree(name, new Set([this.runId]));
722
+ }
723
+ async bindBorrowedWorktree(binding) {
724
+ const write = this.borrowedWorktreeWrite.then(async () => {
725
+ const records = [...await this.borrowedWorktreeRecords(false)];
726
+ const existing = records.find((candidate) => candidate.name === binding.name);
727
+ if (existing) {
728
+ if (JSON.stringify(existing) !== JSON.stringify(binding))
729
+ throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${binding.name} changed`);
730
+ return;
731
+ }
732
+ records.push(binding);
733
+ await atomicJson(join(this.directory, "borrowed-worktrees.json"), records);
734
+ });
735
+ this.borrowedWorktreeWrite = write.then(() => undefined, () => undefined);
736
+ await write;
737
+ }
567
738
  async snapshotWorktree(owner) {
568
739
  try {
569
740
  const write = this.snapshotWrite.then(async () => {
@@ -593,13 +764,16 @@ export class RunStore {
593
764
  async worktrees() {
594
765
  const records = await json(join(this.directory, "worktrees.json")).catch((error) => { if (error.code === "ENOENT")
595
766
  return []; throw error; });
596
- const validated = await Promise.all(records.map(async (record) => { try {
767
+ const bindings = await this.borrowedWorktreeRecords();
768
+ const boundOwners = new Set(bindings.map((binding) => binding.owner));
769
+ const owned = await Promise.all(records.filter((record) => !boundOwners.has(record.owner)).map(async (record) => { try {
597
770
  return await this.validateWorktree(record.owner);
598
771
  }
599
772
  catch {
600
773
  return undefined;
601
774
  } }));
602
- return validated.filter((record) => record !== undefined);
775
+ const borrowed = await Promise.all(bindings.map(async (binding) => (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference));
776
+ return [...owned.filter((record) => record !== undefined), ...borrowed];
603
777
  }
604
778
  async changedWorktrees() {
605
779
  const changed = [];
@@ -63,6 +63,7 @@ export interface InspectorViewState {
63
63
  scroll: number;
64
64
  }
65
65
  export declare function transcriptLines(entries: readonly SessionEntry[]): string[];
66
+ export declare function transcriptFileLines(path: string): string[];
66
67
  export declare function matchSession(query: string, sessions: readonly SessionInfo[]): SessionInfo;
67
68
  export declare function loadSessionReport(path: string, home?: string): Promise<SessionReport>;
68
69
  export declare function renderInspector(report: SessionReport, state: InspectorViewState, width?: number, height?: number, highlighter?: (script: string) => string[]): string[];
@@ -45,6 +45,9 @@ export function transcriptLines(entries) {
45
45
  return index ? ["", ...lines] : lines;
46
46
  });
47
47
  }
48
+ export function transcriptFileLines(path) {
49
+ return transcriptLines(SessionManager.open(path).buildContextEntries());
50
+ }
48
51
  function mergedModels(groups) {
49
52
  const totals = new Map();
50
53
  for (const group of groups)
@@ -707,6 +707,7 @@ export async function replayWorkflowScript(script, args = null, signal) {
707
707
  active -= 1;
708
708
  }
709
709
  },
710
+ worktree: async () => ({ path: "/worktrees/eval", branch: "eval-branch" }),
710
711
  phase: (name) => { phases.push(name); },
711
712
  log: (message) => { logs.push(message); },
712
713
  }, signal);
@@ -860,7 +861,7 @@ function semanticJudgePrompt(evalCase, calls, cwd, home) {
860
861
  return [];
861
862
  } }));
862
863
  const roleText = [...usedRoles].map((role) => `${role}: ${roles[role]?.description ?? "no description"}`).join("\n") || "none";
863
- const docs = "agent(prompt, options) delegates; parallel(name, tasks) runs independent tasks concurrently; pipeline(name, items, stages) applies ordered stages; prompt(template, data) carries values into prompts. A role owns model/thinking/tools policy.";
864
+ const docs = "agent(prompt, options) delegates; shell(command, options) runs a deterministic host command and returns exitCode/stdout/stderr; parallel(name, tasks) runs independent tasks concurrently; pipeline(name, items, stages) applies ordered stages; prompt(template, data) carries values into prompts. A role owns model/thinking/tools policy.";
864
865
  return `Judge whether the captured workflow design satisfies each criterion. Do not execute it. Return only JSON: {"criteria":[{"id":"criterion id","pass":true,"evidence":"specific script evidence"}]}.\n\nOriginal request:\n${evalCase.prompt}\n\nCriteria:\n${JSON.stringify(evalCase.semanticCriteria ?? [])}\n\nDSL:\n${docs}\n\nRelevant roles:\n${roleText}\n\nCaptured workflow script(s):\n${calls.map((call, index) => `--- ${String(index)} ---\n${call.script ?? "<missing>"}`).join("\n")}`;
865
866
  }
866
867
  async function runSemanticJudge(input, calls, cwd, home, sessionDir, maxCost) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensible-workflows",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "description": "Deterministic multi-agent workflow orchestration for Pi",
5
5
  "homepage": "https://vekexasia.github.io/pi-extensible-workflows/",
6
6
  "repository": {
@@ -31,8 +31,9 @@
31
31
  "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/src/cli.js",
32
32
  "inspect": "node dist/src/cli.js inspect",
33
33
  "lint": "eslint .",
34
- "test": "npm run build && node --test 'dist/test/*.test.js'",
35
- "acceptance": "npm run build && node --test dist/test/runtime-acceptance.test.js",
34
+ "test": "npm run build && TEST_FILES='dist/test/*.test.js' npm run test:run",
35
+ "test:run": "tmp=$(mktemp -d); trap 'rm -rf \"$tmp\"' EXIT; TMPDIR=\"$tmp\" node --test $TEST_FILES",
36
+ "acceptance": "npm run build && TEST_FILES='dist/test/runtime-acceptance.test.js' npm run test:run",
36
37
  "docs:check": "node scripts/check-docs.mjs",
37
38
  "check": "npm run lint && npm test && npm run docs:check",
38
39
  "evals": "npm run build && node dist/src/workflow-evals.js",
@@ -4,87 +4,80 @@ description: Use when the task is complex enough to require multiple subagents o
4
4
  ---
5
5
 
6
6
  # pi-extensible-workflows
7
-
8
- Use `workflow` exclusively for genuinely multi-agent orchestration. For one agent, use ordinary tools or `Agent` directly. Do not wrap a single agent in a workflow; define distinct responsibilities and keep the result flow explicit.
7
+ Use `workflow` only for genuinely multi-agent orchestration; one agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
9
8
 
10
9
  ## Pattern
11
-
12
10
  ```js
13
- const reportSchema = {
14
- type: "object",
15
- properties: {
16
- summary: { type: "string" },
17
- findings: { type: "array", items: { type: "string" } },
18
- },
19
- required: ["summary", "findings"],
20
- additionalProperties: false,
21
- };
11
+ const reportSchema = { type: "object", properties: { summary: { type: "string" }, findings: { type: "array", items: { type: "string" } } }, required: ["summary", "findings"], additionalProperties: false };
22
12
 
23
13
  const reports = await parallel("research", {
24
14
  first: () => agent("Research the first target.", { role: "scout", outputSchema: reportSchema }),
25
15
  second: () => agent("Research the second target.", { role: "scout", outputSchema: reportSchema }),
26
16
  });
27
17
 
28
- // Add a downstream agent only when synthesis or independent review is a real phase.
29
18
  return agent(
30
19
  prompt("Review these reports:\n\n{reports}", { reports }),
31
20
  { role: "reviewer", outputSchema: reportSchema },
32
21
  );
33
22
  ```
34
23
 
35
- To pass structured input from the main agent, include `args`:
24
+ Pass structured input from the main agent with `args`:
36
25
  ```json
37
26
  { "workflow": "workflowName", "args": { "issue": 42 } }
38
27
  ```
39
- Inside the workflow, read `args.issue`; omitted `args` is `null`.
40
- Use `workflow_stop` with the exact run ID to stop an active background run from the current Pi session.
41
- If `workflow_catalog` is available, call it once before creating the first workflow for a task. Use the returned global functions, variables, registered workflows, and configured model aliases as needed for the rest of that task. Alias targets are catalog metadata, not an availability probe. Do not try to reinvent already exposed functions.
28
+ Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes; unnamed or missing names create new worktrees.
29
+ If `workflow_catalog` is available, call it once before creating the first workflow for a task. The name-less result is a compact index with launch-ready input schemas, descriptions, variables, and configured model aliases. Use those inputs to launch registered functions directly with `{ "workflow": "name", "args": { ... } }`; their input and output schemas are enforced. Request full detail with `{ "name": "name" }` only when composing a function programmatically or inspecting its output contract and extension metadata; do not load full definitions unconditionally. Alias targets are catalog metadata, not an availability probe. Do not try to reinvent already exposed functions.
42
30
 
43
- Pass downstream only needed results. Workflow JavaScript has no imports, filesystem, network, process, or timers; delegate such work to agents with the required tools.
31
+ Workflow JavaScript has no imports, filesystem, network, process, or timers. Delegate that work to agents. `shell(command, options)` is the trusted host RPC for deterministic gates: it inherits the workflow or active-worktree cwd, merges string `env` overrides, and returns `{ exitCode, stdout, stderr }`; nonzero exits are results, but launch failures and timeouts fail with `SHELL_FAILED`.
44
32
 
45
- ## `agent()` options
33
+ Use a bounded verification loop when command output controls the gate:
34
+ ```js
35
+ return withWorktree("fix-tests", async ({ path, branch }) => {
36
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
37
+ const tests = await shell("yarn test", { env: { CI: "1" } });
38
+ if (tests.exitCode === 0) return { path, branch, tests };
39
+ await agent(prompt("Fix these failures:\n\n{output}", { output: tests.stderr || tests.stdout }));
40
+ }
41
+ return { path, branch, tests: await shell("yarn test", { env: { CI: "1" } }) };
42
+ });
43
+ ```
44
+ Shell results are journaled only after process exit and RPC validation. A host crash after side effects but before journaling can rerun the command on resume; use `shell()` mainly for verification and bounded gates, not exactly-once mutations.
46
45
 
46
+ ## `agent()` options
47
47
  ```typescript
48
48
  interface AgentOptions {
49
- label?: string; // optional non-empty display name
50
- model?: string; // configured alias or provider/model[:thinking]
51
- thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
52
- role?: string; // one of the available workflow roles
53
- tools?: string[]; // [] = no tools; omitted uses role or launch tools
54
- outputSchema?: JsonSchema;
55
- retries?: number; // non-negative; use for safe, repeatable work
56
- timeoutMs?: number | null; // positive milliseconds; null means unlimited
49
+ label?: string; model?: string; role?: string; tools?: string[];
50
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null;
57
51
  }
58
52
  ```
59
53
 
60
- Extensions may define additional JSON-compatible agent option keys such as `advisor: true`. Core-owned keys still use the validation and role constraints above; extension options are passed to setup hooks and native setup but are not inherited by child agents.
61
-
62
- Agent calls are unnamed. Direct `agent(...)` calls receive hidden source call-site identity; JavaScript aliases for workflow calls are unsupported. Calls from one source call site must not race outside `parallel` or `pipeline`, whose structural keys keep replay deterministic.
54
+ Extensions may add JSON-compatible agent options such as `advisor: true`; core keys retain validation and role constraints. Extension options go to setup hooks/native setup and are not inherited by child agents.
63
55
 
64
- ## Shared worktree scope
56
+ Agent calls are unnamed. Direct calls receive hidden source call-site identity; aliases are unsupported, and calls from one source site must not race outside `parallel` or `pipeline`, whose structural keys make replay deterministic.
65
57
 
66
- Use `withWorktree(callback)` or `withWorktree(name, callback)` when top-level agents should collaborate in one worktree:
58
+ ## Persistent conversations
59
+ Use `conversation(name, options)` when several agent turns should share a persisted transcript and build on one another. Unlike independent `agent(prompt, options)` calls, the returned handle continues from the last successful turn:
67
60
 
68
61
  ```js
69
- const results = await withWorktree("implementation", async () => parallel("implementation", {
70
- api: () => agent("Implement the API"),
71
- tests: () => agent("Add integration tests"),
72
- }));
62
+ const handle = conversation("developer", { role: "developer" });
63
+ const findings = await handle.run("Inspect the implementation.");
64
+ const fix = await handle.run("Now propose the smallest fix.");
65
+ return { findings, fix };
73
66
  ```
74
67
 
75
- The callback result is returned unchanged and the worktree is created only when the first enclosed agent launches. Concurrent agents share mutable files, so give them non-conflicting work or coordinate explicitly.
76
-
77
- `parallel()` tasks may call any workflow function, not only `agent()`:
68
+ Await each `handle.run(prompt, turnOptions)` call before starting the next one; conversation turns must be sequential and cannot overlap. Conversation creation accepts the same execution-policy options as `agent()`. `timeoutMs` and `retries` passed to `run()` are turn-local, so a failed turn does not advance the persisted conversation head.
78
69
 
70
+ ## Worktrees
71
+ Use `withWorktree(callback)` or `withWorktree(name, callback)` for top-level agents that collaborate in one worktree:
79
72
  ```js
80
- const results = await parallel("checks", {
81
- security: () => reviewRepository({ focus: "security" }),
82
- release: () => reviewRepository({ focus: "release readiness" }),
73
+ const result = await withWorktree("issue", async ({ path, branch }) => {
74
+ const report = await agent("Implement the issue");
75
+ return { path, branch, report };
83
76
  });
84
77
  ```
78
+ Entering the scope materializes its worktree before the callback. The callback receives a frozen reference containing only the real string `path` and `branch`; callbacks may ignore the argument, and their bare return value is preserved. Concurrent agents share mutable files, so assign non-conflicting work or coordinate explicitly.
85
79
 
86
- Use separate named scopes when each parallel branch needs its own worktree:
87
-
80
+ Branches may call any workflow function, not only `agent()`. Use separate named scopes when parallel branches need isolated worktrees:
88
81
  ```js
89
82
  const results = await parallel("implementation", {
90
83
  api: () => withWorktree("api", () => agent("Implement the API")),
@@ -92,26 +85,17 @@ const results = await parallel("implementation", {
92
85
  });
93
86
  ```
94
87
 
95
- Registered extension functions receive `withWorktree` in their context, so they may create a shared scope internally. They can compose other registered functions without importing their source:
96
- ```ts
97
- const report = await context.invoke("reviewRepository", { focus: "security" });
98
- ```
99
- Their public inputs and outputs must remain JSON; callbacks cannot cross the extension-function boundary.
88
+ Registered extension functions receive `withWorktree` in context and can compose other registered functions with `context.invoke("reviewRepository", { focus: "security" })`. Their public inputs and outputs remain JSON; callbacks cannot cross the extension boundary.
100
89
 
101
90
  ## Rules
102
-
103
- - Do not create a workflow for one agent. Phases must have distinct work.
104
- - Use `log(messageString)` in the script to surface brief status messages to the operator.
105
- - A role owns its execution policy. When `role` is present, do not also set `model`, `thinking`, or `tools`; only task-specific options such as `outputSchema`, retries, timeout, or a `withWorktree` scope may accompany it.
106
- - Use `parallel()` for independent tasks with different flows. Use `pipeline()` when each keyed item passes through the same ordered stages; do not duplicate identical stage chains inside `parallel()` branches.
107
- - Call shapes are `parallel(operationName, tasksRecord)` and `pipeline(operationName, itemsRecord, stagesRecord)`; object keys are stable task, item, and stage names.
108
- - Preserve item metadata in workflow code between pipeline stages instead of requiring agents to echo it through `outputSchema`.
109
- - Repeated work uses a JavaScript loop; each direct `agent(...)` call receives deterministic call-site and occurrence identity.
91
+ - Use `log(messageString)` for brief operator status.
92
+ - A role owns execution policy: with `role`, do not set `model`, `thinking`, or `tools`; only task options such as `outputSchema`, retries, timeout, or a `withWorktree` scope may accompany it.
93
+ - Use `parallel()` for independent tasks with different flows and `pipeline()` when every keyed item follows the same ordered stages; do not duplicate identical chains in `parallel()`. Signatures are `parallel(operationName, tasksRecord)` and `pipeline(operationName, itemsRecord, stagesRecord)`; keys are stable task, item, and stage names.
94
+ - Preserve item metadata in workflow code between pipeline stages instead of making agents echo it through `outputSchema`.
95
+ - Use a JavaScript loop for repeated work; each direct `agent(...)` call gets deterministic call-site and occurrence identity.
110
96
  - Runs default to background; set tool-call `foreground: true` when asked to wait.
111
- - Add `budget` only when the run needs aggregate limits. The only valid dimension names are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches` (never `cost`, `duration`, `launches`, or other shorthand). Each dimension is `{ soft?: number, hard?: number }`; `soft` must be less than `hard`.
112
- - A `budget_exhausted` run is resumable through `workflow_resume`. Omitted patch values stay unchanged, explicit `null` removes a limit, and any relaxation requires an exact human-approved proposal through `workflow_respond`.
113
- - `parallel()` and `pipeline()` return keyed bare values. Await results before use.
114
- - Interpolate results with `prompt("...{value}", { value })`; placeholders in plain strings stay literal.
115
- - Use `outputSchema` only when another phase must compare, aggregate, or validate the result. Never add it to a final agent whose prose is returned directly. Keep only fields the consumer needs, and avoid repeating the same evidence in multiple schemas.
116
- - With `outputSchema`, agents must call `workflow_result`; one repair prompt is built in. Omit `retries` unless an additional retry is justified and the work is idempotent.
117
- - Do not add "persona" specs to the prompt for agents. Just define the task.
97
+ - Add `budget` only for aggregate limits. Valid dimensions are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches`; each is `{ soft?: number, hard?: number }` with `soft < hard`.
98
+ - `budget_exhausted` runs resume through `workflow_resume`: omitted patch values stay unchanged, `null` removes a limit, and tightening resumes directly. Relaxation stores the exact proposal and returns `{ state: "awaiting_approval", proposalId }`; `workflow_respond` must answer that ID. Rejection leaves the run exhausted; approval applies the budget and cold-resumes it.
99
+ - `parallel()` and `pipeline()` return keyed bare values; await them before use. Interpolate results with `prompt("...{value}", { value })`; placeholders in plain strings remain literal.
100
+ - Use `outputSchema` only when another phase compares, aggregates, or validates a result, never for final prose. Keep only consumer-needed fields and avoid repeated evidence. Agents with it must call `workflow_result`; one repair prompt is built in. Omit `retries` unless an extra retry is justified and work is idempotent.
101
+ - Do not add persona specifications to agent prompts; define the task directly.