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.
@@ -26,6 +26,7 @@ export type PendingWorkflowDecision = BudgetApprovalRequest
26
26
  export type PersistedOwnershipNode = OwnershipRecord
27
27
  type Journal = { completed: Record<string, CompletedOperation>; awaiting?: Record<string, AwaitingCheckpoint>; decisions?: Record<string, PendingWorkflowDecision> };
28
28
  export interface WorktreeReference { owner: string; path: string; branch: string; cwd: string; base: string }
29
+ export interface BorrowedWorktreeBinding { name: string; sourceRunId: string; owner: string }
29
30
 
30
31
  const execute = promisify(execFile);
31
32
  const gitIdentity = {
@@ -191,12 +192,13 @@ export class RunStore {
191
192
  // ponytail: serializes one RunStore instance; cross-process run sharing remains unsupported.
192
193
  private stateWrite: Promise<void> = Promise.resolve();
193
194
  private worktreeWrite: Promise<void> = Promise.resolve();
195
+ private borrowedWorktreeWrite: Promise<void> = Promise.resolve();
194
196
  private snapshotWrite: Promise<void> = Promise.resolve();
195
197
  private launchSnapshotWrite: Promise<void> = Promise.resolve();
196
198
  // ponytail: the session lease prevents concurrent RunStore writers for one run.
197
199
  private systemPromptWrite: Promise<void> = Promise.resolve();
198
200
  private conversationWrite: Promise<void> = Promise.resolve();
199
- constructor(readonly cwd: string, readonly sessionId: string, readonly runId: string, home = homedir()) {
201
+ constructor(readonly cwd: string, readonly sessionId: string, readonly runId: string, readonly home = homedir()) {
200
202
  this.cwd = resolve(cwd);
201
203
  this.directory = join(runsDirectory(this.cwd, sessionId, home), safePart(runId));
202
204
  }
@@ -212,6 +214,7 @@ export class RunStore {
212
214
  await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {}, decisions: {} });
213
215
  await atomicJson(join(temporary, "ownership.json"), []);
214
216
  await atomicJson(join(temporary, "worktrees.json"), []);
217
+ await atomicJson(join(temporary, "borrowed-worktrees.json"), []);
215
218
  await atomicJson(join(temporary, "state.json"), run);
216
219
  await atomicJson(join(temporary, "system-prompts.json"), { version: 1, entries: [] });
217
220
  await atomicJson(join(temporary, "conversations.json"), { version: 1, conversations: {} });
@@ -392,6 +395,24 @@ export class RunStore {
392
395
  return join(this.directory, `worktree-${key}.creating`);
393
396
  }
394
397
 
398
+ private namedWorktreeOwner(name: string): string {
399
+ if (!name.trim()) throw new WorkflowError("WORKTREE_FAILED", "Named worktree names must be non-empty");
400
+ return structuralPath("worktree", "named", name.trim());
401
+ }
402
+
403
+ private worktreeName(owner: string): string | undefined {
404
+ const prefix = `${structuralPath("worktree", "named")}/`;
405
+ if (!owner.startsWith(prefix)) return undefined;
406
+ const encoded = owner.slice(prefix.length);
407
+ if (!encoded || encoded.includes("/")) return undefined;
408
+ try {
409
+ const name = decodeURIComponent(encoded);
410
+ return name.trim() ? name : undefined;
411
+ } catch {
412
+ return undefined;
413
+ }
414
+ }
415
+
395
416
  private structuralWorktree(owner: string, record: unknown): WorktreeReference {
396
417
  if (!record || typeof record !== "object") throw new Error(`Invalid worktree record for ${owner}`);
397
418
  const candidate = record as Partial<WorktreeReference>;
@@ -402,6 +423,110 @@ export class RunStore {
402
423
  return candidate as WorktreeReference;
403
424
  }
404
425
 
426
+ private async borrowedWorktreeRecords(wait = true): Promise<readonly BorrowedWorktreeBinding[]> {
427
+ if (wait) await this.borrowedWorktreeWrite;
428
+ const records = await json<unknown[]>(join(this.directory, "borrowed-worktrees.json")).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
429
+ if (!Array.isArray(records)) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings are invalid");
430
+ const seen = new Set<string>();
431
+ return records.map((record) => {
432
+ if (!record || typeof record !== "object") throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree binding is invalid");
433
+ const candidate = record as Partial<BorrowedWorktreeBinding>;
434
+ 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)) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree binding is invalid");
435
+ if (seen.has(candidate.name)) throw new WorkflowError("WORKTREE_FAILED", `Duplicate borrowed worktree binding for ${candidate.name}`);
436
+ seen.add(candidate.name);
437
+ return { name: candidate.name, sourceRunId: candidate.sourceRunId, owner: candidate.owner };
438
+ });
439
+ }
440
+
441
+ async borrowedWorktrees(): Promise<readonly BorrowedWorktreeBinding[]> { return this.borrowedWorktreeRecords(); }
442
+
443
+ private async borrowedWorktree(name: string): Promise<BorrowedWorktreeBinding | undefined> {
444
+ return (await this.borrowedWorktreeRecords()).find((binding) => binding.name === name);
445
+ }
446
+
447
+ private async sourceRun(sourceRunId: string): Promise<RunStore> {
448
+ if (!sourceRunId || sourceRunId === this.runId) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree source run is invalid");
449
+ const source = new RunStore(this.cwd, this.sessionId, sourceRunId, this.home);
450
+ try {
451
+ const loaded = await source.load();
452
+ if (!["completed", "failed", "stopped"].includes(loaded.run.state)) throw new Error(`Source run ${sourceRunId} is not terminal`);
453
+ return source;
454
+ } catch (error) {
455
+ if (error instanceof WorkflowError && error.code === "WORKTREE_FAILED") throw error;
456
+ throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
457
+ }
458
+ }
459
+
460
+ async validateParentRun(parentRunId: string): Promise<void> { await this.sourceRun(parentRunId); }
461
+
462
+ private async ownedWorktree(owner: string, cwd?: string): Promise<WorktreeReference> {
463
+ const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
464
+ const matches = records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner);
465
+ if (matches.length !== 1) throw new Error(`Missing or duplicate worktree record for ${owner}`);
466
+ const record = this.structuralWorktree(owner, matches[0]);
467
+ if (cwd !== undefined && resolve(cwd) !== resolve(record.cwd)) throw new Error(`Invalid worktree record for ${owner}`);
468
+ await access(record.cwd);
469
+ return record;
470
+ }
471
+
472
+ private async resolveBorrowedWorktree(binding: BorrowedWorktreeBinding, seen: Set<string>): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string }> {
473
+ try {
474
+ const source = await this.sourceRun(binding.sourceRunId);
475
+ const resolved = await source.findNamedWorktree(binding.name, seen);
476
+ if (!resolved) throw new Error(`Missing named worktree ${binding.name} in source run ${binding.sourceRunId}`);
477
+ if (resolved.owner !== binding.owner) throw new Error(`Borrowed worktree binding does not match source owner for ${binding.name}`);
478
+ return resolved;
479
+ } catch (error) {
480
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
481
+ }
482
+ }
483
+
484
+ private async findNamedWorktree(name: string, seen: Set<string> = new Set()): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string } | undefined> {
485
+ const owner = this.namedWorktreeOwner(name);
486
+ if (seen.has(this.runId)) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings contain a cycle");
487
+ const nextSeen = new Set(seen);
488
+ nextSeen.add(this.runId);
489
+ const binding = await this.borrowedWorktree(name);
490
+ if (binding) {
491
+ const loaded = await this.load();
492
+ if (loaded.run.parentRunId === undefined) throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree ${name} has no parent run`);
493
+ const parent = await this.sourceRun(loaded.run.parentRunId);
494
+ const resolved = await parent.findNamedWorktree(name, nextSeen);
495
+ if (!resolved || resolved.sourceRunId !== binding.sourceRunId || resolved.owner !== binding.owner) throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${name} is not inherited from its parent run`);
496
+ return resolved;
497
+ }
498
+ const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
499
+ const matches = records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner);
500
+ if (matches.length === 0) return undefined;
501
+ try {
502
+ const reference = await this.ownedWorktree(owner);
503
+ return { reference, sourceRunId: this.runId, owner };
504
+ } catch (error) {
505
+ throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
506
+ }
507
+ }
508
+
509
+ async resolveNamedWorktree(name: string, seen: Set<string> = new Set()): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string }> {
510
+ const resolved = await this.findNamedWorktree(name, seen);
511
+ if (!resolved) throw new WorkflowError("WORKTREE_FAILED", `Missing named worktree ${name}`);
512
+ return resolved;
513
+ }
514
+
515
+ async validateBorrowedWorktrees(): Promise<void> {
516
+ try {
517
+ const loaded = await this.load();
518
+ if (loaded.run.parentRunId !== undefined) await this.validateParentRun(loaded.run.parentRunId);
519
+ for (const binding of await this.borrowedWorktreeRecords()) await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
520
+ } catch (error) {
521
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
522
+ }
523
+ }
524
+
525
+ async ownsWorktree(owner: string): Promise<boolean> {
526
+ const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
527
+ return records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner).length === 1;
528
+ }
529
+
405
530
  private async cleanupMarker(markerPath: string): Promise<void> {
406
531
  let marker: Partial<{ owner: string; path: string; branch: string; base: string }>;
407
532
  try { marker = await json(markerPath); } catch { return; }
@@ -426,13 +551,14 @@ export class RunStore {
426
551
  async validateWorktree(owner: string, cwd?: string): Promise<WorktreeReference> {
427
552
  try {
428
553
  await this.load();
429
- const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
430
- const matches = records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner);
431
- if (matches.length !== 1) throw new Error(`Missing or duplicate worktree record for ${owner}`);
432
- const record = this.structuralWorktree(owner, matches[0]);
433
- if (cwd !== undefined && resolve(cwd) !== resolve(record.cwd)) throw new Error(`Invalid worktree record for ${owner}`);
434
- await access(record.cwd);
435
- return record;
554
+ const name = this.worktreeName(owner);
555
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
556
+ if (binding) {
557
+ const resolved = await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
558
+ if (cwd !== undefined && resolve(cwd) !== resolve(resolved.reference.cwd)) throw new Error(`Invalid worktree record for ${owner}`);
559
+ return resolved.reference;
560
+ }
561
+ return await this.ownedWorktree(owner, cwd);
436
562
  } catch (error) {
437
563
  throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
438
564
  }
@@ -440,9 +566,19 @@ export class RunStore {
440
566
 
441
567
  async worktree(owner: string): Promise<WorktreeReference> {
442
568
  const write = this.worktreeWrite.then(async () => {
443
- await this.load();
569
+ const loaded = await this.load();
444
570
  const recordsPath = join(this.directory, "worktrees.json");
445
571
  let records = await json<WorktreeReference[]>(recordsPath).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
572
+ const name = this.worktreeName(owner);
573
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
574
+ if (binding) return (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference;
575
+ if (name && loaded.run.parentRunId !== undefined) {
576
+ const resolved = await this.resolveNamedWorktreeFromParent(name, loaded.run.parentRunId);
577
+ if (resolved) {
578
+ await this.bindBorrowedWorktree({ name, sourceRunId: resolved.sourceRunId, owner: resolved.owner });
579
+ return resolved.reference;
580
+ }
581
+ }
446
582
  const existing = records.find((record) => record.owner === owner);
447
583
  if (existing) return this.validateWorktree(owner);
448
584
  const { path, branch } = this.expectedWorktree(owner);
@@ -488,6 +624,25 @@ export class RunStore {
488
624
  return write;
489
625
  }
490
626
 
627
+ private async resolveNamedWorktreeFromParent(name: string, parentRunId: string): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string } | undefined> {
628
+ const source = await this.sourceRun(parentRunId);
629
+ return source.findNamedWorktree(name, new Set([this.runId]));
630
+ }
631
+
632
+ private async bindBorrowedWorktree(binding: BorrowedWorktreeBinding): Promise<void> {
633
+ const write = this.borrowedWorktreeWrite.then(async () => {
634
+ const records = [...await this.borrowedWorktreeRecords(false)];
635
+ const existing = records.find((candidate) => candidate.name === binding.name);
636
+ if (existing) {
637
+ if (JSON.stringify(existing) !== JSON.stringify(binding)) throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${binding.name} changed`);
638
+ return;
639
+ }
640
+ records.push(binding);
641
+ await atomicJson(join(this.directory, "borrowed-worktrees.json"), records);
642
+ });
643
+ this.borrowedWorktreeWrite = write.then(() => undefined, () => undefined);
644
+ await write;
645
+ }
491
646
  async snapshotWorktree(owner: string): Promise<string> {
492
647
  try {
493
648
  const write = this.snapshotWrite.then(async () => {
@@ -510,11 +665,13 @@ export class RunStore {
510
665
  throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
511
666
  }
512
667
  }
513
-
514
668
  async worktrees(): Promise<readonly WorktreeReference[]> {
515
669
  const records = await json<WorktreeReference[]>(join(this.directory, "worktrees.json")).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
516
- const validated = await Promise.all(records.map(async (record) => { try { return await this.validateWorktree(record.owner); } catch { return undefined; } }));
517
- return validated.filter((record): record is WorktreeReference => record !== undefined);
670
+ const bindings = await this.borrowedWorktreeRecords();
671
+ const boundOwners = new Set(bindings.map((binding) => binding.owner));
672
+ const owned = await Promise.all(records.filter((record) => !boundOwners.has(record.owner)).map(async (record) => { try { return await this.validateWorktree(record.owner); } catch { return undefined; } }));
673
+ const borrowed = await Promise.all(bindings.map(async (binding) => (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference));
674
+ return [...owned.filter((record): record is WorktreeReference => record !== undefined), ...borrowed];
518
675
  }
519
676
 
520
677
  async changedWorktrees(): Promise<readonly WorktreeReference[]> {
@@ -53,6 +53,10 @@ export function transcriptLines(entries: readonly SessionEntry[]): string[] {
53
53
  });
54
54
  }
55
55
 
56
+ export function transcriptFileLines(path: string): string[] {
57
+ return transcriptLines(SessionManager.open(path).buildContextEntries());
58
+ }
59
+
56
60
  function mergedModels(groups: readonly (readonly ModelUsage[])[]): ModelUsage[] {
57
61
  const totals = new Map<string, number>();
58
62
  for (const group of groups) for (const item of group) totals.set(item.model, (totals.get(item.model) ?? 0) + item.cost);
@@ -485,6 +485,7 @@ export async function replayWorkflowScript(script: string, args: JsonValue = nul
485
485
  return `fake:${prompt}`;
486
486
  } finally { active -= 1; }
487
487
  },
488
+ worktree: async () => ({ path: "/worktrees/eval", branch: "eval-branch" }),
488
489
  phase: (name) => { phases.push(name); },
489
490
  log: (message) => { logs.push(message); },
490
491
  }, signal);
@@ -593,7 +594,7 @@ function semanticJudgePrompt(evalCase: WorkflowEvalCase, calls: readonly Capture
593
594
  const roles = loadAgentDefinitions(cwd, join(home, ".pi", "agent"));
594
595
  const usedRoles = new Set(calls.flatMap(({ script }) => { try { return script ? inspectWorkflowScript(script).flatMap((call) => call.kind === "agent" && call.role ? [call.role] : []) : []; } catch { return []; } }));
595
596
  const roleText = [...usedRoles].map((role) => `${role}: ${roles[role]?.description ?? "no description"}`).join("\n") || "none";
596
- 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.";
597
+ 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.";
597
598
  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")}`;
598
599
  }
599
600