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,189 @@
1
+ /**
2
+ * spec-store.ts — SpecRecord store + provenance mint (ADR-6 §1/§4, WP-6 step 2;
3
+ * round-1 security fix: provenance v2).
4
+ *
5
+ * TWO stores, split by trust:
6
+ * - WORKSPACE store `<projectCrewRoot(cwd)>/state/specs/<id>.json` — the
7
+ * generated/worker-influenced store. NEVER trusted by the strict gate.
8
+ * - USER store `~/.pi/agent/specs/<projectSlug>/<id>.json` — the ONLY store a
9
+ * user action (CLI import / explicit command) mints into. Trust lives here
10
+ * because `.crew/state/` is worker-writable runtime state: any prompt-injected
11
+ * worker can write a workspace `*.json` + `*.trusted` pair (round-1 P1).
12
+ * Raising the anchor out of the workspace makes workspace tampering
13
+ * structurally insufficient — no documented worker path writes `~/.pi/agent/specs`.
14
+ *
15
+ * Digest-bound sidecar: the user-store `<id>.trusted` marker contains the
16
+ * sha-256 of the canonical record JSON. `isSpecTrusted` verifies the binding,
17
+ * so editing a legitimately-minted record while keeping its real sidecar
18
+ * (content-swap attack) degrades to untrusted. Residual (documented in ADR
19
+ * erratum): a fully malicious same-user process can forge both files in
20
+ * `~/.pi/agent/specs` — but such a process already has arbitrary exec as the
21
+ * user; the gate defends against prompt-injected WORKERS via documented paths.
22
+ *
23
+ * Trust is evaluated ONCE at freeze (dispatch): `freezeSpecSnapshot` records
24
+ * `trustedAtFreeze` into the snapshot, and the strict gate reads ONLY that
25
+ * frozen bit — post-freeze mint/delete cannot affect a running task (TOCTOU
26
+ * window closed, round-1 P1 variant c).
27
+ */
28
+
29
+ import { createHash } from "node:crypto";
30
+ import * as fs from "node:fs";
31
+ import * as os from "node:os";
32
+ import * as path from "node:path";
33
+ import { projectCrewRoot } from "../../utils/paths.ts";
34
+ import { atomicWriteJson } from "../atomic-write.ts";
35
+ import type { SpecRecord, SpecSnapshot } from "../types.ts";
36
+
37
+ function specsDir(cwd: string): string {
38
+ return path.join(projectCrewRoot(cwd), "state", "specs");
39
+ }
40
+
41
+ function userSpecsDir(cwd: string): string {
42
+ const slugSource = (() => {
43
+ try {
44
+ return fs.realpathSync(path.resolve(cwd));
45
+ } catch {
46
+ return path.resolve(cwd);
47
+ }
48
+ })();
49
+ const slug = createHash("sha256").update(slugSource).digest("hex").slice(0, 16);
50
+ return path.join(os.homedir(), ".pi", "agent", "specs", slug);
51
+ }
52
+
53
+ function recordPath(dir: string, id: string): string {
54
+ assertSafeSpecId(id);
55
+ return path.join(dir, `${id}.json`);
56
+ }
57
+
58
+ function sidecarPath(dir: string, id: string): string {
59
+ assertSafeSpecId(id);
60
+ return path.join(dir, `${id}.trusted`);
61
+ }
62
+
63
+ const SPEC_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
64
+
65
+ function assertSafeSpecId(id: string): void {
66
+ if (!SPEC_ID_PATTERN.test(id)) throw new Error(`Invalid spec id: ${id}`);
67
+ }
68
+
69
+ /** Stable canonical JSON (sorted keys, recursive) — the digest basis. */
70
+ export function canonicalSpecJson(value: unknown): string {
71
+ const sort = (v: unknown): unknown => {
72
+ if (Array.isArray(v)) return v.map(sort);
73
+ if (v && typeof v === "object") {
74
+ return Object.fromEntries(
75
+ Object.entries(v as Record<string, unknown>)
76
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
77
+ .map(([k, val]) => [k, sort(val)]),
78
+ );
79
+ }
80
+ return v;
81
+ };
82
+ return JSON.stringify(sort(value));
83
+ }
84
+
85
+ export interface SaveSpecOptions {
86
+ /** Only USER-facing actions (CLI import / explicit user command) may mint. */
87
+ userAction?: boolean;
88
+ }
89
+
90
+ export function saveSpecRecord(cwd: string, record: SpecRecord, options: SaveSpecOptions = {}): SpecRecord {
91
+ assertSafeSpecId(record.id);
92
+ const userAction = options.userAction === true;
93
+ if (userAction && record.source.kind === "manual") {
94
+ // USER store mint — the only trusted path. persisted.trusted mirrors the
95
+ // digest sidecar (informational); the gate reads the sidecar digest.
96
+ const persisted: SpecRecord = { ...record, trusted: true };
97
+ const dir = userSpecsDir(cwd);
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ atomicWriteJson(recordPath(dir, record.id), persisted);
100
+ const digest = createHash("sha256").update(canonicalSpecJson(persisted), "utf8").digest("hex");
101
+ fs.writeFileSync(sidecarPath(dir, record.id), `${digest}\n`, "utf-8");
102
+ return persisted;
103
+ }
104
+ // Workspace store — the worker/skill path AND user-imported generated
105
+ // records. ALWAYS persisted generated + untrusted regardless of payload.
106
+ const persisted: SpecRecord = {
107
+ ...record,
108
+ source: { ...record.source, kind: "generated" },
109
+ trusted: false,
110
+ };
111
+ fs.mkdirSync(specsDir(cwd), { recursive: true });
112
+ atomicWriteJson(recordPath(specsDir(cwd), record.id), persisted);
113
+ // Teardown any stale workspace sidecar (defense in depth: a hand-forged
114
+ // workspace sidecar must never resurrect trust).
115
+ try {
116
+ fs.unlinkSync(sidecarPath(specsDir(cwd), record.id));
117
+ } catch {
118
+ /* absent — fine */
119
+ }
120
+ return persisted;
121
+ }
122
+
123
+ function readRecordAt(file: string, id: string): SpecRecord | undefined {
124
+ try {
125
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as SpecRecord;
126
+ if (parsed?.id !== id) return undefined;
127
+ return parsed;
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ /** User store first (authoritative for trusted specs), then workspace.
134
+ * Invalid/unsafe ids degrade to undefined (read path never throws). */
135
+ export function loadSpecRecord(cwd: string, id: string): SpecRecord | undefined {
136
+ if (!SPEC_ID_PATTERN.test(id)) return undefined;
137
+ const fromUser = readRecordAt(recordPath(userSpecsDir(cwd), id), id);
138
+ if (fromUser) return fromUser;
139
+ return readRecordAt(recordPath(specsDir(cwd), id), id);
140
+ }
141
+
142
+ /** Strict-gate trust check — USER-store sidecar exists AND its sha-256 digest
143
+ * binds to the record content. When `record` is provided (freeze path) the
144
+ * binding is checked against THAT record, killing the content-swap attack on
145
+ * legitimately-minted specs. */
146
+ export function isSpecTrusted(cwd: string, id: string, record?: SpecRecord): boolean {
147
+ if (!SPEC_ID_PATTERN.test(id)) return false;
148
+ const dir = userSpecsDir(cwd);
149
+ const candidate = record ?? readRecordAt(recordPath(dir, id), id);
150
+ if (!candidate) return false;
151
+ try {
152
+ const sidecar = fs.readFileSync(sidecarPath(dir, id), "utf-8").trim();
153
+ const digest = createHash("sha256").update(canonicalSpecJson(candidate), "utf8").digest("hex");
154
+ return sidecar === digest;
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ export function listSpecIds(cwd: string): string[] {
161
+ const ids = new Set<string>();
162
+ for (const dir of [userSpecsDir(cwd), specsDir(cwd)]) {
163
+ try {
164
+ for (const f of fs.readdirSync(dir)) {
165
+ if (!f.endsWith(".json")) continue;
166
+ const id = f.slice(0, -".json".length);
167
+ if (SPEC_ID_PATTERN.test(id)) ids.add(id);
168
+ }
169
+ } catch {
170
+ /* absent dir — fine */
171
+ }
172
+ }
173
+ return [...ids];
174
+ }
175
+
176
+ /** Freeze the current record into an immutable SpecSnapshot (dispatch time).
177
+ * `trustedAtFreeze` is decided HERE — the strict gate never re-reads the live
178
+ * sidecar, so post-freeze mint/delete cannot change a running task's trust. */
179
+ export function freezeSpecSnapshot(record: SpecRecord, cwd: string): SpecSnapshot {
180
+ return {
181
+ specId: record.id,
182
+ version: record.version,
183
+ frozenAt: new Date().toISOString(),
184
+ trustedAtFreeze: isSpecTrusted(cwd, record.id, record),
185
+ items: record.requirements.flatMap((requirement) =>
186
+ record.acceptance.filter((a) => a.requirementId === requirement.id).map((acceptance) => ({ requirement, acceptance })),
187
+ ),
188
+ };
189
+ }
@@ -17,13 +17,15 @@ import {
17
17
  flushPendingAtomicWrites,
18
18
  readJsonFile,
19
19
  } from "../atomic-write.ts";
20
- import { canTransitionRunStatus, isTeamTaskStatus } from "../contracts.ts";
20
+ import { canTransitionRunStatus } from "../contracts.ts";
21
21
  import { withRunLock, withRunLockSync } from "../coordination/locks.ts";
22
22
  import { appendEvent } from "../event-log/event-log.ts";
23
- import { reconstructTasksFromEvents } from "../event-log/event-reconstructor.ts";
24
23
  import type { TeamRunManifest, TeamTaskState } from "../types.ts";
25
- import { CURRENT_SCHEMA_VERSION, CURRENT_TASKS_SCHEMA_VERSION } from "../types.ts";
24
+ import { CURRENT_SCHEMA_VERSION } from "../types.ts";
26
25
  import { unregisterActiveRun } from "./active-run-registry.ts";
26
+ import { extractTaskArray, loadTasksWithRecovery, loadTasksWithRecoveryAsync, quarantineCorruptFile } from "./manifest-io.ts";
27
+
28
+ export { loadManifestWithRecovery, loadTasksWithRecovery } from "./manifest-io.ts";
27
29
 
28
30
  /**
29
31
  * stat() the manifest with a brief retry on Windows for the AV-scan window.
@@ -87,6 +89,10 @@ export interface ManifestCacheEntry {
87
89
  // (e.g., parent + child processes writing to the same run) relies on the
88
90
  // mtime/size checks in loadRunManifestById, not on this counter.
89
91
  const manifestCacheGeneration = new Map<string, number>();
92
+ // R5-M1 (Round 5 MEDIUM-1): safety-net FIFO cap. stateRoots are few in
93
+ // practice, but nothing else bounded this Map — it leaked ~1 entry per run
94
+ // for the process lifetime (manifestCache itself has TTL + LRU eviction).
95
+ const MANIFEST_CACHE_GENERATION_MAX = 64;
90
96
  function genOf(stateRoot: string): number {
91
97
  return manifestCacheGeneration.get(stateRoot) ?? 0;
92
98
  }
@@ -151,7 +157,20 @@ function invalidateRunCache(stateRoot: string): void {
151
157
  // F1: only bump the generation of THIS stateRoot — sibling runs keep their
152
158
  // generation intact, so concurrent writes to run A no longer evict run B's
153
159
  // hot cache.
154
- manifestCacheGeneration.set(stateRoot, genOf(stateRoot) + 1);
160
+ // R5-M1: delete-then-set couples this generation entry to the cache entry it
161
+ // guards (same lifecycle point) and refreshes its insertion order, so the
162
+ // FIFO safety net below evicts the least-recently-invalidated stateRoot.
163
+ const nextGen = genOf(stateRoot) + 1;
164
+ manifestCacheGeneration.delete(stateRoot);
165
+ manifestCacheGeneration.set(stateRoot, nextGen);
166
+ while (manifestCacheGeneration.size > MANIFEST_CACHE_GENERATION_MAX) {
167
+ const oldest = manifestCacheGeneration.keys().next().value;
168
+ if (oldest === undefined) break;
169
+ // Dropping a generation entry makes genOf read 0 — also drop that root's
170
+ // manifest cache entry so a 0-stamped entry can never falsely match.
171
+ manifestCache.delete(oldest);
172
+ manifestCacheGeneration.delete(oldest);
173
+ }
155
174
  }
156
175
 
157
176
  function scopeBaseRoot(cwd: string): string {
@@ -241,7 +260,42 @@ export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
241
260
  };
242
261
  }
243
262
 
244
- export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig, team: TeamConfig, cwd: string): TeamTaskState[] {
263
+ /**
264
+ * Trailing phrases a step template leaves in front of `{goal}` — "Explore
265
+ * the codebase for the goal: {goal}" should name the task ("Explore the
266
+ * codebase"), not quote the whole run goal after every verb. The run goal is
267
+ * run-level context (statusline shows it once); a task list that repeats it
268
+ * per row reads like a roster of workers, not a list of work.
269
+ */
270
+ const GOAL_TAILS = ["for the goal", "for goal", "the goal", "goal", "cho mục tiêu", "của mục tiêu", "mục tiêu", "for", "of", "cho", "của"];
271
+
272
+ function stripGoalTail(prefix: string): string {
273
+ let name = prefix.replace(/[\s:—-]+$/, "").trim();
274
+ let changed = true;
275
+ while (changed) {
276
+ changed = false;
277
+ const lowered = name.toLowerCase();
278
+ for (const tail of GOAL_TAILS) {
279
+ if (lowered.endsWith(tail) && lowered.length > tail.length) {
280
+ name = name
281
+ .slice(0, name.length - tail.length)
282
+ .replace(/[\s:—-]+$/, "")
283
+ .trim();
284
+ changed = true;
285
+ break;
286
+ }
287
+ }
288
+ }
289
+ return name;
290
+ }
291
+
292
+ export function createTasksFromWorkflow(
293
+ runId: string,
294
+ workflow: WorkflowConfig,
295
+ team: TeamConfig,
296
+ cwd: string,
297
+ goal?: string,
298
+ ): TeamTaskState[] {
245
299
  const stepToTaskId = new Map(workflow.steps.map((step, index) => [step.id, createTaskId(step.id, index)]));
246
300
  return workflow.steps.map((step, index) => {
247
301
  const role = team.roles.find((candidate) => candidate.name === step.role);
@@ -251,13 +305,48 @@ export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig,
251
305
  .filter((candidate) => candidate.dependsOn?.includes(step.id))
252
306
  .map((candidate) => stepToTaskId.get(candidate.id))
253
307
  .filter((childId): childId is string => childId !== undefined);
308
+ // The plan's own words become the task's display identity: a heading
309
+ // from the step body if present, else its first content line; the
310
+ // remaining body is kept as `description` for detail surfaces (task
311
+ // list detail line, view). Falls back to the step id when the body
312
+ // carries no text of its own — `task` is optional on literal
313
+ // WorkflowStep objects.
314
+ //
315
+ // `{goal}` in the title line is the RUN's goal, context for every
316
+ // task, not this task's name. The template prefix before the
317
+ // placeholder names the task; a placeholder-only body ("{goal}",
318
+ // direct-run) adopts the goal itself as the title. `description`
319
+ // lines substitute the placeholder (the prompt path substitutes
320
+ // separately — unchanged).
321
+ const bodyLines =
322
+ typeof step.task === "string" && step.task.trim()
323
+ ? step.task
324
+ .split("\n")
325
+ .map((line) => line.trim())
326
+ .filter(Boolean)
327
+ : [];
328
+ const heading = bodyLines.find((line) => line.startsWith("#"));
329
+ const titleLine = heading ?? bodyLines[0];
330
+ const substitute = (text: string): string => (goal ? text.replaceAll("{goal}", goal) : text);
331
+ let title: string;
332
+ let restLines: string[];
333
+ const placeholderAt = titleLine?.indexOf("{goal}") ?? -1;
334
+ if (titleLine && placeholderAt >= 0) {
335
+ title = stripGoalTail(titleLine.slice(0, placeholderAt)) || goal || step.id;
336
+ restLines = bodyLines.filter((line) => line !== titleLine);
337
+ } else {
338
+ title = (titleLine ?? step.id).replace(/^#+\s*/, "");
339
+ restLines = heading ? bodyLines.filter((line) => line !== titleLine) : bodyLines.slice(1);
340
+ }
341
+ const description = restLines.map(substitute).join("\n");
254
342
  return {
255
343
  id,
256
344
  runId,
257
345
  stepId: step.id,
258
346
  role: step.role,
259
347
  agent: role?.agent ?? step.role,
260
- title: step.id,
348
+ title: title.slice(0, 120),
349
+ description: description && description !== title ? description : undefined,
261
350
  status: "queued",
262
351
  dependsOn: dependencies,
263
352
  cwd,
@@ -286,7 +375,7 @@ export function createRunManifest(params: {
286
375
  }): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
287
376
  const paths = createRunPaths(params.cwd);
288
377
  const now = new Date().toISOString();
289
- const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd) : [];
378
+ const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd, params.goal) : [];
290
379
  const manifest: TeamRunManifest = {
291
380
  schemaVersion: CURRENT_SCHEMA_VERSION,
292
381
  runId: paths.runId,
@@ -444,8 +533,13 @@ function shouldPersistTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
444
533
  if (tasks.length > 0) return true;
445
534
  const existing = extractTaskArray(readJsonFile<unknown>(manifest.tasksPath));
446
535
  if (existing.length > 0) {
447
- console.warn(
448
- `[state-store] refusing to persist empty tasks over ${existing.length} existing task(s) — possible corrupt-load cascade (runId=${manifest.runId})`,
536
+ logInternalError(
537
+ "state-store",
538
+ new Error(
539
+ `refusing to persist empty tasks over ${existing.length} existing task(s) — possible corrupt-load cascade (runId=${manifest.runId})`,
540
+ ),
541
+ undefined,
542
+ "warn",
449
543
  );
450
544
  return false;
451
545
  }
@@ -693,6 +787,7 @@ export function __test__manifestCacheSize(): number {
693
787
 
694
788
  export function __test__clearManifestCache(): void {
695
789
  manifestCache.clear();
790
+ manifestCacheGeneration.clear();
696
791
  }
697
792
 
698
793
  /**
@@ -707,17 +802,18 @@ export function __test__clearManifestCache(): void {
707
802
  * `fs.statSync`-based invalidation. It is a safe exit hatch for callers that
708
803
  * want a guaranteed cache drop (e.g. cleanup paths, end-of-run shutdown).
709
804
  *
710
- * Scope note: `flushPendingAtomicWrites()` flushes ALL pending coalesced
711
- * writes process-wide, not just those under `stateRoot`. The coalescer has no
712
- * per-stateRoot filter; flushing the whole queue is the simplest correct
713
- * behavior and matches the existing `flushPendingAtomicWrites()` contract
714
- * used by cleanupRuntime / process exit handlers.
805
+ * Scope note (R10-2): the flush is scoped to the run's `tasks.json` — the only
806
+ * file that (a) feeds this cache and (b) can have a pending coalesced write
807
+ * (`manifest.json` is only ever written through immediate/durable paths, and
808
+ * `agents.json`/`status.json` coalesced writes live outside this cache).
809
+ * Unrelated coalesced writes for OTHER runs stay pending on their own timers;
810
+ * unloading one run must not block on them.
715
811
  */
716
812
  export async function unloadRun(stateRoot: string): Promise<void> {
717
813
  // Flush first so any in-flight buffered write lands on disk before we drop
718
814
  // the cache entry. Otherwise a coalesced write could fire AFTER unloadRun
719
815
  // returns, re-populate the cache from disk, and re-stale it.
720
- flushPendingAtomicWrites();
816
+ flushPendingAtomicWrites(path.join(stateRoot, "tasks.json"));
721
817
  // invalidateRunCache bumps the per-stateRoot generation counter so even if
722
818
  // some in-process reader has a stale reference, the next cache lookup
723
819
  // misses (generation mismatch) and re-reads from disk.
@@ -769,215 +865,6 @@ async function readJsonFileAsync<T>(filePath: string): Promise<T | undefined> {
769
865
  }
770
866
  }
771
867
 
772
- /**
773
- * ST-4: Rename a corrupt file to a quarantine path (`.corrupt-<ts>`) so it is
774
- * preserved for debugging but no longer read as the primary source of truth.
775
- */
776
- function quarantineCorruptFile(filePath: string): void {
777
- try {
778
- fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`);
779
- } catch {
780
- // Best-effort — if rename fails (file already gone, permission, etc.),
781
- // we still proceed with reconstruction from the event log.
782
- }
783
- }
784
-
785
- /**
786
- * ST-4: Convert event-reconstructor output into TeamTaskState[].
787
- * Reconstructed tasks carry lifecycle data (id, status, timing) from the
788
- * event log; auxiliary fields (role, agent, title) are filled with defaults
789
- * since they are not present in lifecycle events.
790
- */
791
- function reconstructTasksFromEventLog(eventsPath: string, runId: string): TeamTaskState[] {
792
- try {
793
- const result = reconstructTasksFromEvents(eventsPath);
794
- const tasks: TeamTaskState[] = [];
795
- for (const [, rt] of result.tasks) {
796
- tasks.push({
797
- id: rt.id,
798
- runId,
799
- role: "unknown",
800
- agent: "unknown",
801
- title: "reconstructed from events",
802
- status: isTeamTaskStatus(rt.status) ? rt.status : "queued",
803
- dependsOn: [],
804
- cwd: "",
805
- startedAt: rt.startedAt,
806
- finishedAt: rt.finishedAt,
807
- error: rt.error,
808
- segment: rt.segment,
809
- diagnostics: rt.diagnostics,
810
- metrics: rt.metrics,
811
- });
812
- }
813
- return tasks;
814
- } catch {
815
- return [];
816
- }
817
- }
818
-
819
- /**
820
- * ST-4: Load tasks.json with corruption recovery (sync path).
821
- *
822
- * Distinguishes:
823
- * - ENOENT / ENOTDIR → legitimate empty → [] (NOT quarantined).
824
- * - SyntaxError (parse failure) → corrupt → quarantine `.corrupt-<ts>` AND
825
- * reconstruct from events.jsonl. If reconstruction yields tasks, persist
826
- * them so subsequent loads see a valid file.
827
- * - Non-array JSON (e.g. `{}`) → corrupt → same as SyntaxError.
828
- * - Valid array → return as-is.
829
- */
830
-
831
- /**
832
- * ST-9: Extract the task array from a tasks.json payload.
833
- *
834
- * Accepts both the v0 legacy bare-array format and the v1+ envelope
835
- * `{ schemaVersion, tasks }`. Returns [] for unrecognized shapes.
836
- */
837
- function extractTaskArray(raw: unknown): TeamTaskState[] {
838
- if (Array.isArray(raw)) return raw as TeamTaskState[];
839
- if (raw !== null && typeof raw === "object" && "tasks" in raw) {
840
- const envelope = raw as { tasks?: unknown };
841
- if (Array.isArray(envelope.tasks)) return envelope.tasks as TeamTaskState[];
842
- }
843
- return [];
844
- }
845
-
846
- /**
847
- * ST-9: Whether `parsed` is a recognizable tasks.json shape (v0 bare array
848
- * or v1+ envelope). Used to distinguish legitimate formats from corruption.
849
- */
850
- function isRecognizableTasksPayload(parsed: unknown): boolean {
851
- if (Array.isArray(parsed)) return true;
852
- if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) {
853
- return Array.isArray((parsed as { tasks?: unknown }).tasks);
854
- }
855
- return false;
856
- }
857
-
858
- /**
859
- * ST-9: Version-check + migration hook for tasks.json.
860
- *
861
- * tasks.json has two on-disk shapes:
862
- * - v0 (current): bare JSON array `TeamTaskState[]` — what saveRunTasks*
863
- * write today (backward-compatible; no schemaVersion envelope).
864
- * - v1+ (future): envelope `{ schemaVersion: number, tasks: TeamTaskState[] }`
865
- * — read-supported defensively for a future write-side switch.
866
- *
867
- * This detects the shape and returns the task array. v0 needs NO migration
868
- * (it IS the current write format). For v1+ envelopes, a schemaVersion
869
- * mismatch warns (mirroring the manifest check). Future breaking changes
870
- * add real migration logic here.
871
- */
872
- function migrateTasksFile(parsed: unknown, runId: string): TeamTaskState[] {
873
- // v0 current: bare array (no schemaVersion envelope) — what writers produce.
874
- if (Array.isArray(parsed)) {
875
- // v0 bare array is the CURRENT write format (saveRunTasks* write the
876
- // array directly, by design — backward compat). Nothing to migrate:
877
- // return as-is. (v1+ envelope read-support below is defensive, for a
878
- // future write-side switch.) Do NOT warn here — it would fire for 100%
879
- // of runs on every load and flood the UI on startup.
880
- return parsed as TeamTaskState[];
881
- }
882
- // v1+ envelope: { schemaVersion, tasks }.
883
- if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) {
884
- const envelope = parsed as { schemaVersion?: unknown; tasks?: unknown };
885
- const detected = typeof envelope.schemaVersion === "number" ? envelope.schemaVersion : 0;
886
- if (detected !== CURRENT_TASKS_SCHEMA_VERSION) {
887
- console.warn(
888
- `[state-store] tasks.json schemaVersion mismatch: expected ${CURRENT_TASKS_SCHEMA_VERSION}, got ${detected}. Run ${runId} may be incompatible.`,
889
- );
890
- }
891
- }
892
- return extractTaskArray(parsed);
893
- }
894
- export function loadTasksWithRecovery(tasksPath: string, eventsPath: string, runId: string): TeamTaskState[] {
895
- let content: string;
896
- try {
897
- content = fs.readFileSync(tasksPath, "utf-8");
898
- } catch {
899
- // ENOENT / ENOTDIR / other read errors → empty (retry loop handles
900
- // transient instability; ENOENT is a legitimate empty run).
901
- return [];
902
- }
903
- let parsed: unknown;
904
- try {
905
- parsed = JSON.parse(content);
906
- } catch {
907
- // SyntaxError — corrupt file.
908
- quarantineCorruptFile(tasksPath);
909
- const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
910
- if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
911
- return reconstructed;
912
- }
913
- if (!isRecognizableTasksPayload(parsed)) {
914
- // Neither v0 bare array nor v1+ envelope (e.g. `{}`) — corrupt.
915
- quarantineCorruptFile(tasksPath);
916
- const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
917
- if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
918
- return reconstructed;
919
- }
920
- return migrateTasksFile(parsed, runId);
921
- }
922
-
923
- /**
924
- * STATE-3: Load manifest.json with corruption quarantine (sync). Distinguishes:
925
- * - ENOENT / read error → undefined (legitimate missing run — NOT quarantined).
926
- * - SyntaxError (unparseable) → CORRUPT → quarantine `.corrupt-<ts>` + log + undefined.
927
- * Manifest CANNOT be reconstructed from events.jsonl (run.created only carries
928
- * {team, workflow}), so quarantine + visible log is the recovery — do NOT attempt
929
- * reconstruction (unlike loadTasksWithRecovery). This prevents a corrupt manifest
930
- * from silently making a run invisible (STATE-3).
931
- */
932
- export function loadManifestWithRecovery(manifestPath: string, runId: string): TeamRunManifest | undefined {
933
- let content: string;
934
- try {
935
- content = fs.readFileSync(manifestPath, "utf-8");
936
- } catch {
937
- // ENOENT / ENOTDIR / other read error → legitimate missing run.
938
- return undefined;
939
- }
940
- try {
941
- return JSON.parse(content) as TeamRunManifest;
942
- } catch {
943
- // SyntaxError → corrupt manifest. Quarantine (preserve for diagnosis) + log,
944
- // then treat as missing. Do NOT reconstruct (infeasible from events).
945
- quarantineCorruptFile(manifestPath);
946
- console.error(
947
- `[state-store] STATE-3: manifest.json for run ${runId} is corrupt (unparseable) — quarantined to ${manifestPath}.corrupt-*. Run is now treated as missing. Preserve the .corrupt-* file for diagnosis.`,
948
- );
949
- return undefined;
950
- }
951
- }
952
-
953
- /**
954
- * ST-4: async twin of {@link loadTasksWithRecovery}.
955
- */
956
- async function loadTasksWithRecoveryAsync(tasksPath: string, eventsPath: string, runId: string): Promise<TeamTaskState[]> {
957
- let content: string;
958
- try {
959
- content = await fs.promises.readFile(tasksPath, "utf-8");
960
- } catch {
961
- return [];
962
- }
963
- let parsed: unknown;
964
- try {
965
- parsed = JSON.parse(content);
966
- } catch {
967
- quarantineCorruptFile(tasksPath);
968
- const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
969
- if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
970
- return reconstructed;
971
- }
972
- if (!isRecognizableTasksPayload(parsed)) {
973
- quarantineCorruptFile(tasksPath);
974
- const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
975
- if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
976
- return reconstructed;
977
- }
978
- return migrateTasksFile(parsed, runId);
979
- }
980
-
981
868
  /**
982
869
  * Load a run manifest and its tasks by runId.
983
870
  * WARNING: This function provides best-effort consistency only. The sentinel-based
@@ -1094,8 +981,13 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
1094
981
  // S-01: warn (do not throw) on schemaVersion mismatch — future version
1095
982
  // bumps will add migration logic here.
1096
983
  if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1097
- console.warn(
1098
- `[state-store] Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
984
+ logInternalError(
985
+ "state-store",
986
+ new Error(
987
+ `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
988
+ ),
989
+ undefined,
990
+ "warn",
1099
991
  );
1100
992
  }
1101
993
  // STATE-3: readJsonFile returns undefined for BOTH missing (ENOENT) and corrupt
@@ -1105,8 +997,13 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
1105
997
  // infeasible (run.created lacks manifest fields), so quarantine+log is the recovery.
1106
998
  if (!manifest && fs.existsSync(manifestPath)) {
1107
999
  quarantineCorruptFile(manifestPath);
1108
- console.error(
1109
- `[state-store] STATE-3: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
1000
+ logInternalError(
1001
+ "state-store",
1002
+ new Error(
1003
+ `STATE-3: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
1004
+ ),
1005
+ undefined,
1006
+ "error",
1110
1007
  );
1111
1008
  return undefined;
1112
1009
  }
@@ -1231,8 +1128,13 @@ export async function loadRunManifestByIdAsync(
1231
1128
  // S-01: warn (do not throw) on schemaVersion mismatch — future version
1232
1129
  // bumps will add migration logic here.
1233
1130
  if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1234
- console.warn(
1235
- `[state-store] Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
1131
+ logInternalError(
1132
+ "state-store",
1133
+ new Error(
1134
+ `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
1135
+ ),
1136
+ undefined,
1137
+ "warn",
1236
1138
  );
1237
1139
  }
1238
1140
  // STATE-3 (async twin): readJsonFileAsync returns undefined for BOTH missing (ENOENT)
@@ -1242,8 +1144,13 @@ export async function loadRunManifestByIdAsync(
1242
1144
  // lacks manifest fields), so quarantine+log is the recovery.
1243
1145
  if (!manifest && fs.existsSync(manifestPath)) {
1244
1146
  quarantineCorruptFile(manifestPath);
1245
- console.error(
1246
- `[state-store] STATE-3 async: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
1147
+ logInternalError(
1148
+ "state-store",
1149
+ new Error(
1150
+ `STATE-3 async: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
1151
+ ),
1152
+ undefined,
1153
+ "error",
1247
1154
  );
1248
1155
  return undefined;
1249
1156
  }