pi-crew 0.8.14 → 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 (80) hide show
  1. package/CHANGELOG.md +271 -0
  2. package/README.md +112 -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/package.json +2 -4
  35. package/skills/orchestration/SKILL.md +11 -11
  36. package/src/agents/agent-config.ts +4 -0
  37. package/src/config/config.ts +39 -0
  38. package/src/config/types.ts +11 -0
  39. package/src/extension/action-suggestions.ts +2 -1
  40. package/src/extension/async-notifier.ts +10 -0
  41. package/src/extension/help.ts +14 -0
  42. package/src/extension/registration/commands.ts +27 -0
  43. package/src/extension/team-tool/destructive-gate.ts +1 -1
  44. package/src/extension/team-tool/goal-wrap.ts +288 -0
  45. package/src/extension/team-tool/goal.ts +405 -0
  46. package/src/extension/team-tool/run.ts +103 -4
  47. package/src/extension/team-tool/workflow-manage.ts +194 -0
  48. package/src/extension/team-tool.ts +20 -0
  49. package/src/hooks/types.ts +3 -1
  50. package/src/runtime/async-runner.ts +24 -2
  51. package/src/runtime/background-runner.ts +68 -19
  52. package/src/runtime/child-pi.ts +6 -1
  53. package/src/runtime/completion-guard.ts +1 -1
  54. package/src/runtime/dynamic-workflow-context.ts +450 -0
  55. package/src/runtime/dynamic-workflow-runner.ts +180 -0
  56. package/src/runtime/global-worker-cap.ts +96 -0
  57. package/src/runtime/goal-evaluator.ts +294 -0
  58. package/src/runtime/goal-loop-runner.ts +612 -0
  59. package/src/runtime/goal-state-store.ts +209 -0
  60. package/src/runtime/pi-args.ts +10 -2
  61. package/src/runtime/result-extractor.ts +32 -0
  62. package/src/runtime/team-runner.ts +11 -1
  63. package/src/runtime/verification-gates.ts +85 -5
  64. package/src/runtime/verification-integrity.ts +110 -0
  65. package/src/runtime/verification-worktree.ts +136 -0
  66. package/src/runtime/workspace-lock.ts +448 -0
  67. package/src/schema/config-schema.ts +26 -0
  68. package/src/schema/team-tool-schema.ts +39 -4
  69. package/src/state/atomic-write.ts +9 -0
  70. package/src/state/contracts.ts +14 -0
  71. package/src/state/crew-init.ts +18 -5
  72. package/src/state/event-log.ts +7 -1
  73. package/src/state/state-store.ts +2 -0
  74. package/src/state/types.ts +82 -0
  75. package/src/state/worker-atomic-writer.ts +176 -0
  76. package/src/utils/redaction.ts +104 -24
  77. package/src/workflows/discover-workflows.ts +25 -1
  78. package/src/workflows/workflow-config.ts +13 -0
  79. package/teams/parallel-research.team.md +1 -1
  80. package/workflows/examples/hello.dwf.ts +24 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * team-tool/goal.ts — Handler for `team action='goal'` (P0 skeleton).
3
+ *
4
+ * Sub-actions (anchor sub-action pattern, team-tool.ts:1224):
5
+ * - start : create GoalLoopState + spawn background goal-loop process
6
+ * - status : show objective/state/turn/budget/last verdict
7
+ * - pause : cooperative pause (flip GoalLoopState.state)
8
+ * - resume : cooperative resume (re-spawn next turn) — P0: status-only stub
9
+ * - stop : cooperative cancel + handleCancel(currentRunId)
10
+ * - step : run exactly one more turn (debug) — P0: status-only stub
11
+ * - clear : remove GoalLoopState file
12
+ *
13
+ * Plan: 07-PLAN.md v3 §0c C10 (assertSafePathId, evaluatorModel required, ≤4000 chars),
14
+ * §0c C11 (control path: cooperative flag + handleCancel(currentRunId) via abortOwned).
15
+ */
16
+
17
+ import { result, type TeamContext } from "./context.ts";
18
+ import { createRunPaths, saveRunManifest } from "../../state/state-store.ts";
19
+ import { appendEvent } from "../../state/event-log.ts";
20
+ import { spawnBackgroundTeamRun } from "../../subagents/async-entry.ts";
21
+ import { GoalStore } from "../../runtime/goal-state-store.ts";
22
+ import { logInternalError } from "../../utils/internal-error.ts";
23
+ import { snapshotManifests } from "../../runtime/verification-integrity.ts";
24
+ import { acquireWorkspaceLock, isWorkspaceBusy, type WorkspaceLockHandle } from "../../runtime/workspace-lock.ts";
25
+ import type { GoalLoopState, GoalLoopStatus, TeamRunManifest } from "../../state/types.ts";
26
+ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
27
+
28
+ const MAX_GOAL_OBJECTIVE_CHARS = 4000;
29
+
30
+ interface GoalSubActionInput {
31
+ params: TeamToolParamsValue;
32
+ ctx: TeamContext;
33
+ store: GoalStore;
34
+ }
35
+
36
+ /** Extract a clean objective string from params (goalObjective || goal || task), validated. */
37
+ function readObjective(params: TeamToolParamsValue): string {
38
+ const raw = (params.config?.objective as string | undefined) ?? params.goal ?? params.task ?? "";
39
+ const objective = typeof raw === "string" ? raw.trim() : "";
40
+ if (!objective) {
41
+ throw new Error("`goal start` requires a non-empty objective (config.objective, goal, or task).");
42
+ }
43
+ if (objective.length > MAX_GOAL_OBJECTIVE_CHARS) {
44
+ throw new Error(`Objective too long: ${objective.length} > ${MAX_GOAL_OBJECTIVE_CHARS} chars (Claude/Codex parity).`);
45
+ }
46
+ return objective;
47
+ }
48
+
49
+ /** Validate evaluatorModel is set (required per §0c C10 — no silent default). */
50
+ function readEvaluatorModel(params: TeamToolParamsValue): string {
51
+ const model = (params.config?.evaluatorModel as string | undefined) ?? (params.config?.model as string | undefined) ?? params.model;
52
+ if (!model || typeof model !== "string") {
53
+ throw new Error("`goal start` requires config.evaluatorModel (the goal-judge model). No silent default.");
54
+ }
55
+ return model;
56
+ }
57
+
58
+ function formatGoalStatus(goal: GoalLoopState): string {
59
+ const last = goal.verdicts[goal.verdicts.length - 1];
60
+ const budgetPct = goal.budgetTotal ? `${Math.round((goal.budgetUsed / goal.budgetTotal) * 100)}%` : "n/a";
61
+ return [
62
+ `Goal ${goal.goalId} [${goal.state}]`,
63
+ ` objective: ${goal.objective.slice(0, 200)}${goal.objective.length > 200 ? "…" : ""}`,
64
+ ` turn: ${goal.turnsUsed}/${goal.maxTurns} budget: ${goal.budgetUsed}/${goal.budgetTotal ?? "∞"} (${budgetPct})`,
65
+ ` evaluator: ${goal.evaluatorModel} worker: ${goal.workerAgent ?? "executor"}`,
66
+ last ? ` last verdict (turn ${last.turn}): ${last.achieved ? "ACHIEVED" : "not-achieved"} — ${last.reason.slice(0, 300)}` : " (no verdicts yet)",
67
+ goal.nextTurnFeedback ? ` next-turn feedback: ${goal.nextTurnFeedback.slice(0, 200)}` : "",
68
+ goal.currentRunId ? ` current turn runId: ${goal.currentRunId}` : "",
69
+ ].filter(Boolean).join("\n");
70
+ }
71
+
72
+ /** `goal start` — create state + spawn background process (P0: writes state; spawn wiring TBD per host). */
73
+ async function handleStart(input: GoalSubActionInput): Promise<ReturnType<typeof result>> {
74
+ const { params, ctx, store } = input;
75
+ try {
76
+ const objective = readObjective(params);
77
+ const evaluatorModel = readEvaluatorModel(params);
78
+ const cwd = ctx.cwd;
79
+ const goalId = store.createGoalId();
80
+ const ownerSessionId = ctx.sessionId ?? "unknown";
81
+ const now = new Date().toISOString();
82
+ const maxTurns = typeof params.config?.maxTurns === "number" && params.config.maxTurns > 0
83
+ ? params.config.maxTurns
84
+ : 20; // Claude/Codex parity default
85
+ // P1d (RFC v0.5 §P1d): budget is REQUIRED. Either an explicit budgetTotal (>=1000, enforced
86
+ // by schema) OR an explicit budgetUnlimited:true opt-out (audit-logged). No silent unbounded
87
+ // default — without a cap the loop could spend unboundedly across many turns × workers.
88
+ const budgetUnlimited = params.config?.budgetUnlimited === true;
89
+ const hasBudgetTotal = typeof params.budgetTotal === "number" && params.budgetTotal >= 1000;
90
+ if (!budgetUnlimited && !hasBudgetTotal) {
91
+ throw new Error("`goal start` requires either config.budgetTotal (>=1000, schema-enforced) OR config.budgetUnlimited:true (audit-logged opt-out). No silent unbounded-spend default.");
92
+ }
93
+ // Cold-review #2 nit: reject the mutually-exclusive combination. If both are set the
94
+ // runner silently lets budgetUnlimited win, surprising the user.
95
+ if (budgetUnlimited && hasBudgetTotal) {
96
+ throw new Error("`goal start`: config.budgetTotal and config.budgetUnlimited are mutually exclusive. Set exactly one.");
97
+ }
98
+ // P1g (cold-review #2 BLOCKING fix): pre-check the workspace lock BEFORE spawning. If another
99
+ // goal already owns this cwd's lock, fail-fast with a clear error (the runner would otherwise
100
+ // queue silently inside the background process). isWorkspaceBusy peeks the lockfile without
101
+ // acquiring. The authoritative acquisition happens in runGoalLoop.
102
+ const busyOwner = isWorkspaceBusy(cwd);
103
+ if (busyOwner) {
104
+ throw new Error(`Workspace '${cwd}' is already locked by goal '${busyOwner}'. Concurrent goals on the same single-workspace cwd are serialized to prevent edit clobbering. Stop the other goal first, or use a separate workspace.`);
105
+ }
106
+ const verification = params.config?.verification as { commands: string[]; allowManualEvidence?: boolean; mode?: string } | undefined;
107
+ const isTextOnly = verification?.mode === "text-only";
108
+ // P1a (RFC v0.5 §P1a): take manifest-integrity snapshot at start IF verification.commands
109
+ // declared. For text-only mode (no objective oracle), mark "none-text-only" explicitly so
110
+ // the runner knows no snapshot guard applies. No auto-detect (B2: auto-detect is a confused
111
+ // deputy — the user MUST declare verification explicitly).
112
+ let verificationIntegrity: import("../../state/types.ts").GoalLoopState["verificationIntegrity"];
113
+ if (isTextOnly || !verification?.commands?.length) {
114
+ verificationIntegrity = "none-text-only";
115
+ } else {
116
+ try {
117
+ const snap = snapshotManifests(cwd);
118
+ verificationIntegrity = { snapshot: snap, takenAt: now };
119
+ } catch (error) {
120
+ logInternalError("goal.start.integritySnapshot", error, `goalId=${goalId}`);
121
+ // Non-fatal: proceed without integrity guard (downgraded to text-only behavior).
122
+ verificationIntegrity = "none-text-only";
123
+ }
124
+ }
125
+ const goalState: import("../../state/types.ts").GoalLoopState = {
126
+ goalId,
127
+ ownerSessionId,
128
+ objective,
129
+ scope: typeof params.config?.scope === "string" ? params.config.scope : undefined,
130
+ verification,
131
+ state: "running",
132
+ maxTurns,
133
+ turnsUsed: 0,
134
+ budgetTotal: hasBudgetTotal ? (params.budgetTotal as number) : undefined,
135
+ budgetUnlimited: budgetUnlimited || undefined,
136
+ budgetWarning: typeof params.budgetWarning === "number" ? params.budgetWarning : 0.8,
137
+ budgetAbort: typeof params.budgetAbort === "number" ? params.budgetAbort : 0.95,
138
+ budgetUsed: 0,
139
+ verificationIntegrity,
140
+ evaluatorModel,
141
+ workerModel: typeof params.model === "string" ? params.model : undefined,
142
+ workerAgent: typeof params.config?.workerAgent === "string" ? params.config.workerAgent : undefined,
143
+ team: typeof params.team === "string" ? params.team : undefined,
144
+ cwd,
145
+ verdicts: [],
146
+ history: [],
147
+ createdAt: now,
148
+ updatedAt: now,
149
+ };
150
+ store.save(goalState);
151
+
152
+ // ── Spawn the background goal-loop process (Fix P0-1 v2) ────────────────
153
+ // Convention: the goal-loop manifest's runId IS the goalId, so background-runner's
154
+ // `case "goal-loop"` can load GoalLoopState via `store.load(manifest.runId)`.
155
+ // Fix P0-1: build paths via createRunPaths(cwd, goalId) so ALL paths
156
+ // (stateRoot/artifactsRoot/eventsPath/tasksPath) are consistent with runId=goalId.
157
+ // The previous fix overrode runId AFTER createRunManifest (which used a random id),
158
+ // so the manifest was written to runs/<randomId>/ but background-runner looked for
159
+ // runs/<goalId>/ → "Run not found" → silent death. (Review #2 F1.)
160
+ const paths = createRunPaths(cwd, goalId);
161
+ const now2 = new Date().toISOString();
162
+ const goalLoopManifest: TeamRunManifest = {
163
+ schemaVersion: 1,
164
+ runId: goalId, // paths.runId === goalId by construction
165
+ sessionId: ownerSessionId,
166
+ team: `goal-${goalId}`,
167
+ workflow: "goal-loop",
168
+ goal: objective,
169
+ status: "queued",
170
+ workspaceMode: "single",
171
+ createdAt: now2,
172
+ updatedAt: now2,
173
+ cwd,
174
+ stateRoot: paths.stateRoot,
175
+ artifactsRoot: paths.artifactsRoot,
176
+ tasksPath: paths.tasksPath,
177
+ eventsPath: paths.eventsPath,
178
+ artifacts: [],
179
+ ownerSessionId,
180
+ runKind: "goal-loop",
181
+ };
182
+ saveRunManifest(goalLoopManifest);
183
+ appendEvent(paths.eventsPath, { type: "goal.loop_start", runId: goalId, data: { goalId, objective, maxTurns, statePath: `${cwd}/.crew/state/goals/${goalId}.json` } });
184
+
185
+ try {
186
+ const spawned = await spawnBackgroundTeamRun(goalLoopManifest);
187
+ const pid = spawned.pid ?? 0;
188
+ const withAsync = { ...goalState, async: { pid, logPath: spawned.logPath, spawnedAt: new Date().toISOString() } };
189
+ store.save(withAsync);
190
+ return result(
191
+ `Goal loop started (background pid=${pid}).\n${formatGoalStatus(withAsync)}\n\nNext: \`team action='goal' config.subAction='status' config.goalId='${goalId}'\`. The loop runs up to ${maxTurns} turns, judging each against the objective. Log: ${spawned.logPath}`,
192
+ { action: "goal", status: "ok", data: { goalId, state: goalState.state, maxTurns, pid } },
193
+ false,
194
+ );
195
+ } catch (spawnError) {
196
+ const message = spawnError instanceof Error ? spawnError.message : String(spawnError);
197
+ store.setStatus(goalId, "blocked", paths.eventsPath);
198
+ return result(`goal start: state saved but background spawn failed: ${message}`, { action: "goal", status: "error", data: { goalId } }, true);
199
+ }
200
+ } catch (error) {
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ return result(`goal start failed: ${message}`, { action: "goal", status: "error" }, true);
203
+ }
204
+ }
205
+
206
+ /** `goal status` — show current state (and list goals if no goalId). */
207
+ function handleStatus(input: GoalSubActionInput): ReturnType<typeof result> {
208
+ const { params, store } = input;
209
+ const goalId = (params.config?.goalId as string | undefined) ?? (params.config?.goalId as string | undefined);
210
+ if (goalId) {
211
+ const goal = store.load(goalId);
212
+ if (!goal) return result(`Goal '${goalId}' not found.`, { action: "goal", status: "error" }, true);
213
+ return result(formatGoalStatus(goal), { action: "goal", status: "ok", data: { goalId, state: goal.state, turnsUsed: goal.turnsUsed } }, false);
214
+ }
215
+ const goals = store.list();
216
+ if (goals.length === 0) return result("No goals found. Start one with `team action='goal' config.subAction='start' config.objective='...' config.evaluatorModel='...'`.", { action: "goal", status: "ok" }, false);
217
+ return result(`Goals (${goals.length}):\n\n${goals.map(formatGoalStatus).join("\n\n")}`, { action: "goal", status: "ok", data: { count: goals.length } }, false);
218
+ }
219
+
220
+ /** Cooperative pause/resume/stop/clear — flip GoalLoopState.state. */
221
+ function assertGoalOwnership(goal: GoalLoopState, ctx: TeamContext, action: string): ReturnType<typeof result> | undefined {
222
+ // Fix round-6 A01: goal sub-actions must check session ownership (like handleCancel's abortOwned).
223
+ // status (read-only) is allowed for any session; mutating actions require ownership or force.
224
+ const owner = goal.ownerSessionId;
225
+ const current = ctx.sessionId;
226
+ // Fix round-7: ownership check must fire for running AND paused goals (paused still has
227
+ // an in-flight turn potentially). Previously only 'running' was gated — a paused goal
228
+ // with an active currentRunId could be stop/cancelled by a foreign session without force.
229
+ if (owner && current && owner !== current && (goal.state === "running" || goal.state === "paused")) {
230
+ return result(`Goal '${goal.goalId}' belongs to session '${owner}' (you are '${current}') and is still running. Use force:true to override.`, { action, status: "error", data: { goalId: goal.goalId, ownerSessionId: owner } }, true);
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ function handleStateFlip(input: GoalSubActionInput, nextState: GoalLoopStatus, label: string): ReturnType<typeof result> {
236
+ const { params, ctx, store } = input;
237
+ const goalId = params.config?.goalId as string | undefined;
238
+ if (!goalId) return result(`${label} requires config.goalId.`, { action: "goal", status: "error" }, true);
239
+ const existing = store.load(goalId);
240
+ if (!existing) return result(`Goal '${goalId}' not found.`, { action: "goal", status: "error" }, true);
241
+ if (params.force !== true) {
242
+ const denied = assertGoalOwnership(existing, ctx, "goal");
243
+ if (denied) return denied;
244
+ }
245
+ const eventsPath = createRunPaths(ctx.cwd, goalId).eventsPath;
246
+ const updated = store.setStatus(goalId, nextState, eventsPath);
247
+ if (!updated) return result(`Goal '${goalId}' not found.`, { action: "goal", status: "error" }, true);
248
+ return result(`Goal ${goalId} ${label} (state='${updated.state}').`, { action: "goal", status: "ok", data: { goalId, state: updated.state } }, false);
249
+ }
250
+
251
+ /**
252
+ * `goal stop`/`cancel`/`clear`/`reset` — cooperative flag + cancel the in-flight turn (H-5).
253
+ * Flips GoalLoopState.state='cancelled' so the loop exits at the next turn boundary,
254
+ * AND calls handleCancel(currentRunId) to kill a running turn immediately. §0c C11.
255
+ */
256
+ async function handleStop(input: GoalSubActionInput): Promise<ReturnType<typeof result>> {
257
+ const { params, ctx, store } = input;
258
+ const goalId = params.config?.goalId as string | undefined;
259
+ if (!goalId) return result("stop requires config.goalId.", { action: "goal", status: "error" }, true);
260
+ const eventsPath = createRunPaths(ctx.cwd, goalId).eventsPath;
261
+ const before = store.load(goalId);
262
+ if (!before) return result(`Goal '${goalId}' not found.`, { action: "goal", status: "error" }, true);
263
+ if (params.force !== true) {
264
+ const denied = assertGoalOwnership(before, ctx, "goal");
265
+ if (denied) return denied;
266
+ }
267
+ const updated = store.setStatus(goalId, "cancelled", eventsPath)!;
268
+ // If a turn is mid-flight, cancel it now (not just at the next turn boundary).
269
+ let cancelMsg = "";
270
+ if (updated.currentRunId) {
271
+ try {
272
+ const { handleCancel } = await import("./cancel.ts");
273
+ const cancelResult = await handleCancel({ action: "cancel", runId: updated.currentRunId, force: true, config: { intent: "user requested goal stop" } }, ctx);
274
+ cancelMsg = ` In-flight turn ${updated.currentRunId} cancel: ${(cancelResult.content[0] as { text?: string } | undefined)?.text ?? "ok"}.`;
275
+ } catch (error) {
276
+ cancelMsg = ` (in-flight turn ${updated.currentRunId} cancel failed: ${error instanceof Error ? error.message : String(error)}; the loop will still exit at the next turn boundary.)`;
277
+ }
278
+ }
279
+ return result(`Goal ${goalId} stopped (state='cancelled').${cancelMsg}`, { action: "goal", status: "ok", data: { goalId, state: "cancelled", cancelledRunId: updated.currentRunId } }, false);
280
+ }
281
+
282
+ /**
283
+ * `goal resume` (P1b, RFC v0.5 §P1b): promote from P0 stub to real handler.
284
+ * Resumes a paused OR stuck goal via CAS (state -> "running") + injects the user's
285
+ * optional hint into nextTurnFeedback + re-spawns the background loop.
286
+ *
287
+ * No double-turn-execution: the loop is single-threaded per goal, and any in-flight
288
+ * turn (from before the pause) completes normally; nextTurnFeedback is read at turn
289
+ * N+1's composeGoalPrompt, so the hint applies to N+1, not the in-flight turn.
290
+ *
291
+ * `goal start`'s workspace-lock ownership is reused: a resumed goal does NOT re-acquire
292
+ * the lock (the lock was held for the goal's lifetime at start). If the original goal
293
+ * had released its lock (e.g. it crashed and the lock was reclaimed), the resumed
294
+ * loop will fail-fast at the first worker turn that needs it — surfaced via events.
295
+ */
296
+ async function handleResume(input: GoalSubActionInput): Promise<ReturnType<typeof result>> {
297
+ const { params, ctx, store } = input;
298
+ const goalId = params.config?.goalId as string | undefined;
299
+ if (!goalId) return result("resume requires config.goalId.", { action: "goal", status: "error" }, true);
300
+ const existing = store.load(goalId);
301
+ if (!existing) return result(`Goal '${goalId}' not found.`, { action: "goal", status: "error" }, true);
302
+ if (params.force !== true) {
303
+ const denied = assertGoalOwnership(existing, ctx, "goal");
304
+ if (denied) return denied;
305
+ }
306
+ // Only paused/stuck goals are resumable. (running = already running; terminal states
307
+ // achieved/max_turns/blocked/cancelled/budget_exceeded are done.)
308
+ if (existing.state !== "paused" && existing.state !== "stuck") {
309
+ return result(`Goal '${goalId}' is in state '${existing.state}' — only 'paused' or 'stuck' goals can be resumed.`, { action: "goal", status: "error", data: { goalId, state: existing.state } }, true);
310
+ }
311
+ const hint = typeof params.config?.hint === "string" ? params.config.hint.trim() : undefined;
312
+ const eventsPath = createRunPaths(ctx.cwd, goalId).eventsPath;
313
+ // CAS: only resume if the state is still what we loaded. A concurrent stop/cancel wins.
314
+ const updated = store.compareAndSetStatus(goalId, existing.state, "running", eventsPath);
315
+ if (!updated) {
316
+ return result(`Goal '${goalId}' state changed concurrently (resume aborted; another actor won the race).`, { action: "goal", status: "error", data: { goalId } }, true);
317
+ }
318
+ // Inject the hint as next-turn feedback (applies to turn N+1's worker prompt).
319
+ let withHint = updated;
320
+ if (hint) {
321
+ withHint = store.patch(goalId, { nextTurnFeedback: hint }, eventsPath) ?? updated;
322
+ }
323
+ appendEvent(eventsPath, { type: "goal.resumed", runId: goalId, data: { goalId, fromState: existing.state, hint: hint?.slice(0, 200) } });
324
+ // Re-spawn the background loop. The loop checks goal.state === "running" before each
325
+ // turn; since we just set it to running, it proceeds.
326
+ try {
327
+ const manifest: TeamRunManifest = {
328
+ schemaVersion: 1,
329
+ runId: goalId,
330
+ sessionId: existing.ownerSessionId,
331
+ team: `goal-${goalId}`,
332
+ workflow: "goal-loop",
333
+ goal: existing.objective,
334
+ status: "queued",
335
+ workspaceMode: "single",
336
+ createdAt: existing.createdAt,
337
+ updatedAt: new Date().toISOString(),
338
+ cwd: existing.cwd,
339
+ stateRoot: createRunPaths(existing.cwd, goalId).stateRoot,
340
+ artifactsRoot: createRunPaths(existing.cwd, goalId).artifactsRoot,
341
+ tasksPath: createRunPaths(existing.cwd, goalId).tasksPath,
342
+ eventsPath,
343
+ artifacts: [],
344
+ ownerSessionId: existing.ownerSessionId,
345
+ runKind: "goal-loop",
346
+ };
347
+ const spawned = await spawnBackgroundTeamRun(manifest);
348
+ return result(`Goal ${goalId} resumed from '${existing.state}' (background pid=${spawned.pid ?? 0}).${hint ? ` Hint injected for next turn.` : ""}`, { action: "goal", status: "ok", data: { goalId, state: "running", fromState: existing.state, pid: spawned.pid } }, false);
349
+ } catch (spawnError) {
350
+ const msg = spawnError instanceof Error ? spawnError.message : String(spawnError);
351
+ // Cold-review #2 nit: roll back to the PRIOR state (paused/stuck) so the user can retry
352
+ // 'goal resume' (which requires paused/stuck). Leaving it at 'running' with no process made
353
+ // the goal un-resumable — the user had to pause-then-resume as a workaround.
354
+ store.compareAndSetStatus(goalId, "running", existing.state, eventsPath);
355
+ appendEvent(eventsPath, { type: "goal.resume_spawn_failed", runId: goalId, data: { goalId, error: msg, rolledBackTo: existing.state } });
356
+ return result(`Goal ${goalId} background re-spawn failed: ${msg}. State rolled back to '${existing.state}'. Retry 'goal resume' to re-attempt.`, { action: "goal", status: "error", data: { goalId, spawnFailed: true, state: existing.state } }, true);
357
+ }
358
+ }
359
+
360
+ /** `team action='goal'` dispatch. */
361
+ export async function handleGoal(params: TeamToolParamsValue, ctx: TeamContext): Promise<ReturnType<typeof result>> {
362
+ const store = new GoalStore(ctx.cwd);
363
+ const subAction = typeof params.config?.subAction === "string" ? params.config.subAction : "status";
364
+ const input: GoalSubActionInput = { params, ctx, store };
365
+ switch (subAction) {
366
+ case "start":
367
+ return handleStart(input);
368
+ case "status":
369
+ return handleStatus(input);
370
+ case "pause":
371
+ return handleStateFlip(input, "paused", "paused");
372
+ case "resume":
373
+ return await handleResume(input);
374
+ case "stop":
375
+ case "cancel":
376
+ case "reset":
377
+ return await handleStop(input);
378
+ case "clear": {
379
+ // Fix P1-3 + round-5 P2: remove the goal file. But refuse if the loop is still
380
+ // running (would leave a zombie background process). Require stop first.
381
+ const clearGoalId = params.config?.goalId as string | undefined;
382
+ if (!clearGoalId) return result("clear requires config.goalId.", { action: "goal", status: "error" }, true);
383
+ const existing = store.load(clearGoalId);
384
+ if (!existing) return result(`Goal '${clearGoalId}' not found (already cleared?).`, { action: "goal", status: "error" }, true);
385
+ if (params.force !== true) {
386
+ const denied = assertGoalOwnership(existing, ctx, "goal");
387
+ if (denied) return denied;
388
+ }
389
+ if (existing.state === "running" || existing.state === "paused") {
390
+ return result(`Goal '${clearGoalId}' is still ${existing.state}. Stop it first (team action='goal' subAction='stop' goalId='${clearGoalId}'), then clear.`, { action: "goal", status: "error", data: { goalId: clearGoalId, state: existing.state } }, true);
391
+ }
392
+ const removed = store.remove(clearGoalId);
393
+ if (!removed) return result(`Goal '${clearGoalId}' could not be removed.`, { action: "goal", status: "error" }, true);
394
+ return result(`Goal '${clearGoalId}' cleared (file removed).`, { action: "goal", status: "ok", data: { goalId: clearGoalId, cleared: true } }, false);
395
+ }
396
+ case "step":
397
+ // P0: step is a status-only stub — single-turn execution lands with P1.
398
+ return handleStatus(input);
399
+ default:
400
+ return result(`Unknown goal subAction '${subAction}'. Known: start, status, pause, resume, stop, step, clear.`, { action: "goal", status: "error" }, true);
401
+ }
402
+ }
403
+
404
+ // Touch logInternalError so the import is not tree-shaken in type-only builds (defensive).
405
+ void logInternalError;
@@ -46,8 +46,17 @@ import { expandParallelResearchWorkflow } from "../../runtime/parallel-research.
46
46
  * module body evaluates exactly once regardless of fanout. Same pattern as
47
47
  * runtime-warmup.ts / the v0.8.1 peer-dep latch, applied to this specific
48
48
  * dynamic-import race site.
49
+ *
50
+ * IMPORTANT: must be `var` (not `let`) — when this module is loaded via
51
+ * `jiti.import()` (the pi extension loader) wrapped in an async function,
52
+ * `let` causes a Temporal Dead Zone error because the function declaration
53
+ * below is hoisted and can be called before this `let` line executes under
54
+ * certain microtask schedules. `var` is hoisted with `undefined`, avoiding
55
+ * the TDZ. Round-11 cold review reproduction:
56
+ * `team action='run' workflow='<dynamic>'` → "Cannot access 'crewInitPromise'
57
+ * before initialization" at run.ts load. See RFC 17 + commit fixing this.
49
58
  */
50
- let crewInitPromise: Promise<typeof import("../../state/crew-init.ts")> | undefined;
59
+ var crewInitPromise: Promise<typeof import("../../state/crew-init.ts")> | undefined;
51
60
  function loadCrewInit(): Promise<typeof import("../../state/crew-init.ts")> {
52
61
  if (!crewInitPromise) {
53
62
  crewInitPromise = import("../../state/crew-init.ts");
@@ -62,6 +71,7 @@ import * as fs from "node:fs";
62
71
  import * as path from "node:path";
63
72
  import type { PiTeamsToolResult } from "../tool-result.ts";
64
73
  import { buildParentContext, result, type TeamContext } from "./context.ts";
74
+ import { isGoalWrapEnabled, shouldGoalWrap, startGoalWrappedRun } from "./goal-wrap.ts";
65
75
  import { effectiveRunConfig } from "./config-patch.ts";
66
76
 
67
77
  function tailFile(filePath: string, maxBytes = 4096): string | undefined {
@@ -186,7 +196,33 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
186
196
  steps: [{ id: "01_agent", role: params.role ?? "agent", task: "{goal}", model: params.model }],
187
197
  } : workflows.find((item) => item.name === workflowName);
188
198
  if (!baseWorkflow) return result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true);
189
- const workflow = directAgent ? baseWorkflow : expandParallelResearchWorkflow(baseWorkflow, resolvedCtx.cwd);
199
+ // LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { expandParallelResearchWorkflow }` above (issue #28, RFC 17). At call time the module body has fully evaluated, so the dynamic import returns a live binding.
200
+ const { expandParallelResearchWorkflow: expandParallelResearch } = await import("../../runtime/parallel-research.ts");
201
+ const workflow = directAgent ? baseWorkflow : expandParallelResearch(baseWorkflow, resolvedCtx.cwd);
202
+
203
+ // RFC v0.5 vision: goal-wrap. If .crew/config.json has goalWrap[workflow.name].enabled=true,
204
+ // route to a goal loop where this workflow runs as the worker turn (judge → feedback → redo
205
+ // until achieved). Only for eligible builtins (implementation, fast-fix, default). Per-workflow
206
+ // toggle; OFF by default. See goal-wrap.ts.
207
+ //
208
+ // SAFETY (commit b57bad3): multi-step workflows crash non-deterministically
209
+ // in the background goal-loop process (V8/libuv race in team-runner batch
210
+ // transition). When goal-wrap is unsafe for this workflow, we do NOT error
211
+ // out — we fall through to the normal team-run path so the user still gets
212
+ // the run they asked for. The disabled reason is logged for traceability.
213
+ if (!directAgent && workflow.source === "builtin" && isGoalWrapEnabled(resolvedCtx.cwd, workflow.name)) {
214
+ const decision = shouldGoalWrap(resolvedCtx.cwd, workflow);
215
+ if (decision.enabled) {
216
+ return await startGoalWrappedRun(params, ctx, workflow, goal);
217
+ }
218
+ // goal-wrap disabled for this workflow — fall through silently to normal
219
+ // team-run path. Log the reason so it's discoverable in events.jsonl and
220
+ // debug logs. This preserves the trace of WHY goal-wrap was bypassed for
221
+ // a given run (vs. just disappearing without explanation).
222
+ if (decision.message) {
223
+ logInternalError("team-tool.run.goalWrapBypassed", new Error(decision.message), `workflow=${workflow.name} reason=${decision.reason}`);
224
+ }
225
+ }
190
226
 
191
227
  // Check if this is a pipeline workflow - special handling for multi-stage execution
192
228
  const isPipelineWorkflow = workflowName === "pipeline" && !directAgent;
@@ -225,12 +261,16 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
225
261
  ].join("\n"), { action: "run", status: "ok" }, false);
226
262
  }
227
263
 
228
- const validationErrors = validateWorkflowForTeam(workflow, team);
264
+ // LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { validateWorkflowForTeam }` above (issue #28, RFC 17).
265
+ const { validateWorkflowForTeam: validateWorkflow } = await import("../../workflows/validate-workflow.ts");
266
+ const validationErrors = validateWorkflow(workflow, team);
229
267
  if (validationErrors.length > 0) {
230
268
  return result([`Workflow '${workflow.name}' is not valid for team '${team.name}':`, ...validationErrors.map((error) => `- ${error}`)].join("\n"), { action: "run", status: "error" }, true);
231
269
  }
232
270
 
233
- const skillOverride = normalizeSkillOverride(params.skill);
271
+ // LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { normalizeSkillOverride }` above (issue #28, RFC 17).
272
+ const { normalizeSkillOverride: normalizeSkill } = await import("../../runtime/skill-instructions.ts");
273
+ const skillOverride = normalizeSkill(params.skill);
234
274
  const { manifest, tasks, paths } = createRunManifest({
235
275
  cwd: resolvedCtx.cwd,
236
276
  team,
@@ -238,6 +278,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
238
278
  goal,
239
279
  workspaceMode: params.workspaceMode,
240
280
  ownerSessionId: ctx.sessionId,
281
+ runKind: params.runKind,
241
282
  });
242
283
  const goalArtifact = writeArtifact(paths.artifactsRoot, {
243
284
  kind: "prompt",
@@ -249,6 +290,64 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
249
290
  atomicWriteJson(paths.manifestPath, updatedManifest);
250
291
  registerActiveRun(updatedManifest);
251
292
 
293
+ // P2: dynamic-workflow dispatch — when the resolved workflow is a .dwf.ts (runtime:"dynamic"),
294
+ // run it via runDynamicWorkflow instead of the static executeTeamRun path. The script
295
+ // orchestrates subagents via ctx.agent(); only ctx.setResult() reaches the main context.
296
+ // Placed AFTER manifest creation so runId/paths/artifactsRoot are available.
297
+ if (!directAgent && (workflow as import("../../workflows/workflow-config.ts").DynamicWorkflowConfig).runtime === "dynamic") {
298
+ const { runDynamicWorkflow } = await import("../../runtime/dynamic-workflow-runner.ts");
299
+ // Re-synthesize a dynamic-team (§0c C9) for role resolution.
300
+ const dwfTeam: import("../../teams/team-config.ts").TeamConfig = {
301
+ name: `dwf-${manifest.runId.slice(-12)}`,
302
+ description: `Dynamic workflow run for ${workflow.name}`,
303
+ source: "dynamic",
304
+ filePath: "<dynamic-workflow>",
305
+ roles: [{ name: "worker", agent: params.agent ?? "executor" }],
306
+ workspaceMode: "single",
307
+ };
308
+ const dwfManifest: import("../../state/types.ts").TeamRunManifest = {
309
+ ...updatedManifest,
310
+ runKind: "dynamic-workflow",
311
+ team: dwfTeam.name,
312
+ };
313
+ atomicWriteJson(paths.manifestPath, dwfManifest);
314
+ try {
315
+ let dwfResult: import("../../runtime/dynamic-workflow-runner.ts").RunDynamicWorkflowResult | undefined;
316
+ try {
317
+ dwfResult = await runDynamicWorkflow({
318
+ manifest: dwfManifest,
319
+ workflow: workflow as import("../../workflows/workflow-config.ts").DynamicWorkflowConfig,
320
+ team: dwfTeam,
321
+ signal: ctx.signal ?? AbortSignal.timeout(3_600_000),
322
+ modelOverride: params.model,
323
+ });
324
+ } catch (runnerError) {
325
+ // Round-11 runtime fix: persist manifest with status=failed when runner throws
326
+ // (e.g., script timeout, script syntax error, async failure). Previously the
327
+ // manifest stayed at 'queued' indefinitely, leaving an orphan state file.
328
+ const failureReason = runnerError instanceof Error ? runnerError.message : String(runnerError);
329
+ const failedManifest = { ...dwfManifest, status: "failed" as const, summary: `Dynamic workflow '${workflow.name}' failed: ${failureReason}`.slice(0, 2000), updatedAt: new Date().toISOString() };
330
+ atomicWriteJson(paths.manifestPath, failedManifest);
331
+ return result(
332
+ `Dynamic workflow '${workflow.name}' failed: ${failureReason}`,
333
+ { action: "run", status: "error", runId: failedManifest.runId, artifactsRoot: failedManifest.artifactsRoot },
334
+ true,
335
+ );
336
+ }
337
+ // Round-10 runtime-test fix: persist the updated manifest with status=completed
338
+ // so status queries / cancel / cleanup see the real state. Previously run.ts
339
+ // returned the result without atomicWriteJson, leaving manifest at 'queued' forever.
340
+ atomicWriteJson(paths.manifestPath, dwfResult.manifest);
341
+ return result(
342
+ `Dynamic workflow '${workflow.name}' completed.\n${dwfResult.manifest.summary ?? ""}`,
343
+ { action: "run", status: dwfResult.manifest.status === "failed" ? "error" : "ok", runId: dwfResult.manifest.runId, artifactsRoot: dwfResult.manifest.artifactsRoot },
344
+ dwfResult.manifest.status === "failed",
345
+ );
346
+ } finally {
347
+ unregisterActiveRun(dwfManifest.runId);
348
+ }
349
+ }
350
+
252
351
  const loadedConfig = loadConfig(resolvedCtx.cwd);
253
352
  // DX (Round 16 F4): surface config errors/warnings instead of silently
254
353
  // proceeding with defaults. Non-blocking: emit a config.warning event so