pi-crew 0.8.13 → 0.9.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +296 -0
  2. package/README.md +118 -2
  3. package/docs/FEATURE_INTAKE.md +1 -1
  4. package/docs/HARNESS.md +20 -19
  5. package/docs/PROJECT_REVIEW.md +132 -133
  6. package/docs/PROJECT_REVIEW_FIXES.md +130 -131
  7. package/docs/actions-reference.md +127 -121
  8. package/docs/architecture.md +1 -1
  9. package/docs/code-review-2026-05-11.md +134 -134
  10. package/docs/commands-reference.md +108 -106
  11. package/docs/comparison-pi-subagents-vs-pi-crew.md +105 -105
  12. package/docs/deep-review-report.md +1 -1
  13. package/docs/dynamic-workflows.md +90 -0
  14. package/docs/fixes/BATCH_A_H1_H2.md +17 -17
  15. package/docs/fixes/bug-007-async-notifier-stale-ctx.md +23 -23
  16. package/docs/followup-plan-2026-05-12.md +135 -135
  17. package/docs/followup-review-2026-05-12.md +86 -86
  18. package/docs/followup-review-round3-2026-05-12.md +123 -123
  19. package/docs/goals.md +59 -0
  20. package/docs/implementation-plan-top3.md +4 -4
  21. package/docs/issue-29-analysis.md +2 -2
  22. package/docs/oh-my-pi-research.md +154 -154
  23. package/docs/optimization-plan.md +2 -0
  24. package/docs/perf/baseline-2026-05.md +9 -9
  25. package/docs/perf/final-report-2026-05.md +2 -2
  26. package/docs/perf/sprint-1-report.md +2 -2
  27. package/docs/perf/sprint-2-report.md +1 -1
  28. package/docs/perf/upgrade-plan-2026-05.md +72 -72
  29. package/docs/pi-crew-bugs.md +230 -230
  30. package/docs/pi-crew-investigation-report.md +102 -102
  31. package/docs/pi-crew-test-round5.md +4 -4
  32. package/docs/runtime-analysis-child-vs-live.md +57 -57
  33. package/docs/runtime-migration-in-process-analysis.md +97 -97
  34. package/install.mjs +3 -2
  35. package/package.json +2 -4
  36. package/skills/orchestration/SKILL.md +11 -11
  37. package/src/agents/agent-config.ts +4 -0
  38. package/src/config/config.ts +39 -0
  39. package/src/config/types.ts +11 -0
  40. package/src/extension/action-suggestions.ts +2 -1
  41. package/src/extension/async-notifier.ts +10 -0
  42. package/src/extension/help.ts +14 -0
  43. package/src/extension/project-init.ts +7 -20
  44. package/src/extension/registration/commands.ts +27 -0
  45. package/src/extension/team-tool/destructive-gate.ts +1 -1
  46. package/src/extension/team-tool/goal-wrap.ts +288 -0
  47. package/src/extension/team-tool/goal.ts +405 -0
  48. package/src/extension/team-tool/run.ts +103 -4
  49. package/src/extension/team-tool/workflow-manage.ts +194 -0
  50. package/src/extension/team-tool.ts +20 -0
  51. package/src/hooks/types.ts +3 -1
  52. package/src/runtime/async-runner.ts +24 -2
  53. package/src/runtime/background-runner.ts +68 -19
  54. package/src/runtime/child-pi.ts +6 -1
  55. package/src/runtime/completion-guard.ts +1 -1
  56. package/src/runtime/dynamic-workflow-context.ts +450 -0
  57. package/src/runtime/dynamic-workflow-runner.ts +180 -0
  58. package/src/runtime/global-worker-cap.ts +96 -0
  59. package/src/runtime/goal-evaluator.ts +294 -0
  60. package/src/runtime/goal-loop-runner.ts +612 -0
  61. package/src/runtime/goal-state-store.ts +209 -0
  62. package/src/runtime/pi-args.ts +10 -2
  63. package/src/runtime/result-extractor.ts +32 -0
  64. package/src/runtime/team-runner.ts +11 -1
  65. package/src/runtime/verification-gates.ts +85 -5
  66. package/src/runtime/verification-integrity.ts +110 -0
  67. package/src/runtime/verification-worktree.ts +136 -0
  68. package/src/runtime/workspace-lock.ts +448 -0
  69. package/src/schema/config-schema.ts +26 -0
  70. package/src/schema/team-tool-schema.ts +39 -4
  71. package/src/state/atomic-write.ts +9 -0
  72. package/src/state/contracts.ts +14 -0
  73. package/src/state/crew-init.ts +18 -5
  74. package/src/state/event-log.ts +7 -1
  75. package/src/state/state-store.ts +2 -0
  76. package/src/state/types.ts +82 -0
  77. package/src/state/worker-atomic-writer.ts +176 -0
  78. package/src/utils/redaction.ts +104 -24
  79. package/src/workflows/discover-workflows.ts +25 -1
  80. package/src/workflows/workflow-config.ts +13 -0
  81. package/teams/parallel-research.team.md +1 -1
  82. package/workflows/examples/hello.dwf.ts +24 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * team-tool/workflow-manage.ts — Handlers for the 5 workflow-management actions (P3).
3
+ *
4
+ * Plan: 07-PLAN.md v3 P3 + §0c C3 (destructive-gate, NOT autonomous-policy) + C5 (paths).
5
+ *
6
+ * Actions:
7
+ * - workflow-create : write a .dwf.ts from params.config.script. SECURITY: gated by
8
+ * destructive-gate.ts (confirm:true required) + path-allowlist
9
+ * (resolveRealContainedPath) + content validation. NEVER auto-invoked
10
+ * by the agent (the gate enforces this).
11
+ * - workflow-get : return a workflow's source/metadata (read-only).
12
+ * - workflow-list : list all workflows incl. runtime discriminator (extends existing list).
13
+ * - workflow-save : persist an ephemeral script as a named reusable workflow.
14
+ * - workflow-delete : remove a dynamic workflow file (confirm-gated).
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
18
+ import { dirname, join, basename } from "node:path";
19
+ import { result, type TeamContext } from "./context.ts";
20
+ import { assertSafePathId, resolveRealContainedPath } from "../../utils/safe-paths.ts";
21
+ import { projectCrewRoot, userPiRoot, packageRoot } from "../../utils/paths.ts";
22
+ import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workflows.ts";
23
+ import { logInternalError } from "../../utils/internal-error.ts";
24
+ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
25
+
26
+ /** The 3 allowed bases for dynamic-workflow scripts (§0c C5). */
27
+ function allowedWorkflowDirs(cwd: string): string[] {
28
+ // Fix round-6: align with discoverWorkflows (which reads userPiRoot/workflows, NOT
29
+ // userCrewRoot/workflows). The old userCrewRoot path silently orphaned user-scope workflows.
30
+ return [
31
+ join(projectCrewRoot(cwd), "workflows"),
32
+ join(userPiRoot(), "workflows"),
33
+ join(packageRoot(), "workflows"),
34
+ ];
35
+ }
36
+
37
+ /** Best-effort ADVISORY content check (review H-3): trivially bypassable
38
+ * (require('child'+'_process'), globalThis.process.mainModule.require, dynamic import,
39
+ * String.fromCharCode, etc.). This is NOT a security boundary — it catches only
40
+ * the most obvious accidental violations. The real boundary is the path-allowlist
41
+ * + commit-review trust model. Do NOT rely on this for security. */
42
+ const FORBIDDEN_PATTERNS = [
43
+ /require\s*\(\s*['"]child_process['"]/,
44
+ /\bprocess\.exit\s*\(/,
45
+ /import\s+.*['"]net['"]/,
46
+ /import\s+.*['"]http['"]/,
47
+ /import\s+.*['"]https['"]/,
48
+ /eval\s*\(\s*new\s+Function/,
49
+ ];
50
+
51
+ function validateScriptContent(content: string): string | undefined {
52
+ for (const pattern of FORBIDDEN_PATTERNS) {
53
+ if (pattern.test(content)) {
54
+ return `Script content matches a forbidden pattern (${pattern.source}). Dynamic workflows must not spawn processes, exit, or make network calls directly — use ctx.agent().`;
55
+ }
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /** Resolve a workflow name + scope to a safe write path inside an allowed dir. */
61
+ function resolveWorkflowWritePath(cwd: string, name: string, scope: "user" | "project" = "project"): string {
62
+ assertSafePathId("workflowName", name);
63
+ // Fix round-6: user scope must use userPiRoot/workflows (matches discovery), not userCrewRoot.
64
+ const base = scope === "user" ? join(userPiRoot(), "workflows") : join(projectCrewRoot(cwd), "workflows");
65
+ return resolveRealContainedPath(base, `${name}.dwf.ts`);
66
+ }
67
+
68
+ export function handleWorkflowCreate(params: TeamToolParamsValue, ctx: TeamContext): ReturnType<typeof result> {
69
+ // SECURITY (§0c C3): the destructive-gate.ts set enforces confirm:true BEFORE this handler
70
+ // runs (the run is blocked at the tool_call layer if confirm is missing). We re-check here
71
+ // as defense-in-depth in case the gate is bypassed.
72
+ if (params.confirm !== true) {
73
+ return result("workflow-create is a new arbitrary-code-execution surface and requires confirm:true. Add the action to DESTRUCTIVE_TEAM_ACTIONS (destructive-gate.ts) so the runtime gate enforces this.", { action: "workflow-create", status: "error" }, true);
74
+ }
75
+ const name = params.config?.name as string | undefined;
76
+ const script = params.config?.script as string | undefined;
77
+ if (!name || typeof name !== "string") {
78
+ return result("workflow-create requires config.name (the workflow name, path-safe).", { action: "workflow-create", status: "error" }, true);
79
+ }
80
+ if (!script || typeof script !== "string") {
81
+ return result("workflow-create requires config.script (the .dwf.ts source).", { action: "workflow-create", status: "error" }, true);
82
+ }
83
+ const validationError = validateScriptContent(script);
84
+ if (validationError) {
85
+ return result(`workflow-create rejected: ${validationError}`, { action: "workflow-create", status: "error" }, true);
86
+ }
87
+ try {
88
+ const scope = (params.scope === "user" ? "user" : "project") as "user" | "project";
89
+ const filePath = resolveWorkflowWritePath(ctx.cwd, name, scope);
90
+ writeFileSync(filePath, script, "utf-8");
91
+ return result(`Dynamic workflow '${name}' created at ${filePath}.\n\nIt is now runnable via: team action='run' workflow='${name}' goal='...'\n/scripts are commit-reviewed (postinstall-equivalent trust — see docs/dynamic-workflows.md).`, { action: "workflow-create", status: "ok", data: { name, filePath, scope } }, false);
92
+ } catch (error) {
93
+ const message = error instanceof Error ? error.message : String(error);
94
+ return result(`workflow-create failed: ${message}`, { action: "workflow-create", status: "error" }, true);
95
+ }
96
+ }
97
+
98
+ export function handleWorkflowGet(params: TeamToolParamsValue, ctx: TeamContext): ReturnType<typeof result> {
99
+ const name = (params.config?.name as string | undefined) ?? params.workflow;
100
+ if (!name) return result("workflow-get requires config.name or workflow.", { action: "workflow-get", status: "error" }, true);
101
+ const wf = allWorkflows(discoverWorkflows(ctx.cwd)).find((w) => w.name === name);
102
+ if (!wf) return result(`Workflow '${name}' not found.`, { action: "workflow-get", status: "error" }, true);
103
+ const isDynamic = wf.runtime === "dynamic";
104
+ let source = "(static workflow — no script source)";
105
+ if (isDynamic && wf.filePath && existsSync(wf.filePath)) {
106
+ try {
107
+ source = readFileSync(wf.filePath, "utf-8").slice(0, 8000);
108
+ } catch (error) {
109
+ logInternalError("workflow-manage.get", error, `filePath=${wf.filePath}`);
110
+ }
111
+ }
112
+ return result(
113
+ [
114
+ `Workflow: ${wf.name} [${isDynamic ? "dynamic" : "static"}]`,
115
+ ` description: ${wf.description}`,
116
+ ` source: ${wf.source}`,
117
+ ` filePath: ${wf.filePath}`,
118
+ isDynamic ? ` dynamicScript: ${wf.dynamicScript}` : ` steps: ${wf.steps.length}`,
119
+ "",
120
+ isDynamic ? "Script source:" : "",
121
+ isDynamic ? "```" : "",
122
+ isDynamic ? source : "",
123
+ isDynamic ? "```" : "",
124
+ ].filter(Boolean).join("\n"),
125
+ { action: "workflow-get", status: "ok", data: { name: wf.name, runtime: wf.runtime ?? "static", filePath: wf.filePath } },
126
+ false,
127
+ );
128
+ }
129
+
130
+ export function handleWorkflowList(params: TeamToolParamsValue, ctx: TeamContext): ReturnType<typeof result> {
131
+ const workflows = allWorkflows(discoverWorkflows(ctx.cwd));
132
+ if (workflows.length === 0) return result("No workflows found.", { action: "workflow-list", status: "ok" }, false);
133
+ const lines = workflows.map((w) => {
134
+ const tag = w.runtime === "dynamic" ? "[dynamic]" : "[static] ";
135
+ const detail = w.runtime === "dynamic" ? w.dynamicScript ?? w.filePath : `${w.steps.length} steps`;
136
+ return ` ${tag} ${w.name.padEnd(20)} ${detail}`;
137
+ });
138
+ return result(`Workflows (${workflows.length}):\n${lines.join("\n")}`, { action: "workflow-list", status: "ok", data: { count: workflows.length, workflows: workflows.map((w) => ({ name: w.name, runtime: w.runtime ?? "static" })) } }, false);
139
+ }
140
+
141
+ export function handleWorkflowSave(params: TeamToolParamsValue, ctx: TeamContext): ReturnType<typeof result> {
142
+ // H-1 (review): workflow-save writes an arbitrary .dwf.ts (ACE-equivalent) — gate it
143
+ // via destructive-gate.ts confirm:true (now in DESTRUCTIVE_TEAM_ACTIONS) + re-check here.
144
+ if (params.confirm !== true) {
145
+ return result("workflow-save writes an executable .dwf.ts and requires confirm:true (gated by destructive-gate.ts).", { action: "workflow-save", status: "error" }, true);
146
+ }
147
+ // workflow-save: persist an ephemeral run's script as a named reusable workflow.
148
+ // Reads the source from config.script (the caller provides what to save).
149
+ const name = params.config?.name as string | undefined;
150
+ const script = params.config?.script as string | undefined;
151
+ if (!name || !script) return result("workflow-save requires config.name + config.script.", { action: "workflow-save", status: "error" }, true);
152
+ const validationError = validateScriptContent(script);
153
+ if (validationError) return result(`workflow-save rejected: ${validationError}`, { action: "workflow-save", status: "error" }, true);
154
+ try {
155
+ const filePath = resolveWorkflowWritePath(ctx.cwd, name, "project");
156
+ writeFileSync(filePath, script, "utf-8");
157
+ return result(`Saved dynamic workflow '${name}' → ${filePath}.`, { action: "workflow-save", status: "ok", data: { name, filePath } }, false);
158
+ } catch (error) {
159
+ const message = error instanceof Error ? error.message : String(error);
160
+ return result(`workflow-save failed: ${message}`, { action: "workflow-save", status: "error" }, true);
161
+ }
162
+ }
163
+
164
+ export function handleWorkflowDelete(params: TeamToolParamsValue, ctx: TeamContext): ReturnType<typeof result> {
165
+ // workflow-delete is destructive (removes a file) — gated by destructive-gate.ts confirm:true.
166
+ if (params.confirm !== true) {
167
+ return result("workflow-delete requires confirm:true (gated by destructive-gate.ts).", { action: "workflow-delete", status: "error" }, true);
168
+ }
169
+ const name = (params.config?.name as string | undefined) ?? params.workflow;
170
+ if (!name) return result("workflow-delete requires config.name.", { action: "workflow-delete", status: "error" }, true);
171
+ const wf = allWorkflows(discoverWorkflows(ctx.cwd)).find((w) => w.name === name);
172
+ if (!wf) return result(`Workflow '${name}' not found.`, { action: "workflow-delete", status: "error" }, true);
173
+ if (wf.runtime !== "dynamic") return result(`Workflow '${name}' is not a dynamic workflow (only .dwf.ts files can be deleted via this action).`, { action: "workflow-delete", status: "error" }, true);
174
+ try {
175
+ assertSafePathId("workflowName", name);
176
+ // Verify the file is inside an allowed dir before deleting.
177
+ const allowed = allowedWorkflowDirs(ctx.cwd);
178
+ const contained = allowed.some((base) => {
179
+ try {
180
+ return resolveRealContainedPath(base, wf.filePath) === wf.filePath;
181
+ } catch {
182
+ return false;
183
+ }
184
+ });
185
+ if (!contained) return result(`Refusing to delete '${wf.filePath}': not inside an allowed workflows directory.`, { action: "workflow-delete", status: "error" }, true);
186
+ rmSync(wf.filePath, { force: true });
187
+ return result(`Deleted dynamic workflow '${name}' (${wf.filePath}).`, { action: "workflow-delete", status: "ok", data: { name, filePath: wf.filePath } }, false);
188
+ } catch (error) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ return result(`workflow-delete failed: ${message}`, { action: "workflow-delete", status: "error" }, true);
191
+ }
192
+ }
193
+
194
+ void dirname; // (import kept for future expansion; currently unused)
@@ -125,6 +125,14 @@ import {
125
125
  handleAutoSummarizeOn,
126
126
  handleAutoSummarizeStatus,
127
127
  } from "./team-tool/auto-summarize.ts";
128
+ import { handleGoal } from "./team-tool/goal.ts";
129
+ import {
130
+ handleWorkflowCreate,
131
+ handleWorkflowGet,
132
+ handleWorkflowList,
133
+ handleWorkflowSave,
134
+ handleWorkflowDelete,
135
+ } from "./team-tool/workflow-manage.ts";
128
136
  import {
129
137
  type CacheControlDeps,
130
138
  invalidateSnapshot,
@@ -1237,6 +1245,18 @@ export async function handleTeamTool(
1237
1245
  return handleAnchorStatus(params, ctx);
1238
1246
  }
1239
1247
  }
1248
+ case "goal":
1249
+ return handleGoal(params, ctx);
1250
+ case "workflow-create":
1251
+ return handleWorkflowCreate(params, ctx);
1252
+ case "workflow-get":
1253
+ return handleWorkflowGet(params, ctx);
1254
+ case "workflow-list":
1255
+ return handleWorkflowList(params, ctx);
1256
+ case "workflow-save":
1257
+ return handleWorkflowSave(params, ctx);
1258
+ case "workflow-delete":
1259
+ return handleWorkflowDelete(params, ctx);
1240
1260
  case "auto-summarize":
1241
1261
  case "auto_boomerang": {
1242
1262
  const subAction =
@@ -12,7 +12,9 @@ export type HookName =
12
12
  | "session_before_switch"
13
13
  | "session_after_connect"
14
14
  | "session_after_disconnect"
15
- | "run_recovery";
15
+ | "run_recovery"
16
+ | "before_goal_step"
17
+ | "before_goal_abort";
16
18
 
17
19
  /**
18
20
  * Hook exit codes inspired by claude-mem's lifecycle architecture:
@@ -115,14 +115,22 @@ export function getBackgroundRunnerCommand(
115
115
  // defaults to ~1.5GB on 64-bit systems, which combined with jiti compilation
116
116
  // and child processes can exhaust system memory.
117
117
  const memoryLimit = "--max-old-space-size=512";
118
+ // Phase 1.5 #3 (RFC 17): opt-in V8 diagnostic report on fatal error. Writes
119
+ // a report file next to the manifest when the process dies abnormally.
120
+ // Crucial for diagnosing the non-deterministic multi-step goal-wrap crash
121
+ // (commit a9f6e09) — gives us native stack, environment, and load info that
122
+ // application-level signal handlers cannot capture.
123
+ const reportFlags = process.env.PI_CREW_BG_REPORT_ON_FATAL === "1" || process.env.PI_TEAMS_BG_REPORT_ON_FATAL === "1"
124
+ ? ["--report-on-fatalerror", "--report-compact", `--report-directory=${path.dirname(runnerPath)}`]
125
+ : [];
118
126
  if (loader.kind === "jiti") {
119
127
  return {
120
- args: [memoryLimit, "--trace-uncaught", "--import", pathToFileURL(loader.path).href, runnerPath, "--cwd", cwd, "--run-id", runId],
128
+ args: [memoryLimit, ...reportFlags, "--trace-uncaught", "--import", pathToFileURL(loader.path).href, runnerPath, "--cwd", cwd, "--run-id", runId],
121
129
  loader: "jiti",
122
130
  };
123
131
  }
124
132
  return {
125
- args: [memoryLimit, "--experimental-strip-types", runnerPath, "--cwd", cwd, "--run-id", runId],
133
+ args: [memoryLimit, ...reportFlags, "--experimental-strip-types", runnerPath, "--cwd", cwd, "--run-id", runId],
126
134
  loader: "strip-types",
127
135
  };
128
136
  }
@@ -198,6 +206,20 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
198
206
  "PI_TEAMS_PI_BIN",
199
207
  "PI_TEAMS_MOCK_CHILD_PI",
200
208
  "PI_CREW_ALLOW_MOCK",
209
+ // Phase 1.5: worker-thread atomic writer opt-in (RFC 15).
210
+ "PI_CREW_WORKER_ATOMIC_WRITER",
211
+ "PI_TEAMS_WORKER_ATOMIC_WRITER",
212
+ // Phase 1.5 #1: verification env sanitization opt-in (RFC 13 §6).
213
+ "PI_CREW_VERIFICATION_SANITIZE_ENV",
214
+ "PI_TEAMS_VERIFICATION_SANITIZE_ENV",
215
+ "PI_CREW_VERIFICATION_PRESERVE_ENV",
216
+ "PI_TEAMS_VERIFICATION_PRESERVE_ENV",
217
+ // Phase 1.5 #2: verification git-worktree sandbox opt-in (RFC 16).
218
+ "PI_CREW_VERIFICATION_WORKTREE",
219
+ "PI_TEAMS_VERIFICATION_WORKTREE",
220
+ // Phase 1.5 #3: V8 diagnostic report on fatal error (RFC 17 — investigation).
221
+ "PI_CREW_BG_REPORT_ON_FATAL",
222
+ "PI_TEAMS_BG_REPORT_ON_FATAL",
201
223
  ],
202
224
  });
203
225
  // FIX: removed delete workarounds — with explicit allowlist, these vars
@@ -537,6 +537,47 @@ async function main(): Promise<void> {
537
537
  const fd = fs.openSync(manifest.eventsPath, "a");
538
538
  try { fs.fsyncSync(fd); } finally { try { fs.closeSync(fd); } catch { /* best-effort */ } }
539
539
  } catch { /* best-effort */ } // FORCE flush so we see this before death
540
+ // Fix round-4 CRITICAL: goal-loop and dynamic-workflow manifests use SYNTHETIC
541
+ // team/workflow names not in discoverTeams/discoverWorkflows. The team+workflow
542
+ // lookup below would throw "Team not found" BEFORE the runKind switch, making the
543
+ // goal feature unreachable from background. Short-circuit the new runKinds here.
544
+ process.env.PI_CREW_BACKGROUND_MODE = "1";
545
+ let earlyResult: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
546
+ let result: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
547
+ if (manifest.runKind === "goal-loop" || manifest.runKind === "dynamic-workflow") {
548
+ debugLog(`[background-runner] short-circuiting ${manifest.runKind} (synthetic team/workflow)`,
549
+ );
550
+ if (manifest.runKind === "goal-loop") {
551
+ const { runGoalLoop } = await import("./goal-loop-runner.ts");
552
+ const { GoalStore } = await import("./goal-state-store.ts");
553
+ const { discoverAgents, allAgents } = await import("../agents/discover-agents.ts");
554
+ const store = new GoalStore(manifest.cwd);
555
+ const goalState = store.load(manifest.runId);
556
+ if (!goalState) throw new Error(`runKind="goal-loop" but GoalLoopState '${manifest.runId}' not found (cwd=${manifest.cwd})`);
557
+ const goalResult = await runGoalLoop({ goalState, manifest, signal: abortController.signal, deps: { discoverAgents: (c: string) => allAgents(discoverAgents(c)) } });
558
+ // Fix P1-1 + round-6 #5: persist terminal status reflecting the goal's actual outcome,
559
+ // not a blanket 'completed'. Map goal state → manifest status.
560
+ const goalStatusToRunStatus: Record<string, TeamRunManifest["status"]> = {
561
+ achieved: "completed", max_turns: "completed", budget_exceeded: "completed",
562
+ blocked: "blocked", cancelled: "cancelled", paused: "blocked", running: "running",
563
+ };
564
+ const runStatus = goalStatusToRunStatus[goalResult.goalState.state] ?? "completed";
565
+ const finalGoalManifest: TeamRunManifest = { ...goalResult.manifest, status: runStatus, updatedAt: new Date().toISOString() };
566
+ saveRunManifest(finalGoalManifest);
567
+ earlyResult = { manifest: finalGoalManifest, tasks: goalResult.tasks };
568
+ } else {
569
+ const { runDynamicWorkflow } = await import("./dynamic-workflow-runner.ts");
570
+ const { allWorkflows, discoverWorkflows } = await import("../workflows/discover-workflows.ts");
571
+ const wf = allWorkflows(discoverWorkflows(manifest.cwd)).find((w) => w.name === manifest.workflow);
572
+ if (!wf || wf.runtime !== "dynamic" || !wf.dynamicScript) throw new Error(`runKind="dynamic-workflow" but workflow '${manifest.workflow}' is not dynamic (runId=${manifest.runId})`);
573
+ const dwfResult = await runDynamicWorkflow({ manifest, workflow: wf as import("../workflows/workflow-config.ts").DynamicWorkflowConfig, signal: abortController.signal });
574
+ saveRunManifest(dwfResult.manifest);
575
+ earlyResult = dwfResult;
576
+ }
577
+ console.log(`[background-runner] ${manifest.runKind} returned, status=${earlyResult.manifest.status}`);
578
+ result = earlyResult;
579
+ }
580
+ if (!earlyResult) {
540
581
  debugLog(`[background-runner] calling directTeamAndWorkflowFromRun`,
541
582
  );
542
583
  const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
@@ -612,24 +653,31 @@ async function main(): Promise<void> {
612
653
  // NOTE: abortController is already created above (before heartbeat/interrupt guard start)
613
654
  // so it is available here and its signal is passed through to executeTeamRun → child-pi.
614
655
 
615
- debugLog(`[background-runner] calling executeTeamRun`);
616
- let result;
656
+ debugLog(`[background-runner] dispatching runKind=${manifest.runKind ?? "team-run"}`);
617
657
  try {
618
- result = await executeTeamRun({
619
- manifest,
620
- tasks,
621
- team,
622
- workflow,
623
- agents,
624
- executeWorkers,
625
- limits: runConfig.limits,
626
- runtime,
627
- runtimeConfig: runConfig.runtime,
628
- skillOverride: manifest.skillOverride,
629
- reliability: runConfig.reliability,
630
- workspaceId: manifest.ownerSessionId ?? manifest.cwd,
631
- signal: abortController.signal,
632
- });
658
+ // Fix round-4: goal-loop/dynamic-workflow handled by the short-circuit above.
659
+ // This switch now only carries the traditional team-run path.
660
+ switch (manifest.runKind ?? "team-run") {
661
+ default: {
662
+ // Existing "team-run" path — unchanged behavior.
663
+ result = await executeTeamRun({
664
+ manifest,
665
+ tasks,
666
+ team,
667
+ workflow,
668
+ agents,
669
+ executeWorkers,
670
+ limits: runConfig.limits,
671
+ runtime,
672
+ runtimeConfig: runConfig.runtime,
673
+ skillOverride: manifest.skillOverride,
674
+ reliability: runConfig.reliability,
675
+ workspaceId: manifest.ownerSessionId ?? manifest.cwd,
676
+ signal: abortController.signal,
677
+ });
678
+ break;
679
+ }
680
+ }
633
681
  console.log(`[background-runner] executeTeamRun returned, status=${result.manifest.status}`,
634
682
  );
635
683
  } catch (execError) {
@@ -639,8 +687,9 @@ async function main(): Promise<void> {
639
687
  );
640
688
  throw execError;
641
689
  }
642
- manifest = result.manifest;
643
- tasks = result.tasks;
690
+ } // close if (!earlyResult) — team-run setup+execute done; earlyResult path skips to here
691
+ manifest = result!.manifest;
692
+ tasks = result!.tasks;
644
693
  appendEvent(manifest.eventsPath, {
645
694
  type: "async.completed",
646
695
  runId: manifest.runId,
@@ -847,7 +847,12 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
847
847
  unregisterChildProcess(child.pid);
848
848
  }
849
849
  // Build comprehensive exit error for unexpected exits
850
- const isUnexpectedExit = !childExited && !settled && !responseTimeoutHit && !abortRequested;
850
+ // Round-10 test fix: also require non-zero exit code OR a known abnormal condition.
851
+ // Previously fired "exited unexpectedly" on every clean exit (code=0) because the
852
+ // OS-level 'exit' event fires BEFORE pi's 'agent_end' JSON event reaches the line
853
+ // observer (race). Worker actually succeeded but onLifecycleEvent reported an error.
854
+ const abnormalExit = code !== 0 && code !== null;
855
+ const isUnexpectedExit = !childExited && !settled && !responseTimeoutHit && !abortRequested && abnormalExit;
851
856
  const exitError = isUnexpectedExit
852
857
  ? new Error(
853
858
  `Child Pi process exited unexpectedly (code=${code ?? "null"} signal=${signal ?? "null"}). `
@@ -55,7 +55,7 @@ function isMutatingTool(tool: string, args: unknown): boolean {
55
55
  return false;
56
56
  }
57
57
 
58
- function collectToolCallsFromEvent(event: unknown): Array<{ tool: string; args?: unknown }> {
58
+ export function collectToolCallsFromEvent(event: unknown): Array<{ tool: string; args?: unknown }> {
59
59
  const record = asRecord(event);
60
60
  if (!record) return [];
61
61
  const calls: Array<{ tool: string; args?: unknown }> = [];