pi-herdr-agents 0.0.1

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.
Files changed (49) hide show
  1. package/AGENTS.md +116 -0
  2. package/CONTEXT.md +159 -0
  3. package/LICENSE +21 -0
  4. package/README.md +874 -0
  5. package/RELEASING.md +139 -0
  6. package/agents/adversarial-reviewer.md +80 -0
  7. package/agents/claude-reviewer.md +23 -0
  8. package/agents/planner.md +539 -0
  9. package/agents/poteto.md +32 -0
  10. package/agents/reviewer.md +164 -0
  11. package/agents/scout.md +106 -0
  12. package/agents/visual-tester.md +224 -0
  13. package/agents/worker.md +132 -0
  14. package/config.json.example +8 -0
  15. package/docs/README.md +42 -0
  16. package/docs/adr/0001-btw-ephemeral-side-questions.md +142 -0
  17. package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +265 -0
  18. package/docs/adr/0003-installable-role-packs.md +135 -0
  19. package/docs/adr/0004-require-active-user-approval-for-workflow-execution.md +17 -0
  20. package/docs/adr/0005-parent-owns-workflow-script-authority.md +17 -0
  21. package/docs/adr/0006-limit-v1-execution-effects-to-isolated-worktrees.md +18 -0
  22. package/docs/adr/0007-require-fresh-review-for-workflow-scripts.md +19 -0
  23. package/docs/orchestrated-review-workflow-plan.md +479 -0
  24. package/docs/research/pdw-architecture-assessment.md +525 -0
  25. package/docs/research/pi-workflows-sol-advisor.md +255 -0
  26. package/docs/research/worktree-subagent-orchestration.md +317 -0
  27. package/docs/worktree-subagents.md +196 -0
  28. package/examples/role-pack/extension.ts +18 -0
  29. package/examples/role-pack/package.json +16 -0
  30. package/examples/role-pack/roles/example-reviewer.md +12 -0
  31. package/package.json +58 -0
  32. package/pi-extension/subagents/activity.ts +511 -0
  33. package/pi-extension/subagents/completion.ts +177 -0
  34. package/pi-extension/subagents/herdr.ts +541 -0
  35. package/pi-extension/subagents/index.ts +4730 -0
  36. package/pi-extension/subagents/lifecycle.ts +477 -0
  37. package/pi-extension/subagents/model-config.ts +95 -0
  38. package/pi-extension/subagents/plan-skill.md +262 -0
  39. package/pi-extension/subagents/plugin/.claude-plugin/plugin.json +5 -0
  40. package/pi-extension/subagents/plugin/hooks/hooks.json +15 -0
  41. package/pi-extension/subagents/plugin/hooks/on-stop.sh +68 -0
  42. package/pi-extension/subagents/runtime-routing.ts +313 -0
  43. package/pi-extension/subagents/session.ts +216 -0
  44. package/pi-extension/subagents/status.ts +513 -0
  45. package/pi-extension/subagents/subagent-done.ts +326 -0
  46. package/pi-extension/subagents/terminal.ts +163 -0
  47. package/pi-extension/subagents/workflow-worker.js +56 -0
  48. package/pi-extension/subagents/workflow.ts +1210 -0
  49. package/skills/orchestrate/SKILL.md +184 -0
@@ -0,0 +1,1210 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import {
4
+ appendFileSync,
5
+ closeSync,
6
+ existsSync,
7
+ lstatSync,
8
+ openSync,
9
+ readdirSync,
10
+ realpathSync,
11
+ readFileSync,
12
+ writeSync,
13
+ } from "node:fs";
14
+ import { Worker } from "node:worker_threads";
15
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
+ import {
17
+ isThinkingLevel,
18
+ parseExactModelRef,
19
+ resolveRuntimePlan,
20
+ type ModelRegistryAdapter,
21
+ type ThinkingLevel,
22
+ } from "./runtime-routing.ts";
23
+
24
+ const MAX_WORKFLOW_BYTES = 256 * 1024;
25
+ const MAX_AGENTS = 8;
26
+ const MAX_CONCURRENCY = 4;
27
+ const MAX_LOGS = 100;
28
+ const MAX_LOG_CHARS = 4_000;
29
+ const MAX_RESULT_BYTES = 64 * 1024;
30
+ const DEFAULT_DEADLINE_MS = 30 * 60 * 1_000;
31
+ const CANCELLED_AGENT_RESULT = Object.freeze({
32
+ ok: false as const,
33
+ code: "cancelled",
34
+ message: "Workflow cancelled.",
35
+ retryable: false as const,
36
+ });
37
+ const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
38
+ const METADATA_FIELDS = new Set([
39
+ "version",
40
+ "name",
41
+ "sources",
42
+ "baseSha",
43
+ "maxAgents",
44
+ "maxConcurrency",
45
+ "roles",
46
+ ]);
47
+ const ROLE_FIELDS = new Set(["role", "kind", "model", "thinking"]);
48
+
49
+ export class WorkflowPreparationError extends Error {
50
+ constructor(message: string) {
51
+ super(message);
52
+ this.name = "WorkflowPreparationError";
53
+ }
54
+ }
55
+
56
+ export interface WorkflowRole {
57
+ name: string;
58
+ source: string;
59
+ path: string;
60
+ body?: string;
61
+ model?: string;
62
+ thinking?: ThinkingLevel;
63
+ tools?: string;
64
+ skills?: string;
65
+ denyTools?: string;
66
+ spawning?: boolean;
67
+ autoExit?: boolean;
68
+ interactive?: boolean;
69
+ sessionMode?: string;
70
+ cwd?: string;
71
+ disableModelInvocation?: boolean;
72
+ cli?: string;
73
+ }
74
+
75
+ interface WorkflowMetadataRole {
76
+ role: string;
77
+ kind: "review";
78
+ model: string;
79
+ thinking: ThinkingLevel;
80
+ }
81
+
82
+ interface WorkflowMetadata {
83
+ version: 1;
84
+ name: string;
85
+ sources: string[];
86
+ baseSha: string;
87
+ maxAgents: number;
88
+ maxConcurrency: number;
89
+ roles: WorkflowMetadataRole[];
90
+ }
91
+
92
+ export interface WorkflowRolePolicy {
93
+ role: string;
94
+ model: string;
95
+ thinking: ThinkingLevel;
96
+ tools: string[];
97
+ promptHash: string;
98
+ fingerprint: string;
99
+ }
100
+
101
+ export interface PendingWorkflow {
102
+ runId: string;
103
+ path: string;
104
+ scriptHash: string;
105
+ bytes: string;
106
+ metadata: WorkflowMetadata;
107
+ repository: { root: string; commonDir: string };
108
+ baseSha: string;
109
+ sources: string[];
110
+ rolePolicies: WorkflowRolePolicy[];
111
+ parentSession: { id: string; file: string; prepareLeafId: string };
112
+ }
113
+
114
+ export interface PrepareWorkflowInput {
115
+ cwd: string;
116
+ path: string;
117
+ roles: WorkflowRole[];
118
+ modelRegistry: ModelRegistryAdapter;
119
+ parentSession: PendingWorkflow["parentSession"];
120
+ extensionDenyTools?: ReadonlySet<string>;
121
+ }
122
+
123
+ function fail(message: string): never {
124
+ throw new WorkflowPreparationError(message);
125
+ }
126
+
127
+ function hash(value: string): string {
128
+ return createHash("sha256").update(value).digest("hex");
129
+ }
130
+
131
+ function git(cwd: string, args: string[]): string {
132
+ try {
133
+ return execFileSync("git", ["-C", cwd, ...args], {
134
+ encoding: "utf8",
135
+ }).trim();
136
+ } catch {
137
+ return fail(`Git command failed: git ${args.join(" ")}`);
138
+ }
139
+ }
140
+
141
+ function contains(root: string, path: string): boolean {
142
+ const rel = relative(root, path);
143
+ return (
144
+ rel === "" ||
145
+ (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))
146
+ );
147
+ }
148
+
149
+ function requireRegularPath(path: string, label: string) {
150
+ let stat: ReturnType<typeof lstatSync>;
151
+ try {
152
+ stat = lstatSync(path);
153
+ } catch {
154
+ fail(`${label} does not exist: ${path}`);
155
+ }
156
+ if (stat.isSymbolicLink()) fail(`${label} must not be a symlink: ${path}`);
157
+ }
158
+
159
+ function resolveArtifact(cwd: string, requestedPath: string) {
160
+ const repositoryPath = git(cwd, ["rev-parse", "--show-toplevel"]);
161
+ const root = realpathSync(repositoryPath);
162
+ const requested = resolve(cwd, requestedPath);
163
+ const requestedCwd = resolve(cwd);
164
+ const canonicalCwd = realpathSync(requestedCwd);
165
+ for (let candidate = requested; ; candidate = dirname(candidate)) {
166
+ if (candidate === requestedCwd || candidate === canonicalCwd) break;
167
+ requireRegularPath(candidate, "workflow artifact path");
168
+ if (candidate === dirname(candidate))
169
+ fail("workflow path must be inside the current project");
170
+ }
171
+ const path = realpathSync(requested);
172
+ const plans = join(root, ".pi", "plans");
173
+ if (!contains(plans, path)) fail("workflow path must be inside .pi/plans");
174
+
175
+ const parts = relative(plans, path).split(sep);
176
+ if (
177
+ parts.length !== 2 ||
178
+ parts[0] === "" ||
179
+ parts[0] === "." ||
180
+ parts[0] === ".." ||
181
+ parts[1] !== "workflow.js"
182
+ ) {
183
+ fail("workflow path must have the shape .pi/plans/<run>/workflow.js");
184
+ }
185
+
186
+ try {
187
+ lstatSync(join(dirname(path), "run.jsonl"));
188
+ fail("workflow run journal already exists");
189
+ } catch (error) {
190
+ if (error instanceof WorkflowPreparationError) throw error;
191
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
192
+ fail(
193
+ `Cannot inspect workflow run journal: ${error instanceof Error ? error.message : String(error)}`,
194
+ );
195
+ }
196
+ }
197
+
198
+ return { root, path, runId: parts[0] };
199
+ }
200
+
201
+ function object(value: unknown, label: string): Record<string, unknown> {
202
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
203
+ fail(`${label} must be an object`);
204
+ }
205
+ return value as Record<string, unknown>;
206
+ }
207
+
208
+ function exactKeys(
209
+ value: Record<string, unknown>,
210
+ allowed: ReadonlySet<string>,
211
+ label: string,
212
+ ) {
213
+ const unsupported = Object.keys(value).filter((key) => !allowed.has(key));
214
+ if (unsupported.length > 0)
215
+ fail(`${label} has unsupported field(s): ${unsupported.join(", ")}`);
216
+ }
217
+
218
+ function string(value: unknown, label: string): string {
219
+ if (typeof value !== "string" || value.trim() === "")
220
+ fail(`${label} must be a non-empty string`);
221
+ return value;
222
+ }
223
+
224
+ function positiveInteger(
225
+ value: unknown,
226
+ label: string,
227
+ maximum: number,
228
+ ): number {
229
+ if (
230
+ !Number.isInteger(value) ||
231
+ (value as number) < 1 ||
232
+ (value as number) > maximum
233
+ ) {
234
+ fail(`${label} must be an integer from 1 to ${maximum}`);
235
+ }
236
+ return value as number;
237
+ }
238
+
239
+ function parseMetadata(source: string): WorkflowMetadata {
240
+ const match = source.match(/^\/\* herdr-workflow\n([\s\S]*?)\n\*\//);
241
+ if (!match)
242
+ fail("workflow metadata must be the first herdr-workflow comment");
243
+
244
+ let parsed: unknown;
245
+ try {
246
+ parsed = JSON.parse(match[1]);
247
+ } catch {
248
+ return fail("workflow metadata must contain valid JSON");
249
+ }
250
+ const metadata = object(parsed, "workflow metadata");
251
+ exactKeys(metadata, METADATA_FIELDS, "workflow metadata");
252
+ if (metadata.version !== 1) fail("workflow metadata version must be 1");
253
+ const name = string(metadata.name, "workflow metadata name");
254
+ const baseSha = string(metadata.baseSha, "workflow metadata baseSha");
255
+ if (!/^[a-f0-9]{40}$/.test(baseSha))
256
+ fail("workflow metadata baseSha must be a full lowercase commit SHA");
257
+ const maxAgents = positiveInteger(
258
+ metadata.maxAgents,
259
+ "workflow metadata maxAgents",
260
+ MAX_AGENTS,
261
+ );
262
+ const maxConcurrency = positiveInteger(
263
+ metadata.maxConcurrency,
264
+ "workflow metadata maxConcurrency",
265
+ MAX_CONCURRENCY,
266
+ );
267
+ if (maxConcurrency > maxAgents)
268
+ fail("workflow metadata maxConcurrency cannot exceed maxAgents");
269
+ if (!Array.isArray(metadata.sources) || metadata.sources.length === 0) {
270
+ fail("workflow metadata sources must be a non-empty array");
271
+ }
272
+ const sources = metadata.sources.map((source, index) =>
273
+ string(source, `workflow metadata sources[${index}]`),
274
+ );
275
+ if (!Array.isArray(metadata.roles) || metadata.roles.length === 0) {
276
+ fail("workflow metadata roles must be a non-empty array");
277
+ }
278
+ const roleNames = new Set<string>();
279
+ const roles = metadata.roles.map((candidate, index) => {
280
+ const role = object(candidate, `workflow metadata roles[${index}]`);
281
+ exactKeys(role, ROLE_FIELDS, `workflow metadata roles[${index}]`);
282
+ const name = string(role.role, `workflow metadata roles[${index}].role`);
283
+ if (roleNames.has(name))
284
+ fail(`workflow metadata has duplicate role ${JSON.stringify(name)}`);
285
+ roleNames.add(name);
286
+ if (role.kind !== "review")
287
+ fail(`workflow role ${JSON.stringify(name)} must have kind "review"`);
288
+ const model = string(
289
+ role.model,
290
+ `workflow role ${JSON.stringify(name)} model`,
291
+ );
292
+ const thinking = string(
293
+ role.thinking,
294
+ `workflow role ${JSON.stringify(name)} thinking`,
295
+ );
296
+ if (!isThinkingLevel(thinking))
297
+ fail(`workflow role ${JSON.stringify(name)} has unsupported thinking`);
298
+ return { role: name, kind: "review" as const, model, thinking };
299
+ });
300
+
301
+ return {
302
+ version: 1,
303
+ name,
304
+ sources,
305
+ baseSha,
306
+ maxAgents,
307
+ maxConcurrency,
308
+ roles,
309
+ };
310
+ }
311
+
312
+ function isProvenanceSource(source: string): boolean {
313
+ if (/^#[0-9]+$/.test(source) || /^[A-Z][A-Z0-9]+-[0-9]+$/.test(source))
314
+ return true;
315
+ try {
316
+ const url = new URL(source);
317
+ return url.protocol === "http:" || url.protocol === "https:";
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ function validateSources(root: string, sources: string[]) {
324
+ for (const source of sources) {
325
+ if (isProvenanceSource(source)) continue;
326
+ const path = resolve(root, source);
327
+ if (!contains(root, path))
328
+ fail(`local source escapes the repository: ${source}`);
329
+ try {
330
+ if (!contains(root, realpathSync(path))) {
331
+ fail(
332
+ `local source escapes the repository through a symlink: ${source}`,
333
+ );
334
+ }
335
+ } catch (error) {
336
+ if (error instanceof WorkflowPreparationError) throw error;
337
+ fail(`local source does not exist: ${source}`);
338
+ }
339
+ }
340
+ }
341
+
342
+ function deriveTools(
343
+ role: WorkflowRole,
344
+ extensionDenyTools: ReadonlySet<string>,
345
+ ): string[] {
346
+ const roleDenyTools = new Set(
347
+ (role.denyTools ?? "")
348
+ .split(",")
349
+ .map((tool) => tool.trim())
350
+ .filter(Boolean),
351
+ );
352
+ if (role.spawning === false) {
353
+ for (const tool of [
354
+ "subagent",
355
+ "subagent_interrupt",
356
+ "subagent_resume",
357
+ "subagents_list",
358
+ ]) {
359
+ roleDenyTools.add(tool);
360
+ }
361
+ }
362
+ return [
363
+ ...new Set(
364
+ (role.tools ?? "")
365
+ .split(",")
366
+ .map((tool) => tool.trim())
367
+ .filter(Boolean),
368
+ ),
369
+ ].filter(
370
+ (tool) =>
371
+ READ_ONLY_TOOLS.has(tool) &&
372
+ !roleDenyTools.has(tool) &&
373
+ !extensionDenyTools.has(tool),
374
+ );
375
+ }
376
+
377
+ function resolveRolePolicies(
378
+ metadata: WorkflowMetadata,
379
+ roles: WorkflowRole[],
380
+ modelRegistry: ModelRegistryAdapter,
381
+ extensionDenyTools: ReadonlySet<string>,
382
+ ): WorkflowRolePolicy[] {
383
+ const available = new Map(roles.map((role) => [role.name, role]));
384
+ return metadata.roles.map((declared) => {
385
+ const role = available.get(declared.role);
386
+ if (!role || role.disableModelInvocation || role.cli)
387
+ fail(`workflow role ${JSON.stringify(declared.role)} is unavailable`);
388
+ const model = parseExactModelRef(declared.model);
389
+ if (!model || declared.model !== `${model.provider}/${model.modelId}`) {
390
+ fail(
391
+ `workflow role ${JSON.stringify(declared.role)} model must be an exact provider/model-id`,
392
+ );
393
+ }
394
+ const found = modelRegistry.find(model.provider, model.modelId);
395
+ if (!found || !modelRegistry.hasConfiguredAuth(found)) {
396
+ fail(
397
+ `workflow role ${JSON.stringify(declared.role)} model is not authenticated`,
398
+ );
399
+ }
400
+ try {
401
+ resolveRuntimePlan(
402
+ { model: declared.model, thinking: declared.thinking },
403
+ {},
404
+ {
405
+ provider: model.provider,
406
+ modelId: model.modelId,
407
+ thinking: declared.thinking,
408
+ },
409
+ modelRegistry,
410
+ );
411
+ } catch (error) {
412
+ fail(
413
+ `workflow role ${JSON.stringify(declared.role)} runtime is invalid: ${error instanceof Error ? error.message : String(error)}`,
414
+ );
415
+ }
416
+ const tools = deriveTools(role, extensionDenyTools);
417
+ if (tools.length === 0)
418
+ fail(
419
+ `workflow role ${JSON.stringify(declared.role)} has no permitted read-only tools`,
420
+ );
421
+ const promptHash = hash(role.body ?? "");
422
+ const fingerprint = hash(
423
+ JSON.stringify({
424
+ role: {
425
+ name: role.name,
426
+ source: role.source,
427
+ path: role.path,
428
+ body: role.body ?? "",
429
+ model: role.model,
430
+ thinking: role.thinking,
431
+ tools: role.tools,
432
+ skills: role.skills,
433
+ denyTools: role.denyTools,
434
+ spawning: role.spawning,
435
+ autoExit: role.autoExit,
436
+ interactive: role.interactive,
437
+ sessionMode: role.sessionMode,
438
+ cwd: role.cwd,
439
+ cli: role.cli,
440
+ },
441
+ runtime: { model: declared.model, thinking: declared.thinking },
442
+ tools,
443
+ }),
444
+ );
445
+ return {
446
+ role: declared.role,
447
+ model: declared.model,
448
+ thinking: declared.thinking,
449
+ tools,
450
+ promptHash,
451
+ fingerprint,
452
+ };
453
+ });
454
+ }
455
+
456
+ export function prepareWorkflow(input: PrepareWorkflowInput): PendingWorkflow {
457
+ if (
458
+ !input.parentSession.id ||
459
+ !input.parentSession.file ||
460
+ !input.parentSession.prepareLeafId
461
+ ) {
462
+ fail("workflow preparation requires a persistent parent session");
463
+ }
464
+ const artifact = resolveArtifact(input.cwd, input.path);
465
+ const bytes = readFileSync(artifact.path, "utf8");
466
+ if (Buffer.byteLength(bytes) > MAX_WORKFLOW_BYTES)
467
+ fail("workflow source exceeds 256 KiB");
468
+ const metadata = parseMetadata(bytes);
469
+ try {
470
+ new Function(`async () => {\n${bytes}\n}`);
471
+ } catch (error) {
472
+ fail(
473
+ `workflow JavaScript does not compile: ${error instanceof Error ? error.message : String(error)}`,
474
+ );
475
+ }
476
+ const baseSha = git(artifact.root, [
477
+ "rev-parse",
478
+ "--verify",
479
+ `${metadata.baseSha}^{commit}`,
480
+ ]);
481
+ if (baseSha !== metadata.baseSha)
482
+ fail("workflow metadata baseSha must resolve to the exact commit");
483
+ const commonDir = realpathSync(
484
+ git(artifact.root, [
485
+ "rev-parse",
486
+ "--path-format=absolute",
487
+ "--git-common-dir",
488
+ ]),
489
+ );
490
+ validateSources(artifact.root, metadata.sources);
491
+ const rolePolicies = resolveRolePolicies(
492
+ metadata,
493
+ input.roles,
494
+ input.modelRegistry,
495
+ input.extensionDenyTools ?? new Set(),
496
+ );
497
+
498
+ return {
499
+ runId: artifact.runId,
500
+ path: artifact.path,
501
+ scriptHash: hash(bytes),
502
+ bytes,
503
+ metadata,
504
+ repository: { root: artifact.root, commonDir },
505
+ baseSha,
506
+ sources: metadata.sources,
507
+ rolePolicies,
508
+ parentSession: input.parentSession,
509
+ };
510
+ }
511
+
512
+ export interface WorkflowApproval {
513
+ entryId: string;
514
+ }
515
+
516
+ interface WorkflowSessionEntry {
517
+ id?: unknown;
518
+ type?: unknown;
519
+ message?: { role?: unknown; content?: unknown };
520
+ }
521
+
522
+ function userMessageText(entry: WorkflowSessionEntry): string | undefined {
523
+ if (entry.type !== "message" || entry.message?.role !== "user")
524
+ return undefined;
525
+ if (!Array.isArray(entry.message.content)) return undefined;
526
+ const text = entry.message.content
527
+ .filter(
528
+ (block): block is { type: string; text: string } =>
529
+ !!block &&
530
+ typeof block === "object" &&
531
+ (block as { type?: unknown }).type === "text" &&
532
+ typeof (block as { text?: unknown }).text === "string",
533
+ )
534
+ .map((block) => block.text)
535
+ .join("");
536
+ return text || undefined;
537
+ }
538
+
539
+ export function validateWorkflowApproval(
540
+ candidate: PendingWorkflow,
541
+ parent: {
542
+ sessionId: string;
543
+ sessionFile: string;
544
+ branch: WorkflowSessionEntry[];
545
+ },
546
+ ): WorkflowApproval {
547
+ if (
548
+ parent.sessionId !== candidate.parentSession.id ||
549
+ parent.sessionFile !== candidate.parentSession.file
550
+ ) {
551
+ fail(
552
+ "workflow approval must use the parent session that prepared the candidate",
553
+ );
554
+ }
555
+ const leaf = parent.branch.findIndex(
556
+ (entry) => entry.id === candidate.parentSession.prepareLeafId,
557
+ );
558
+ if (leaf === -1)
559
+ fail("workflow preparation is not on the active parent branch");
560
+ let approval: WorkflowSessionEntry | undefined;
561
+ for (const entry of parent.branch.slice(leaf + 1)) {
562
+ if (userMessageText(entry) !== undefined) approval = entry;
563
+ }
564
+ const text = approval && userMessageText(approval);
565
+ const expected = `APPROVE ${candidate.scriptHash.slice(0, 8)}`;
566
+ if (text !== expected || typeof approval?.id !== "string") {
567
+ fail(
568
+ `workflow approval must be the latest user message and exactly ${expected}`,
569
+ );
570
+ }
571
+ return { entryId: approval.id };
572
+ }
573
+
574
+ export function sameWorkflowCandidate(
575
+ left: PendingWorkflow,
576
+ right: PendingWorkflow,
577
+ ): boolean {
578
+ return (
579
+ JSON.stringify({
580
+ scriptHash: left.scriptHash,
581
+ repository: left.repository,
582
+ baseSha: left.baseSha,
583
+ sources: left.sources,
584
+ rolePolicies: left.rolePolicies,
585
+ }) ===
586
+ JSON.stringify({
587
+ scriptHash: right.scriptHash,
588
+ repository: right.repository,
589
+ baseSha: right.baseSha,
590
+ sources: right.sources,
591
+ rolePolicies: right.rolePolicies,
592
+ })
593
+ );
594
+ }
595
+
596
+ export interface WorkflowJournal {
597
+ path: string;
598
+ append(type: string, details?: Record<string, unknown>): string;
599
+ }
600
+
601
+ const WORKFLOW_TERMINAL_EVENTS = new Set<WorkflowTerminalState>([
602
+ "completed",
603
+ "failed",
604
+ "cancelled",
605
+ "interrupted",
606
+ ]);
607
+
608
+ export interface WorkflowStartupRecord {
609
+ runId: string;
610
+ journalPath: string;
611
+ lastEvent?: Record<string, unknown>;
612
+ interrupted: boolean;
613
+ }
614
+
615
+ function readLastValidWorkflowEvent(path: string): Record<string, unknown> | undefined {
616
+ let lines: string[];
617
+ try {
618
+ lines = readFileSync(path, "utf8").split("\n");
619
+ } catch {
620
+ return undefined;
621
+ }
622
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
623
+ if (!lines[index].trim()) continue;
624
+ try {
625
+ const event: unknown = JSON.parse(lines[index]);
626
+ if (
627
+ event &&
628
+ typeof event === "object" &&
629
+ !Array.isArray(event) &&
630
+ typeof (event as Record<string, unknown>).type === "string"
631
+ ) {
632
+ return event as Record<string, unknown>;
633
+ }
634
+ } catch {
635
+ // A torn final line is not evidence of a newer workflow state.
636
+ }
637
+ }
638
+ return undefined;
639
+ }
640
+
641
+ function workflowRepositoryIdentity(cwd: string): string {
642
+ return realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]));
643
+ }
644
+
645
+ /**
646
+ * Recover only evidence left running by a full process restart. This is a
647
+ * startup scan, not replay: terminal and delivery events are settled, and no
648
+ * checkout or child process is touched.
649
+ */
650
+ export function recoverWorkflowStartup(
651
+ cwd: string,
652
+ liveRunIds: ReadonlySet<string> = new Set(),
653
+ ): WorkflowStartupRecord[] {
654
+ let root: string;
655
+ try {
656
+ root = workflowRepositoryIdentity(cwd);
657
+ } catch {
658
+ return [];
659
+ }
660
+ const plans = join(root, ".pi", "plans");
661
+ let entries;
662
+ try {
663
+ entries = readdirSync(plans, { withFileTypes: true, encoding: "utf8" });
664
+ } catch {
665
+ return [];
666
+ }
667
+ const records: WorkflowStartupRecord[] = [];
668
+ for (const entry of entries) {
669
+ if (!entry.isDirectory()) continue;
670
+ const runId = entry.name;
671
+ const journalPath = join(plans, runId, "run.jsonl");
672
+ let stat: ReturnType<typeof lstatSync>;
673
+ try {
674
+ stat = lstatSync(journalPath);
675
+ } catch {
676
+ continue;
677
+ }
678
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
679
+ const lastEvent = readLastValidWorkflowEvent(journalPath);
680
+ const record: WorkflowStartupRecord = {
681
+ runId,
682
+ journalPath,
683
+ lastEvent,
684
+ interrupted: false,
685
+ };
686
+ if (!lastEvent || liveRunIds.has(runId)) {
687
+ records.push(record);
688
+ continue;
689
+ }
690
+ const type = lastEvent.type;
691
+ if (typeof type !== "string" || type === "approved") {
692
+ records.push(record);
693
+ continue;
694
+ }
695
+ if (type === "delivery" || WORKFLOW_TERMINAL_EVENTS.has(type as WorkflowTerminalState)) {
696
+ records.push(record);
697
+ continue;
698
+ }
699
+ const event = {
700
+ id: randomUUID(),
701
+ type: "interrupted",
702
+ at: new Date().toISOString(),
703
+ envelope: {
704
+ runId,
705
+ state: "interrupted",
706
+ error: {
707
+ code: "process_restarted",
708
+ message: "Workflow was interrupted by a full process restart.",
709
+ },
710
+ },
711
+ };
712
+ try {
713
+ const journal = readFileSync(journalPath, "utf8");
714
+ const separator = journal.length > 0 && !journal.endsWith("\n") ? "\n" : "";
715
+ appendFileSync(journalPath, `${separator}${JSON.stringify(event)}\n`, "utf8");
716
+ record.lastEvent = event;
717
+ record.interrupted = true;
718
+ } catch {
719
+ // Preserve the journal and report its last known evidence.
720
+ }
721
+ records.push(record);
722
+ }
723
+ return records;
724
+ }
725
+
726
+ export function createWorkflowJournal(
727
+ candidate: PendingWorkflow,
728
+ approval: WorkflowApproval,
729
+ ): WorkflowJournal {
730
+ const path = join(dirname(candidate.path), "run.jsonl");
731
+ const append = (type: string, details: Record<string, unknown> = {}) => {
732
+ const id = randomUUID();
733
+ appendFileSync(
734
+ path,
735
+ `${JSON.stringify({ id, type, at: new Date().toISOString(), ...details })}\n`,
736
+ "utf8",
737
+ );
738
+ return id;
739
+ };
740
+ let fd: number | undefined;
741
+ try {
742
+ fd = openSync(path, "wx");
743
+ writeSync(
744
+ fd,
745
+ `${JSON.stringify({
746
+ id: randomUUID(),
747
+ type: "approved",
748
+ at: new Date().toISOString(),
749
+ scriptHash: candidate.scriptHash,
750
+ repository: candidate.repository,
751
+ baseSha: candidate.baseSha,
752
+ preparingSession: candidate.parentSession,
753
+ approvingUserEntryId: approval.entryId,
754
+ sources: candidate.sources,
755
+ rolePolicies: candidate.rolePolicies,
756
+ })}\n`,
757
+ );
758
+ } catch (error) {
759
+ fail(
760
+ `Cannot create workflow journal: ${error instanceof Error ? error.message : String(error)}`,
761
+ );
762
+ } finally {
763
+ if (fd !== undefined) closeSync(fd);
764
+ }
765
+ return { path, append };
766
+ }
767
+
768
+ export interface WorkflowReaderCheckout {
769
+ path: string;
770
+ status: "disposed" | "retained";
771
+ reason?: string;
772
+ }
773
+
774
+ export function createWorkflowReaderCheckout(
775
+ candidate: PendingWorkflow,
776
+ journal: WorkflowJournal,
777
+ ): string {
778
+ const path = join(dirname(candidate.path), "reader-checkout");
779
+ if (existsSync(path))
780
+ throw new Error(`Workflow reader checkout already exists: ${path}`);
781
+ let created = false;
782
+ try {
783
+ execFileSync(
784
+ "git",
785
+ ["worktree", "add", "--detach", path, candidate.baseSha],
786
+ {
787
+ cwd: candidate.repository.root,
788
+ stdio: "pipe",
789
+ },
790
+ );
791
+ created = true;
792
+ const head = git(path, ["rev-parse", "HEAD"]);
793
+ const commonDir = realpathSync(
794
+ git(path, ["rev-parse", "--path-format=absolute", "--git-common-dir"]),
795
+ );
796
+ if (
797
+ head !== candidate.baseSha ||
798
+ commonDir !== candidate.repository.commonDir
799
+ ) {
800
+ throw new Error(
801
+ "Workflow reader checkout identity does not match the approved repository and base",
802
+ );
803
+ }
804
+ journal.append("reader_checkout_ready", { path, baseSha: head, commonDir });
805
+ return path;
806
+ } catch (error) {
807
+ const reason = error instanceof Error ? error.message : String(error);
808
+ if (created) journal.append("reader_checkout_retained", { path, reason });
809
+ throw new Error(
810
+ `Cannot create approved workflow reader checkout: ${reason}`,
811
+ );
812
+ }
813
+ }
814
+
815
+ export function disposeWorkflowReaderCheckout(
816
+ candidate: PendingWorkflow,
817
+ path: string,
818
+ journal: WorkflowJournal,
819
+ ): WorkflowReaderCheckout {
820
+ try {
821
+ const status = execFileSync(
822
+ "git",
823
+ ["status", "--porcelain=v1", "--untracked-files=all"],
824
+ {
825
+ cwd: path,
826
+ encoding: "utf8",
827
+ },
828
+ );
829
+ if (status) {
830
+ journal.append("reader_checkout_retained", { path, reason: "dirty" });
831
+ return { path, status: "retained", reason: "dirty" };
832
+ }
833
+ execFileSync("git", ["worktree", "remove", path], {
834
+ cwd: candidate.repository.root,
835
+ stdio: "pipe",
836
+ });
837
+ journal.append("reader_checkout_disposed", { path });
838
+ return { path, status: "disposed" };
839
+ } catch (error) {
840
+ const reason = error instanceof Error ? error.message : String(error);
841
+ journal.append("reader_checkout_retained", { path, reason });
842
+ return { path, status: "retained", reason };
843
+ }
844
+ }
845
+
846
+ export type JsonValue =
847
+ | null
848
+ | boolean
849
+ | number
850
+ | string
851
+ | JsonValue[]
852
+ | { [key: string]: JsonValue };
853
+
854
+ export type WorkflowTerminalState =
855
+ | "completed"
856
+ | "failed"
857
+ | "cancelled"
858
+ | "interrupted";
859
+
860
+ export type WorkflowExecutionResult =
861
+ | { state: "completed"; result: JsonValue }
862
+ | { state: "failed"; error: { code: string; message: string } }
863
+ | { state: "cancelled"; error?: { code: string; message: string } }
864
+ | { state: "interrupted"; error?: { code: string; message: string } };
865
+
866
+ export type WorkflowGatePhase = "running" | "cancelling" | "terminal";
867
+
868
+ export interface WorkflowTerminalOutcome {
869
+ state: WorkflowTerminalState;
870
+ result?: JsonValue;
871
+ error?: { code: string; message: string };
872
+ }
873
+
874
+ export interface WorkflowTerminalGate {
875
+ phase: WorkflowGatePhase;
876
+ outcome?: WorkflowTerminalOutcome;
877
+ }
878
+
879
+ export function createWorkflowTerminalGate(): WorkflowTerminalGate {
880
+ return { phase: "running" };
881
+ }
882
+
883
+ export function beginWorkflowCancellation(
884
+ gate: WorkflowTerminalGate,
885
+ ): { claimed: true } | { claimed: false; outcome?: WorkflowTerminalOutcome } {
886
+ if (gate.phase === "terminal") {
887
+ if (!gate.outcome)
888
+ throw new Error("Workflow terminal gate is missing its outcome");
889
+ return { claimed: false, outcome: gate.outcome };
890
+ }
891
+ if (gate.phase === "cancelling") {
892
+ return gate.outcome
893
+ ? { claimed: false, outcome: gate.outcome }
894
+ : { claimed: false };
895
+ }
896
+ gate.phase = "cancelling";
897
+ return { claimed: true };
898
+ }
899
+
900
+ export function claimWorkflowTerminal(
901
+ gate: WorkflowTerminalGate,
902
+ outcome: WorkflowTerminalOutcome,
903
+ ): boolean {
904
+ if (gate.phase === "terminal") return false;
905
+ if (
906
+ gate.phase === "cancelling" &&
907
+ outcome.state !== "cancelled" &&
908
+ outcome.state !== "failed"
909
+ ) {
910
+ return false;
911
+ }
912
+ gate.phase = "terminal";
913
+ gate.outcome = outcome;
914
+ return true;
915
+ }
916
+
917
+ /** Map confirmed process exit evidence into the cancel terminal outcome. */
918
+ export function cancelTerminationResult(
919
+ survivingPids: readonly number[],
920
+ checkoutPath?: string,
921
+ options: { identityUnconfirmed?: boolean } = {},
922
+ ): {
923
+ outcome: WorkflowTerminalOutcome;
924
+ checkout?: WorkflowReaderCheckout;
925
+ retainCheckout: boolean;
926
+ } {
927
+ const unconfirmed =
928
+ survivingPids.length > 0 || options.identityUnconfirmed === true;
929
+ if (unconfirmed) {
930
+ const message =
931
+ survivingPids.length > 0
932
+ ? `Workflow cancellation could not confirm process exit for: ${survivingPids.join(", ")}`
933
+ : "Workflow cancellation could not confirm process exit: active pane process identity was not captured.";
934
+ return {
935
+ retainCheckout: true,
936
+ outcome: {
937
+ state: "failed",
938
+ error: {
939
+ code: "cancel_termination_failed",
940
+ message,
941
+ },
942
+ },
943
+ ...(checkoutPath
944
+ ? {
945
+ checkout: {
946
+ path: checkoutPath,
947
+ status: "retained" as const,
948
+ reason: "cancel_termination_failed",
949
+ },
950
+ }
951
+ : {}),
952
+ };
953
+ }
954
+ return {
955
+ retainCheckout: false,
956
+ outcome: {
957
+ state: "cancelled",
958
+ error: { code: "cancelled", message: "Workflow cancelled." },
959
+ },
960
+ };
961
+ }
962
+
963
+ function validateJson(
964
+ value: unknown,
965
+ seen = new Set<object>(),
966
+ ): value is JsonValue {
967
+ if (value === null || typeof value === "boolean" || typeof value === "string")
968
+ return true;
969
+ if (typeof value === "number") return Number.isFinite(value);
970
+ if (typeof value !== "object" || seen.has(value)) return false;
971
+ if (
972
+ Object.getPrototypeOf(value) !== Object.prototype &&
973
+ !Array.isArray(value)
974
+ )
975
+ return false;
976
+ seen.add(value);
977
+ const valid = Array.isArray(value)
978
+ ? Array.from(
979
+ { length: value.length },
980
+ (_, index) =>
981
+ Object.hasOwn(value, index) && validateJson(value[index], seen),
982
+ ).every(Boolean) &&
983
+ Reflect.ownKeys(value).every(
984
+ (key) =>
985
+ typeof key === "string" &&
986
+ (key === "length" ||
987
+ (/^(0|[1-9]\d*)$/.test(key) && Number(key) < value.length)),
988
+ )
989
+ : Reflect.ownKeys(value).every(
990
+ (key) =>
991
+ typeof key === "string" &&
992
+ Object.prototype.propertyIsEnumerable.call(value, key) &&
993
+ validateJson(value[key as keyof typeof value], seen),
994
+ );
995
+ seen.delete(value);
996
+ return valid;
997
+ }
998
+
999
+ export async function executeWorkflow(
1000
+ candidate: PendingWorkflow,
1001
+ options: {
1002
+ deadlineMs?: number;
1003
+ signal?: AbortSignal;
1004
+ onLog?: (message: string) => void;
1005
+ onWorker?: (worker: Worker) => void;
1006
+ onAgent?: (
1007
+ prompt: string,
1008
+ options: unknown,
1009
+ ) => JsonValue | Promise<JsonValue>;
1010
+ } = {},
1011
+ ): Promise<WorkflowExecutionResult> {
1012
+ return new Promise((resolve) => {
1013
+ const worker = new Worker(
1014
+ new URL("./workflow-worker.js", import.meta.url),
1015
+ {
1016
+ workerData: { source: candidate.bytes, filename: candidate.path },
1017
+ },
1018
+ );
1019
+ options.onWorker?.(worker);
1020
+ let settled = false;
1021
+ let logs = 0;
1022
+ let agents = 0;
1023
+ let activeAgents = 0;
1024
+ const agentQueue: Array<(forced?: JsonValue) => void> = [];
1025
+ const drainQueue = (result: JsonValue = CANCELLED_AGENT_RESULT) => {
1026
+ const queued = agentQueue.splice(0);
1027
+ for (const start of queued) start(result);
1028
+ };
1029
+ const invokeAgent = (prompt: string, agentOptions: unknown) =>
1030
+ new Promise<JsonValue>((resolveAgent) => {
1031
+ const start = (forced?: JsonValue) => {
1032
+ if (forced !== undefined) {
1033
+ resolveAgent(forced);
1034
+ return;
1035
+ }
1036
+ if (options.signal?.aborted) {
1037
+ resolveAgent(CANCELLED_AGENT_RESULT);
1038
+ return;
1039
+ }
1040
+ activeAgents += 1;
1041
+ void (async () => {
1042
+ try {
1043
+ const result = await options.onAgent?.(prompt, agentOptions);
1044
+ resolveAgent(
1045
+ result ?? {
1046
+ ok: false,
1047
+ code: "agent_unavailable",
1048
+ message:
1049
+ "Workflow agent execution is not available in this slice",
1050
+ retryable: false,
1051
+ },
1052
+ );
1053
+ } catch (error) {
1054
+ resolveAgent({
1055
+ ok: false,
1056
+ code: "workflow_agent_error",
1057
+ message: error instanceof Error ? error.message : String(error),
1058
+ retryable: false,
1059
+ });
1060
+ } finally {
1061
+ activeAgents -= 1;
1062
+ const next = agentQueue.shift();
1063
+ if (next) next();
1064
+ }
1065
+ })();
1066
+ };
1067
+ if (options.signal?.aborted) {
1068
+ resolveAgent(CANCELLED_AGENT_RESULT);
1069
+ return;
1070
+ }
1071
+ if (activeAgents < candidate.metadata.maxConcurrency) start();
1072
+ else agentQueue.push(start);
1073
+ });
1074
+ const finish = (result: WorkflowExecutionResult) => {
1075
+ if (settled) return;
1076
+ settled = true;
1077
+ clearTimeout(deadline);
1078
+ options.signal?.removeEventListener("abort", onAbort);
1079
+ drainQueue();
1080
+ void worker.terminate().finally(() => resolve(result));
1081
+ };
1082
+ const fail = (code: string, message: string) =>
1083
+ finish({ state: "failed", error: { code, message } });
1084
+ const onAbort = () =>
1085
+ finish({
1086
+ state: "cancelled",
1087
+ error: { code: "cancelled", message: "Workflow cancelled." },
1088
+ });
1089
+ const deadline = setTimeout(
1090
+ () => fail("workflow_deadline", "Workflow deadline exceeded"),
1091
+ options.deadlineMs ?? DEFAULT_DEADLINE_MS,
1092
+ );
1093
+ if (options.signal) {
1094
+ if (options.signal.aborted) {
1095
+ onAbort();
1096
+ return;
1097
+ }
1098
+ options.signal.addEventListener("abort", onAbort, { once: true });
1099
+ }
1100
+
1101
+ worker.on("message", (message: unknown) => {
1102
+ if (settled) return;
1103
+ if (!message || typeof message !== "object")
1104
+ return fail("workflow_protocol", "Invalid workflow Worker message");
1105
+ const event = message as Record<string, unknown>;
1106
+ if (event.type === "log") {
1107
+ if (typeof event.message !== "string")
1108
+ return fail("workflow_protocol", "Workflow log must be a string");
1109
+ if (++logs > MAX_LOGS || event.message.length > MAX_LOG_CHARS) {
1110
+ return fail("workflow_limit", "Workflow log limit exceeded");
1111
+ }
1112
+ try {
1113
+ options.onLog?.(event.message);
1114
+ } catch (error) {
1115
+ return fail(
1116
+ "workflow_log_error",
1117
+ error instanceof Error ? error.message : String(error),
1118
+ );
1119
+ }
1120
+ return;
1121
+ }
1122
+ if (event.type === "agent") {
1123
+ const id = typeof event.id === "string" ? event.id : "";
1124
+ const prompt = event.prompt;
1125
+ if (!id || typeof prompt !== "string")
1126
+ return fail("workflow_protocol", "Invalid workflow agent request");
1127
+ if (options.signal?.aborted) {
1128
+ worker.postMessage({
1129
+ type: "agent_result",
1130
+ id,
1131
+ result: CANCELLED_AGENT_RESULT,
1132
+ });
1133
+ return;
1134
+ }
1135
+ if (++agents > candidate.metadata.maxAgents) {
1136
+ worker.postMessage({
1137
+ type: "agent_result",
1138
+ id,
1139
+ result: {
1140
+ ok: false,
1141
+ code: "agent_limit",
1142
+ message: "Workflow agent limit exceeded.",
1143
+ retryable: false,
1144
+ },
1145
+ });
1146
+ return;
1147
+ }
1148
+ if (prompt.length > 100_000)
1149
+ return fail("workflow_limit", "Workflow prompt limit exceeded");
1150
+ void invokeAgent(prompt, event.options).then((result) => {
1151
+ if (settled) return;
1152
+ if (!validateJson(result))
1153
+ return fail(
1154
+ "workflow_agent_result_invalid",
1155
+ "Workflow agent returned a non-JSON-compatible result",
1156
+ );
1157
+ worker.postMessage({ type: "agent_result", id, result });
1158
+ });
1159
+ return;
1160
+ }
1161
+ if (event.type === "error") {
1162
+ return fail(
1163
+ "workflow_error",
1164
+ typeof event.message === "string"
1165
+ ? event.message
1166
+ : "Workflow Worker failed",
1167
+ );
1168
+ }
1169
+ if (event.type !== "result")
1170
+ return fail("workflow_protocol", "Unknown workflow Worker message");
1171
+ if (!validateJson(event.result))
1172
+ return fail(
1173
+ "workflow_result_invalid",
1174
+ "Workflow returned a non-JSON-compatible result",
1175
+ );
1176
+ const serialized = JSON.stringify(event.result);
1177
+ if (Buffer.byteLength(serialized) > MAX_RESULT_BYTES) {
1178
+ return fail("workflow_result_limit", "Workflow result exceeds 64 KiB");
1179
+ }
1180
+ finish({ state: "completed", result: event.result });
1181
+ });
1182
+ worker.once("error", (error: unknown) =>
1183
+ fail(
1184
+ "workflow_worker_error",
1185
+ error instanceof Error ? error.message : String(error),
1186
+ ),
1187
+ );
1188
+ worker.once("exit", (code: number) => {
1189
+ if (!settled)
1190
+ fail(
1191
+ "workflow_worker_exit",
1192
+ `Workflow Worker exited with code ${code}`,
1193
+ );
1194
+ });
1195
+ });
1196
+ }
1197
+
1198
+ export function formatApprovalPacket(candidate: PendingWorkflow): string {
1199
+ return [
1200
+ `Prepared workflow ${candidate.runId}; no workflow has started.`,
1201
+ `Script SHA-256: ${candidate.scriptHash}`,
1202
+ `Repository: ${candidate.repository.root}`,
1203
+ `Git common directory: ${candidate.repository.commonDir}`,
1204
+ `Base commit: ${candidate.baseSha}`,
1205
+ `Sources: ${candidate.sources.join(", ")}`,
1206
+ `Roles: ${candidate.rolePolicies.map((role) => `${role.role} (${role.model}, ${role.thinking}; ${role.tools.join(", ")})`).join("; ")}`,
1207
+ `Role policy fingerprints: ${candidate.rolePolicies.map((role) => `${role.role}=${role.fingerprint}`).join(", ")}`,
1208
+ `To execute this exact workflow once, reply: APPROVE ${candidate.scriptHash.slice(0, 8)}`,
1209
+ ].join("\n");
1210
+ }