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
@@ -17,13 +17,39 @@ import { canTransitionTaskStatus, isTeamTaskStatus } from "../../../state/contra
17
17
  import { withRunLockSync } from "../../../state/coordination/locks.ts";
18
18
  import { claimTask, releaseTaskClaim, transitionClaimedTaskStatus } from "../../../state/coordination/task-claims.ts";
19
19
  import { appendEvent } from "../../../state/event-log/event-log.ts";
20
- import { saveRunTasks } from "../../../state/stores/state-store.ts";
20
+ import { loadRunManifestById, saveRunTasks } from "../../../state/stores/state-store.ts";
21
+ import type { TeamTaskState } from "../../../state/types.ts";
22
+ import { RUN_NOT_FOUND_HINT } from "../run-not-found.ts";
21
23
  import type { ApiHandlerContext, ApiOperationHandler } from "./handler-context.ts";
22
24
 
23
25
  /** Find a task by id OR stepId — the convention used by every claim op. */
24
- function findTaskByIdOrStepId(ctx: ApiHandlerContext, taskId: string | undefined) {
26
+ function findTaskByIdOrStepId(tasks: TeamTaskState[], taskId: string | undefined) {
25
27
  if (!taskId) return undefined;
26
- return ctx.loaded.tasks.find((item) => item.id === taskId || item.stepId === taskId);
28
+ return tasks.find((item) => item.id === taskId || item.stepId === taskId);
29
+ }
30
+
31
+ /**
32
+ * R13-S1: fresh re-read INSIDE the lock (respond.ts:43 canonical pattern).
33
+ * `loaded.manifest` passed to withRunLockSync is used ONLY for lock-path
34
+ * derivation (locks.ts:596-598 — the lock does NOT re-read); the DATA
35
+ * (tasks, status) must always come from this fresh read.
36
+ */
37
+ function freshRunOrMissing(
38
+ loaded: ApiHandlerContext["loaded"],
39
+ result: ApiHandlerContext["result"],
40
+ ): { manifest: import("../../../state/types.ts").TeamRunManifest; tasks: TeamTaskState[] } | ReturnType<ApiHandlerContext["result"]> {
41
+ const fresh = loadRunManifestById(loaded.manifest.cwd, loaded.manifest.runId);
42
+ if (!fresh)
43
+ return result(
44
+ `Run '${loaded.manifest.runId}' not found.${RUN_NOT_FOUND_HINT}`,
45
+ {
46
+ action: "api",
47
+ status: "error",
48
+ runId: loaded.manifest.runId,
49
+ },
50
+ true,
51
+ );
52
+ return fresh;
27
53
  }
28
54
 
29
55
  /** handleApi operation: `claim-task`. */
@@ -31,7 +57,7 @@ export const handleClaimTask: ApiOperationHandler = (ctx) => {
31
57
  const { cfg, loaded, result, paramRequired } = ctx;
32
58
  const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
33
59
  const owner = typeof cfg.owner === "string" ? cfg.owner : "api";
34
- const task = findTaskByIdOrStepId(ctx, taskId);
60
+ const task = findTaskByIdOrStepId(loaded.tasks, taskId);
35
61
  if (!task)
36
62
  return result(
37
63
  paramRequired(
@@ -48,13 +74,28 @@ export const handleClaimTask: ApiOperationHandler = (ctx) => {
48
74
  );
49
75
  try {
50
76
  return withRunLockSync(loaded.manifest, () => {
51
- const updatedTask = claimTask(task, owner);
52
- const tasks = loaded.tasks.map((item) => (item.id === task.id ? updatedTask : item));
53
- saveRunTasks(loaded.manifest, tasks);
54
- appendEvent(loaded.manifest.eventsPath, {
77
+ const fresh = freshRunOrMissing(loaded, result);
78
+ if (!("tasks" in fresh)) return fresh;
79
+ const freshTask = findTaskByIdOrStepId(fresh.tasks, taskId);
80
+ if (!freshTask)
81
+ return result(
82
+ `Task '${taskId}' not found in run '${fresh.manifest.runId}'.`,
83
+ {
84
+ action: "api",
85
+ status: "error",
86
+ runId: fresh.manifest.runId,
87
+ },
88
+ true,
89
+ );
90
+ // R13-S1: claim operates on the FRESH task — a task that became terminal on
91
+ // disk keeps its terminal status (no resurrection via the stale array).
92
+ const updatedTask = claimTask(freshTask, owner);
93
+ const tasks = fresh.tasks.map((item) => (item.id === freshTask.id ? updatedTask : item));
94
+ saveRunTasks(fresh.manifest, tasks);
95
+ appendEvent(fresh.manifest.eventsPath, {
55
96
  type: "task.claimed",
56
- runId: loaded.manifest.runId,
57
- taskId: task.id,
97
+ runId: fresh.manifest.runId,
98
+ taskId: freshTask.id,
58
99
  data: {
59
100
  owner,
60
101
  token: "[REDACTED]",
@@ -64,8 +105,8 @@ export const handleClaimTask: ApiOperationHandler = (ctx) => {
64
105
  return result(JSON.stringify(updatedTask.claim, null, 2), {
65
106
  action: "api",
66
107
  status: "ok",
67
- runId: loaded.manifest.runId,
68
- artifactsRoot: loaded.manifest.artifactsRoot,
108
+ runId: fresh.manifest.runId,
109
+ artifactsRoot: fresh.manifest.artifactsRoot,
69
110
  });
70
111
  });
71
112
  } catch (error) {
@@ -88,7 +129,7 @@ export const handleReleaseTaskClaim: ApiOperationHandler = (ctx) => {
88
129
  const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
89
130
  const owner = typeof cfg.owner === "string" ? cfg.owner : undefined;
90
131
  const token = typeof cfg.token === "string" ? cfg.token : undefined;
91
- const task = findTaskByIdOrStepId(ctx, taskId);
132
+ const task = findTaskByIdOrStepId(loaded.tasks, taskId);
92
133
  if (!task || !owner || !token)
93
134
  return result(
94
135
  paramRequired(
@@ -105,20 +146,33 @@ export const handleReleaseTaskClaim: ApiOperationHandler = (ctx) => {
105
146
  );
106
147
  try {
107
148
  return withRunLockSync(loaded.manifest, () => {
108
- const updatedTask = releaseTaskClaim(task, owner, token);
109
- const tasks = loaded.tasks.map((item) => (item.id === task.id ? updatedTask : item));
110
- saveRunTasks(loaded.manifest, tasks);
111
- appendEvent(loaded.manifest.eventsPath, {
149
+ const fresh = freshRunOrMissing(loaded, result);
150
+ if (!("tasks" in fresh)) return fresh;
151
+ const freshTask = findTaskByIdOrStepId(fresh.tasks, taskId);
152
+ if (!freshTask)
153
+ return result(
154
+ `Task '${taskId}' not found in run '${fresh.manifest.runId}'.`,
155
+ {
156
+ action: "api",
157
+ status: "error",
158
+ runId: fresh.manifest.runId,
159
+ },
160
+ true,
161
+ );
162
+ const updatedTask = releaseTaskClaim(freshTask, owner, token);
163
+ const tasks = fresh.tasks.map((item) => (item.id === freshTask.id ? updatedTask : item));
164
+ saveRunTasks(fresh.manifest, tasks);
165
+ appendEvent(fresh.manifest.eventsPath, {
112
166
  type: "task.claim_released",
113
- runId: loaded.manifest.runId,
114
- taskId: task.id,
167
+ runId: fresh.manifest.runId,
168
+ taskId: freshTask.id,
115
169
  data: { owner },
116
170
  });
117
171
  return result(JSON.stringify(updatedTask, null, 2), {
118
172
  action: "api",
119
173
  status: "ok",
120
- runId: loaded.manifest.runId,
121
- artifactsRoot: loaded.manifest.artifactsRoot,
174
+ runId: fresh.manifest.runId,
175
+ artifactsRoot: fresh.manifest.artifactsRoot,
122
176
  });
123
177
  });
124
178
  } catch (error) {
@@ -142,7 +196,7 @@ export const handleTransitionTaskStatus: ApiOperationHandler = (ctx) => {
142
196
  const owner = typeof cfg.owner === "string" ? cfg.owner : undefined;
143
197
  const token = typeof cfg.token === "string" ? cfg.token : undefined;
144
198
  const to = cfg.status;
145
- const task = findTaskByIdOrStepId(ctx, taskId);
199
+ const task = findTaskByIdOrStepId(loaded.tasks, taskId);
146
200
  if (!task || !owner || !token || !isTeamTaskStatus(to))
147
201
  return result(
148
202
  paramRequired(
@@ -157,32 +211,47 @@ export const handleTransitionTaskStatus: ApiOperationHandler = (ctx) => {
157
211
  },
158
212
  true,
159
213
  );
160
- if (!canTransitionTaskStatus(task.status, to))
161
- return result(
162
- `Invalid task status transition: ${task.status} -> ${to}`,
163
- {
164
- action: "api",
165
- status: "error",
166
- runId: loaded.manifest.runId,
167
- },
168
- true,
169
- );
170
214
  try {
171
215
  return withRunLockSync(loaded.manifest, () => {
172
- const updatedTask = transitionClaimedTaskStatus(task, owner, token, to);
173
- const tasks = loaded.tasks.map((item) => (item.id === task.id ? updatedTask : item));
174
- saveRunTasks(loaded.manifest, tasks);
175
- appendEvent(loaded.manifest.eventsPath, {
216
+ const fresh = freshRunOrMissing(loaded, result);
217
+ if (!("tasks" in fresh)) return fresh;
218
+ const freshTask = findTaskByIdOrStepId(fresh.tasks, taskId);
219
+ if (!freshTask)
220
+ return result(
221
+ `Task '${taskId}' not found in run '${fresh.manifest.runId}'.`,
222
+ {
223
+ action: "api",
224
+ status: "error",
225
+ runId: fresh.manifest.runId,
226
+ },
227
+ true,
228
+ );
229
+ // R13-S1: validate the transition against FRESH task status — a task that
230
+ // became terminal on disk must be rejected (no terminal flip / no resurrection).
231
+ if (!canTransitionTaskStatus(freshTask.status, to))
232
+ return result(
233
+ `Invalid task status transition: ${freshTask.status} -> ${to}`,
234
+ {
235
+ action: "api",
236
+ status: "error",
237
+ runId: fresh.manifest.runId,
238
+ },
239
+ true,
240
+ );
241
+ const updatedTask = transitionClaimedTaskStatus(freshTask, owner, token, to);
242
+ const tasks = fresh.tasks.map((item) => (item.id === freshTask.id ? updatedTask : item));
243
+ saveRunTasks(fresh.manifest, tasks);
244
+ appendEvent(fresh.manifest.eventsPath, {
176
245
  type: "task.status_transitioned",
177
- runId: loaded.manifest.runId,
178
- taskId: task.id,
246
+ runId: fresh.manifest.runId,
247
+ taskId: freshTask.id,
179
248
  data: { owner, status: to },
180
249
  });
181
250
  return result(JSON.stringify(updatedTask, null, 2), {
182
251
  action: "api",
183
252
  status: "ok",
184
- runId: loaded.manifest.runId,
185
- artifactsRoot: loaded.manifest.artifactsRoot,
253
+ runId: fresh.manifest.runId,
254
+ artifactsRoot: fresh.manifest.artifactsRoot,
186
255
  });
187
256
  });
188
257
  } catch (error) {
@@ -25,12 +25,16 @@ import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
25
25
  const RETRYABLE_STATUSES: ReadonlySet<string> = new Set(["failed", "cancelled"]);
26
26
 
27
27
  /**
28
- * Pure pre-lock decision for `action='retry'`: a run whose manifest status is
29
- * "completed" (terminal success) and has no retryable tasks has nothing to
30
- * retry. Returns true so the caller short-circuits with a clear message
31
- * BEFORE acquiring the run lock — avoiding a misleading
32
- * "run.lock is locked by another operation" error from a stale lock file left
33
- * behind by a completed async run (finding #4, real-test-2026-08-10-full-9-tier).
28
+ * Decision for `action='retry'`: a run whose manifest status is "completed"
29
+ * (terminal success) and has no retryable tasks has nothing to retry. Returns
30
+ * true so the caller short-circuits with a clear message instead of retrying.
31
+ *
32
+ * R13-3 fold: the caller MUST evaluate this on FRESH state read INSIDE the
33
+ * run lock — a pre-lock decision on a best-effort snapshot could wrongly
34
+ * refuse a run that was re-queued/failed on disk (finding #4,
35
+ * real-test-2026-08-10-full-9-tier). Kept as a short-circuit so a completed
36
+ * async run does not surface a misleading "run.lock is locked by another
37
+ * operation" error from a stale lock file left behind by that run.
34
38
  *
35
39
  * Exported for unit testing (handleRetry itself needs filesystem state).
36
40
  */
@@ -148,22 +152,43 @@ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext,
148
152
 
149
153
  const targetTaskId = typeof params.taskId === "string" ? params.taskId : undefined;
150
154
 
151
- // Pre-lock terminal-status check: a completed run has nothing to retry.
152
- // Short-circuit BEFORE acquiring the run lock so a stale lock file left by a
153
- // completed async run does not surface a misleading "run.lock is locked by
154
- // another operation" error (finding #4 in real-test-2026-08-10-full-9-tier).
155
- if (retryShortCircuitsCompleted(loaded.manifest.status, loaded.tasks, targetTaskId)) {
156
- return result(
157
- `Run ${loaded.manifest.runId} is already completed; retry only applies to failed/cancelled runs.`,
158
- { action: "retry", status: "error", runId: loaded.manifest.runId },
159
- true,
160
- );
161
- }
162
-
163
155
  return withRunLockSync(loaded.manifest, () => {
156
+ // R13-1 (2026-08-14): fresh re-read INSIDE the lock. `loaded` was captured
157
+ // lock-free BEFORE the unbounded before_retry hook gap; a concurrent writer
158
+ // may have completed the run or re-queued tasks on disk. All decisions and
159
+ // writes below derive from `fresh` — never from the stale `loaded` snapshot.
160
+ const fresh = loadRunManifestById(loaded.manifest.cwd, params.runId!); // NOTE: inside withRunLockSync - consistent read
161
+ if (!fresh) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "retry", status: "error" }, true);
162
+
163
+ // R15-S1 (2026-08-14): in-lock ownership re-check on the FRESH manifest.
164
+ // The pre-lock check above ran on the `loaded` snapshot captured BEFORE
165
+ // the unbounded before_retry hook gap; ownership may have changed on disk
166
+ // in that window. This in-lock check is authoritative — the pre-lock check
167
+ // remains only as the fast-path UX message.
168
+ if (typeof fresh.manifest.ownerSessionId === "string" && fresh.manifest.ownerSessionId !== ctx.sessionId && params.force !== true) {
169
+ return result(
170
+ `Run ${fresh.manifest.runId} belongs to another session. Use force: true to override.`,
171
+ { action: "retry", status: "error", runId: fresh.manifest.runId },
172
+ true,
173
+ );
174
+ }
175
+
176
+ // Terminal-status check on FRESH in-lock state (R13-3 fold): a completed
177
+ // run has nothing to retry. Short-circuit with a clear message instead of
178
+ // surfacing a misleading "run.lock is locked by another operation" error
179
+ // from a stale lock file left by a completed async run (finding #4 in
180
+ // real-test-2026-08-10-full-9-tier).
181
+ if (retryShortCircuitsCompleted(fresh.manifest.status, fresh.tasks, targetTaskId)) {
182
+ return result(
183
+ `Run ${fresh.manifest.runId} is already completed; retry only applies to failed/cancelled runs.`,
184
+ { action: "retry", status: "error", runId: fresh.manifest.runId },
185
+ true,
186
+ );
187
+ }
188
+
164
189
  const retryableStatuses: ReadonlySet<string> = new Set(["failed", "cancelled"]);
165
190
 
166
- const matchingTasks = loaded.tasks.filter((task) => {
191
+ const matchingTasks = fresh.tasks.filter((task) => {
167
192
  if (targetTaskId && task.id !== targetTaskId) return false;
168
193
  return retryableStatuses.has(task.status);
169
194
  });
@@ -174,26 +199,26 @@ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext,
174
199
  {
175
200
  action: "retry",
176
201
  status: "error",
177
- runId: loaded.manifest.runId,
202
+ runId: fresh.manifest.runId,
178
203
  },
179
204
  true,
180
205
  );
181
206
  }
182
207
 
183
208
  const retriedIds = new Set(matchingTasks.map((t) => t.id));
184
- const tasks = loaded.tasks.map((task) => {
209
+ const tasks = fresh.tasks.map((task) => {
185
210
  if (!retriedIds.has(task.id)) return task;
186
211
  const { error: _error, finishedAt: _finishedAt, terminalEvidence: _terminalEvidence, ...rest } = task;
187
212
  return { ...rest, status: "queued" as const };
188
213
  });
189
- saveRunTasks(loaded.manifest, tasks);
214
+ saveRunTasks(fresh.manifest, tasks);
190
215
  try {
191
216
  saveCrewAgents(
192
- loaded.manifest,
193
- tasks.map((task) => recordFromTask(loaded.manifest, task, "child-process")),
217
+ fresh.manifest,
218
+ tasks.map((task) => recordFromTask(fresh.manifest, task, "child-process")),
194
219
  );
195
220
  } catch (error) {
196
- logInternalError("team-tool.handleRetry.crewAgents", error, `runId=${loaded.manifest.runId}`);
221
+ logInternalError("team-tool.handleRetry.crewAgents", error, `runId=${fresh.manifest.runId}`);
197
222
  }
198
223
 
199
224
  const retriedTaskIds = [...retriedIds];
@@ -201,27 +226,27 @@ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext,
201
226
  // H1 (2026-08-10): inside a sync run-lock callback — cannot await;
202
227
  // fire-and-forget async. task.retried is informational; the queued
203
228
  // status is the authoritative record in tasks.json.
204
- void appendEventAsync(loaded.manifest.eventsPath, {
229
+ void appendEventAsync(fresh.manifest.eventsPath, {
205
230
  type: "task.retried",
206
- runId: loaded.manifest.runId,
231
+ runId: fresh.manifest.runId,
207
232
  taskId,
208
233
  message: `Task ${taskId} queued for retry.`,
209
234
  }).catch((error) =>
210
235
  logInternalError(
211
236
  "cancel.retry-event",
212
237
  error instanceof Error ? error : new Error(String(error)),
213
- `runId=${loaded.manifest.runId}`,
238
+ `runId=${fresh.manifest.runId}`,
214
239
  ),
215
240
  );
216
241
  }
217
242
 
218
- if (deps) invalidateSnapshot(loaded.manifest.runId, runCwd, deps);
219
- return result(`Retried ${retriedTaskIds.length} task(s) in run ${loaded.manifest.runId}.`, {
243
+ if (deps) invalidateSnapshot(fresh.manifest.runId, runCwd, deps);
244
+ return result(`Retried ${retriedTaskIds.length} task(s) in run ${fresh.manifest.runId}.`, {
220
245
  action: "retry",
221
246
  status: "ok",
222
- runId: loaded.manifest.runId,
247
+ runId: fresh.manifest.runId,
223
248
  retriedTaskIds: retriedTaskIds,
224
- intent: `retrying ${retriedTaskIds.length} task(s) in ${loaded.manifest.runId}`,
249
+ intent: `retrying ${retriedTaskIds.length} task(s) in ${fresh.manifest.runId}`,
225
250
  });
226
251
  });
227
252
  }
@@ -290,26 +315,35 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
290
315
  }
291
316
 
292
317
  return withRunLockSync(loaded.manifest, () => {
293
- if ((loaded.manifest.status === "completed" || loaded.manifest.status === "cancelled") && params.force !== true)
318
+ // R13-2 (2026-08-14): fresh re-read INSIDE the lock. `loaded` was captured
319
+ // lock-free BEFORE the before_cancel hook + live-agent termination gap; a
320
+ // concurrent writer may have completed the run on disk. The terminal-status
321
+ // short-circuit and ALL writes below derive from `fresh` — never flip a
322
+ // terminal status (completed → cancelled) from the stale `loaded` snapshot.
323
+ const fresh = loadRunManifestById(loaded.manifest.cwd, loaded.manifest.runId); // NOTE: inside withRunLockSync - consistent read
324
+ if (!fresh)
325
+ return result(`Run '${loaded.manifest.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "cancel", status: "error" }, true);
326
+
327
+ if ((fresh.manifest.status === "completed" || fresh.manifest.status === "cancelled") && params.force !== true)
294
328
  return result(
295
- `Run ${loaded.manifest.runId} is already ${loaded.manifest.status}; nothing to cancel. Use force: true to mark it cancelled anyway.`,
329
+ `Run ${fresh.manifest.runId} is already ${fresh.manifest.status}; nothing to cancel. Use force: true to mark it cancelled anyway.`,
296
330
  {
297
331
  action: "cancel",
298
332
  status: "ok",
299
- runId: loaded.manifest.runId,
300
- artifactsRoot: loaded.manifest.artifactsRoot,
333
+ runId: fresh.manifest.runId,
334
+ artifactsRoot: fresh.manifest.artifactsRoot,
301
335
  },
302
336
  );
303
337
 
304
338
  // Classify tasks for foreign-aware cancellation
305
- const abortResult = abortOwned(loaded.manifest.runId, undefined, ctx, params.force);
339
+ const abortResult = abortOwned(fresh.manifest.runId, undefined, ctx, params.force);
306
340
  if (abortResult.abortedIds.length === 0 && abortResult.foreignIds.length > 0 && params.force !== true) {
307
341
  return result(
308
- `Run ${loaded.manifest.runId} belongs to another session. Use force: true to override.`,
342
+ `Run ${fresh.manifest.runId} belongs to another session. Use force: true to override.`,
309
343
  {
310
344
  action: "cancel",
311
345
  status: "error",
312
- runId: loaded.manifest.runId,
346
+ runId: fresh.manifest.runId,
313
347
  foreignIds: abortResult.foreignIds,
314
348
  },
315
349
  true,
@@ -321,7 +355,7 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
321
355
  const cancelData = cancelIntent ? { reason: cancelReason.code, intent: cancelIntent } : { reason: cancelReason.code };
322
356
  const cancelMessage = `${cancelReason.message} (${cancelReason.code})`;
323
357
 
324
- const tasks = loaded.tasks.map((task) => {
358
+ const tasks = fresh.tasks.map((task) => {
325
359
  if (cancellableIds.has(task.id) && (task.status === "queued" || task.status === "running" || task.status === "waiting")) {
326
360
  const base = {
327
361
  ...task,
@@ -342,32 +376,32 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
342
376
  }
343
377
  return task;
344
378
  });
345
- saveRunTasks(loaded.manifest, tasks);
379
+ saveRunTasks(fresh.manifest, tasks);
346
380
  try {
347
381
  saveCrewAgents(
348
- loaded.manifest,
349
- tasks.map((task) => recordFromTask(loaded.manifest, task, loaded.manifest.runtimeResolution?.kind ?? "child-process")),
382
+ fresh.manifest,
383
+ tasks.map((task) => recordFromTask(fresh.manifest, task, fresh.manifest.runtimeResolution?.kind ?? "child-process")),
350
384
  );
351
385
  } catch (error) {
352
- logInternalError("team-tool.handleCancel.crewAgents", error, `runId=${loaded.manifest.runId}`);
386
+ logInternalError("team-tool.handleCancel.crewAgents", error, `runId=${fresh.manifest.runId}`);
353
387
  }
354
388
  try {
355
- writeForegroundInterruptRequest(loaded.manifest, cancelMessage);
389
+ writeForegroundInterruptRequest(fresh.manifest, cancelMessage);
356
390
  } catch (error) {
357
- logInternalError("team-tool.handleCancel.interruptRequest", error, `runId=${loaded.manifest.runId}`);
391
+ logInternalError("team-tool.handleCancel.interruptRequest", error, `runId=${fresh.manifest.runId}`);
358
392
  }
359
- ctx.abortForegroundRun?.(loaded.manifest.runId);
393
+ ctx.abortForegroundRun?.(fresh.manifest.runId);
360
394
  for (const taskId of abortResult.abortedIds) {
361
- appendEvent(loaded.manifest.eventsPath, {
395
+ appendEvent(fresh.manifest.eventsPath, {
362
396
  type: "task.cancelled",
363
- runId: loaded.manifest.runId,
397
+ runId: fresh.manifest.runId,
364
398
  taskId,
365
399
  message: cancelMessage,
366
400
  data: cancelData,
367
401
  });
368
402
  }
369
403
  const updated = updateRunStatus(
370
- loaded.manifest,
404
+ fresh.manifest,
371
405
  "cancelled",
372
406
  `${cancelMessage} Already-finished worker processes are not retroactively changed.`,
373
407
  { data: cancelData },
@@ -26,6 +26,7 @@ export { handleAutomateDomain, handleControlDomain, handleManageDomain, handleRu
26
26
  const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
27
27
  // run domain (9)
28
28
  run: "run",
29
+ plans: "run",
29
30
  parallel: "run",
30
31
  plan: "run",
31
32
  orchestrate: "run",
@@ -19,6 +19,7 @@ import { handleGoal } from "../goal.ts";
19
19
  import { handleOrchestrate } from "../orchestrate.ts";
20
20
  import { handleParallel } from "../parallel-dispatch.ts";
21
21
  import { handlePlan } from "../plan.ts";
22
+ import { handlePlans } from "../plans.ts";
22
23
 
23
24
  /**
24
25
  * Actions owned by the Run domain. Single source of truth for the switch
@@ -27,7 +28,7 @@ import { handlePlan } from "../plan.ts";
27
28
  * the `default` branch errors if a RunDomainAction is added here without a
28
29
  * matching `case`.
29
30
  */
30
- export const RUN_DOMAIN_ACTIONS = ["run", "parallel", "plan", "orchestrate", "resume", "retry", "wait", "steer", "goal"] as const;
31
+ export const RUN_DOMAIN_ACTIONS = ["run", "parallel", "plan", "plans", "orchestrate", "resume", "retry", "wait", "steer", "goal"] as const;
31
32
  type RunDomainAction = (typeof RUN_DOMAIN_ACTIONS)[number];
32
33
 
33
34
  export async function handleRunDomain(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
@@ -40,6 +41,8 @@ export async function handleRunDomain(params: TeamToolParamsValue, ctx: TeamCont
40
41
  return handleParallel(params, ctx);
41
42
  case "plan":
42
43
  return handlePlan(params, ctx);
44
+ case "plans":
45
+ return handlePlans(params, ctx);
43
46
  case "orchestrate":
44
47
  return handleOrchestrate(params, ctx);
45
48
  case "resume":
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
4
+ import { allAgents, discoverAgents, getSecurityEventLog } from "../../agents/discover-agents.ts";
5
5
  import { loadConfig } from "../../config/config.ts";
6
6
  import { DEFAULT_PATHS } from "../../config/defaults.ts";
7
7
  import { type DriftReport, detectDrift, formatDriftReport } from "../../config/drift-detector.ts";
@@ -14,6 +14,7 @@ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
14
14
  import { TeamToolParams } from "../../schema/team-tool-schema.ts";
15
15
  import { atomicWriteFile } from "../../state/atomic-write.ts";
16
16
  import { allTeams, discoverTeams } from "../../teams/discover-teams.ts";
17
+ import { type FatalFsCause, fsFailureLabel } from "../../utils/fs-errno.ts";
17
18
  import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
18
19
  import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workflows.ts";
19
20
  import type { PiTeamsToolResult } from "../tool-result.ts";
@@ -26,6 +27,71 @@ interface DoctorCheck {
26
27
  detail: string;
27
28
  }
28
29
 
30
+ /** bug-026 sub-issue B: how many of the most recent runs to scan for tasks
31
+ * carrying a fatal-fs failureCause. Bounded so doctor stays fast on projects
32
+ * with long run histories. */
33
+ const FS_FAILURE_SCAN_RUN_LIMIT = 10;
34
+
35
+ function relativeTimeAgo(iso: string): string {
36
+ const ms = Date.now() - Date.parse(iso);
37
+ if (!Number.isFinite(ms)) return iso;
38
+ const minutes = Math.floor(ms / 60_000);
39
+ if (minutes < 1) return "just now";
40
+ if (minutes < 60) return `${minutes}m ago`;
41
+ const hours = Math.floor(minutes / 60);
42
+ if (hours < 24) return `${hours}h ago`;
43
+ return `${Math.floor(hours / 24)}d ago`;
44
+ }
45
+
46
+ /** bug-026 sub-issue B: scan the most recent runs (by mtime, capped) for
47
+ * tasks with a fatal-fs failureCause (enospc/edquot/emfile/enfile). Reads
48
+ * tasks.json directly (no state-store import) and never throws — doctor is
49
+ * a diagnostic tool; unreadable runs are simply skipped. */
50
+ function scanRecentFsFailureCauses(cwd: string): string {
51
+ const runsRoot = path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir);
52
+ let recentRunIds: string[];
53
+ try {
54
+ recentRunIds = fs
55
+ .readdirSync(runsRoot, { withFileTypes: true })
56
+ .filter((entry) => entry.isDirectory())
57
+ .map((entry) => {
58
+ let mtimeMs = 0;
59
+ try {
60
+ mtimeMs = fs.statSync(path.join(runsRoot, entry.name)).mtimeMs;
61
+ } catch {
62
+ /* unreadable run dir — mtime 0 sorts last */
63
+ }
64
+ return { runId: entry.name, mtimeMs };
65
+ })
66
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
67
+ .slice(0, FS_FAILURE_SCAN_RUN_LIMIT)
68
+ .map((entry) => entry.runId);
69
+ } catch {
70
+ return "no run history";
71
+ }
72
+ let count = 0;
73
+ let last: { runId: string; cause: FatalFsCause; finishedAt?: string } | undefined;
74
+ for (const runId of recentRunIds) {
75
+ let tasks: unknown;
76
+ try {
77
+ tasks = JSON.parse(fs.readFileSync(path.join(runsRoot, runId, "tasks.json"), "utf-8"));
78
+ } catch {
79
+ continue;
80
+ }
81
+ if (!Array.isArray(tasks)) continue;
82
+ for (const task of tasks) {
83
+ const failureCause = (task as { failureCause?: FatalFsCause }).failureCause;
84
+ if (!failureCause) continue;
85
+ count += 1;
86
+ const finishedAt = (task as { finishedAt?: string }).finishedAt;
87
+ if (!last || (finishedAt ?? "") > (last.finishedAt ?? "")) last = { runId, cause: failureCause, finishedAt };
88
+ }
89
+ }
90
+ if (count === 0) return `none in last ${FS_FAILURE_SCAN_RUN_LIMIT} runs`;
91
+ const when = last?.finishedAt ? `, ${relativeTimeAgo(last.finishedAt)}` : "";
92
+ return `${count} task(s) in last ${FS_FAILURE_SCAN_RUN_LIMIT} runs (last: ${last?.runId}${when}, ${fsFailureLabel(last!.cause)})`;
93
+ }
94
+
29
95
  function firstOutputLine(stdout: string | null | undefined, stderr: string | null | undefined): string {
30
96
  const output = `${stdout ?? ""}\n${stderr ?? ""}`.trim();
31
97
  return (
@@ -43,6 +109,10 @@ function firstOutputLine(stdout: string | null | undefined, stderr: string | nul
43
109
  // file with 12 tests would take 20s+ even with empty cwd. The cache is
44
110
  // safe: a doctor check is informational, and a stale ok=true would
45
111
  // self-correct on the next process restart.
112
+ // MISSED-2 (R5): FIFO cap so the memo cannot grow unbounded. Naturally
113
+ // bounded by ~10-20 probed commands; the cap is a safety net (Map preserves
114
+ // insertion order).
115
+ const MAX_COMMAND_EXISTS_CACHE = 128;
46
116
  const commandExistsCache = new Map<string, { ok: boolean; detail: string }>();
47
117
  function commandExists(command: string, args: string[]): { ok: boolean; detail: string } {
48
118
  const cacheKey = `${command} ${args.join(" ")}`;
@@ -74,6 +144,11 @@ function commandExists(command: string, args: string[]): { ok: boolean; detail:
74
144
  };
75
145
  }
76
146
  commandExistsCache.set(cacheKey, result);
147
+ // MISSED-2 (R5): FIFO eviction — drop the oldest entry past the cap.
148
+ if (commandExistsCache.size > MAX_COMMAND_EXISTS_CACHE) {
149
+ const oldest = commandExistsCache.keys().next().value;
150
+ if (oldest !== undefined) commandExistsCache.delete(oldest);
151
+ }
77
152
  return result;
78
153
  }
79
154
 
@@ -235,6 +310,14 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
235
310
  ok: true,
236
311
  detail: path.join(projectCrewRoot(input.cwd), DEFAULT_PATHS.state.artifactsSubdir),
237
312
  },
313
+ {
314
+ // bug-026 sub-issue B: INFORMATIONAL (ok always true) — a historical
315
+ // disk-full incident must not permanently fail doctor. The line makes
316
+ // fs failureCauses discoverable without digging through run logs.
317
+ label: "fs failure causes",
318
+ ok: true,
319
+ detail: scanRecentFsFailureCauses(input.cwd),
320
+ },
238
321
  ];
239
322
  }),
240
323
  section("Model Routing", () => {
@@ -393,6 +476,25 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
393
476
  return checks;
394
477
  }),
395
478
  ];
479
+ // R7-13: surface security telemetry. The event log was previously
480
+ // write-only (0 production readers); doctor now reports a compact summary
481
+ // — total events plus the latest event's time and scope. Emitted only
482
+ // when events exist (cheap single line, no per-event dump). A non-empty
483
+ // log is informational, not a failure: blocked registrations mean the
484
+ // SEC-001 protection fired.
485
+ const securityEvents = getSecurityEventLog();
486
+ const lastSecurityEvent = securityEvents.at(-1);
487
+ if (lastSecurityEvent) {
488
+ sections.push(
489
+ section("Security", () => [
490
+ {
491
+ label: "security events",
492
+ ok: true,
493
+ detail: `${securityEvents.length} logged; last ${lastSecurityEvent.type} agent="${lastSecurityEvent.name}" at ${new Date(lastSecurityEvent.timestamp).toISOString()}`,
494
+ },
495
+ ]),
496
+ );
497
+ }
396
498
  if (input.smokeChildPi) {
397
499
  sections.push([`Child check`, `- ${input.smokeChildPi.ok ? "OK" : "FAIL"} child Pi smoke: ${input.smokeChildPi.detail}`]);
398
500
  }