@yemi33/minions 0.1.2447 → 0.1.2449

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 (315) hide show
  1. package/bin/cli-api-client.js +1 -1
  2. package/bin/install-internal-minions.js +1382 -44
  3. package/bin/install-layout.js +150 -0
  4. package/bin/minions.js +460 -167
  5. package/dashboard/docs/typography.md +65 -12
  6. package/dashboard/js/command-center.js +66 -7
  7. package/dashboard/js/detail-panel.js +36 -0
  8. package/dashboard/js/memory-panel.js +59 -12
  9. package/dashboard/js/qa.js +186 -45
  10. package/dashboard/js/refresh.js +148 -12
  11. package/dashboard/js/render-dispatch.js +3 -4
  12. package/dashboard/js/render-inbox.js +2 -2
  13. package/dashboard/js/render-other.js +3 -3
  14. package/dashboard/js/render-pipelines.js +14 -0
  15. package/dashboard/js/render-plans.js +57 -9
  16. package/dashboard/js/render-prd.js +132 -23
  17. package/dashboard/js/render-prs.js +195 -166
  18. package/dashboard/js/render-schedules.js +63 -3
  19. package/dashboard/js/render-utils.js +3 -3
  20. package/dashboard/js/render-watches.js +19 -3
  21. package/dashboard/js/render-work-items.js +238 -30
  22. package/dashboard/js/settings.js +205 -54
  23. package/dashboard/js/utils.js +51 -1
  24. package/dashboard/pages/home.html +1 -1
  25. package/dashboard/pages/qa.html +1 -16
  26. package/dashboard/pages/work.html +40 -0
  27. package/dashboard/shared/cc-limits.js +79 -0
  28. package/dashboard/shared/pr-filters.js +21 -38
  29. package/dashboard/shared/project-git-summary.js +1 -1
  30. package/dashboard/shared/record-filters.js +169 -0
  31. package/dashboard/shared/watches-source.js +1 -1
  32. package/dashboard/shared/welcome-popup.js +1 -1
  33. package/dashboard/shared/wi-filters.js +302 -0
  34. package/dashboard/slim/body.html +1 -0
  35. package/dashboard/slim/js/command-send.js +26 -0
  36. package/dashboard/slim/js/modals-tiles.js +380 -39
  37. package/dashboard/slim/js/status.js +13 -21
  38. package/dashboard/slim/layout.html +1 -0
  39. package/dashboard/slim/panel-bootstrap.js +6 -2
  40. package/dashboard/slim/styles.css +38 -0
  41. package/dashboard/styles.css +159 -55
  42. package/dashboard-build.js +52 -6
  43. package/dashboard.js +956 -423
  44. package/docs/README.md +11 -6
  45. package/docs/api-errors.md +2 -2
  46. package/docs/architecture-review-2026-07-09.md +1 -1
  47. package/docs/architecture.excalidraw +2 -2
  48. package/docs/auto-discovery.md +18 -9
  49. package/docs/branch-derivation.md +4 -4
  50. package/docs/capture-demos.js +39 -2
  51. package/docs/ci-runner-canary.md +123 -0
  52. package/docs/claude-md-propagation.md +2 -2
  53. package/docs/cloud-agent-dispatch.md +204 -0
  54. package/docs/command-center.md +7 -7
  55. package/docs/completion-reports.md +43 -20
  56. package/docs/constants.md +10 -3
  57. package/docs/constellation-bridge.md +134 -6
  58. package/docs/constellation-style-telemetry.md +4 -4
  59. package/docs/contracts/capability-protocol.v1.json +165 -0
  60. package/docs/cooldown-merge-semantics.md +12 -12
  61. package/docs/copilot-cli-schema.md +7 -7
  62. package/docs/cross-repo-plans.md +10 -10
  63. package/docs/dead-code-audit-retractions.md +5 -5
  64. package/docs/default-branch-ci.md +173 -0
  65. package/docs/deprecated.json +31 -31
  66. package/docs/design-inbox-entries-schema.md +3 -3
  67. package/docs/design-language.md +1051 -0
  68. package/docs/design-state-storage.md +11 -11
  69. package/docs/diagnostics-crash-reports.md +9 -9
  70. package/docs/diagnostics-memory.md +5 -5
  71. package/docs/documentation-audit-2026-07-09.md +7 -7
  72. package/docs/engine-restart.md +90 -5
  73. package/docs/harness-mode.md +1 -1
  74. package/docs/internal-install.md +338 -39
  75. package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
  76. package/docs/kb-pr3223-cascade-archiving.md +1 -1
  77. package/docs/kb-pr696-merge-conflict-docs.md +6 -6
  78. package/docs/kb-sweep.md +35 -35
  79. package/docs/keep-processes.md +1 -1
  80. package/docs/live-checkout-mode.md +30 -30
  81. package/docs/managed-spawn.md +18 -14
  82. package/docs/named-agents.md +7 -7
  83. package/docs/plan-lifecycle.md +69 -2
  84. package/docs/pr-author-identity.md +114 -0
  85. package/docs/pr-auto-fix-dispatch.md +19 -4
  86. package/docs/pr-comment-followup.md +6 -6
  87. package/docs/pr-review-fix-loop.md +59 -10
  88. package/docs/process-termination.md +40 -0
  89. package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
  90. package/docs/qa-runbook-lifecycle.md +367 -17
  91. package/docs/qa-runbooks.md +3 -3
  92. package/docs/rfc-completion-json.md +18 -18
  93. package/docs/runtime-adapters.md +26 -21
  94. package/docs/security.md +6 -6
  95. package/docs/self-improvement.md +4 -4
  96. package/docs/shared-lifecycle-module-map.md +473 -472
  97. package/docs/skills.md +52 -3
  98. package/docs/slim-ux/concepts.md +121 -116
  99. package/docs/specs/agent-configurability.md +18 -18
  100. package/docs/specs/agent-rename.md +18 -18
  101. package/docs/team-memory.md +38 -21
  102. package/docs/timeouts-and-liveness.md +118 -10
  103. package/docs/tutorials/01-install-and-connect.md +1 -1
  104. package/docs/watches.md +40 -39
  105. package/docs/workspace-manifests.md +4 -4
  106. package/docs/worktree-lifecycle.md +293 -14
  107. package/engine/README.md +46 -0
  108. package/engine/{ado-comment.js → ado/comment.js} +8 -8
  109. package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
  110. package/engine/{ado.js → ado/index.js} +417 -63
  111. package/engine/{ado-status.js → ado/status.js} +6 -8
  112. package/engine/{ado-token.js → ado/token.js} +1 -1
  113. package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
  114. package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
  115. package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
  116. package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
  117. package/engine/{harness-context.js → agents/harness-context.js} +5 -5
  118. package/engine/{harness.js → agents/harness.js} +3 -3
  119. package/engine/{llm.js → agents/llm.js} +18 -14
  120. package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
  121. package/engine/{playbook.js → agents/playbook.js} +155 -22
  122. package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
  123. package/engine/{preflight.js → agents/preflight.js} +29 -10
  124. package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
  125. package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
  126. package/engine/{steering.js → agents/steering.js} +5 -5
  127. package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
  128. package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
  129. package/engine/{api-validation.js → api/api-validation.js} +1 -1
  130. package/engine/api/bridge.js +787 -0
  131. package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
  132. package/engine/api/companion.js +560 -0
  133. package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
  134. package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
  135. package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
  136. package/engine/api-contracts/agent-content.js +4 -4
  137. package/engine/api-contracts/capability-manifest.js +236 -0
  138. package/engine/api-contracts/capability-protocol.js +333 -0
  139. package/engine/api-contracts/cc-ops.js +1 -1
  140. package/engine/api-contracts/config-runtime.js +5 -0
  141. package/engine/api-contracts/core.js +28 -1
  142. package/engine/api-contracts/index.js +100 -0
  143. package/engine/api-contracts/orchestration.js +18 -5
  144. package/engine/api-contracts/pull-requests.js +37 -6
  145. package/engine/api-contracts/qa-process.js +29 -6
  146. package/engine/api-contracts/work-plan-prd.js +21 -1
  147. package/engine/cloud/contract.js +212 -0
  148. package/engine/cloud/index.js +159 -0
  149. package/engine/{execution-model.js → core/execution-model.js} +1 -1
  150. package/engine/{features.js → core/features.js} +4 -4
  151. package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
  152. package/engine/{queries.js → core/queries.js} +201 -36
  153. package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
  154. package/engine/{shared.js → core/shared.js} +1637 -175
  155. package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
  156. package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
  157. package/engine/db/index.js +11 -2
  158. package/engine/db/migrations/002-dispatches.js +3 -3
  159. package/engine/db/migrations/003-work-items.js +1 -1
  160. package/engine/db/migrations/004-pull-requests.js +1 -1
  161. package/engine/db/migrations/006-metrics.js +1 -1
  162. package/engine/db/migrations/007-watches.js +2 -2
  163. package/engine/db/migrations/008-small-state.js +1 -1
  164. package/engine/db/migrations/009-qa.js +1 -1
  165. package/engine/db/migrations/010-pr-links.js +1 -1
  166. package/engine/db/migrations/011-remaining-state.js +1 -1
  167. package/engine/db/migrations/012-steering-deliveries.js +2 -2
  168. package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
  169. package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
  170. package/engine/db/migrations/015-plans-prds.js +0 -0
  171. package/engine/db/migrations/018-sql-only-cutover.js +2 -2
  172. package/engine/db/migrations/021-archived-work-items.js +1 -1
  173. package/engine/db/migrations/022-global-cc-session.js +1 -1
  174. package/engine/db/migrations/023-engine-state.js +1 -1
  175. package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
  176. package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
  177. package/engine/db/migrations/029-repair-reused-versions.js +20 -0
  178. package/engine/db/migrations/031-pr-author-identity.js +137 -0
  179. package/engine/{consolidation.js → memory/consolidation.js} +6 -6
  180. package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
  181. package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
  182. package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
  183. package/engine/{memory-store.js → memory/memory-store.js} +3 -3
  184. package/engine/{promotion.js → memory/promotion.js} +3 -3
  185. package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
  186. package/engine/{review-learning.js → memory/review-learning.js} +10 -5
  187. package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
  188. package/engine/{logs-store.js → observability/logs-store.js} +5 -5
  189. package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
  190. package/engine/{check-status.js → operations/check-status.js} +3 -3
  191. package/engine/{cli.js → operations/cli.js} +271 -113
  192. package/engine/{distribution.js → operations/distribution.js} +5 -6
  193. package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
  194. package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
  195. package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
  196. package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
  197. package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
  198. package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
  199. package/engine/{meeting.js → orchestration/meeting.js} +6 -16
  200. package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
  201. package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
  202. package/engine/{routing.js → orchestration/routing.js} +3 -3
  203. package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
  204. package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
  205. package/engine/{timeout.js → orchestration/timeout.js} +158 -109
  206. package/engine/{db-events.js → persistence/db-events.js} +2 -2
  207. package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
  208. package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
  209. package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
  210. package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
  211. package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
  212. package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
  213. package/engine/persistence/state-operations.js +350 -0
  214. package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
  215. package/engine/{issues.js → planning/issues.js} +2 -2
  216. package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
  217. package/engine/planning/prd-result-sidecar.js +190 -0
  218. package/engine/{prd-store.js → planning/prd-store.js} +17 -17
  219. package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
  220. package/engine/{projects.js → planning/projects.js} +10 -10
  221. package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
  222. package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
  223. package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
  224. package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
  225. package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
  226. package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
  227. package/engine/{process-utils.js → processes/process-utils.js} +599 -55
  228. package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
  229. package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
  230. package/engine/{comment-format.js → providers/comment-format.js} +5 -5
  231. package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
  232. package/engine/{gh-token.js → providers/gh-token.js} +4 -4
  233. package/engine/{github.js → providers/github.js} +131 -54
  234. package/engine/{pr-action.js → providers/pr-action.js} +13 -12
  235. package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
  236. package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
  237. package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
  238. package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
  239. package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
  240. package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
  241. package/engine/{pr-track.js → providers/pr-track.js} +11 -13
  242. package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
  243. package/engine/qa/auto-prd-qa.js +313 -0
  244. package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
  245. package/engine/qa/prd-session.js +240 -0
  246. package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
  247. package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
  248. package/engine/{qa-runs.js → qa/runs.js} +286 -15
  249. package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
  250. package/engine/qa/visual-journey.js +654 -0
  251. package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
  252. package/engine/qa-runners/maestro.js +3 -3
  253. package/engine/qa-runners/playwright.js +2 -2
  254. package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
  255. package/engine/recovery/stop-stack.js +607 -0
  256. package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
  257. package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
  258. package/engine/runtimes/claude.js +14 -12
  259. package/engine/runtimes/codex.js +8 -6
  260. package/engine/runtimes/copilot.js +17 -16
  261. package/engine/{watch-actions.js → watches/actions.js} +13 -13
  262. package/engine/{watches.js → watches/index.js} +43 -32
  263. package/engine/{watches-store.js → watches/store.js} +4 -4
  264. package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
  265. package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
  266. package/engine/worktrees/inventory.js +671 -0
  267. package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
  268. package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
  269. package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
  270. package/engine/worktrees/quarantine-refs.js +173 -0
  271. package/engine.js +1137 -208
  272. package/minions.js +147 -77
  273. package/package.json +10 -6
  274. package/playbooks/_pr-description-audit.md +110 -78
  275. package/playbooks/build-fix-complex.md +2 -0
  276. package/playbooks/fix.md +16 -12
  277. package/playbooks/implement-shared.md +2 -0
  278. package/playbooks/implement.md +19 -20
  279. package/playbooks/plan-to-prd.md +18 -3
  280. package/playbooks/qa-session-draft.md +136 -1
  281. package/playbooks/qa-session-execute.md +80 -2
  282. package/playbooks/qa-session-setup.md +17 -1
  283. package/playbooks/qa-validate.md +1 -1
  284. package/playbooks/setup.md +2 -0
  285. package/playbooks/shared-rules.md +25 -32
  286. package/playbooks/templates/followup-dispatch.md +4 -3
  287. package/playbooks/verify.md +1 -1
  288. package/prompts/cc-system.md +19 -27
  289. package/watch-plugins/README.md +92 -0
  290. package/watch-plugins/ado-author-prs.js +336 -0
  291. package/watch-plugins/gh-author-prs.js +375 -0
  292. package/watch-plugins/http.js +474 -0
  293. package/watch-plugins/teams-channel.js +869 -0
  294. package/docs/dev-composite-workflow.md +0 -101
  295. package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
  296. package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
  297. package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
  298. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  299. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  300. package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
  301. package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
  302. package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
  303. package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
  304. package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
  305. package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
  306. package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
  307. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  308. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  309. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  310. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  311. package/docs/visual-evidence-ci.md +0 -103
  312. package/engine/bridge.js +0 -379
  313. package/engine/quarantine-refs.js +0 -103
  314. package/engine/state-operations.js +0 -178
  315. /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
@@ -0,0 +1,350 @@
1
+ // Explicit operational tooling for the SQL-only runtime state store.
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { getDb } = require('../db');
7
+
8
+ /** How many ids per entity are sampled into a state summary. */
9
+ const IDENTITY_SAMPLE_LIMIT = 50;
10
+
11
+ function _quoteSqlString(value) {
12
+ return `'${String(value).replace(/'/g, "''")}'`;
13
+ }
14
+
15
+ /** Table names are interpolated into SQL, so they are validated, never trusted. */
16
+ function _tableNames(db) {
17
+ const names = db.prepare(`
18
+ SELECT name FROM sqlite_master
19
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
20
+ ORDER BY name
21
+ `).all().map(row => row.name);
22
+ for (const name of names) {
23
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`unsafe table name: ${name}`);
24
+ }
25
+ return names;
26
+ }
27
+
28
+ function checkState(db = getDb()) {
29
+ const schemaVersion = Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_version').get().version) || 0;
30
+ const quickCheck = db.prepare('PRAGMA quick_check').all().map(row => Object.values(row)[0]);
31
+ const foreignKeyViolations = db.prepare('PRAGMA foreign_key_check').all();
32
+ const invariants = {
33
+ orphanPrdItems: Number(db.prepare('SELECT COUNT(*) AS n FROM prd_items pi LEFT JOIN prds p ON p.id=pi.prd_id WHERE p.id IS NULL').get().n) || 0,
34
+ orphanVerifyPrs: Number(db.prepare('SELECT COUNT(*) AS n FROM prd_verify_prs vp LEFT JOIN prds p ON p.id=vp.prd_id WHERE p.id IS NULL').get().n) || 0,
35
+ brokenWorkItemPrdLinks: Number(db.prepare('SELECT COUNT(*) AS n FROM work_items wi LEFT JOIN prd_items pi ON pi.id=wi.prd_item_id WHERE wi.prd_item_id IS NOT NULL AND pi.id IS NULL').get().n) || 0,
36
+ };
37
+ const ok = quickCheck.length === 1
38
+ && quickCheck[0] === 'ok'
39
+ && foreignKeyViolations.length === 0
40
+ && Object.values(invariants).every(count => count === 0);
41
+ return { ok, schemaVersion, quickCheck, foreignKeyViolations, invariants };
42
+ }
43
+
44
+ /** Absolute path of the `main` database file behind an open connection. */
45
+ function _mainDbFile(db) {
46
+ try {
47
+ const row = db.prepare('PRAGMA database_list').all().find(entry => entry.name === 'main');
48
+ return row && row.file ? row.file : null;
49
+ } catch { return null; }
50
+ }
51
+
52
+ function _walBytes(db) {
53
+ const file = _mainDbFile(db);
54
+ if (!file) return 0;
55
+ try { return fs.statSync(`${file}-wal`).size; } catch { return 0; }
56
+ }
57
+
58
+ // Best-effort WAL quiescence. Deliberately PASSIVE: TRUNCATE/RESTART block
59
+ // until every reader is on the newest snapshot, so with the engine and
60
+ // dashboard connected they either burn the busy timeout and report `busy` (a
61
+ // RESULT ROW that `db.exec()` silently discards, which is how the old code
62
+ // claimed a checkpoint it never got) or raise outright and abort the whole
63
+ // backup. Quiescence is an optimisation here, not a correctness requirement,
64
+ // so every failure is captured and reported instead of thrown.
65
+ function _passiveCheckpoint(db) {
66
+ try {
67
+ const row = db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() || {};
68
+ const busy = Number(row.busy) || 0;
69
+ const log = Number(row.log);
70
+ const checkpointed = Number(row.checkpointed);
71
+ // `log` is the total frame count in the WAL and `checkpointed` the number
72
+ // already folded back into the database, so equality means fully quiesced.
73
+ // Both are -1 outside WAL mode, which is also quiesced.
74
+ const complete = busy === 0
75
+ && Number.isFinite(log)
76
+ && Number.isFinite(checkpointed)
77
+ && checkpointed >= log;
78
+ return { checkpointed: complete, error: null };
79
+ } catch (e) {
80
+ return { checkpointed: false, error: (e && e.message) ? e.message : String(e) };
81
+ }
82
+ }
83
+
84
+ function backupState(targetPath) {
85
+ if (!targetPath) throw new Error('state backup requires an output path');
86
+ const resolved = path.resolve(targetPath);
87
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
88
+ if (fs.existsSync(resolved)) throw new Error(`backup target already exists: ${resolved}`);
89
+ const db = getDb();
90
+ const walBytesAtStart = _walBytes(db);
91
+ const checkpoint = _passiveCheckpoint(db);
92
+ if (checkpoint.error) {
93
+ console.warn(`[state] backup: WAL checkpoint skipped, continuing (non-fatal): ${checkpoint.error}`);
94
+ }
95
+ // Authoritative step. VACUUM INTO takes a read transaction whose snapshot
96
+ // already includes committed WAL frames and writes a standalone, internally
97
+ // consistent database, so it is correct with or without the checkpoint above
98
+ // and is the ONLY step allowed to fail the backup. Never byte-copy state.db:
99
+ // that drops committed-but-uncheckpointed transactions.
100
+ db.exec(`VACUUM INTO ${_quoteSqlString(resolved)}`);
101
+ return {
102
+ ok: true,
103
+ path: resolved,
104
+ bytes: fs.statSync(resolved).size,
105
+ checkpointed: checkpoint.checkpointed,
106
+ walBytesAtStart,
107
+ };
108
+ }
109
+
110
+ function _sampleIds(db, table, column = 'id', limit = IDENTITY_SAMPLE_LIMIT) {
111
+ try {
112
+ return db.prepare(`SELECT "${column}" AS id FROM "${table}" ORDER BY "${column}" LIMIT ${Number(limit) | 0}`)
113
+ .all().map(row => String(row.id));
114
+ } catch { return []; }
115
+ }
116
+
117
+ /**
118
+ * Row-level census of the SQL state, used to prove state continuity across an
119
+ * install/migration/restore. The table list is derived from `sqlite_master` so
120
+ * tables added by future migrations are covered without touching this module.
121
+ */
122
+ function summarizeState(db = getDb()) {
123
+ const schemaVersion = Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_version').get().version) || 0;
124
+ const tables = {};
125
+ for (const table of _tableNames(db)) {
126
+ // A table that cannot be counted (an FTS shadow table on a read-only
127
+ // handle, say) records `null` — unknown, never a fabricated zero.
128
+ try { tables[table] = Number(db.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get().n) || 0; }
129
+ catch { tables[table] = null; }
130
+ }
131
+ const identities = {
132
+ workItems: _sampleIds(db, 'work_items'),
133
+ pullRequests: _sampleIds(db, 'pull_requests'),
134
+ };
135
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify({
136
+ schemaVersion,
137
+ tables: Object.keys(tables).sort().map(name => [name, tables[name]]),
138
+ })).digest('hex');
139
+ return { schemaVersion, tables, identities, fingerprint };
140
+ }
141
+
142
+ function _assertSummary(summary, label) {
143
+ if (!summary || typeof summary !== 'object' || !summary.tables || typeof summary.tables !== 'object') {
144
+ throw new Error(`compareStateSummaries: ${label} summary is missing or malformed`);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Pure comparison of two `summarizeState()` results. A table that was non-empty
150
+ * before and is empty after is a hard regression, so a fresh/empty database can
151
+ * never be reported as a match for a populated baseline.
152
+ *
153
+ * `opts.ignoreTables` opts specific tables out; everything else is compared.
154
+ * This module deliberately holds no policy about WHICH tables are durable —
155
+ * that judgement belongs to the caller, because it depends on what the caller is
156
+ * gating. The install-time gate in `bin/install-internal-minions.js` derives its
157
+ * `ignoreTables` from an explicit durable-table ALLOWLIST
158
+ * (`CONTINUITY_DURABLE_TABLES`) rather than a denylist of volatile tables: most
159
+ * of the SQL state legitimately shrinks across a package cutover, so a denylist
160
+ * fails open on every table nobody remembered to enumerate.
161
+ */
162
+ function compareStateSummaries(before, after, opts = {}) {
163
+ _assertSummary(before, 'before');
164
+ _assertSummary(after, 'after');
165
+ const ignore = new Set(Array.isArray(opts.ignoreTables) ? opts.ignoreTables : []);
166
+ const regressions = [];
167
+ const missingTables = [];
168
+ const emptyAfterNonEmptyBefore = [];
169
+ for (const table of Object.keys(before.tables).sort()) {
170
+ if (ignore.has(table)) continue;
171
+ if (!Object.prototype.hasOwnProperty.call(after.tables, table)) {
172
+ missingTables.push(table);
173
+ continue;
174
+ }
175
+ const beforeCount = before.tables[table];
176
+ const afterCount = after.tables[table];
177
+ // A null count means the table could not be counted on one side; treat it
178
+ // as unknown rather than inventing a regression.
179
+ if (!Number.isFinite(beforeCount) || !Number.isFinite(afterCount)) continue;
180
+ if (beforeCount > 0 && afterCount === 0) emptyAfterNonEmptyBefore.push(table);
181
+ if (afterCount < beforeCount) regressions.push({ table, before: beforeCount, after: afterCount });
182
+ }
183
+ const ok = regressions.length === 0 && missingTables.length === 0 && emptyAfterNonEmptyBefore.length === 0;
184
+ return { ok, regressions, missingTables, emptyAfterNonEmptyBefore };
185
+ }
186
+
187
+ /** Open a backup file read-only and run the same integrity + census checks against it. */
188
+ function verifyBackup(backupPath) {
189
+ if (!backupPath) throw new Error('state verify-backup requires a backup path');
190
+ const resolved = path.resolve(backupPath);
191
+ if (!fs.existsSync(resolved)) throw new Error(`backup not found: ${resolved}`);
192
+ const { installSqliteWarningFilter } = require('../db');
193
+ installSqliteWarningFilter();
194
+ const { DatabaseSync } = require('node:sqlite');
195
+ // Read-only so verifying can never mutate, WAL, or lock the artifact it is
196
+ // supposed to be vouching for.
197
+ const db = new DatabaseSync(resolved, { readOnly: true });
198
+ try {
199
+ const integrity = checkState(db);
200
+ const summary = summarizeState(db);
201
+ return { ok: integrity.ok, path: resolved, integrity, summary };
202
+ } finally {
203
+ try { db.close(); } catch { /* already closed */ }
204
+ }
205
+ }
206
+
207
+ function exportState(targetPath) {
208
+ if (!targetPath) throw new Error('state export requires an output path');
209
+ const resolved = path.resolve(targetPath);
210
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
211
+ const db = getDb();
212
+ const tables = _tableNames(db);
213
+ const data = {
214
+ format: 'minions-sql-diagnostic-export-v1',
215
+ exportedAt: new Date().toISOString(),
216
+ integrity: checkState(),
217
+ tables: {},
218
+ };
219
+ for (const table of tables) {
220
+ data.tables[table] = db.prepare(`SELECT * FROM "${table}"`).all();
221
+ }
222
+ const tmp = `${resolved}.tmp.${process.pid}`;
223
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
224
+ fs.renameSync(tmp, resolved);
225
+ return { ok: true, path: resolved, tables: tables.length, bytes: fs.statSync(resolved).size };
226
+ }
227
+
228
+ function _readJson(filePath, expected) {
229
+ if (!fs.existsSync(filePath)) return null;
230
+ const value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
231
+ if (expected === 'array' && !Array.isArray(value)) throw new Error(`expected array in ${filePath}`);
232
+ if (expected === 'object' && (!value || typeof value !== 'object' || Array.isArray(value))) {
233
+ throw new Error(`expected object in ${filePath}`);
234
+ }
235
+ return value;
236
+ }
237
+
238
+ function importLegacyJson(sourceRoot) {
239
+ if (!sourceRoot) throw new Error('state import-legacy-json requires a source directory');
240
+ const root = path.resolve(sourceRoot);
241
+ if (!fs.statSync(root).isDirectory()) throw new Error(`legacy source is not a directory: ${root}`);
242
+ const imported = {};
243
+ const replace = (modulePath, fn, value) => {
244
+ if (value == null) return;
245
+ require(modulePath)[fn](() => value);
246
+ imported[fn] = (imported[fn] || 0) + 1;
247
+ };
248
+
249
+ replace(
250
+ './small-state-store',
251
+ 'applyEngineStateMutation',
252
+ _readJson(path.join(root, 'engine', 'state.json'), 'object'),
253
+ );
254
+ replace('../dispatch-store', 'applyDispatchMutation', _readJson(path.join(root, 'engine', 'dispatch.json'), 'object'));
255
+ replace('../observability/metrics-store', 'applyMetricsMutation', _readJson(path.join(root, 'engine', 'metrics.json'), 'object'));
256
+ replace('../watches/store', 'applyWatchesMutation', _readJson(path.join(root, 'engine', 'watches.json'), 'array'));
257
+
258
+ const small = [
259
+ ['schedule-runs.json', 'applyScheduleRunsMutation', 'object'],
260
+ ['pipeline-runs.json', 'applyPipelineRunsMutation', 'object'],
261
+ ['managed-processes.json', 'applyManagedProcessesMutation', 'object'],
262
+ ['worktree-pool.json', 'applyWorktreePoolMutation', 'object'],
263
+ ['qa-runs.json', 'applyQaRunsMutation', 'array'],
264
+ ['qa-sessions.json', 'applyQaSessionsMutation', 'array'],
265
+ ['pr-links.json', 'applyPrLinksMutation', 'object'],
266
+ ['cooldowns.json', 'applyCooldownsMutation', 'object'],
267
+ ['pending-rebases.json', 'applyPendingRebasesMutation', 'array'],
268
+ ['cc-sessions.json', 'applyCcSessionsMutation', 'array'],
269
+ ['doc-sessions.json', 'applyDocSessionsMutation', 'object'],
270
+ ];
271
+ for (const [filename, fn, shape] of small) {
272
+ replace('./small-state-store', fn, _readJson(path.join(root, 'engine', filename), shape));
273
+ }
274
+ replace(
275
+ './small-state-store',
276
+ 'applyCcGlobalSessionMutation',
277
+ _readJson(path.join(root, 'engine', 'cc-session.json'), 'object'),
278
+ );
279
+
280
+ const workItemsStore = require('../planning/work-items-store');
281
+ const pullRequestsStore = require('./pull-requests-store');
282
+ const importScope = (scope, dir) => {
283
+ const workItems = _readJson(path.join(dir, 'work-items.json'), 'array');
284
+ if (workItems) {
285
+ workItemsStore.applyWorkItemsMutation(scope, () => workItems);
286
+ imported.workItemScopes = (imported.workItemScopes || 0) + 1;
287
+ }
288
+ const archivedWorkItems = _readJson(path.join(dir, 'work-items-archive.json'), 'array');
289
+ if (archivedWorkItems) {
290
+ workItemsStore.replaceArchivedWorkItems(scope, archivedWorkItems);
291
+ imported.archivedWorkItemScopes = (imported.archivedWorkItemScopes || 0) + 1;
292
+ }
293
+ const pullRequests = _readJson(path.join(dir, 'pull-requests.json'), 'array');
294
+ if (pullRequests) {
295
+ pullRequestsStore.applyPullRequestsMutation(scope, () => pullRequests);
296
+ imported.pullRequestScopes = (imported.pullRequestScopes || 0) + 1;
297
+ }
298
+ };
299
+ importScope('central', root);
300
+ try {
301
+ for (const entry of fs.readdirSync(path.join(root, 'projects'), { withFileTypes: true })) {
302
+ if (entry.isDirectory() && entry.name !== '.archived') {
303
+ importScope(entry.name, path.join(root, 'projects', entry.name));
304
+ }
305
+ }
306
+ } catch { /* projects are optional */ }
307
+
308
+ const prdStore = require('../planning/prd-store');
309
+ for (const [dir, archived] of [[path.join(root, 'prd'), false], [path.join(root, 'prd', 'archive'), true]]) {
310
+ let files = [];
311
+ try { files = fs.readdirSync(dir).filter(name => name.endsWith('.json')); } catch { continue; }
312
+ for (const filename of files) {
313
+ const prd = _readJson(path.join(dir, filename), 'object');
314
+ prdStore.writePrd(filename, prd, { archived });
315
+ imported.prds = (imported.prds || 0) + 1;
316
+ }
317
+ }
318
+
319
+ const logs = _readJson(path.join(root, 'engine', 'log.json'), 'array');
320
+ if (logs) {
321
+ const db = getDb();
322
+ const { withTransaction } = require('../db');
323
+ withTransaction(db, () => {
324
+ db.exec('DELETE FROM logs');
325
+ const insert = db.prepare('INSERT INTO logs (ts_ms, level, message, meta) VALUES (?, ?, ?, ?)');
326
+ for (const entry of logs) {
327
+ const timestamp = Date.parse(entry.timestamp);
328
+ const meta = { ...entry };
329
+ delete meta.timestamp;
330
+ delete meta.level;
331
+ delete meta.message;
332
+ const { level, message } = entry;
333
+ insert.run(Number.isFinite(timestamp) ? timestamp : Date.now(), level || 'info', String(message || ''), Object.keys(meta).length ? JSON.stringify(meta) : null);
334
+ }
335
+ });
336
+ imported.logs = logs.length;
337
+ }
338
+ return { ok: true, source: root, imported, integrity: checkState() };
339
+ }
340
+
341
+ module.exports = {
342
+ checkState,
343
+ backupState,
344
+ summarizeState,
345
+ compareStateSummaries,
346
+ verifyBackup,
347
+ exportState,
348
+ importLegacyJson,
349
+ _passiveCheckpoint, // exported for testing
350
+ };
@@ -1,8 +1,8 @@
1
- // engine/steering-store.js — SQL-backed observable delivery state for
1
+ // engine/persistence/steering-store.js — SQL-backed observable delivery state for
2
2
  // inbox steering messages.
3
3
  //
4
4
  // One row per steering message in the steering_deliveries table.
5
- // Mirrors the shape of engine/dispatch-store.js / engine/small-state-store.js:
5
+ // Mirrors the shape of engine/persistence/dispatch-store.js / engine/persistence/small-state-store.js:
6
6
  // - routes every read/write through getDb() (no JSON sidecar)
7
7
  // - emits emitStateEvent('steering', {agentId, id, status}) on every
8
8
  // status transition so the dashboard's MAX(events.id) cache check
@@ -80,7 +80,7 @@ function insert(rec) {
80
80
  throw new Error(`steering-store.insert: invalid status '${status}'`);
81
81
  }
82
82
 
83
- const { getDb } = require('./db');
83
+ const { getDb } = require('../db');
84
84
  const db = getDb();
85
85
  const existing = db.prepare('SELECT * FROM steering_deliveries WHERE id = ?').get(id);
86
86
  if (existing) return _rowToRecord(existing);
@@ -120,7 +120,7 @@ function updateStatus(id, status, opts = {}) {
120
120
  if (!VALID_STATUSES.has(status)) {
121
121
  throw new Error(`steering-store.updateStatus: invalid status '${status}'`);
122
122
  }
123
- const { getDb } = require('./db');
123
+ const { getDb } = require('../db');
124
124
  const db = getDb();
125
125
  const row = db.prepare('SELECT * FROM steering_deliveries WHERE id = ?').get(id);
126
126
  if (!row) return null;
@@ -159,7 +159,7 @@ function listForAgent(agentId, opts = {}) {
159
159
  throw new Error(`steering-store.listForAgent: invalid status '${status}'`);
160
160
  }
161
161
  let db;
162
- try { const { getDb } = require('./db'); db = getDb(); }
162
+ try { const { getDb } = require('../db'); db = getDb(); }
163
163
  catch { return []; }
164
164
  const statusClause = status === null ? '' : ' AND status = ?';
165
165
  const params = status === null
@@ -177,7 +177,7 @@ function listForAgent(agentId, opts = {}) {
177
177
  function getById(id) {
178
178
  if (!id) return null;
179
179
  let db;
180
- try { const { getDb } = require('./db'); db = getDb(); }
180
+ try { const { getDb } = require('../db'); db = getDb(); }
181
181
  catch { return null; }
182
182
  const row = db.prepare('SELECT * FROM steering_deliveries WHERE id = ?').get(String(id));
183
183
  return _rowToRecord(row);
@@ -5,8 +5,8 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { execFileSync: _execFileSync } = require('child_process');
8
- const shared = require('./shared');
9
- const ghToken = require('./gh-token');
8
+ const shared = require('../core/shared');
9
+ const ghToken = require('../providers/gh-token');
10
10
 
11
11
  const DEFAULT_REPO = 'opg-microsoft/minions';
12
12
  const DEFAULT_LABELS = ['bug'];
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('path');
4
- const api = require('./api-validation');
5
- const shared = require('./shared');
4
+ const api = require('../api/api-validation');
5
+ const shared = require('../core/shared');
6
6
 
7
7
  const COMPLEXITY_VALUES = Object.freeze(['small', 'medium', 'large']);
8
8
  const BRANCH_STRATEGY_VALUES = Object.freeze(['parallel', 'shared-branch']);
@@ -37,6 +37,12 @@ const PLAN_ACTION_STATUSES = Object.freeze({
37
37
  shared.PLAN_STATUS.AWAITING_APPROVAL,
38
38
  shared.PLAN_STATUS.APPROVED,
39
39
  shared.PLAN_STATUS.PAUSED,
40
+ // W-msa0mrus00mh466f — a PRD already in `revision-requested` is gated by the
41
+ // handler's own revision state machine, not by this table: an ACTIVE
42
+ // revision is rejected with `revision-in-progress` (plus the existing work
43
+ // item id), while a FAILED/dangling one must stay re-requestable so the PRD
44
+ // is not stranded in `revision-requested` with no way out.
45
+ shared.PLAN_STATUS.REVISION_REQUESTED,
40
46
  ]),
41
47
  discuss: new Set([
42
48
  shared.PLAN_STATUS.AWAITING_APPROVAL,
@@ -0,0 +1,190 @@
1
+ // engine/planning/prd-result-sidecar.js
2
+ //
3
+ // The one-shot plan-to-PRD result sidecar contract (W-ms9tcry701xz0389).
4
+ //
5
+ // A plan-to-prd agent writes exactly ONE envelope to
6
+ // `agents/<agent-id>/prd-result.json`:
7
+ //
8
+ // { "filename": "<expected>.json", "work_item": "W-…", "prd": { … } }
9
+ //
10
+ // The engine validates that envelope against the work item's PERSISTED
11
+ // expected identity (`item._prdFilename`, stable across retries) and imports
12
+ // it into the SQL PRD store. Both import sites — the pre-spawn recovery in
13
+ // `engine.js#discoverCentralWorkItems` and the post-completion import in
14
+ // `engine/orchestration/lifecycle.js` — go through this module so the
15
+ // validation and consume semantics can never drift apart.
16
+ //
17
+ // Two invariants this module exists to hold:
18
+ //
19
+ // 1. FAIL CLOSED. There is no permissive filename fallback: an envelope that
20
+ // does not DECLARE the expected filename is rejected rather than assumed
21
+ // to be ours. Combined with the source-plan check (and the optional
22
+ // `work_item` binding) a sidecar left behind by a different work item or
23
+ // a different source plan can never be imported.
24
+ // 2. EXACTLY ONCE. The sidecar is CLAIMED with an atomic rename before the
25
+ // PRD is written, and the claim is removed after a successful import. A
26
+ // concurrent or repeated consume finds no file, so one sidecar can never
27
+ // materialize two PRDs, and a consumed sidecar can never be inherited by
28
+ // a later unrelated dispatch from the same agent.
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+
33
+ const shared = require('../core/shared');
34
+
35
+ const PRD_RESULT_FILENAME = 'prd-result.json';
36
+ // Sibling of the sidecar, inside the same agent directory, so the claim rename
37
+ // is always an intra-directory (atomic) move on every supported platform.
38
+ const PRD_RESULT_CLAIM_FILENAME = 'prd-result.claimed.json';
39
+
40
+ // Stable outcome codes. Callers branch on these, never on the prose `reason`.
41
+ const PRD_RESULT_OUTCOME = Object.freeze({
42
+ IMPORTED: 'imported',
43
+ ABSENT: 'absent',
44
+ NO_AGENT: 'no-agent',
45
+ UNREADABLE: 'unreadable',
46
+ REJECTED: 'rejected',
47
+ ALREADY_CONSUMED: 'already-consumed',
48
+ CLAIM_FAILED: 'claim-failed',
49
+ IMPORT_FAILED: 'import-failed',
50
+ });
51
+
52
+ /** Absolute path of an agent's one-shot PRD result sidecar. */
53
+ function prdResultSidecarPath(agentsDir, agentId) {
54
+ if (!agentsDir || !agentId) return null;
55
+ return path.join(agentsDir, String(agentId), PRD_RESULT_FILENAME);
56
+ }
57
+
58
+ /**
59
+ * Validate a parsed sidecar envelope against the dispatch's authoritative
60
+ * expectations. Returns `{ ok: true, filename, prd }` or
61
+ * `{ ok: false, reason }` — never throws for malformed agent input.
62
+ *
63
+ * `workItemId` is enforced only when the envelope declares `work_item`: the
64
+ * field is newer than the filename/source-plan binding, and an envelope that
65
+ * matches BOTH the expected filename and the source plan is by construction
66
+ * the PRD for this work item's plan (same-plan work items converge on one
67
+ * PRD filename by design).
68
+ */
69
+ function validatePrdResultEnvelope(envelope, { expectedFilename, planFile, workItemId } = {}) {
70
+ if (!expectedFilename) return { ok: false, reason: 'no expected PRD filename is pinned on the work item' };
71
+ if (!planFile) return { ok: false, reason: 'work item has no planFile to validate the sidecar against' };
72
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
73
+ return { ok: false, reason: 'sidecar is not a JSON object' };
74
+ }
75
+
76
+ const declared = envelope.filename;
77
+ if (typeof declared !== 'string' || !declared) {
78
+ return { ok: false, reason: `sidecar does not declare a "filename" (expected "${expectedFilename}")` };
79
+ }
80
+ if (declared !== expectedFilename) {
81
+ return { ok: false, reason: `sidecar filename "${declared}" does not match expected "${expectedFilename}"` };
82
+ }
83
+
84
+ const declaredWi = envelope.work_item;
85
+ if (declaredWi != null && declaredWi !== '' && workItemId && String(declaredWi) !== String(workItemId)) {
86
+ return { ok: false, reason: `sidecar work_item "${declaredWi}" belongs to another work item (expected "${workItemId}")` };
87
+ }
88
+
89
+ const prd = envelope.prd;
90
+ if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
91
+ return { ok: false, reason: 'sidecar has no "prd" object' };
92
+ }
93
+ if (!shared.prdMatchesSourcePlan(prd.source_plan, planFile)) {
94
+ return { ok: false, reason: `sidecar source_plan "${prd.source_plan ?? '(unknown)'}" does not match "${planFile}"` };
95
+ }
96
+
97
+ try {
98
+ require('./prd-store').validatePrdFilename(declared);
99
+ } catch (err) {
100
+ return { ok: false, reason: `sidecar filename is not importable: ${err.message}` };
101
+ }
102
+
103
+ return { ok: true, filename: declared, prd };
104
+ }
105
+
106
+ /**
107
+ * Read → validate → atomically claim → import → remove an agent's one-shot
108
+ * PRD result sidecar.
109
+ *
110
+ * Returns `{ imported, code, reason, filename }`. `code` is the stable,
111
+ * machine-readable outcome (`PRD_RESULT_OUTCOME.*`) — callers branch on it
112
+ * rather than on the human-readable `reason` prose. A rejected sidecar is left
113
+ * in place untouched (it may legitimately belong to a sibling work item that
114
+ * has not been retried yet); only a sidecar this call successfully imported is
115
+ * removed. Never throws.
116
+ *
117
+ * `revisionForPrd` is the work item's `_revisionForPrd` reverse stamp
118
+ * (W-msa0mrus00mh466f). When it names the PRD being imported, the envelope is
119
+ * normalized through `shared.applyPrdRevisionCompletion` as part of the SAME
120
+ * `writePrd` that imports it, so a completed revision is never observable
121
+ * alongside the stale recovery controls it just resolved. The rule lives here
122
+ * — beside the only write — for the same reason the validation does: both
123
+ * import sites must apply it identically or a recovered revision silently
124
+ * lands in a different state than a completed one.
125
+ */
126
+ function consumePrdResultSidecar({
127
+ agentsDir, agentId, expectedFilename, planFile, workItemId, revisionForPrd, plansDir,
128
+ } = {}) {
129
+ const resultPath = prdResultSidecarPath(agentsDir, agentId);
130
+ if (!resultPath) {
131
+ return { imported: false, code: PRD_RESULT_OUTCOME.NO_AGENT, reason: 'no agent to read a sidecar from', filename: null };
132
+ }
133
+ if (!fs.existsSync(resultPath)) {
134
+ return { imported: false, code: PRD_RESULT_OUTCOME.ABSENT, reason: 'no sidecar on disk', filename: null };
135
+ }
136
+
137
+ const envelope = shared.safeJsonNoRestore(resultPath);
138
+ if (envelope === null) {
139
+ return { imported: false, code: PRD_RESULT_OUTCOME.UNREADABLE, reason: 'sidecar is not readable JSON', filename: null };
140
+ }
141
+
142
+ const verdict = validatePrdResultEnvelope(envelope, { expectedFilename, planFile, workItemId });
143
+ if (!verdict.ok) {
144
+ return { imported: false, code: PRD_RESULT_OUTCOME.REJECTED, reason: verdict.reason, filename: null };
145
+ }
146
+
147
+ // Atomic claim: whoever wins the rename owns the import. A loser (or a
148
+ // repeat call) sees ENOENT and reports "already consumed" instead of
149
+ // materializing the same PRD twice.
150
+ const claimPath = path.join(path.dirname(resultPath), PRD_RESULT_CLAIM_FILENAME);
151
+ try {
152
+ fs.renameSync(resultPath, claimPath);
153
+ } catch (err) {
154
+ if (err && err.code === 'ENOENT') {
155
+ return { imported: false, code: PRD_RESULT_OUTCOME.ALREADY_CONSUMED, reason: 'sidecar already consumed', filename: null };
156
+ }
157
+ return { imported: false, code: PRD_RESULT_OUTCOME.CLAIM_FAILED, reason: `could not claim sidecar: ${err.message}`, filename: null };
158
+ }
159
+
160
+ try {
161
+ const prdStore = require('./prd-store');
162
+ // A revision import is normalized into the SAME write that imports it.
163
+ // Gated on the structural `_revisionForPrd` ⇄ expected-filename relation
164
+ // (never title text), so a plain regeneration is written untouched.
165
+ const finalPrd = revisionForPrd && revisionForPrd === expectedFilename
166
+ ? shared.applyPrdRevisionCompletion(verdict.prd, {
167
+ plansDir,
168
+ previous: prdStore.readPrd(expectedFilename),
169
+ })
170
+ : verdict.prd;
171
+ prdStore.writePrd(verdict.filename, finalPrd);
172
+ } catch (err) {
173
+ // Import failed — put the sidecar back so the content is not lost and a
174
+ // later attempt (or the agent's own retry) can still recover it.
175
+ try { fs.renameSync(claimPath, resultPath); } catch { /* best effort */ }
176
+ return { imported: false, code: PRD_RESULT_OUTCOME.IMPORT_FAILED, reason: `PRD import failed: ${err.message}`, filename: null };
177
+ }
178
+
179
+ try { fs.unlinkSync(claimPath); } catch { /* claim file is inert once imported */ }
180
+ return { imported: true, code: PRD_RESULT_OUTCOME.IMPORTED, reason: null, filename: verdict.filename };
181
+ }
182
+
183
+ module.exports = {
184
+ PRD_RESULT_FILENAME,
185
+ PRD_RESULT_CLAIM_FILENAME,
186
+ PRD_RESULT_OUTCOME,
187
+ prdResultSidecarPath,
188
+ validatePrdResultEnvelope,
189
+ consumePrdResultSidecar,
190
+ };