pi-crew 0.9.67 → 0.10.1

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 (222) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/agents/analyst.md +1 -1
  3. package/agents/cold-verifier.md +3 -1
  4. package/agents/critic.md +1 -1
  5. package/agents/executor.md +1 -1
  6. package/agents/explorer.md +1 -1
  7. package/agents/planner.md +1 -1
  8. package/agents/reviewer.md +1 -1
  9. package/agents/security-reviewer.md +1 -1
  10. package/agents/test-engineer.md +1 -1
  11. package/agents/verifier.md +1 -1
  12. package/agents/writer.md +1 -1
  13. package/dist/index.mjs +65847 -60331
  14. package/docs/README.md +2 -0
  15. package/docs/actions-reference.md +31 -0
  16. package/docs/commands-reference.md +17 -6
  17. package/docs/resource-formats.md +13 -0
  18. package/package.json +9 -6
  19. package/schema.json +15 -1
  20. package/scripts/resource-sampler.mjs +36 -2
  21. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  22. package/skills/widget-rendering/SKILL.md +7 -7
  23. package/src/agents/agent-config.ts +36 -14
  24. package/src/agents/discover-agents.ts +23 -14
  25. package/src/config/config-merge.ts +183 -0
  26. package/src/config/config-validation.ts +681 -0
  27. package/src/config/config.ts +22 -864
  28. package/src/config/defaults.ts +35 -1
  29. package/src/config/drift-detector.ts +1 -1
  30. package/src/config/env-vars.ts +691 -0
  31. package/src/config/role-tools.ts +8 -8
  32. package/src/config/sanitize-project-config.ts +172 -0
  33. package/src/config/types.ts +30 -0
  34. package/src/extension/async-notifier.ts +25 -2
  35. package/src/extension/crew-vibes/config.ts +2 -1
  36. package/src/extension/plan-orchestrate.ts +132 -0
  37. package/src/extension/registration/commands/dashboard.ts +158 -0
  38. package/src/extension/registration/commands/index.ts +35 -0
  39. package/src/extension/registration/commands/manage.ts +303 -0
  40. package/src/extension/registration/commands/run.ts +214 -0
  41. package/src/extension/registration/commands/shared.ts +631 -0
  42. package/src/extension/registration/commands/status.ts +60 -0
  43. package/src/extension/registration/commands.ts +13 -1224
  44. package/src/extension/registration/lifecycle-handlers.ts +126 -15
  45. package/src/extension/registration/subagent-tools.ts +218 -9
  46. package/src/extension/rpc-hmac.ts +5 -3
  47. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  48. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  49. package/src/extension/team-tool/api/task-claims.ts +109 -40
  50. package/src/extension/team-tool/cancel.ts +84 -50
  51. package/src/extension/team-tool/dispatch/index.ts +1 -0
  52. package/src/extension/team-tool/dispatch/run.ts +4 -1
  53. package/src/extension/team-tool/doctor.ts +103 -1
  54. package/src/extension/team-tool/orchestrate.ts +66 -1
  55. package/src/extension/team-tool/plans.ts +192 -0
  56. package/src/extension/team-tool/respond.ts +197 -65
  57. package/src/extension/team-tool/run-deadline.ts +16 -1
  58. package/src/extension/team-tool/run-intent.ts +63 -0
  59. package/src/extension/team-tool/run.ts +48 -18
  60. package/src/extension/team-tool/status.ts +84 -26
  61. package/src/extension/team-tool.ts +11 -2
  62. package/src/hooks/registry.ts +1 -6
  63. package/src/i18n.ts +9 -0
  64. package/src/prompt/prompt-runtime.ts +521 -2
  65. package/src/prompt/worker-events-channel.ts +173 -0
  66. package/src/runtime/README.md +8 -8
  67. package/src/runtime/async-runner.ts +7 -3
  68. package/src/runtime/background-runner.ts +42 -14
  69. package/src/runtime/broker/broker-issuer.ts +9 -2
  70. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  71. package/src/runtime/broker/crew-broker.ts +838 -10
  72. package/src/runtime/broker/wait-status-cache.ts +157 -0
  73. package/src/runtime/budget-enforcement.ts +281 -0
  74. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  75. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  76. package/src/runtime/child-pi/child-pi.ts +97 -201
  77. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  78. package/src/runtime/crew-agent-records.ts +259 -14
  79. package/src/runtime/delegate-spawn.ts +148 -0
  80. package/src/runtime/deterministic-ast.ts +2 -1
  81. package/src/runtime/dispatch-batch.ts +945 -0
  82. package/src/runtime/finalize-run.ts +557 -0
  83. package/src/runtime/goal-workflow/adaptive-plan.ts +87 -13
  84. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -2
  85. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  86. package/src/runtime/group-join.ts +11 -125
  87. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  88. package/src/runtime/merge-gate.ts +7 -1
  89. package/src/runtime/merge-loop.ts +130 -0
  90. package/src/runtime/model/model-budget-summary.ts +53 -0
  91. package/src/runtime/model/model-fallback.ts +32 -2
  92. package/src/runtime/model/pi-args.ts +10 -0
  93. package/src/runtime/model/provider-extensions.ts +10 -0
  94. package/src/runtime/orphan-worker-registry.ts +1 -1
  95. package/src/runtime/output/output-validator.ts +45 -0
  96. package/src/runtime/parent-guard.ts +3 -1
  97. package/src/runtime/peer-dep.ts +2 -1
  98. package/src/runtime/per-write-validator.ts +0 -5
  99. package/src/runtime/pi-spawn.ts +61 -15
  100. package/src/runtime/plan-approval.ts +125 -0
  101. package/src/runtime/plan-replan.ts +151 -0
  102. package/src/runtime/recovery/checkpoint.ts +0 -18
  103. package/src/runtime/recovery/crash-recovery.ts +95 -46
  104. package/src/runtime/scheduler-context.ts +98 -0
  105. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  106. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  107. package/src/runtime/scheduling/nested-slots.ts +70 -0
  108. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  109. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  110. package/src/runtime/scratchpad/README.md +10 -5
  111. package/src/runtime/scratchpad/guest.ts +103 -2
  112. package/src/runtime/scratchpad/protocol.ts +13 -1
  113. package/src/runtime/scratchpad/transform.ts +206 -12
  114. package/src/runtime/settings-store.ts +219 -0
  115. package/src/runtime/spawn-policy.ts +217 -0
  116. package/src/runtime/stale-reconciler.ts +87 -6
  117. package/src/runtime/subagent-manager.ts +25 -1
  118. package/src/runtime/task-output-context.ts +230 -9
  119. package/src/runtime/task-packet.ts +23 -1
  120. package/src/runtime/task-runner/child-executor.ts +106 -7
  121. package/src/runtime/task-runner/post-execution.ts +125 -1
  122. package/src/runtime/task-runner/pre-execution.ts +39 -1
  123. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  124. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  125. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  126. package/src/runtime/task-runner/state-helpers.ts +26 -24
  127. package/src/runtime/task-runner.ts +11 -0
  128. package/src/runtime/team-runner.ts +129 -1671
  129. package/src/runtime/verification/spec-sandbox.ts +255 -0
  130. package/src/runtime/verification/verification-gates.ts +3 -2
  131. package/src/runtime/verification/verification-worktree.ts +2 -1
  132. package/src/runtime/workflow-phase-advance.ts +100 -0
  133. package/src/runtime/workspace-tree.ts +9 -0
  134. package/src/schema/config-schema.ts +65 -25
  135. package/src/schema/sensitive-config-paths.ts +64 -0
  136. package/src/schema/team-tool-schema.ts +13 -3
  137. package/src/state/README.md +4 -10
  138. package/src/state/atomic-write.ts +20 -3
  139. package/src/state/contracts.ts +38 -0
  140. package/src/state/coordination/mailbox.ts +12 -2
  141. package/src/state/event-log/cursor.ts +223 -0
  142. package/src/state/event-log/event-log-rotation.ts +12 -4
  143. package/src/state/event-log/event-log.ts +152 -369
  144. package/src/state/event-log/sequence-cache.ts +373 -0
  145. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  146. package/src/state/stores/active-run-registry.ts +3 -2
  147. package/src/state/stores/manifest-io.ts +237 -0
  148. package/src/state/stores/ownership-map.ts +162 -0
  149. package/src/state/stores/plan-store.ts +241 -0
  150. package/src/state/stores/run-cache.ts +0 -90
  151. package/src/state/stores/spec-store.ts +189 -0
  152. package/src/state/stores/state-store.ts +66 -229
  153. package/src/state/types.ts +197 -0
  154. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  155. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  156. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  157. package/src/ui/heartbeat-aggregator.ts +9 -1
  158. package/src/ui/keybinding-map.ts +54 -13
  159. package/src/ui/powerbar-publisher.ts +52 -1
  160. package/src/ui/run-dashboard.ts +31 -5
  161. package/src/ui/run-snapshot-cache.ts +57 -30
  162. package/src/ui/snapshot-types.ts +6 -1
  163. package/src/ui/widget/widget-renderer.ts +9 -1
  164. package/src/utils/file-coalescer.ts +0 -4
  165. package/src/utils/fs-errno.ts +66 -0
  166. package/src/utils/fs-watch.ts +1 -1
  167. package/src/utils/internal-error.ts +3 -1
  168. package/src/utils/paths.ts +11 -3
  169. package/src/utils/task-name-generator.ts +1 -8
  170. package/src/workflows/discover-workflows.ts +19 -2
  171. package/src/workflows/validate-workflow.ts +7 -1
  172. package/src/workflows/workflow-config.ts +10 -0
  173. package/src/workflows/workflow-serializer.ts +3 -0
  174. package/src/worktree/worktree-manager.ts +22 -0
  175. package/src/agents/agent-search.ts +0 -98
  176. package/src/benchmark/benchmark-runner.ts +0 -313
  177. package/src/benchmark/feedback-loop.ts +0 -73
  178. package/src/config/resilient-parser.ts +0 -117
  179. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  180. package/src/extension/result-watcher.ts +0 -139
  181. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  182. package/src/observability/metric-retention.ts +0 -64
  183. package/src/runtime/compaction/compaction-summary.ts +0 -278
  184. package/src/runtime/errors/crew-errors.ts +0 -162
  185. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  186. package/src/runtime/loop-gates.ts +0 -128
  187. package/src/runtime/metric-parser.ts +0 -36
  188. package/src/runtime/output/stream-preview.ts +0 -184
  189. package/src/runtime/output/tool-progress.ts +0 -278
  190. package/src/runtime/phase-tracker.ts +0 -385
  191. package/src/runtime/pipeline-runner.ts +0 -523
  192. package/src/runtime/process/process-lifecycle.ts +0 -491
  193. package/src/runtime/recovery/retry-runner.ts +0 -330
  194. package/src/runtime/run-drift.ts +0 -219
  195. package/src/runtime/scratchpad/snapshot-hmac.ts +0 -167
  196. package/src/runtime/task-quality.ts +0 -199
  197. package/src/runtime/task-runner/run-projection.ts +0 -128
  198. package/src/runtime/verification/post-checks.ts +0 -142
  199. package/src/state/coordination/schedule.ts +0 -166
  200. package/src/state/event-log/jsonl-writer.ts +0 -115
  201. package/src/state/hook-instinct-bridge.ts +0 -94
  202. package/src/state/hook-integrations.ts +0 -51
  203. package/src/state/session-state-map.ts +0 -51
  204. package/src/state/stores/blob-store.ts +0 -308
  205. package/src/state/stores/instinct-store.ts +0 -275
  206. package/src/state/stores/observation-store.ts +0 -176
  207. package/src/state/tiered-eval.ts +0 -480
  208. package/src/state/types-eval.ts +0 -58
  209. package/src/tools/safe-bash-extension.ts +0 -54
  210. package/src/tools/safe-bash.ts +0 -505
  211. package/src/ui/agent-management-overlay.ts +0 -160
  212. package/src/ui/crew-footer.ts +0 -102
  213. package/src/ui/crew-select-list.ts +0 -114
  214. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  215. package/src/ui/transcript-entries.ts +0 -256
  216. package/src/utils/conflict-detect.ts +0 -721
  217. package/src/utils/fingerprint.ts +0 -180
  218. package/src/utils/gh-protocol.ts +0 -556
  219. package/src/utils/project-detector.ts +0 -160
  220. package/src/utils/sse-parser.ts +0 -131
  221. package/src/workflows/cost-estimator.ts +0 -34
  222. package/src/workflows/intermediate-store.ts +0 -166
@@ -0,0 +1,162 @@
1
+ /**
2
+ * WP-1/R1 — Unified agent identity (H6): the ownership map store.
3
+ *
4
+ * One ownership map PER RUN, persisted at `<manifest.stateRoot>/ownership-map.json`
5
+ * (stateRoot is already on `TeamRunManifest`). It records the
6
+ * `task ⇄ subagentId ⇄ pid ⇄ artifactsDir` links that both spawn paths contribute:
7
+ * - one-shot Agent-tool spawn (subagent-tools.ts): taskId + subagentId + artifactsDir (+ depth)
8
+ * - team-run child executor dispatch (child-executor.ts, onSpawn hook): taskId + pid + artifactsDir
9
+ *
10
+ * Because the file is scoped to a single run, `withRunLockSync(manifest)` (which
11
+ * locks `<stateRoot>/run.lock`) is the correct mutual-exclusion primitive: lock
12
+ * scope === file scope, so two runs never contend on the same lock for this file.
13
+ *
14
+ * DESIGN DECISION (documented deviation from the explorer-2 sketch): a
15
+ * workspace-wide map keyed by taskId was REJECTED because task ids are per-run
16
+ * (createTaskId output repeats across runs) — a workspace-wide
17
+ * `Record<taskId, entry>` would collide between concurrent runs, and a run-scoped
18
+ * lock would not serialize a workspace-scoped file. Consequently ALL read/write
19
+ * APIs are manifest-scoped; there is no cwd-only resolution path.
20
+ *
21
+ * Readers (widget/status/steer) use the read APIs, which are best-effort and
22
+ * lock-free (mirroring the `loadRunManifestById` precedent). Writers never throw
23
+ * (log + continue) — a failure must never break spawn/steer paths — and there is
24
+ * no `throw` inside any `finally`.
25
+ */
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import { logInternalError } from "../../utils/internal-error.ts";
29
+ import { atomicWriteJson, flushPendingAtomicWrites } from "../atomic-write.ts";
30
+ import { withRunLockSync } from "../coordination/locks.ts";
31
+ import type { TeamRunManifest } from "../types.ts";
32
+
33
+ /** One ownership link: task ⇄ subagentId ⇄ pid ⇄ artifacts dir, per run. */
34
+ export interface OwnershipEntry {
35
+ /** Key — team-run task id (createTaskId output; unique within a run). */
36
+ taskId: string;
37
+ /** The run this task belongs to (manifest.runId). */
38
+ runId: string;
39
+ /** Set by the one-shot Agent-tool spawn path (subagent-tools.ts). */
40
+ subagentId?: string;
41
+ /** Set by the child-executor onSpawn hook (real spawns only; last spawn
42
+ * attempt wins — model-fallback retries overwrite with the live pid). */
43
+ pid?: number;
44
+ /** manifest.artifactsRoot — where the task's steering/<taskId>.jsonl lives. */
45
+ artifactsDir: string;
46
+ /** Spawn depth (0 for a root one-shot). Optional for back-compat. */
47
+ depth?: number;
48
+ /** ISO-8601 timestamp; stamped at merge time when the caller omits it. */
49
+ updatedAt?: string;
50
+ }
51
+
52
+ export interface OwnershipMapFile {
53
+ version: 1;
54
+ entries: Record<string, OwnershipEntry>;
55
+ }
56
+
57
+ /** Fresh empty map — NEVER a shared singleton: callers may mutate the returned
58
+ * object's entries in upsert paths (fresh.entries[x] = ...), and a module-level
59
+ * constant would leak that mutation across runs (review finding R3: cross-run
60
+ * contamination of ownership-map.json). Allocate per call. */
61
+ function emptyOwnershipMap(): OwnershipMapFile {
62
+ return { version: 1, entries: {} };
63
+ }
64
+
65
+ /** Per-run ownership-map file, co-located with manifest.json / tasks.json. */
66
+ export function ownershipMapPath(manifest: TeamRunManifest): string {
67
+ return path.join(manifest.stateRoot, "ownership-map.json");
68
+ }
69
+
70
+ /**
71
+ * Best-effort read of the ownership map. Lock-free (readers only). ENOENT and
72
+ * corrupt JSON both yield the empty map — the store is regenerable, so it is
73
+ * never quarantined (unlike manifest STATE-3 handling). Never throws.
74
+ */
75
+ export function readOwnershipMap(manifest: TeamRunManifest): OwnershipMapFile {
76
+ try {
77
+ const raw = fs.readFileSync(ownershipMapPath(manifest), "utf-8");
78
+ const parsed = JSON.parse(raw) as unknown;
79
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyOwnershipMap();
80
+ const entries = (parsed as { entries?: unknown }).entries;
81
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) return emptyOwnershipMap();
82
+ return { version: 1, entries: entries as Record<string, OwnershipEntry> };
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
85
+ logInternalError("ownership-map.read", error, ownershipMapPath(manifest), "warn");
86
+ }
87
+ return emptyOwnershipMap();
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Upsert one entry under the run lock (withRunLockSync + fresh-reload pattern,
93
+ * mirroring persistSingleTaskUpdate in state-helpers.ts).
94
+ *
95
+ * Merge-not-overwrite: only the provided fields change, so the one-shot writer
96
+ * (subagentId) and the dispatch writer (pid) never clobber each other.
97
+ * Fresh-reload INSIDE the lock (BUG-028 lesson) + atomicWriteJson (temp+rename)
98
+ * keeps the write crash-safe. Best-effort: never throws into spawn/steer paths.
99
+ */
100
+ export function upsertOwnershipEntry(manifest: TeamRunManifest, entry: OwnershipEntry): void {
101
+ try {
102
+ withRunLockSync(manifest, () => {
103
+ // Defeat the atomic-write coalescer's stale-read window: force any
104
+ // pending buffered write for THIS file to land before we reload.
105
+ flushPendingAtomicWrites(ownershipMapPath(manifest));
106
+ const fresh = readOwnershipMap(manifest);
107
+ const prev = fresh.entries[entry.taskId] ?? {};
108
+ const merged: OwnershipEntry = {
109
+ ...prev,
110
+ ...entry,
111
+ taskId: entry.taskId,
112
+ updatedAt: entry.updatedAt ?? new Date().toISOString(),
113
+ };
114
+ // Merge contract: undefined-valued fields mean "not provided" — never
115
+ // persist them, or a later partial write (e.g. the dispatch writer
116
+ // adding pid) would clobber a value set by the other spawn path
117
+ // (e.g. subagentId). JSON.stringify would drop them anyway, but
118
+ // filtering here keeps the in-memory merge honest.
119
+ const clean = Object.fromEntries(Object.entries(merged).filter(([, value]) => value !== undefined)) as OwnershipEntry;
120
+ fresh.entries[entry.taskId] = clean;
121
+ atomicWriteJson(ownershipMapPath(manifest), fresh, { compact: true });
122
+ });
123
+ } catch (error) {
124
+ // Best-effort — a failure here must not break the spawn/steer caller.
125
+ logInternalError("ownership-map.write", error, `taskId=${entry.taskId}, runId=${entry.runId}`);
126
+ }
127
+ }
128
+
129
+ /** Resolve an entry by its task id (the map's key). Lock-free read. */
130
+ export function resolveEntryByTaskId(manifest: TeamRunManifest, taskId: string): OwnershipEntry | undefined {
131
+ return readOwnershipMap(manifest).entries[taskId];
132
+ }
133
+
134
+ /** Resolve an entry by subagent id (scan). Used by steer to find the taskId. */
135
+ export function resolveEntryBySubagentId(manifest: TeamRunManifest, subagentId: string): OwnershipEntry | undefined {
136
+ const entries = readOwnershipMap(manifest).entries;
137
+ for (const entry of Object.values(entries)) {
138
+ if (entry.subagentId === subagentId) return entry;
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ /**
144
+ * Explorer-2 contract alias for the dispatch writer: `recordOwnership(cwd,
145
+ * manifest, entry)`. The per-run file location derives from the manifest alone
146
+ * (task ids are per-run, so a cwd-only path cannot locate the map), so `cwd` is
147
+ * retained only for caller ergonomics and is not used for path resolution.
148
+ */
149
+ export function recordOwnership(_cwd: string, manifest: TeamRunManifest, entry: Partial<OwnershipEntry> & { taskId: string }): void {
150
+ const merged: OwnershipEntry = {
151
+ taskId: entry.taskId,
152
+ runId: entry.runId ?? manifest.runId,
153
+ artifactsDir: entry.artifactsDir ?? manifest.artifactsRoot,
154
+ updatedAt: entry.updatedAt ?? new Date().toISOString(),
155
+ };
156
+ // Only carry through the fields the caller actually provided — an explicit
157
+ // undefined must not clobber a value written by the other spawn path.
158
+ if (entry.subagentId !== undefined) merged.subagentId = entry.subagentId;
159
+ if (entry.pid !== undefined) merged.pid = entry.pid;
160
+ if (entry.depth !== undefined) merged.depth = entry.depth;
161
+ upsertOwnershipEntry(manifest, merged);
162
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * T2/R4 (ADR-4 docs/decisions/2026-08-17-plan-object.md) — first-class Plan
3
+ * object: the revision-list store.
4
+ *
5
+ * One file per run at `<manifest.stateRoot>/plans/plans.json` holding an
6
+ * APPEND-ONLY list of PlanRecord snapshots. Because the file is run-scoped,
7
+ * `withRunLockSync(manifest)` is the correct mutual-exclusion primitive (lock
8
+ * scope === file scope — same reasoning as ownership-map.ts).
9
+ *
10
+ * Write discipline:
11
+ * - Revision/approval mutations append an event (ADR-4 §9: plan.created /
12
+ * plan.revised / plan.approved / plan.rejected / plan.item.dropped).
13
+ * - The scheduler's `items[].taskIds` linkage (linkTaskToPlanItem) is a
14
+ * single-writer mutation of the CURRENT revision only and appends NO event
15
+ * (task dispatch already logs its own worker.* events).
16
+ * - Reads are lock-free: atomicWriteJson's tmp+rename means readers observe
17
+ * either the full old or the full new file, never a torn one.
18
+ *
19
+ * Migration (ADR-4 §2): `manifest.plan` is the pointer to the current
20
+ * revision. If the pointer is absent but records exist (crash between the
21
+ * plans.json append and the manifest save), `getCurrentPlanRecord` degrades to
22
+ * the highest-version record — the crash window is benign. Readers that must
23
+ * behave like the pre-v2 gate use `effectivePlanApprovalPending`
24
+ * (plan-record-first, manifest-fallback).
25
+ */
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import { logInternalError } from "../../utils/internal-error.ts";
29
+ import { atomicWriteJson } from "../atomic-write.ts";
30
+ import { withRunLockSync } from "../coordination/locks.ts";
31
+ import { appendEvent } from "../event-log/event-log.ts";
32
+ import type { PlanItemRecord, PlanRecord, TeamRunManifest, TeamTaskState } from "../types.ts";
33
+
34
+ interface PlanFile {
35
+ version: 1;
36
+ revisions: PlanRecord[];
37
+ }
38
+
39
+ const PLAN_SUBPATH = path.join("plans", "plans.json");
40
+
41
+ export function planFilePath(manifest: TeamRunManifest): string {
42
+ return path.join(manifest.stateRoot, PLAN_SUBPATH);
43
+ }
44
+
45
+ /** Lock-free best-effort read: missing file → []; corrupt file → [] + internal
46
+ * error log (readers must degrade, never throw — `loadRunManifestById`
47
+ * precedent). Never returns a shared mutable object. */
48
+ export function loadPlanRecords(manifest: TeamRunManifest): PlanRecord[] {
49
+ try {
50
+ const raw = fs.readFileSync(planFilePath(manifest), "utf-8");
51
+ const parsed = JSON.parse(raw) as Partial<PlanFile>;
52
+ if (!parsed || !Array.isArray(parsed.revisions)) return [];
53
+ return parsed.revisions.filter((r): r is PlanRecord => Boolean(r && r.id && typeof r.version === "number"));
54
+ } catch (err) {
55
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
56
+ logInternalError("plan-store.read-failed", err instanceof Error ? err : new Error(String(err)), `run=${manifest.runId}`);
57
+ return [];
58
+ }
59
+ }
60
+
61
+ /** Current revision = manifest.plan pointer when resolvable; else the highest
62
+ * version (benign crash-window fallback, header note). */
63
+ export function getCurrentPlanRecord(manifest: TeamRunManifest): PlanRecord | undefined {
64
+ const revisions = loadPlanRecords(manifest);
65
+ if (revisions.length === 0) return undefined;
66
+ const pointer = manifest.plan;
67
+ if (pointer) {
68
+ const pointed = revisions.find((r) => r.id === pointer.id && r.version === pointer.version);
69
+ if (pointed) return pointed;
70
+ }
71
+ return revisions.reduce((acc, r) => (r.version > acc.version ? r : acc), revisions[0] as PlanRecord);
72
+ }
73
+
74
+ function writePlanFile(manifest: TeamRunManifest, revisions: PlanRecord[]): void {
75
+ const file: PlanFile = { version: 1, revisions };
76
+ fs.mkdirSync(path.dirname(planFilePath(manifest)), { recursive: true });
77
+ atomicWriteJson(planFilePath(manifest), file);
78
+ }
79
+
80
+ /** ADR-4 §3: item ids are unique per revision; carried-over items keep their
81
+ * id across revisions (producer contract checked here — duplicate ids reject
82
+ * the append rather than corrupt linkage). */
83
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
84
+ const TITLE_MAX = 512;
85
+
86
+ /** Security hardening (review S4/S5): producer-supplied ids/titles are
87
+ * untrusted worker-influenced text — constrain charset/length at the store
88
+ * boundary so a poisoned record can't smuggle control chars, path
89
+ * separators, or unbounded blobs into downstream renders and events. */
90
+ function assertRecordWellFormed(record: PlanRecord): void {
91
+ const ids = new Set<string>();
92
+ for (const item of record.items) {
93
+ if (!ID_PATTERN.test(item.id))
94
+ throw new Error(`plan-store: item id must match [A-Za-z0-9][A-Za-z0-9._-]{0,127} — got "${item.id.slice(0, 40)}"`);
95
+ if (item.title.length > TITLE_MAX) throw new Error(`plan-store: item title exceeds ${TITLE_MAX} chars`);
96
+ if (ids.has(item.id)) throw new Error(`plan-store: duplicate item id "${item.id}" in revision v${record.version}`);
97
+ ids.add(item.id);
98
+ }
99
+ const known = new Set(record.items.map((i) => i.id));
100
+ for (const phase of record.phases) {
101
+ for (const itemId of phase.itemIds) {
102
+ if (!known.has(itemId)) throw new Error(`plan-store: phase "${phase.id}" references unknown item "${itemId}"`);
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Append a new revision under the run lock (withRunLockSync + fresh reload).
109
+ * Lineage rules: first record has version 1 and no revisionOf; later records
110
+ * reuse the SAME id, version = last + 1, and revisionOf must match the last
111
+ * revision (auto-filled when omitted). Throws on violation — callers are
112
+ * producers inside team-runner and a corrupt lineage must fail loudly, not
113
+ * silently fork history.
114
+ * Returns the stored record. Emits `plan.created` (v1) or `plan.revised` (v2+).
115
+ */
116
+ export function appendPlanRevision(manifest: TeamRunManifest, record: PlanRecord): PlanRecord {
117
+ return withRunLockSync(manifest, () => {
118
+ const revisions = loadPlanRecords(manifest);
119
+ if (revisions.length === 0) {
120
+ if (record.version !== 1) throw new Error(`plan-store: first revision must be v1, got v${record.version}`);
121
+ if (record.revisionOf) throw new Error("plan-store: first revision must not set revisionOf");
122
+ } else {
123
+ const last = revisions[revisions.length - 1] as PlanRecord;
124
+ if (record.id !== last.id) throw new Error(`plan-store: lineage break — expected id ${last.id}, got ${record.id}`);
125
+ if (record.version !== last.version + 1)
126
+ throw new Error(`plan-store: version must be ${last.version + 1}, got ${record.version}`);
127
+ if (!record.revisionOf) record.revisionOf = { id: last.id, version: last.version };
128
+ else if (record.revisionOf.id !== last.id || record.revisionOf.version !== last.version)
129
+ throw new Error(`plan-store: revisionOf must point at v${last.version} of ${last.id}`);
130
+ // ADR-4 §3 (review F1): item ids are stable across revisions — copy
131
+ // carried-over linkage forward INTO the new revision before it becomes
132
+ // current, under the same lock (producers never set taskIds; the union
133
+ // here is the scheduler-side re-link step the ADR specifies).
134
+ const carried = new Map(last.items.map((i) => [i.id, i.taskIds]));
135
+ for (const item of record.items) {
136
+ const prior = carried.get(item.id);
137
+ if (prior) item.taskIds = [...new Set([...prior, ...item.taskIds])];
138
+ }
139
+ }
140
+ assertRecordWellFormed(record);
141
+ revisions.push(record);
142
+ writePlanFile(manifest, revisions);
143
+ const dropped = record.items.filter((i) => i.status === "dropped").length;
144
+ appendEvent(manifest.eventsPath, {
145
+ type: record.version === 1 ? "plan.created" : "plan.revised",
146
+ runId: manifest.runId,
147
+ message:
148
+ record.version === 1
149
+ ? `Plan v1 created: ${record.items.length} item(s) in ${record.phases.length} phase(s)`
150
+ : `Plan v${record.version} revised (${record.items.length} item(s), ${dropped} dropped)`,
151
+ data: { planId: record.id, version: record.version, dropped },
152
+ });
153
+ return record;
154
+ });
155
+ }
156
+
157
+ /**
158
+ * ADR-4 §3 single-writer linkage: append a taskId to the CURRENT revision's
159
+ * item (dedup; idempotent). Dropped items refuse linkage (they are never
160
+ * re-dispatched). No event — see header. Returns true when the file changed.
161
+ */
162
+ export function linkTaskToPlanItem(manifest: TeamRunManifest, itemId: string, taskId: string): boolean {
163
+ return withRunLockSync(manifest, () => {
164
+ const revisions = loadPlanRecords(manifest);
165
+ if (revisions.length === 0) return false;
166
+ const current = revisions[revisions.length - 1] as PlanRecord;
167
+ const item = current.items.find((i: PlanItemRecord) => i.id === itemId);
168
+ if (!item || item.status === "dropped" || item.taskIds.includes(taskId)) return false;
169
+ item.taskIds.push(taskId);
170
+ writePlanFile(manifest, revisions);
171
+ return true;
172
+ });
173
+ }
174
+
175
+ /**
176
+ * ADR-4 §8 dual-write (record side): set approval on the current revision.
177
+ * `planVersion` must match the current revision — approving a superseded
178
+ * revision is a stale-write bug and throws. `plans reject` calls this with
179
+ * status "rejected" AND writes manifest.planApproval.status = "cancelled"
180
+ * (vocabulary mapping, §8). Emits plan.approved / plan.rejected.
181
+ */
182
+ export function setPlanApproval(
183
+ manifest: TeamRunManifest,
184
+ approval: { status: "approved" | "rejected" | "pending"; by?: string; planVersion: number },
185
+ ): PlanRecord | undefined {
186
+ return withRunLockSync(manifest, () => {
187
+ const revisions = loadPlanRecords(manifest);
188
+ if (revisions.length === 0) return undefined;
189
+ const current = revisions[revisions.length - 1] as PlanRecord;
190
+ if (approval.planVersion !== current.version)
191
+ throw new Error(`plan-store: approval targets v${approval.planVersion} but current is v${current.version}`);
192
+ current.approval = {
193
+ status: approval.status,
194
+ by: approval.by,
195
+ at: new Date().toISOString(),
196
+ planVersion: approval.planVersion,
197
+ };
198
+ writePlanFile(manifest, revisions);
199
+ // ADR-4 §9: events for approval MUTATIONS. `pending` emits none — the
200
+ // request surface (ensurePlanApprovalRequested) appends its own
201
+ // plan.approval_required event.
202
+ if (approval.status !== "pending") {
203
+ appendEvent(manifest.eventsPath, {
204
+ type: approval.status === "approved" ? "plan.approved" : "plan.rejected",
205
+ runId: manifest.runId,
206
+ message: `Plan v${approval.planVersion} ${approval.status}${approval.by ? ` by ${approval.by}` : ""}`,
207
+ data: { planId: current.id, version: approval.planVersion, status: approval.status },
208
+ });
209
+ }
210
+ return current;
211
+ });
212
+ }
213
+
214
+ /** Derived, never stored (ADR-4 §1): per-item progress from linked task statuses. */
215
+ export interface PlanItemProgress {
216
+ itemId: string;
217
+ total: number;
218
+ done: number;
219
+ failed: number;
220
+ running: number;
221
+ pending: number;
222
+ }
223
+
224
+ export function deriveItemProgress(record: PlanRecord, tasks: TeamTaskState[]): Map<string, PlanItemProgress> {
225
+ const byId = new Map(tasks.map((t) => [t.id, t]));
226
+ const out = new Map<string, PlanItemProgress>();
227
+ for (const item of record.items) {
228
+ const p: PlanItemProgress = { itemId: item.id, total: item.taskIds.length, done: 0, failed: 0, running: 0, pending: 0 };
229
+ for (const taskId of item.taskIds) {
230
+ const status = byId.get(taskId)?.status;
231
+ if (status === "completed") p.done++;
232
+ else if (status === "failed") p.failed++;
233
+ else if (status === "running" || status === "waiting" || status === "needs_attention") p.running++;
234
+ else if (status === "queued") p.pending++;
235
+ // cancelled/skipped: linked but resolved neither done nor pending — counted
236
+ // in `total` only (honest derivation; re-plan drops land here).
237
+ }
238
+ out.set(item.id, p);
239
+ }
240
+ return out;
241
+ }
@@ -6,8 +6,6 @@ import { atomicWriteJson } from "../atomic-write.ts";
6
6
  import { withFileLockSync } from "../coordination/locks.ts";
7
7
  import type { TeamTaskState } from "../types.ts";
8
8
 
9
- const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
10
-
11
9
  export interface CacheEntry {
12
10
  key: string;
13
11
  runId: string;
@@ -78,94 +76,6 @@ export function getCachedRun(cwd: string, cacheKey: string): CacheEntry | null {
78
76
  }
79
77
  }
80
78
 
81
- /**
82
- * Save run result to cache.
83
- */
84
- export function saveRunToCache(
85
- cwd: string,
86
- cacheKey: string,
87
- runId: string,
88
- status: string,
89
- tasks: TeamTaskState[],
90
- goal: string,
91
- team: string,
92
- ttlMs: number = DEFAULT_CACHE_TTL_MS,
93
- ): void {
94
- const dir = cacheDir(cwd);
95
-
96
- if (!fs.existsSync(dir)) {
97
- fs.mkdirSync(dir, { recursive: true });
98
- }
99
-
100
- const entry: CacheEntry = {
101
- key: cacheKey,
102
- runId,
103
- status,
104
- tasks,
105
- cachedAt: Date.now(),
106
- expiresAt: Date.now() + ttlMs,
107
- goal,
108
- team,
109
- };
110
-
111
- const entryPath = path.join(dir, `${cacheKey}.json`);
112
- // ST-4: atomic entry write (was raw writeFileSync — inverted priority vs the index).
113
- atomicWriteJson(entryPath, entry);
114
-
115
- // Update index under a file lock (prevents concurrent saveCache from racing on the
116
- // RMW) and write atomically (unique temp + rename — fixes the fixed "index.json.tmp"
117
- // name collision the old manual temp+rename had).
118
- const indexPath = path.join(dir, "index.json");
119
- withFileLockSync(indexPath, () => {
120
- // NEW-P4: TOCTOU fix — readFileSync + ENOENT catch instead of existsSync+read
121
- // (1 syscall, no race). Only ENOENT falls back to {}; a corrupt-but-present
122
- // index still throws, matching the old existsSync → JSON.parse behavior.
123
- let index: CacheIndex = {};
124
- try {
125
- index = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
126
- } catch (error) {
127
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
128
- }
129
- index[cacheKey] = entryPath;
130
- atomicWriteJson(indexPath, index);
131
- });
132
- }
133
-
134
- /**
135
- * Clear all cache entries.
136
- */
137
- export function clearCache(cwd: string): void {
138
- const dir = cacheDir(cwd);
139
- if (!fs.existsSync(dir)) return;
140
-
141
- const indexPath = path.join(dir, "index.json");
142
- if (fs.existsSync(indexPath)) {
143
- try {
144
- const index = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
145
- for (const entryPath of Object.values(index)) {
146
- try {
147
- fs.unlinkSync(entryPath);
148
- } catch {
149
- /* ignore */
150
- }
151
- }
152
- fs.unlinkSync(indexPath);
153
- } catch {
154
- /* ignore */
155
- }
156
- }
157
-
158
- // Remove entry files not in index
159
- const entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
160
- for (const entry of entries) {
161
- try {
162
- fs.unlinkSync(path.join(dir, entry));
163
- } catch {
164
- /* ignore */
165
- }
166
- }
167
- }
168
-
169
79
  /**
170
80
  * Get cache stats.
171
81
  */