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.
- package/README.md +7 -2
- package/dist/src/agent-execution.d.ts +13 -0
- package/dist/src/agent-execution.js +130 -33
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +530 -6
- package/dist/src/doctor.d.ts +4 -4
- package/dist/src/doctor.js +9 -29
- package/dist/src/herdr.d.ts +12 -0
- package/dist/src/herdr.js +74 -0
- package/dist/src/index.d.ts +101 -24
- package/dist/src/index.js +669 -212
- package/dist/src/persistence.d.ts +26 -0
- package/dist/src/persistence.js +186 -12
- package/dist/src/session-inspector.d.ts +1 -0
- package/dist/src/session-inspector.js +3 -0
- package/dist/src/workflow-evals.js +2 -1
- package/package.json +4 -3
- package/skills/pi-extensible-workflows/SKILL.md +48 -64
- package/src/agent-execution.ts +95 -20
- package/src/cli.ts +418 -6
- package/src/doctor.ts +13 -32
- package/src/herdr.ts +73 -0
- package/src/index.ts +595 -193
- package/src/persistence.ts +169 -12
- package/src/session-inspector.ts +4 -0
- package/src/workflow-evals.ts +2 -1
package/src/persistence.ts
CHANGED
|
@@ -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
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
517
|
-
|
|
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[]> {
|
package/src/session-inspector.ts
CHANGED
|
@@ -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);
|
package/src/workflow-evals.ts
CHANGED
|
@@ -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
|
|