pi-crew 0.9.68 → 0.10.2

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 (253) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/analyst.md +1 -1
  5. package/agents/cold-verifier.md +3 -1
  6. package/agents/critic.md +1 -1
  7. package/agents/executor.md +1 -1
  8. package/agents/explorer.md +1 -1
  9. package/agents/planner.md +1 -1
  10. package/agents/reviewer.md +1 -1
  11. package/agents/security-reviewer.md +1 -1
  12. package/agents/test-engineer.md +1 -1
  13. package/agents/verifier.md +1 -1
  14. package/agents/writer.md +1 -1
  15. package/dist/index.mjs +68113 -60774
  16. package/docs/README.md +2 -0
  17. package/docs/actions-reference.md +31 -0
  18. package/docs/commands-reference.md +17 -6
  19. package/docs/resource-formats.md +13 -0
  20. package/package.json +4 -2
  21. package/schema.json +503 -91
  22. package/scripts/resource-sampler.mjs +36 -2
  23. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  24. package/skills/widget-rendering/SKILL.md +7 -7
  25. package/src/agents/agent-config.ts +2 -1
  26. package/src/agents/discover-agents.ts +23 -14
  27. package/src/config/config-merge.ts +183 -0
  28. package/src/config/config-validation.ts +687 -0
  29. package/src/config/config.ts +22 -864
  30. package/src/config/defaults.ts +43 -2
  31. package/src/config/drift-detector.ts +1 -1
  32. package/src/config/env-vars.ts +691 -0
  33. package/src/config/role-tools.ts +11 -9
  34. package/src/config/sanitize-project-config.ts +172 -0
  35. package/src/config/types.ts +49 -1
  36. package/src/extension/async-notifier.ts +25 -2
  37. package/src/extension/crew-cleanup.ts +13 -0
  38. package/src/extension/crew-vibes/config.ts +2 -1
  39. package/src/extension/crew-vibes/footer.ts +19 -0
  40. package/src/extension/crew-vibes/index.ts +11 -1
  41. package/src/extension/plan-orchestrate.ts +132 -0
  42. package/src/extension/register.ts +8 -0
  43. package/src/extension/registration/command-registration.ts +1 -0
  44. package/src/extension/registration/commands/dashboard.ts +158 -0
  45. package/src/extension/registration/commands/index.ts +35 -0
  46. package/src/extension/registration/commands/manage.ts +303 -0
  47. package/src/extension/registration/commands/run.ts +228 -0
  48. package/src/extension/registration/commands/shared.ts +639 -0
  49. package/src/extension/registration/commands/status.ts +60 -0
  50. package/src/extension/registration/commands.ts +13 -1224
  51. package/src/extension/registration/foreground-run-controller.ts +10 -2
  52. package/src/extension/registration/lifecycle-handlers.ts +178 -17
  53. package/src/extension/registration/runtime-cleanup.ts +23 -5
  54. package/src/extension/registration/subagent-tools.ts +218 -9
  55. package/src/extension/registration/team-tool.ts +5 -1
  56. package/src/extension/registration/ui.ts +5 -4
  57. package/src/extension/rpc-hmac.ts +5 -3
  58. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  59. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  60. package/src/extension/team-tool/api/task-claims.ts +109 -40
  61. package/src/extension/team-tool/cancel.ts +84 -50
  62. package/src/extension/team-tool/dispatch/index.ts +1 -0
  63. package/src/extension/team-tool/dispatch/run.ts +4 -1
  64. package/src/extension/team-tool/doctor.ts +103 -1
  65. package/src/extension/team-tool/orchestrate.ts +66 -1
  66. package/src/extension/team-tool/plans.ts +192 -0
  67. package/src/extension/team-tool/respond.ts +197 -65
  68. package/src/extension/team-tool/run-deadline.ts +35 -3
  69. package/src/extension/team-tool/run-intent.ts +63 -0
  70. package/src/extension/team-tool/run.ts +74 -20
  71. package/src/extension/team-tool/status.ts +84 -26
  72. package/src/extension/team-tool.ts +11 -2
  73. package/src/hooks/registry.ts +1 -6
  74. package/src/i18n.ts +9 -0
  75. package/src/prompt/prompt-runtime.ts +521 -2
  76. package/src/prompt/worker-events-channel.ts +173 -0
  77. package/src/runtime/README.md +8 -8
  78. package/src/runtime/async-runner.ts +7 -3
  79. package/src/runtime/background-runner.ts +42 -14
  80. package/src/runtime/broker/broker-issuer.ts +9 -2
  81. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  82. package/src/runtime/broker/crew-broker.ts +838 -10
  83. package/src/runtime/broker/wait-status-cache.ts +157 -0
  84. package/src/runtime/budget-enforcement.ts +281 -0
  85. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  86. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  87. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  88. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  89. package/src/runtime/child-pi/child-pi.ts +97 -201
  90. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  91. package/src/runtime/crew-agent-records.ts +259 -14
  92. package/src/runtime/delegate-spawn.ts +148 -0
  93. package/src/runtime/detached-run-results.ts +90 -0
  94. package/src/runtime/deterministic-ast.ts +2 -1
  95. package/src/runtime/dispatch-batch.ts +945 -0
  96. package/src/runtime/finalize-run.ts +557 -0
  97. package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
  98. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
  99. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  100. package/src/runtime/group-join.ts +11 -125
  101. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  102. package/src/runtime/merge-gate.ts +32 -10
  103. package/src/runtime/merge-loop.ts +130 -0
  104. package/src/runtime/model/model-budget-summary.ts +53 -0
  105. package/src/runtime/model/model-fallback.ts +36 -2
  106. package/src/runtime/model/pi-args.ts +10 -0
  107. package/src/runtime/model/provider-extensions.ts +10 -0
  108. package/src/runtime/orphan-worker-registry.ts +1 -1
  109. package/src/runtime/output/output-validator.ts +45 -0
  110. package/src/runtime/parent-guard.ts +3 -1
  111. package/src/runtime/peer-dep.ts +2 -1
  112. package/src/runtime/per-write-validator.ts +0 -5
  113. package/src/runtime/pi-spawn.ts +61 -15
  114. package/src/runtime/plan-approval.ts +125 -0
  115. package/src/runtime/plan-replan.ts +151 -0
  116. package/src/runtime/process-status.ts +16 -1
  117. package/src/runtime/recovery/checkpoint.ts +0 -18
  118. package/src/runtime/recovery/crash-recovery.ts +111 -46
  119. package/src/runtime/run-tracker.ts +77 -10
  120. package/src/runtime/scheduler-context.ts +98 -0
  121. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  122. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  123. package/src/runtime/scheduling/nested-slots.ts +70 -0
  124. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  125. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  126. package/src/runtime/settings-store.ts +219 -0
  127. package/src/runtime/spawn-policy.ts +217 -0
  128. package/src/runtime/stale-reconciler.ts +87 -6
  129. package/src/runtime/subagent-manager.ts +25 -1
  130. package/src/runtime/task-output-context.ts +230 -9
  131. package/src/runtime/task-packet.ts +23 -1
  132. package/src/runtime/task-runner/child-executor.ts +106 -7
  133. package/src/runtime/task-runner/post-execution.ts +125 -1
  134. package/src/runtime/task-runner/pre-execution.ts +39 -1
  135. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  136. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  137. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  138. package/src/runtime/task-runner/state-helpers.ts +26 -24
  139. package/src/runtime/task-runner.ts +11 -0
  140. package/src/runtime/team-runner.ts +132 -1673
  141. package/src/runtime/verification/spec-sandbox.ts +255 -0
  142. package/src/runtime/verification/verification-gates.ts +3 -2
  143. package/src/runtime/verification/verification-worktree.ts +2 -1
  144. package/src/runtime/workflow-phase-advance.ts +100 -0
  145. package/src/runtime/workspace-tree.ts +9 -0
  146. package/src/schema/config-schema.ts +66 -26
  147. package/src/schema/sensitive-config-paths.ts +64 -0
  148. package/src/schema/team-tool-schema.ts +13 -3
  149. package/src/state/README.md +4 -10
  150. package/src/state/atomic-write.ts +20 -3
  151. package/src/state/contracts.ts +38 -0
  152. package/src/state/coordination/mailbox.ts +12 -2
  153. package/src/state/event-log/cursor.ts +223 -0
  154. package/src/state/event-log/event-log-rotation.ts +12 -4
  155. package/src/state/event-log/event-log.ts +152 -369
  156. package/src/state/event-log/sequence-cache.ts +373 -0
  157. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  158. package/src/state/stores/active-run-registry.ts +3 -2
  159. package/src/state/stores/manifest-io.ts +237 -0
  160. package/src/state/stores/ownership-map.ts +162 -0
  161. package/src/state/stores/plan-store.ts +241 -0
  162. package/src/state/stores/run-cache.ts +0 -90
  163. package/src/state/stores/spec-store.ts +189 -0
  164. package/src/state/stores/state-store.ts +139 -232
  165. package/src/state/types.ts +199 -0
  166. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  167. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  168. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  169. package/src/ui/dock-footer.ts +49 -0
  170. package/src/ui/heartbeat-aggregator.ts +9 -1
  171. package/src/ui/inline-panel/agent-pane.ts +375 -0
  172. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  173. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  174. package/src/ui/inline-panel/crew-editor.ts +192 -0
  175. package/src/ui/inline-panel/index.ts +290 -0
  176. package/src/ui/inline-panel/panel-rows.ts +37 -0
  177. package/src/ui/inline-panel/panel-selection.ts +157 -0
  178. package/src/ui/inline-panel/panel-store.ts +111 -0
  179. package/src/ui/inline-panel/view-session-store.ts +36 -0
  180. package/src/ui/keybinding-map.ts +54 -13
  181. package/src/ui/pi-ui-compat.ts +9 -0
  182. package/src/ui/powerbar-publisher.ts +52 -1
  183. package/src/ui/run-dashboard.ts +31 -5
  184. package/src/ui/run-snapshot-cache.ts +57 -30
  185. package/src/ui/snapshot-types.ts +6 -1
  186. package/src/ui/widget/index.ts +176 -22
  187. package/src/ui/widget/task-list.ts +198 -0
  188. package/src/ui/widget/widget-formatters.ts +240 -4
  189. package/src/ui/widget/widget-renderer.ts +243 -38
  190. package/src/ui/widget/widget-types.ts +11 -0
  191. package/src/utils/child-process-shield.ts +106 -0
  192. package/src/utils/file-coalescer.ts +0 -4
  193. package/src/utils/fs-errno.ts +66 -0
  194. package/src/utils/fs-watch.ts +1 -1
  195. package/src/utils/internal-error.ts +3 -1
  196. package/src/utils/paths.ts +11 -3
  197. package/src/utils/redaction.ts +7 -0
  198. package/src/utils/safe-abort.ts +45 -0
  199. package/src/utils/task-name-generator.ts +1 -8
  200. package/src/workflows/discover-workflows.ts +20 -2
  201. package/src/workflows/validate-workflow.ts +7 -1
  202. package/src/workflows/workflow-config.ts +17 -0
  203. package/src/workflows/workflow-serializer.ts +3 -0
  204. package/src/worktree/worktree-manager.ts +22 -0
  205. package/workflows/default.workflow.md +36 -26
  206. package/workflows/strict-fast-fix.workflow.md +26 -0
  207. package/src/agents/agent-search.ts +0 -98
  208. package/src/benchmark/benchmark-runner.ts +0 -313
  209. package/src/benchmark/feedback-loop.ts +0 -73
  210. package/src/config/resilient-parser.ts +0 -117
  211. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  212. package/src/extension/result-watcher.ts +0 -139
  213. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  214. package/src/observability/metric-retention.ts +0 -64
  215. package/src/runtime/compaction/compaction-summary.ts +0 -278
  216. package/src/runtime/errors/crew-errors.ts +0 -162
  217. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  218. package/src/runtime/loop-gates.ts +0 -128
  219. package/src/runtime/metric-parser.ts +0 -36
  220. package/src/runtime/output/stream-preview.ts +0 -184
  221. package/src/runtime/output/tool-progress.ts +0 -278
  222. package/src/runtime/phase-tracker.ts +0 -385
  223. package/src/runtime/pipeline-runner.ts +0 -523
  224. package/src/runtime/process/process-lifecycle.ts +0 -491
  225. package/src/runtime/recovery/retry-runner.ts +0 -330
  226. package/src/runtime/run-drift.ts +0 -219
  227. package/src/runtime/task-quality.ts +0 -199
  228. package/src/runtime/task-runner/run-projection.ts +0 -128
  229. package/src/runtime/verification/post-checks.ts +0 -142
  230. package/src/state/coordination/schedule.ts +0 -166
  231. package/src/state/event-log/jsonl-writer.ts +0 -115
  232. package/src/state/hook-instinct-bridge.ts +0 -94
  233. package/src/state/hook-integrations.ts +0 -51
  234. package/src/state/session-state-map.ts +0 -51
  235. package/src/state/stores/blob-store.ts +0 -308
  236. package/src/state/stores/instinct-store.ts +0 -275
  237. package/src/state/stores/observation-store.ts +0 -176
  238. package/src/state/tiered-eval.ts +0 -480
  239. package/src/state/types-eval.ts +0 -58
  240. package/src/tools/safe-bash-extension.ts +0 -54
  241. package/src/tools/safe-bash.ts +0 -505
  242. package/src/ui/agent-management-overlay.ts +0 -160
  243. package/src/ui/crew-footer.ts +0 -102
  244. package/src/ui/crew-select-list.ts +0 -114
  245. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  246. package/src/ui/transcript-entries.ts +0 -256
  247. package/src/utils/conflict-detect.ts +0 -721
  248. package/src/utils/fingerprint.ts +0 -180
  249. package/src/utils/gh-protocol.ts +0 -556
  250. package/src/utils/project-detector.ts +0 -160
  251. package/src/utils/sse-parser.ts +0 -131
  252. package/src/workflows/cost-estimator.ts +0 -34
  253. package/src/workflows/intermediate-store.ts +0 -166
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Plan-approval gate helpers.
3
+ *
4
+ * Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
5
+ * plan-approval family). Pure code motion: requiresPlanApproval /
6
+ * isPlanApprovalPending / isMutatingTask / ensurePlanApprovalRequested moved
7
+ * verbatim from team-runner.ts. cancelPlanTasks stays in team-runner.ts
8
+ * (RT-14 structural source-pin test requires it defined there).
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import type { CrewRuntimeConfig } from "../config/config.ts";
13
+ import { parsePlannerPlanOutput } from "../extension/plan-orchestrate.ts";
14
+ import { appendEvent } from "../state/event-log/event-log.ts";
15
+ import { appendPlanRevision, getCurrentPlanRecord, setPlanApproval } from "../state/stores/plan-store.ts";
16
+ import { saveRunManifestAsync } from "../state/stores/state-store.ts";
17
+ import type { PlanRecord, TeamRunManifest, TeamTaskState } from "../state/types.ts";
18
+ import type { WorkflowConfig } from "../workflows/workflow-config.ts";
19
+ import { permissionForRole } from "./role-permission.ts";
20
+
21
+ export function requiresPlanApproval(_workflow: WorkflowConfig, runtimeConfig: CrewRuntimeConfig | undefined): boolean {
22
+ // ROADMAP T1.2: plan-level HITL applies to ANY workflow when
23
+ // config.runtime.requirePlanApproval === true (not just 'implementation').
24
+ // The gate fires at the read-only → mutating (plan → execute) boundary.
25
+ return runtimeConfig?.requirePlanApproval === true;
26
+ }
27
+
28
+ /** State-level predicate shared by every render surface (widget badge,
29
+ * progress banner, powerbar segment) so gating is byte-identical to the
30
+ * action side: `required === true && status === "pending"`. A malformed
31
+ * manifest (`pending` without `required`) must NOT light up surfaces while
32
+ * keys/backends refuse — single source of truth (WP-3 review F2). */
33
+ export function isPlanApprovalStatePending(approval: TeamRunManifest["planApproval"]): boolean {
34
+ return approval?.required === true && approval.status === "pending";
35
+ }
36
+
37
+ export function isPlanApprovalPending(manifest: TeamRunManifest): boolean {
38
+ return isPlanApprovalStatePending(manifest.planApproval);
39
+ }
40
+
41
+ /**
42
+ * T2/R4 (ADR-4 §2, reader migration): plan-record-first approval predicate
43
+ * with manifest fallback. The current revision's approval state, when present,
44
+ * is authoritative; otherwise the legacy manifest gate decides. Pre-v2 runs
45
+ * (no plans.json) take exactly the old path — pinned by the migration
46
+ * negative AC in plan-store.test.ts.
47
+ */
48
+ export function isPlanApprovalPendingEffective(manifest: TeamRunManifest): boolean {
49
+ const current = getCurrentPlanRecord(manifest);
50
+ if (current?.approval) return current.approval.status === "pending";
51
+ return isPlanApprovalPending(manifest);
52
+ }
53
+
54
+ /**
55
+ * T2/R4 (ADR-4 §8 vocabulary mapping): a deny is `cancelled` on the manifest
56
+ * side and `rejected` on the record side — either means "decided negative"
57
+ * to the scheduler cancel paths (team-runner.ts readers).
58
+ */
59
+ export function isPlanApprovalDenied(manifest: TeamRunManifest): boolean {
60
+ if (manifest.planApproval?.status === "cancelled") return true;
61
+ return getCurrentPlanRecord(manifest)?.approval?.status === "rejected";
62
+ }
63
+
64
+ export function isMutatingTask(task: TeamTaskState): boolean {
65
+ return permissionForRole(task.role) !== "read_only";
66
+ }
67
+
68
+ export async function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<TeamRunManifest> {
69
+ if (manifest.planApproval) return manifest;
70
+ const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
71
+ // ROADMAP T1.2: for non-adaptive workflows, fall back to the most recent
72
+ // completed read-only (planning) task as the plan reference.
73
+ const planTask = assessTask ?? [...tasks].reverse().find((t) => t.status === "completed" && !isMutatingTask(t));
74
+ const now = new Date().toISOString();
75
+
76
+ // T2/R4 (ADR-4 §6 producer 3 + §8 dual-write): make sure a PlanRecord
77
+ // exists before the gate lights up, so approval names plan id+version.
78
+ // - Record already present (adaptive producer ran, or a re-request after
79
+ // crash between the record append and the manifest save) → reuse it.
80
+ // - No record → try the planner-tagged `<plan>` contract on the plan
81
+ // artifact; if that fails → manifest-only gate (pre-v2 behavior, the
82
+ // dual-read fallback in plan-store keeps readers correct).
83
+ let record = getCurrentPlanRecord(manifest);
84
+ if (!record && planTask?.resultArtifact?.path) {
85
+ try {
86
+ const text = fs.readFileSync(planTask.resultArtifact.path, "utf-8");
87
+ const parsed: PlanRecord | undefined = parsePlannerPlanOutput(text, manifest.runId, planTask.id);
88
+ if (parsed) record = appendPlanRevision(manifest, parsed);
89
+ } catch {
90
+ // Unreadable/unparsable artifact → manifest-only gate (below); the
91
+ // gate must still light up — never a throw here.
92
+ }
93
+ }
94
+ if (record) {
95
+ setPlanApproval(manifest, { status: "pending", planVersion: record.version });
96
+ }
97
+
98
+ const updated: TeamRunManifest = {
99
+ ...manifest,
100
+ updatedAt: now,
101
+ planApproval: {
102
+ required: true,
103
+ status: "pending",
104
+ requestedAt: now,
105
+ updatedAt: now,
106
+ planTaskId: planTask?.id,
107
+ planArtifactPath: planTask?.resultArtifact?.path,
108
+ },
109
+ plan: record ? { id: record.id, version: record.version } : manifest.plan,
110
+ };
111
+ await saveRunManifestAsync(updated);
112
+ appendEvent(updated.eventsPath, {
113
+ type: "plan.approval_required",
114
+ runId: updated.runId,
115
+ taskId: planTask?.id,
116
+ message: "Plan requires explicit approval before mutating tasks run. Use: team api op=approve-plan runId=...",
117
+ data: { planArtifactPath: planTask?.resultArtifact?.path, planId: record?.id, planVersion: record?.version },
118
+ });
119
+ return updated;
120
+ }
121
+
122
+ /** @internal 1.9(b) test export — exercise requiresPlanApproval directly. */
123
+ export const __test__requiresPlanApproval = requiresPlanApproval;
124
+ /** @internal 1.9(b) test export — exercise ensurePlanApprovalRequested directly. */
125
+ export const __test__ensurePlanApprovalRequested = ensurePlanApprovalRequested;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * T2/R4 (ADR-4 §4) — re-plan sweep: dropped-item soft-cancel.
3
+ *
4
+ * Every scheduler tick (selectDispatchBatch, after the ask-deadline sweep)
5
+ * checks the CURRENT plan revision for `dropped` items and reconciles their
6
+ * tasks:
7
+ * - QUEUED tasks are cancelled outright (never dispatched — the re-plan
8
+ * removed their reason to exist).
9
+ * - RUNNING / WAITING / NEEDS_ATTENTION tasks get a SOFT cancel: a wrap-up
10
+ * advisory appended to the worker's steering file (the same JSONL contract
11
+ * as `team steer` and the ChildPiSteeringController soft limit — advisory
12
+ * first, the existing maxTurns+grace machinery stays the hard enforcement).
13
+ * `task.replanDroppedAt` stamps the advisory as issued (exactly-once across
14
+ * ticks) and doubles as the terminal marker "cancelled-by-replan".
15
+ * - A `plan.item.dropped` event is appended per affected task.
16
+ *
17
+ * Reload+persist discipline mirrors sweepExpiredWaitingTasks: best-effort
18
+ * reload, all mutations under withRunLockSync with a fresh in-lock reload,
19
+ * save only when something changed. Never throws into the scheduler tick —
20
+ * failures are logged and the sweep returns undefined (next tick retries).
21
+ */
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { withRunLockSync } from "../state/coordination/locks.ts";
25
+ import { appendEvent } from "../state/event-log/event-log.ts";
26
+ import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
27
+ import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../state/stores/state-store.ts";
28
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
29
+ import { logInternalError } from "../utils/internal-error.ts";
30
+ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
31
+
32
+ export interface DroppedPlanSweepResult {
33
+ manifest: TeamRunManifest;
34
+ tasks: TeamTaskState[];
35
+ cancelledTaskIds: string[];
36
+ advisedTaskIds: string[];
37
+ }
38
+
39
+ const WRAP_UP_ADVISORY =
40
+ "Plan item dropped by a newer plan revision (re-plan). Wrap up immediately — provide your best final answer now; this task will not be continued.";
41
+
42
+ function appendSteeringAdvisory(manifest: TeamRunManifest, taskId: string): boolean {
43
+ try {
44
+ const steeringDir = path.join(manifest.artifactsRoot, "steering");
45
+ fs.mkdirSync(steeringDir, { recursive: true });
46
+ const safePath = resolveRealContainedPath(steeringDir, `${taskId}.jsonl`);
47
+ // Same 256KB growth cap as the steer tool (subagent-tools.ts) — refuse
48
+ // loudly rather than poisoning the next incarnation's first poll.
49
+ try {
50
+ if (fs.statSync(safePath).size > 256 * 1024) {
51
+ logInternalError("plan-replan.steer-refused-too-large", new Error("steering file over cap"), `taskId=${taskId}`);
52
+ return false;
53
+ }
54
+ } catch {
55
+ /* no file yet — fine */
56
+ }
57
+ fs.appendFileSync(
58
+ safePath,
59
+ `${JSON.stringify({ type: "steer", message: WRAP_UP_ADVISORY, ts: new Date().toISOString() })}\n`,
60
+ "utf-8",
61
+ );
62
+ return true;
63
+ } catch (error) {
64
+ logInternalError("plan-replan.steer-write-failed", error instanceof Error ? error : new Error(String(error)), `taskId=${taskId}`);
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Takes the scheduler's IN-MEMORY manifest+tasks (selectDispatchBatch already
71
+ * holds both) — NO disk load on the no-op path. The per-tick cost for runs
72
+ * without plan-linked tasks is one array scan; plans.json is only read when a
73
+ * task carries planItem, and the reload+persist below only runs when an
74
+ * affected task actually exists.
75
+ */
76
+ export function sweepDroppedPlanItems(initialManifest: TeamRunManifest, initialTasks: TeamTaskState[]): DroppedPlanSweepResult | undefined {
77
+ // Cheap exits FIRST (no I/O): no plan-linked tasks at all.
78
+ if (!initialTasks.some((t) => t.planItem)) return undefined;
79
+ const record = getCurrentPlanRecord(initialManifest);
80
+ if (!record) return undefined;
81
+ const dropped = new Set(record.items.filter((i) => i.status === "dropped").map((i) => i.id));
82
+ if (dropped.size === 0) return undefined;
83
+ if (!initialTasks.some((t) => t.planItem && dropped.has(t.planItem))) return undefined;
84
+
85
+ const cwd = initialManifest.cwd;
86
+ const runId = initialManifest.runId;
87
+ const cancelledTaskIds: string[] = [];
88
+ const advisedTaskIds: string[] = [];
89
+ try {
90
+ const outcome = withRunLockSync(initialManifest, () => {
91
+ const fresh = loadRunManifestById(cwd, runId); // in-lock consistent read
92
+ if (!fresh) return null;
93
+ const freshRecord = getCurrentPlanRecord(fresh.manifest);
94
+ if (!freshRecord) return null;
95
+ const freshDropped = new Set(freshRecord.items.filter((i) => i.status === "dropped").map((i) => i.id));
96
+ if (freshDropped.size === 0) return null;
97
+ let tasks = fresh.tasks;
98
+ let changed = false;
99
+ for (const task of fresh.tasks) {
100
+ if (!task.planItem || !freshDropped.has(task.planItem)) continue;
101
+ if (task.status === "queued") {
102
+ // Never dispatched — cancel outright.
103
+ tasks = tasks.map((t) =>
104
+ t.id === task.id
105
+ ? {
106
+ ...t,
107
+ status: "cancelled" as const,
108
+ finishedAt: new Date().toISOString(),
109
+ error: "Plan item dropped by re-plan.",
110
+ }
111
+ : t,
112
+ );
113
+ cancelledTaskIds.push(task.id);
114
+ appendEvent(fresh.manifest.eventsPath, {
115
+ type: "plan.item.dropped",
116
+ runId,
117
+ taskId: task.id,
118
+ message: `Task ${task.id} cancelled: plan item '${task.planItem}' dropped by re-plan v${freshRecord.version}.`,
119
+ data: { itemId: task.planItem, planId: freshRecord.id, planVersion: freshRecord.version },
120
+ });
121
+ changed = true;
122
+ } else if (
123
+ (task.status === "running" || task.status === "waiting" || task.status === "needs_attention") &&
124
+ !task.replanDroppedAt
125
+ ) {
126
+ // In flight — advisory once; maxTurns+grace stays the hard stop.
127
+ if (!appendSteeringAdvisory(fresh.manifest, task.id)) continue;
128
+ tasks = tasks.map((t) => (t.id === task.id ? { ...t, replanDroppedAt: new Date().toISOString() } : t));
129
+ advisedTaskIds.push(task.id);
130
+ appendEvent(fresh.manifest.eventsPath, {
131
+ type: "plan.item.dropped",
132
+ runId,
133
+ taskId: task.id,
134
+ message: `Wrap-up advisory delivered to ${task.id}: plan item '${task.planItem}' dropped by re-plan v${freshRecord.version}.`,
135
+ data: { itemId: task.planItem, planId: freshRecord.id, planVersion: freshRecord.version, softCancel: true },
136
+ });
137
+ changed = true;
138
+ }
139
+ }
140
+ if (!changed) return { manifest: fresh.manifest, tasks: fresh.tasks, cancelledTaskIds, advisedTaskIds };
141
+ saveRunTasks(fresh.manifest, tasks);
142
+ const manifest = { ...fresh.manifest, updatedAt: new Date().toISOString() };
143
+ saveRunManifest(manifest);
144
+ return { manifest, tasks, cancelledTaskIds, advisedTaskIds };
145
+ });
146
+ return outcome ?? undefined;
147
+ } catch (error) {
148
+ logInternalError("plan-replan.sweep-failed", error instanceof Error ? error : new Error(String(error)), `runId=${runId}`);
149
+ return undefined;
150
+ }
151
+ }
@@ -25,6 +25,12 @@ const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
25
25
  /** Maximum age (ms) for an active run before it's considered stale.
26
26
  * After this time, PID-only liveness is unreliable due to PID recycling. */
27
27
  const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
28
+ /** Window (ms) during which an active run whose agents are all
29
+ * completed/queued-without-evidence still counts as display-active: the
30
+ * runner just wrote the manifest (task transition) and the next worker has
31
+ * not recorded progress yet. Without this the dock flickers off between
32
+ * phases of a healthy run. */
33
+ const SPAWN_GAP_VISIBILITY_MS = 60 * 1000;
28
34
 
29
35
  export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
30
36
  if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
@@ -150,5 +156,14 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
150
156
  // runs, or from cross-cwd registry history; showing them causes noisy 0/0 rows and
151
157
  // needless spinner redraws. The full dashboard can still list historical runs.
152
158
  if (agents.length === 0) return false;
153
- return agents.some(hasDurableActiveAgentEvidence);
159
+ if (agents.some(hasDurableActiveAgentEvidence)) return true;
160
+ // Spawn gap between phases: an active run whose manifest was JUST written
161
+ // and whose next agents are queued/waiting without evidence yet stays
162
+ // visible — the runner is alive and about to spawn them. Stale manifests
163
+ // (zombies) fall through and stay hidden.
164
+ if (agents.some((agent) => agent.status === "queued" || agent.status === "waiting")) {
165
+ const updatedAt = new Date(run.updatedAt).getTime();
166
+ if (Number.isFinite(updatedAt) && now - updatedAt < SPAWN_GAP_VISIBILITY_MS) return true;
167
+ }
168
+ return false;
154
169
  }
@@ -166,13 +166,6 @@ export function getCheckpointStore(stateRoot: string): CheckpointStore {
166
166
  return _stores.get(stateRoot)!;
167
167
  }
168
168
 
169
- /**
170
- * Clear all checkpoint stores (for testing).
171
- */
172
- export function clearCheckpointStores(): void {
173
- _stores.clear();
174
- }
175
-
176
169
  /**
177
170
  * Save a checkpoint during agent execution.
178
171
  */
@@ -219,17 +212,6 @@ export function loadCheckpoint(runId: string, taskId: string, cwd?: string): Che
219
212
  return store.load(runId, taskId);
220
213
  }
221
214
 
222
- /**
223
- * Delete a checkpoint after successful completion.
224
- */
225
- export function clearCheckpoint(runId: string, taskId: string, cwd?: string): void {
226
- assertSafePathId("runId", runId);
227
- assertSafePathId("taskId", taskId);
228
- const stateRoot = path.join(projectCrewRoot(cwd ?? process.cwd()), "state", "runs", runId);
229
- const store = getCheckpointStore(stateRoot);
230
- store.delete(runId, taskId);
231
- }
232
-
233
215
  /**
234
216
  * Check if a checkpoint exists for a task.
235
217
  */
@@ -6,7 +6,7 @@ import type { MetricRegistry } from "../../observability/metric-registry.ts";
6
6
  import { withRunLockSync } from "../../state/coordination/locks.ts";
7
7
  import { appendEvent, scanSequence } from "../../state/event-log/event-log.ts";
8
8
  import { readActiveRunRegistry, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
9
- import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
9
+ import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
10
10
  import type { TeamTaskState } from "../../state/types.ts";
11
11
  import { logInternalError } from "../../utils/internal-error.ts";
12
12
  import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
@@ -17,7 +17,7 @@ import { isWorkerHeartbeatStale } from "../heartbeat/worker-heartbeat.ts";
17
17
  import { terminateLiveAgentsForRun } from "../live-session/live-agent-manager.ts";
18
18
  import type { ManifestCache } from "../manifest-cache.ts";
19
19
  import { checkProcessLiveness } from "../process-status.ts";
20
- import { isPlanApprovalPending, type ReconcileResult, reconcileStaleRun } from "../stale-reconciler.ts";
20
+ import { isIntentionalWait, isPlanApprovalPendingEffective, type ReconcileResult, reconcileStaleRun } from "../stale-reconciler.ts";
21
21
 
22
22
  export interface RecoveryPlan {
23
23
  runId: string;
@@ -114,7 +114,10 @@ export function detectInterruptedRuns(
114
114
  for (const manifest of manifestCache.list(50)) {
115
115
  if (manifest.status !== "running" && manifest.status !== "blocked") continue;
116
116
  // Preserve runs intentionally blocked on plan approval — not crashes.
117
- if (isPlanApprovalPending(manifest)) continue;
117
+ // B1 battery 2026-08-18 (case b): generalized to isIntentionalWait — a run
118
+ // parked on an ask answer is equally intentional and must not be auto-resumed
119
+ // out from under its parked worker.
120
+ if (isIntentionalWait(manifest)) continue;
118
121
  if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) continue;
119
122
  // Skip runs owned by the current live session — a live session B must NOT
120
123
  // detect session A's still-running run as interrupted.
@@ -142,54 +145,87 @@ export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionC
142
145
  runId: plan.runId,
143
146
  cwd: ctx.cwd,
144
147
  });
145
- appendHookEvent(loaded.manifest, hookReport);
146
- if (hookReport.outcome === "block") {
147
- appendEvent(loaded.manifest.eventsPath, {
148
- type: "crew.run.recovery_blocked",
148
+
149
+ // R14-4: the hook await above is an arbitrary async gap — a concurrent writer
150
+ // may have completed/cancelled the run in the meantime. Re-read inside the
151
+ // lock and derive every write from the FRESH snapshot; never reset a run that
152
+ // reached a terminal status.
153
+ withRunLockSync(loaded.manifest, () => {
154
+ const fresh = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: inside withRunLockSync - consistent read
155
+ if (!fresh) throw new Error(`Run '${plan.runId}' not found.`);
156
+ if (fresh.manifest.status === "completed" || fresh.manifest.status === "failed" || fresh.manifest.status === "cancelled") {
157
+ // Run reached a terminal status while the recovery hook was running —
158
+ // do NOT reset it (no task reset, no status change).
159
+ appendEvent(fresh.manifest.eventsPath, {
160
+ type: "crew.run.recovery_skipped",
161
+ runId: plan.runId,
162
+ message: `Recovery skipped: run is already '${fresh.manifest.status}'`,
163
+ data: { status: fresh.manifest.status },
164
+ });
165
+ return;
166
+ }
167
+ appendHookEvent(fresh.manifest, hookReport);
168
+ if (hookReport.outcome === "block") {
169
+ appendEvent(fresh.manifest.eventsPath, {
170
+ type: "crew.run.recovery_blocked",
171
+ runId: plan.runId,
172
+ message: `Recovery blocked by hook: ${hookReport.reason ?? "run_recovery hook blocked the operation."}`,
173
+ data: { hookOutcome: "block", reason: hookReport.reason },
174
+ });
175
+ return;
176
+ }
177
+
178
+ const reset = new Set(plan.resumableTasks);
179
+ const tasks = fresh.tasks.map((task) =>
180
+ reset.has(task.id)
181
+ ? {
182
+ ...task,
183
+ status: "queued" as const,
184
+ startedAt: undefined,
185
+ finishedAt: undefined,
186
+ error: undefined,
187
+ heartbeat: undefined,
188
+ // WP-2/R2 (ADR-0 item 9): a requeued task must not resume parked
189
+ // state — drop any stale ask-park marker along with the other
190
+ // per-attempt transient fields. Tasks NOT reset (e.g. a task parked
191
+ // in `waiting`) pass through untouched, marker intact; the marker's
192
+ // deadline is then owned by the scheduler tick (dispatch-batch).
193
+ waiting: undefined,
194
+ }
195
+ : task,
196
+ );
197
+ saveRunTasks(fresh.manifest, tasks);
198
+ appendEvent(fresh.manifest.eventsPath, {
199
+ type: "crew.run.resumed",
149
200
  runId: plan.runId,
150
- message: `Recovery blocked by hook: ${hookReport.reason ?? "run_recovery hook blocked the operation."}`,
151
- data: { hookOutcome: "block", reason: hookReport.reason },
201
+ message: `Recovered ${plan.resumableTasks.length} interrupted task(s).`,
202
+ data: {
203
+ recoveredFromSeq: plan.lastEventSeq,
204
+ resumableTasks: plan.resumableTasks,
205
+ },
152
206
  });
153
- return;
154
- }
155
-
156
- const reset = new Set(plan.resumableTasks);
157
- const tasks = loaded.tasks.map((task) =>
158
- reset.has(task.id)
159
- ? {
160
- ...task,
161
- status: "queued" as const,
162
- startedAt: undefined,
163
- finishedAt: undefined,
164
- error: undefined,
165
- heartbeat: undefined,
166
- }
167
- : task,
168
- );
169
- saveRunTasks(loaded.manifest, tasks);
170
- appendEvent(loaded.manifest.eventsPath, {
171
- type: "crew.run.resumed",
172
- runId: plan.runId,
173
- message: `Recovered ${plan.resumableTasks.length} interrupted task(s).`,
174
- data: {
175
- recoveredFromSeq: plan.lastEventSeq,
176
- resumableTasks: plan.resumableTasks,
177
- },
207
+ registry?.counter("crew.run.count", "Total runs by status").inc({ status: "resumed" });
178
208
  });
179
- registry?.counter("crew.run.count", "Total runs by status").inc({ status: "resumed" });
180
209
  }
181
210
 
182
211
  export function declineRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionContext, "cwd">): void {
183
212
  const loaded = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
184
213
  if (!loaded) throw new Error(`Run '${plan.runId}' not found.`);
185
- // Log the event first — if appendEvent fails, state remains consistent.
186
- appendEvent(loaded.manifest.eventsPath, {
187
- type: "crew.run.recovery_declined",
188
- runId: plan.runId,
189
- message: "Interrupted run was not resumed.",
190
- data: { recoveredFromSeq: plan.lastEventSeq },
214
+ // R14-4: re-read inside the lock so the decline decision applies to the
215
+ // on-disk state, not the best-effort snapshot read above. Kept SYNC —
216
+ // withRunLockSync is the sync lock variant.
217
+ withRunLockSync(loaded.manifest, () => {
218
+ const fresh = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: inside withRunLockSync - consistent read
219
+ if (!fresh) return;
220
+ // Log the event first — if appendEvent fails, state remains consistent.
221
+ appendEvent(fresh.manifest.eventsPath, {
222
+ type: "crew.run.recovery_declined",
223
+ runId: plan.runId,
224
+ message: "Interrupted run was not resumed.",
225
+ data: { recoveredFromSeq: plan.lastEventSeq },
226
+ });
227
+ updateRunStatus(fresh.manifest, "cancelled", "interrupted-not-resumed");
191
228
  });
192
- updateRunStatus(loaded.manifest, "cancelled", "interrupted-not-resumed");
193
229
  }
194
230
 
195
231
  /**
@@ -224,9 +260,14 @@ export function cancelOrphanedRuns(
224
260
  // Phase 1: Scan project-level manifests via manifestCache
225
261
  for (const manifest of manifestCache.list(50)) {
226
262
  if (manifest.status !== "running" && manifest.status !== "blocked") continue;
227
- // Preserve plan-approval-blocked runs — they belong to their owner and are
228
- // waiting on a human decision, not orphaned by a dead owner process.
229
- if (isPlanApprovalPending(manifest)) {
263
+ // B1 battery 2026-08-18 (case b): preserve runs intentionally blocked on a
264
+ // HUMAN DECISION — plan approval (pre-v2 semantics) OR a pending ask answer
265
+ // within the waiting TTL (ADR-0 item 9: "not crashes and must not be
266
+ // stale-repaired or cancelled by reconciliation"). Live proof: a resumed
267
+ // run parked on ask (worker alive, waitState set, parked workers do NOT
268
+ // heartbeat) was orphan-cancelled 5m18s into a 10-minute park by a THIRD
269
+ // session's startup scan because the predicate only knew plan approval.
270
+ if (isIntentionalWait(manifest)) {
230
271
  skipped.push(manifest.runId);
231
272
  continue;
232
273
  }
@@ -242,6 +283,22 @@ export function cancelOrphanedRuns(
242
283
  continue;
243
284
  }
244
285
 
286
+ // Foreground (in-process) runs have NO async worker PID, so the liveness
287
+ // check above cannot apply. They execute inside THIS pi process — a
288
+ // recently-updated manifest conclusively proves the run is alive; only a
289
+ // run that has gone quiet for the stale threshold can be an orphan (a
290
+ // crashed process stops updating). Without this, a YOUNG foreground run
291
+ // (first worker heartbeat not yet written) was orphan-cancelled by the
292
+ // session that opened its agent view seconds after the run started —
293
+ // killing the very run the user was looking at.
294
+ if (!manifest.async) {
295
+ const updatedMs = manifest.updatedAt ? new Date(manifest.updatedAt).getTime() : Number.NaN;
296
+ if (Number.isFinite(updatedMs) && now - updatedMs <= staleThresholdMs) {
297
+ skipped.push(manifest.runId);
298
+ continue;
299
+ }
300
+ }
301
+
245
302
  // Check for recent heartbeat activity
246
303
  const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
247
304
  if (!loaded) continue;
@@ -293,6 +350,14 @@ export function cancelOrphanedRuns(
293
350
  });
294
351
 
295
352
  saveRunTasks(fresh.manifest, repairedTasks);
353
+ // B1 battery 2026-08-18 (case b leak fix): a cancelled run must not keep
354
+ // its waitState pointer — observed live: orphan-cancel left waitState set,
355
+ // shielding the dead run from staleness repair for the full 24h TTL.
356
+ if (fresh.manifest.waitState) {
357
+ const cleared = { ...fresh.manifest, waitState: undefined, updatedAt: new Date(now).toISOString() };
358
+ saveRunManifest(cleared);
359
+ fresh.manifest = cleared;
360
+ }
296
361
  for (const task of repairedTasks) {
297
362
  try {
298
363
  upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
@@ -655,7 +720,7 @@ export function reconcileAllStaleRuns(
655
720
  // Belt-and-suspenders: reconcileStaleRun itself guards this, but the run
656
721
  // may have flipped to blocked+plan-approval between cache-list and lock
657
722
  // acquisition — re-check the freshest manifest under the lock.
658
- if (isPlanApprovalPending(fresh.manifest)) {
723
+ if (isPlanApprovalPendingEffective(fresh.manifest)) {
659
724
  results.push({
660
725
  runId,
661
726
  verdict: "blocked_awaiting_approval",