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,557 @@
1
+ /**
2
+ * Run finalization for the team-run scheduler loop.
3
+ *
4
+ * Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
5
+ * CORE-4 extraction 8 + the writeProgress cluster + applyPolicy it depends
6
+ * on). Pure code motion: formatTaskProgress / runEffectivenessLines /
7
+ * scratchpadSummaryLines / lastProgressContentHash / writeProgress /
8
+ * applyPolicy / finalizeRun moved verbatim, including the R15-1 / S02 / F1
9
+ * / P6 / RT-7 / RT-7a / I5 / BUG A comments.
10
+ *
11
+ * Import direction: this module imports isRunTerminalPreserved from
12
+ * ./merge-loop.ts (never the reverse) and never imports from team-runner.ts —
13
+ * `writeProgress` / `lastProgressContentHash` / `finalizeRun` are exported so
14
+ * team-runner.ts (core loop + finally) can import them here.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
19
+ import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
20
+ import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
21
+ import { withRunLock } from "../state/coordination/locks.ts";
22
+ import { appendEvent, appendEventAsync, appendEventFireAndForget, readEvents } from "../state/event-log/event-log.ts";
23
+ import { hashArtifactContent as hashContent, writeArtifact } from "../state/stores/artifact-store.ts";
24
+ import { HealthStore } from "../state/stores/health-store.ts";
25
+ import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
26
+ import type { ArtifactDescriptor, PolicyDecision, TeamRunManifest, TeamTaskState } from "../state/types.ts";
27
+ import { aggregateUsage, formatUsage } from "../state/usage.ts";
28
+ import { logInternalError } from "../utils/internal-error.ts";
29
+ import { checkBranchFreshness } from "../worktree/branch-freshness.ts";
30
+ import { effectivenessPolicyDecision, evaluateRunEffectiveness, formatRunEffectivenessLines } from "./effectiveness.ts";
31
+ import { isRunTerminalPreserved } from "./merge-loop.ts";
32
+ import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts";
33
+ import { buildRecoveryLedger } from "./recovery/recovery-recipes.ts";
34
+ import type { SchedulerContext } from "./scheduler-context.ts";
35
+ import { taskGraphSnapshot } from "./scheduling/task-graph-scheduler.ts";
36
+
37
+ function formatTaskProgress(task: TeamTaskState): string {
38
+ return `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.error ? ` - ${task.error}` : ""}`;
39
+ }
40
+
41
+ function runEffectivenessLines(
42
+ manifest: TeamRunManifest,
43
+ tasks: TeamTaskState[],
44
+ executeWorkers: boolean,
45
+ runtimeConfig?: CrewRuntimeConfig,
46
+ ): string[] {
47
+ return formatRunEffectivenessLines(
48
+ evaluateRunEffectiveness({
49
+ manifest,
50
+ tasks,
51
+ executeWorkers,
52
+ runtimeConfig,
53
+ }),
54
+ );
55
+ }
56
+
57
+ // I5 (plan): surface scratchpad adoption in the run summary — but ONLY when
58
+ // non-zero, so the 3 armed roles that never call it add no noise. Counts the
59
+ // metric events the workers appended to the run events log (fire-and-forget
60
+ // scratchpad.cell / scratchpad.restored). Silent when the feature is unused or
61
+ // the events path is missing (no throw).
62
+ function scratchpadSummaryLines(manifest: TeamRunManifest): string[] {
63
+ if (!manifest.eventsPath) return [];
64
+ const events = readEvents(manifest.eventsPath);
65
+ const cells = events.filter((e) => e.type === "scratchpad.cell");
66
+ const restores = events.filter((e) => e.type === "scratchpad.restored");
67
+ if (cells.length === 0 && restores.length === 0) return [];
68
+ return [
69
+ `## Scratchpad (RLM adoption) — I5 metric`,
70
+ `- cells executed: ${cells.length}`,
71
+ `- snapshot restores: ${restores.length}`,
72
+ ...(cells.length > 0
73
+ ? [`- total cell time: ${Math.round(cells.reduce((s, e) => s + ((e.data?.durationMs as number) ?? 0), 0))} ms`]
74
+ : []),
75
+ ];
76
+ }
77
+
78
+ // P6 (perf): Cache the last-rendered progress content so we can skip the
79
+ // artifact write + redaction + atomic write + size/hash read when nothing
80
+ // material changed (rare between batches, but happens between idle heartbeats).
81
+ // The dedup filter also moved from O(N²) findIndex inside .filter(...)
82
+ // (the previous implementation ran 2 redundant passes on every batch) to
83
+ // a single-pass Map-based replacement: remove the existing entry by path, then
84
+ // append the new one. Net complexity: O(N) build + O(1) replace per write.
85
+ // RT-7: key on manifest.runId (stable string) instead of object identity
86
+ // (WeakMap). Every writeProgress mutator returns a NEW manifest object via
87
+ // spread, so object-identity keying meant the cache NEVER hit. Using runId
88
+ // makes back-to-back calls (same millisecond) actually dedup.
89
+ export const lastProgressContentHash = new Map<string, string>();
90
+
91
+ export function writeProgress(
92
+ manifest: TeamRunManifest,
93
+ tasks: TeamTaskState[],
94
+ producer: string,
95
+ executeWorkers = true,
96
+ runtimeConfig?: CrewRuntimeConfig,
97
+ ): TeamRunManifest {
98
+ const counts = new Map<string, number>();
99
+ for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
100
+ const queue = taskGraphSnapshot(tasks);
101
+ const updatedAt = new Date().toISOString();
102
+ const content = [
103
+ `# pi-crew progress ${manifest.runId}`,
104
+ "",
105
+ `Status: ${manifest.status}`,
106
+ `Team: ${manifest.team}`,
107
+ `Workflow: ${manifest.workflow ?? "(none)"}`,
108
+ `Updated: ${updatedAt}`,
109
+ `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
110
+ `Queue: ready=${queue.ready.length}, blocked=${queue.blocked.length}, running=${queue.running.length}, done=${queue.done.length}, failed=${queue.failed.length}, cancelled=${queue.cancelled.length}`,
111
+ "",
112
+ "## Tasks",
113
+ ...tasks.map(formatTaskProgress),
114
+ "",
115
+ "## Effectiveness",
116
+ ...runEffectivenessLines(manifest, tasks, executeWorkers, runtimeConfig),
117
+ "",
118
+ ].join("\n");
119
+
120
+ // P6 content-cache: even with identical status / counts / queue, the
121
+ // `Updated:` timestamp ticks on every call so the content rarely matches
122
+ // byte-for-byte. We DO compare against the previous rendered byte-stream
123
+ // (which used the previous timestamp) — so this only hits on the
124
+ // back-to-back writeProgress calls during the applyPolicy phase, where
125
+ // both calls happen within the same millisecond. It's a minor win but
126
+ // matches the audit recommendation (skip artifact write when nothing
127
+ // material changed).
128
+ // RT-7: compute the content hash ONCE (was hashed twice per call: once
129
+ // for the canSkip comparison and again for the cache .set). Key the cache
130
+ // on manifest.runId (stable) instead of object identity (never hit).
131
+ const contentHash = hashContent(content);
132
+ const prevHash = lastProgressContentHash.get(manifest.runId);
133
+ // Cheap pre-check: avoid the redaction + atomicWrite + readback roundtrip
134
+ // when both the timestamp and the input args are identical to last time.
135
+ const canSkip = prevHash === contentHash;
136
+
137
+ const progress = canSkip
138
+ ? (() => {
139
+ // Reuse the previous artifact rather than rebuilding one via
140
+ // writeArtifact. This skips mkdirSync, resolveRealContainedPath,
141
+ // redactSecrets, atomicWriteFile, and the post-write readFileSync +
142
+ // statSync.
143
+ const existing = manifest.artifacts.find((a) => a.kind === "progress");
144
+ if (existing) {
145
+ // RT-7a: return a FRESH descriptor with a refreshed createdAt
146
+ // instead of reusing the stale existing reference. The existing
147
+ // descriptor's createdAt reflects the FIRST write time, not this
148
+ // skip-write; refreshing it matches the non-skip path (writeArtifact
149
+ // stamps createdAt with the actual write time) so the manifest
150
+ // always carries a descriptor whose createdAt reflects the current
151
+ // write. Content is identical (that's why we skipped), so path /
152
+ // sizeBytes / contentHash / retention are unchanged.
153
+ return { ...existing, createdAt: new Date().toISOString() };
154
+ }
155
+ // No prior progress artifact (rare; first call from a stale manifest
156
+ // view). Fall through to the normal write.
157
+ return writeArtifact(manifest.artifactsRoot, {
158
+ kind: "progress",
159
+ relativePath: "progress.md",
160
+ producer,
161
+ content,
162
+ });
163
+ })()
164
+ : writeArtifact(manifest.artifactsRoot, {
165
+ kind: "progress",
166
+ relativePath: "progress.md",
167
+ producer,
168
+ content,
169
+ });
170
+ lastProgressContentHash.set(manifest.runId, contentHash);
171
+
172
+ // P6 dedup: replace by path in a single Map pass instead of
173
+ // .filter(...) // O(N) to remove the old entry
174
+ // .filter((_, i, self) => self.findIndex(...) === i) // O(N²) for dedup
175
+ // For an artifact list of size 30+ across a long run, this was the
176
+ // dominant cost of writeProgress between batches.
177
+ const byPath = new Map<string, ArtifactDescriptor>();
178
+ for (const artifact of manifest.artifacts) {
179
+ if (artifact.kind === "progress" && artifact.path === progress.path) continue;
180
+ byPath.set(artifact.path, artifact);
181
+ }
182
+ byPath.set(progress.path, progress);
183
+ const deduped = [...byPath.values()];
184
+
185
+ return {
186
+ ...manifest,
187
+ updatedAt,
188
+ artifacts: deduped,
189
+ };
190
+ }
191
+
192
+ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?: CrewLimitsConfig): TeamRunManifest {
193
+ const branchFreshness = checkBranchFreshness(manifest.cwd);
194
+ const branchArtifact = writeArtifact(manifest.artifactsRoot, {
195
+ kind: "metadata",
196
+ relativePath: "metadata/branch-freshness.json",
197
+ producer: "branch-freshness",
198
+ content: `${JSON.stringify(branchFreshness, null, 2)}\n`,
199
+ });
200
+ let decisions: PolicyDecision[] = evaluateCrewPolicy({
201
+ manifest,
202
+ tasks,
203
+ limits,
204
+ });
205
+ if (branchFreshness.status === "stale" || branchFreshness.status === "diverged") {
206
+ const branchDecision: PolicyDecision = {
207
+ action: "notify",
208
+ reason: "branch_stale",
209
+ message: branchFreshness.message,
210
+ createdAt: new Date().toISOString(),
211
+ };
212
+ decisions = [...decisions, branchDecision];
213
+ appendEvent(manifest.eventsPath, {
214
+ type: "branch.stale",
215
+ runId: manifest.runId,
216
+ message: branchFreshness.message,
217
+ data: { branchFreshness },
218
+ });
219
+ }
220
+ const policyArtifact = writeArtifact(manifest.artifactsRoot, {
221
+ kind: "metadata",
222
+ relativePath: "policy-decisions.json",
223
+ producer: "policy-engine",
224
+ content: `${JSON.stringify(decisions, null, 2)}\n`,
225
+ });
226
+ const recoveryLedger = buildRecoveryLedger(decisions);
227
+ const recoveryArtifact = writeArtifact(manifest.artifactsRoot, {
228
+ kind: "metadata",
229
+ relativePath: "recovery-ledger.json",
230
+ producer: "recovery-engine",
231
+ content: `${JSON.stringify(recoveryLedger, null, 2)}\n`,
232
+ });
233
+ for (const item of decisions)
234
+ appendEvent(manifest.eventsPath, {
235
+ type: item.action === "escalate" ? "policy.escalated" : "policy.action",
236
+ runId: manifest.runId,
237
+ taskId: item.taskId,
238
+ message: item.message,
239
+ data: { action: item.action, reason: item.reason },
240
+ });
241
+ for (const item of recoveryLedger.entries)
242
+ appendEvent(manifest.eventsPath, {
243
+ type: item.state === "escalation_required" ? "recovery.escalated" : "recovery.attempted",
244
+ runId: manifest.runId,
245
+ taskId: item.taskId,
246
+ message: item.message,
247
+ data: {
248
+ scenario: item.scenario,
249
+ steps: item.steps,
250
+ attempt: item.attempt,
251
+ state: item.state,
252
+ },
253
+ });
254
+ return {
255
+ ...manifest,
256
+ updatedAt: new Date().toISOString(),
257
+ policyDecisions: decisions,
258
+ artifacts: [
259
+ ...manifest.artifacts.filter(
260
+ (artifact) =>
261
+ !(
262
+ artifact.kind === "metadata" &&
263
+ (artifact.path.endsWith("policy-decisions.json") ||
264
+ artifact.path.endsWith("recovery-ledger.json") ||
265
+ artifact.path.endsWith("branch-freshness.json"))
266
+ ),
267
+ ),
268
+ branchArtifact,
269
+ policyArtifact,
270
+ recoveryArtifact,
271
+ ],
272
+ };
273
+ }
274
+
275
+ /**
276
+ * CORE-4 extraction 8: finalize the run after the scheduler loop exits.
277
+ *
278
+ * Computes the final run status (failed/blocked/completed) from task states,
279
+ * policy decisions, and effectiveness evaluation; writes the workflow output
280
+ * deliverable warning, the `summary.md` artifact, the joint atomic manifest+tasks
281
+ * save, and a health snapshot; then returns the terminal `{ manifest, tasks }`.
282
+ *
283
+ * Reads `ctx.input` (limits/workflow/executeWorkers/runtimeConfig). Mutates
284
+ * `ctx.manifest` / `ctx.tasks` and writes them back before returning so the
285
+ * caller stays in sync. This function is the terminal step of
286
+ * `executeTeamRunCore` — its return value is the run result.
287
+ *
288
+ * @param ctx The scheduler context.
289
+ * @returns The final `{ manifest, tasks }` result for the run.
290
+ */
291
+ export async function finalizeRun(ctx: SchedulerContext): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
292
+ const input = ctx.input;
293
+ let tasks = ctx.tasks;
294
+ let manifest = ctx.manifest;
295
+ // Bug-027 (PR #46 ubuntu CI, 3 consecutive runs): under heavy contention a
296
+ // settled unit's merged result can carry a STALE non-terminal snapshot while
297
+ // the worker's durable completion has already landed in tasks.json — the
298
+ // status-derivation chain below would then flip a phantom 'running' into a
299
+ // false 'blocked: still running' (events: reviewer task.completed 18.184 →
300
+ // batch merge 18.190 → run.blocked 18.223, with the "running" task completed
301
+ // on disk the whole time). Disk is the authority at finalize — the same
302
+ // principle as the R15-1 locked save. Reconcile in-memory non-terminal tasks
303
+ // against a fresh tasks.json read, but ONLY when nothing is in-flight
304
+ // (pendingUnits empty) so a genuinely running worker is never clobbered.
305
+ if (ctx.pendingUnits.size === 0 && tasks.some((task) => task.status === "running" || task.status === "waiting")) {
306
+ try {
307
+ flushPendingAtomicWrites();
308
+ const diskTasks = JSON.parse(fs.readFileSync(manifest.tasksPath, "utf8")) as TeamTaskState[];
309
+ const diskById = new Map(diskTasks.map((task) => [task.id, task] as const));
310
+ const healed: string[] = [];
311
+ tasks = tasks.map((task) => {
312
+ if (task.status !== "running" && task.status !== "waiting") return task;
313
+ const disk = diskById.get(task.id);
314
+ if (disk && TEAM_TERMINAL_TASK_STATUSES.has(disk.status)) {
315
+ healed.push(`${task.id}:${task.status}->${disk.status}`);
316
+ return disk;
317
+ }
318
+ return task;
319
+ });
320
+ if (healed.length > 0) {
321
+ ctx.tasks = tasks;
322
+ appendEventFireAndForget(manifest.eventsPath, {
323
+ type: "task.reconciled_from_disk",
324
+ runId: manifest.runId,
325
+ message: `Finalize healed stale non-terminal snapshot(s) from durable tasks.json: ${healed.join(", ")}`,
326
+ data: { healed },
327
+ });
328
+ }
329
+ } catch (error) {
330
+ // Unreadable/missing tasks.json — keep the in-memory view; the chain
331
+ // below decides as before.
332
+ logInternalError("finalize-run.disk-reconcile", error, manifest.runId, "debug");
333
+ }
334
+ }
335
+ const failed = tasks.find((task) => task.status === "failed");
336
+ const waiting = tasks.find((task) => task.status === "waiting");
337
+ const running = tasks.find((task) => task.status === "running");
338
+ manifest = applyPolicy(manifest, tasks, input.limits);
339
+
340
+ // S02: Verify workflow-declared output files exist before marking completed
341
+ if (input.workflow?.steps) {
342
+ const missingOutputs: string[] = [];
343
+ for (const step of input.workflow.steps) {
344
+ if (step.output && typeof step.output === "string") {
345
+ const outputPath = path.join(manifest.artifactsRoot, step.output);
346
+ if (!fs.existsSync(outputPath)) {
347
+ missingOutputs.push(step.output);
348
+ }
349
+ }
350
+ }
351
+ if (missingOutputs.length > 0) {
352
+ // Emit warning event — run still completes normally to avoid hanging
353
+ appendEventFireAndForget(manifest.eventsPath, {
354
+ type: "run.deliverable_warning",
355
+ runId: manifest.runId,
356
+ message: `Missing workflow output files: ${missingOutputs.join(", ")}`,
357
+ data: { missingFiles: missingOutputs },
358
+ });
359
+ }
360
+ }
361
+
362
+ const effectiveness = evaluateRunEffectiveness({
363
+ manifest,
364
+ tasks,
365
+ executeWorkers: input.executeWorkers,
366
+ runtimeConfig: input.runtimeConfig,
367
+ });
368
+ const effectivenessDecision = effectivenessPolicyDecision(effectiveness);
369
+ if (effectivenessDecision) {
370
+ manifest = {
371
+ ...manifest,
372
+ policyDecisions: [...(manifest.policyDecisions ?? []), effectivenessDecision],
373
+ updatedAt: new Date().toISOString(),
374
+ };
375
+ await appendEventAsync(manifest.eventsPath, {
376
+ type: "run.effectiveness",
377
+ runId: manifest.runId,
378
+ message: effectivenessDecision.message,
379
+ data: { effectiveness, policyDecision: effectivenessDecision },
380
+ });
381
+ }
382
+ const blockingDecision = manifest.policyDecisions?.find((item) => item.action === "block" || item.action === "escalate");
383
+ // R15-1: capture the entry status BEFORE the in-memory status-derivation
384
+ // chain. The chain below computes the final status in-memory ONLY — its
385
+ // updateRunStatus persistence calls are replaced with a pure in-memory
386
+ // status application so ALL persistence happens in the single locked save
387
+ // further below, which re-reads disk first and preserves a disk-terminal
388
+ // status (cancel → "cancelled", reconciler → "failed") instead of
389
+ // overwriting it with the derived status (typically "completed"). The
390
+ // DECISION LOGIC of the chain (branches, conditions, statuses, messages)
391
+ // is unchanged; only the persistence mechanism moved.
392
+ const entryStatus = manifest.status;
393
+ // R15-1: true when the chain applied a derived status via a status branch
394
+ // (mirrors the old chain's updateRunStatus call sites, INCLUDING the
395
+ // from===to cases like `if (failed)` on an already-failed manifest that
396
+ // re-emit run.<status>). The preserve branch below leaves it false so the
397
+ // locked save does NOT emit a status event (matching old behavior).
398
+ let chainAppliedStatus = false;
399
+ const applyStatusInMemory = (status: TeamRunManifest["status"], summary: string): TeamRunManifest => {
400
+ chainAppliedStatus = true;
401
+ return { ...manifest, status, summary, updatedAt: new Date().toISOString() };
402
+ };
403
+ if (failed) {
404
+ manifest = applyStatusInMemory("failed", `Failed at task '${failed.id}'.`);
405
+ } else if (waiting) {
406
+ manifest = applyStatusInMemory("blocked", `Waiting for response to task '${waiting.id}'.`);
407
+ } else if (running) {
408
+ manifest = applyStatusInMemory("blocked", `Task '${running.id}' is still running.`);
409
+ } else if (effectiveness.severity === "failed") {
410
+ manifest = applyStatusInMemory("failed", effectivenessDecision?.message ?? "Run effectiveness guard failed.");
411
+ } else if (effectiveness.severity === "blocked") {
412
+ manifest = applyStatusInMemory("blocked", effectivenessDecision?.message ?? "Run effectiveness guard blocked completion.");
413
+ } else if (blockingDecision) {
414
+ manifest = applyStatusInMemory("blocked", blockingDecision.message);
415
+ } else if (tasks.some((task) => task.status === "queued")) {
416
+ // F1 defense-in-depth: the loop exited with queued tasks still pending
417
+ // (e.g. a hook skipped all ready tasks and downstream tasks never became
418
+ // runnable). This is NOT a completed run — mark it blocked rather than
419
+ // false-green "completed".
420
+ manifest = applyStatusInMemory("blocked", "Run exited with queued tasks still pending.");
421
+ } else if (manifest.status === "failed" || manifest.status === "cancelled") {
422
+ // The run was already marked failed/cancelled mid-run (e.g. handleFailedTask
423
+ // on a coalesced-group race where the failing task's status was later
424
+ // mutated by the group-drain, or a cancel). Preserve that terminal status —
425
+ // do NOT force "completed" here: failed -> completed is not in
426
+ // TEAM_RUN_STATUS_TRANSITIONS and would throw an invalid-transition error.
427
+ // (No updateRunStatus call: from===to is a no-op, but the intent here is
428
+ // explicitly "leave the earlier decision intact".)
429
+ } else {
430
+ manifest = applyStatusInMemory(
431
+ "completed",
432
+ input.executeWorkers ? "Team workflow completed." : "Team workflow scaffold completed without launching child workers.",
433
+ );
434
+ }
435
+ manifest = writeProgress(manifest, tasks, "team-runner", input.executeWorkers, input.runtimeConfig);
436
+ const usage = aggregateUsage(tasks);
437
+
438
+ // R15-1: ALL persistence inside ONE withRunLock that FIRST re-reads disk.
439
+ // The former unlocked `saveRunManifestAsync(manifest)` (pre-lock) is gone —
440
+ // the single locked write below (manifest + tasks via finalManifest) is now
441
+ // the ONLY persistence point, preserving crash-window atomicity. Disk is
442
+ // authoritative for terminal states: if a cancel/reconciler write landed
443
+ // between loop-exit and this save, its terminal status is preserved and the
444
+ // derived in-memory status (typically "completed") is NOT applied.
445
+ const finalManifest = await withRunLock(manifest, async () => {
446
+ flushPendingAtomicWrites();
447
+ const disk = loadRunManifestById(manifest.cwd, manifest.runId);
448
+ const diskManifest = disk?.manifest;
449
+ const diskTerminal = diskManifest && isRunTerminalPreserved(diskManifest.status);
450
+ const signalAborted = ctx.input.signal?.aborted === true;
451
+
452
+ let resolvedManifest: TeamRunManifest;
453
+ let resolvedTasks = tasks;
454
+ if (diskTerminal || signalAborted) {
455
+ // R15-1: preserve the disk terminal status — do NOT overwrite it with
456
+ // the in-memory derived status, do NOT run the completion branch.
457
+ // Signal-aborted also preserves (the run must not complete). Mirror
458
+ // CANCEL-1: disk is authoritative for terminal states — the final
459
+ // tasks write also uses the freshest disk tasks so a cancel that
460
+ // landed between the last merge and this save is not resurrected.
461
+ const preservedStatus = diskTerminal ? diskManifest!.status : "cancelled";
462
+ resolvedManifest = {
463
+ ...manifest,
464
+ status: preservedStatus,
465
+ summary: diskTerminal ? (diskManifest!.summary ?? manifest.summary) : "Run cancelled during finalization.",
466
+ updatedAt: new Date().toISOString(),
467
+ };
468
+ resolvedTasks = disk?.tasks ?? tasks;
469
+ await appendEventAsync(manifest.eventsPath, {
470
+ type: "run.terminal_preserved",
471
+ runId: manifest.runId,
472
+ message: `Run terminal status '${preservedStatus}' preserved from disk; finalize did not overwrite it.`,
473
+ data: { preservedStatus, derivedStatus: manifest.status, signalAborted },
474
+ });
475
+ } else if (chainAppliedStatus) {
476
+ // Apply the derived in-memory status via updateRunStatus (validates
477
+ // the entryStatus→derived transition exactly as the pre-R15-1 chain
478
+ // did — from===to included — emits the run.<status> event, unregisters
479
+ // active-run, persists).
480
+ resolvedManifest = updateRunStatus({ ...manifest, status: entryStatus }, manifest.status, manifest.summary);
481
+ } else {
482
+ // preserve branch from the chain — leave the earlier decision intact
483
+ // (no status event, matching pre-R15-1 behavior).
484
+ resolvedManifest = { ...manifest, updatedAt: new Date().toISOString() };
485
+ }
486
+
487
+ // summaryArtifact + healthSnapshot run in ALL paths (preserved-terminal
488
+ // included) — the summary is written with the FINAL (possibly preserved)
489
+ // status so the run record is complete.
490
+ const summaryArtifact = writeArtifact(resolvedManifest.artifactsRoot, {
491
+ kind: "summary",
492
+ relativePath: "summary.md",
493
+ producer: "team-runner",
494
+ content: [
495
+ `# pi-crew run ${resolvedManifest.runId}`,
496
+ "",
497
+ `Status: ${resolvedManifest.status}`,
498
+ `Team: ${resolvedManifest.team}`,
499
+ `Workflow: ${resolvedManifest.workflow ?? "(none)"}`,
500
+ `Goal: ${resolvedManifest.goal}`,
501
+ `Usage: ${formatUsage(usage)}`,
502
+ "",
503
+ "## Tasks",
504
+ ...resolvedTasks.map(formatTaskProgress),
505
+ "",
506
+ "## Effectiveness",
507
+ ...runEffectivenessLines(resolvedManifest, tasks, input.executeWorkers, input.runtimeConfig),
508
+ "",
509
+ "## Policy decisions",
510
+ ...(resolvedManifest.policyDecisions?.length ? summarizePolicyDecisions(resolvedManifest.policyDecisions) : ["- (none)"]),
511
+ "",
512
+ ...scratchpadSummaryLines(resolvedManifest),
513
+ ].join("\n"),
514
+ });
515
+ // Joint atomic save: wrap manifest + tasks in a single run lock so they are
516
+ // written together or not at all. Crash between separate saveRunManifestAsync
517
+ // and saveRunTasksAsync calls could leave manifest/tasks.json out of sync.
518
+ // R15-1: this is now the ONLY manifest+tasks write (the unlocked pre-lock
519
+ // saveRunManifestAsync was removed).
520
+ const final = {
521
+ ...resolvedManifest,
522
+ updatedAt: new Date().toISOString(),
523
+ artifacts: [...resolvedManifest.artifacts, summaryArtifact],
524
+ };
525
+ await saveRunManifestAsync(final);
526
+ await saveRunTasksAsync(final, resolvedTasks);
527
+ return { manifest: final, tasks: resolvedTasks };
528
+ });
529
+ manifest = finalManifest.manifest;
530
+ const finalTasks = finalManifest.tasks;
531
+ // Save health snapshot on run completion.
532
+ // BUG A (pts/2 hang investigation 2026-06-16): stateRoot = `<crewRoot>/state/runs/<runId>`,
533
+ // so the crew root is THREE dirnames up, not two. Two dirnames gave `<crewRoot>/state`
534
+ // (the state dir), and HealthStore then joined HEALTH_DIR (`.crew/state/health`)
535
+ // onto it → `<crewRoot>/state/.crew/state/health` — a double-joined BOGUS path.
536
+ // That wrote health snapshots to a nonexistent subtree (silently breaking the
537
+ // health feature) AND created junk dirs that the recursive state watcher then
538
+ // attached extra inotify watches to. Fix: compute the real crew root (3 up)
539
+ // and make HEALTH_DIR relative to it.
540
+ const crewRoot = path.dirname(path.dirname(path.dirname(finalManifest.manifest.stateRoot)));
541
+ const healthStore = new HealthStore(crewRoot);
542
+ healthStore.saveSnapshot({
543
+ runId: finalManifest.manifest.runId,
544
+ tasks: finalTasks.map((t) => ({ id: t.id, status: t.status })),
545
+ createdAt: finalManifest.manifest.createdAt,
546
+ });
547
+ ctx.manifest = manifest;
548
+ ctx.tasks = finalTasks;
549
+ return { manifest, tasks: finalTasks };
550
+ }
551
+
552
+ /** @internal RT-7 test export — verify cache is keyed on runId (stable string). */
553
+ export const __test__lastProgressContentHash = lastProgressContentHash;
554
+ /** @internal RT-7 test export — exercise writeProgress directly. */
555
+ export const __test__writeProgress = writeProgress;
556
+ /** @internal R15-1 test export — exercise finalizeRun directly (disk-terminal preservation). */
557
+ export const __test__finalizeRun = finalizeRun;