@yemi33/minions 0.1.2379 → 0.1.2380

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 (96) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +2 -3
  3. package/dashboard/js/render-prd.js +1 -1
  4. package/dashboard/js/render-work-items.js +13 -6
  5. package/dashboard.js +393 -721
  6. package/docs/architecture-review-2026-07-09.md +2 -4
  7. package/docs/auto-discovery.md +36 -49
  8. package/docs/blog-first-successful-dispatch.md +1 -1
  9. package/docs/branch-derivation.md +2 -2
  10. package/docs/command-center.md +1 -1
  11. package/docs/completion-reports.md +14 -4
  12. package/docs/constellation-bridge.md +59 -10
  13. package/docs/constellation-style-telemetry.md +6 -6
  14. package/docs/cooldown-merge-semantics.md +4 -0
  15. package/docs/copilot-cli-schema.md +3 -3
  16. package/docs/cross-repo-plans.md +17 -17
  17. package/docs/deprecated.json +2 -2
  18. package/docs/design-state-storage.md +1 -1
  19. package/docs/documentation-audit-2026-07-09.md +2 -2
  20. package/docs/engine-restart.md +20 -8
  21. package/docs/harness-mode.md +1 -1
  22. package/docs/managed-spawn.md +4 -4
  23. package/docs/onboarding.md +1 -2
  24. package/docs/pr-comment-followup.md +3 -3
  25. package/docs/pr-review-fix-loop.md +1 -1
  26. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  27. package/docs/qa-runbook-lifecycle.md +4 -4
  28. package/docs/qa-runbooks.md +2 -2
  29. package/docs/rfc-completion-json.md +4 -1
  30. package/docs/runtime-adapters.md +1 -1
  31. package/docs/self-improvement.md +4 -5
  32. package/docs/shared-lifecycle-module-map.md +3 -1
  33. package/docs/slim-ux/architecture-suggestions.md +5 -6
  34. package/docs/slim-ux/concepts.md +23 -25
  35. package/docs/watches.md +7 -7
  36. package/docs/workspace-manifests.md +1 -1
  37. package/docs/worktree-lifecycle.md +1 -1
  38. package/engine/abandoned-pr-reconciliation.js +4 -5
  39. package/engine/ado-status.js +5 -5
  40. package/engine/ado.js +20 -25
  41. package/engine/agent-worker-pool.js +58 -1
  42. package/engine/bridge.js +260 -5
  43. package/engine/cleanup.js +48 -131
  44. package/engine/cli.js +125 -83
  45. package/engine/cooldown.js +9 -16
  46. package/engine/db/index.js +22 -9
  47. package/engine/db/migrations/009-qa.js +1 -1
  48. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  49. package/engine/db/migrations/021-archived-work-items.js +72 -0
  50. package/engine/db/migrations/022-global-cc-session.js +31 -0
  51. package/engine/db/migrations/023-engine-state.js +30 -0
  52. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  53. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  54. package/engine/dispatch-store.js +2 -7
  55. package/engine/dispatch.js +34 -41
  56. package/engine/github.js +20 -27
  57. package/engine/lifecycle.js +221 -283
  58. package/engine/llm.js +1 -1
  59. package/engine/logs-store.js +2 -2
  60. package/engine/managed-spawn.js +2 -23
  61. package/engine/meeting.js +6 -6
  62. package/engine/metrics-store.js +2 -2
  63. package/engine/note-link-backfill.js +6 -11
  64. package/engine/pipeline.js +18 -36
  65. package/engine/playbook.js +1 -1
  66. package/engine/prd-store.js +73 -54
  67. package/engine/preflight.js +2 -5
  68. package/engine/projects.js +15 -62
  69. package/engine/pull-requests-store.js +0 -17
  70. package/engine/qa-runbooks.js +2 -2
  71. package/engine/qa-runs.js +1 -8
  72. package/engine/qa-sessions.js +41 -64
  73. package/engine/queries.js +120 -219
  74. package/engine/routing.js +3 -5
  75. package/engine/scheduler.js +0 -4
  76. package/engine/shared-branch-pr-reconcile.js +2 -3
  77. package/engine/shared.js +132 -637
  78. package/engine/small-state-store.js +89 -10
  79. package/engine/state-operations.js +16 -4
  80. package/engine/stdio-timestamps.js +1 -1
  81. package/engine/timeout.js +5 -12
  82. package/engine/watch-actions.js +20 -22
  83. package/engine/watches-store.js +1 -1
  84. package/engine/watches.js +6 -10
  85. package/engine/work-item-validation.js +52 -0
  86. package/engine/work-items-store.js +127 -29
  87. package/engine/worktree-gc.js +2 -2
  88. package/engine/worktree-pool.js +8 -18
  89. package/engine.js +147 -343
  90. package/minions.js +2 -2
  91. package/package.json +1 -1
  92. package/playbooks/plan-to-prd.md +2 -2
  93. package/playbooks/shared-rules.md +3 -3
  94. package/playbooks/templates/followup-dispatch.md +1 -1
  95. package/playbooks/verify.md +1 -1
  96. package/prompts/cc-system.md +2 -2
package/engine/shared.js CHANGED
@@ -71,25 +71,16 @@ const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
71
71
  // don't collide with heap-snapshot pruning.
72
72
  const CRASH_REPORTS_DIR = path.join(ENGINE_DIR, 'diagnostics', 'crash-reports');
73
73
  const CONTROL_PATH = path.join(ENGINE_DIR, 'control.json');
74
- const COOLDOWNS_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
75
74
  // W-mp60tw0u000j3931: Persistent cross-restart engine state (migration markers,
76
75
  // one-shot reconciliation versions, etc.). Distinct from CONTROL_PATH which is
77
76
  // process-lifetime (state/pid/ownerToken). See ENGINE_DEFAULTS.abandonedReconciliationVersion
78
77
  // for the first consumer.
79
- const ENGINE_STATE_PATH = path.join(ENGINE_DIR, 'state.json');
80
- // Phase 9.3: wrap raw safeWrite callers for kb-checkpoint, kb-swept,
81
- // kb-sweep-state, and test-results so they go through mutateJsonFileLocked
82
- // (the same path mutateControl / mutateEngineState / mutateCooldowns use).
83
- // This sets them up for SQL routing via _SMALL_STATE_MUTATE_ROUTES later, and
84
- // in the case of kb-sweep-state.json closes a real cross-process race (the
85
- // dashboard process and the detached kb-sweep-runner child both write it).
78
+ // File-backed coordination and diagnostic state uses lock-safe mutators.
86
79
  const KB_CHECKPOINT_PATH = path.join(ENGINE_DIR, 'kb-checkpoint.json');
87
80
  const KB_SWEPT_PATH = path.join(ENGINE_DIR, 'kb-swept.json');
88
81
  const KB_SWEEP_STATE_PATH = path.join(ENGINE_DIR, 'kb-sweep-state.json');
89
82
  const TEST_RESULTS_PATH = path.join(ENGINE_DIR, 'test-results.json');
90
- const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
91
83
  const PINNED_ITEMS_PATH = path.join(MINIONS_DIR, 'engine', 'kb-pins.json');
92
- const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
93
84
  // Constellation-bridge cross-repo marker (P-wi1-bridge-readonly). Written by the
94
85
  // Constellation agent's bridge on every successful poll; read by `minions bridge
95
86
  // status` to surface the last-seen timestamp. The engine itself never writes
@@ -340,7 +331,7 @@ function buildEditsSeen(comments, prev, cap) {
340
331
 
341
332
  // ── Secret Redaction (SEC-09) ──────────────────────────────────────────────
342
333
  // Pure, side-effect-free redactor applied to every entry on the log write path
343
- // so ADO tokens, JWTs, and azureauth stdout dumps never land in engine/log.json
334
+ // so ADO tokens, JWTs, and azureauth stdout dumps never land in persisted logs
344
335
  // (2500-entry ring buffer readable by any local process).
345
336
  //
346
337
  // Replacements (order matters — azureauth first, then Bearer, then bare JWT):
@@ -464,7 +455,7 @@ let _logFlushTimer = null;
464
455
  function log(level, msg, meta = {}) {
465
456
  // SEC-09: redact sensitive patterns (ADO tokens, JWTs, azureauth stdout)
466
457
  // before both the in-memory buffer push and the console echo — ensures
467
- // nothing sensitive is persisted to engine/log.json or engine stdout logs.
458
+ // nothing sensitive is persisted to the log store or engine stdout logs.
468
459
  const safeMsg = typeof msg === 'string' ? _redactString(msg) : msg;
469
460
  const safeMeta = redactSecrets(meta) || {};
470
461
  const entry = { timestamp: ts(), level, message: safeMsg, ...safeMeta };
@@ -500,30 +491,7 @@ function log(level, msg, meta = {}) {
500
491
  }
501
492
  }
502
493
 
503
- /**
504
- * Resolve the log file path at write time, not at module load time.
505
- *
506
- * `LOG_PATH` is captured eagerly when shared.js first loads. In production this
507
- * is fine — there's a single shared.js instance per process. In tests the
508
- * require cache is busted to swap MINIONS_DIR, but modules NOT in
509
- * ISOLATED_MODULES (engine/github.js, engine/ado.js, etc.) keep a reference to
510
- * the OLD shared.js whose `LOG_PATH` still points at `D:/squad/engine/log.json`.
511
- * That leaked test pollution into the live engine log (e.g. `_test/backoff-*`,
512
- * `this-playbook-does-not-exist-xyz`, `TEST-EXT-*` meeting IDs).
513
- *
514
- * Resolution order:
515
- * 1. `MINIONS_TEST_DIR` — per-test isolation root (regression tests rely
516
- * on this — log goes to `<tmpdir>/engine/log.json`).
517
- * 2. `MINIONS_LOG_PATH` — default test log target. Set at the top of
518
- * `test/unit.test.js` so tests that exercise log() without explicit
519
- * isolation (e.g. github backoff helpers, cooldown helpers) still land
520
- * in a benign tmp file instead of the live `D:/squad/engine/log.json`.
521
- * 3. `MINIONS_HOME` / repo root fallback — production behavior.
522
- *
523
- * Lazy resolution honors the *current* env at flush time, so even unbussed
524
- * dependents write to the test dir while the test owns it.
525
- */
526
- // Resolve the SQL state.db path at write time.
494
+ // Resolve the SQL database at write time so test roots stay isolated.
527
495
  function _currentLogDbPath() {
528
496
  if (process.env.MINIONS_TEST_DIR) {
529
497
  return path.join(path.resolve(process.env.MINIONS_TEST_DIR), 'engine', 'state.db');
@@ -547,9 +515,9 @@ function resolveEngineCacheDir(fallbackEngineDir) {
547
515
  // try/catch and the caller sees them. Dashboard self-open and `minions dash`
548
516
  // / `minions restart` post-health open all funnel through here.
549
517
  //
550
- // Every call writes a structured `event: 'browser-open'` entry to log.json
518
+ // Every call writes a structured `event: 'browser-open'` log entry
551
519
  // (via shared.log) and bumps a per-reason counter in `_engine.browserOpens`
552
- // in metrics.json. Callers MUST pass `opts.reason` so the log/metric is
520
+ // in metrics state. Callers MUST pass `opts.reason` so the log/metric is
553
521
  // attributable; a missing reason is logged at `warn` level with
554
522
  // `reason: 'unknown'` to make a regression loud rather than silent.
555
523
  //
@@ -620,7 +588,7 @@ function openUrlInBrowser(url, opts = {}) {
620
588
  }
621
589
 
622
590
  // Bump per-reason counter under metrics._engine.browserOpens. Best-effort —
623
- // metrics.json writes must never throw out of openUrlInBrowser. Schema:
591
+ // Metrics writes must never throw out of openUrlInBrowser. Schema:
624
592
  // _engine.browserOpens.<reason> = { count, suppressedCount, lastAt }
625
593
  function _bumpBrowserOpenMetric(reason, { suppressed = false } = {}) {
626
594
  try {
@@ -668,7 +636,7 @@ function _flushLogBuffer() {
668
636
  for (const [dbPath, entries] of byDbPath) {
669
637
  store.appendLogsToDbFile(dbPath, entries);
670
638
  }
671
- } catch { /* logs-store unavailable (node<22.5) — JSON mirror still works */ }
639
+ } catch { /* logging is best-effort */ }
672
640
 
673
641
  }
674
642
 
@@ -684,11 +652,6 @@ function flushLogs() {
684
652
  // ── File I/O ─────────────────────────────────────────────────────────────────
685
653
 
686
654
  function safeRead(p) {
687
- const prdStore = require('./prd-store');
688
- if (prdStore.parsePrdPath(p)) {
689
- const prd = prdStore.readPrd(p);
690
- return prd == null ? '' : JSON.stringify(prd, null, 2);
691
- }
692
655
  try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
693
656
  }
694
657
 
@@ -698,24 +661,10 @@ function safeRead(p) {
698
661
  // the client-sent document body with '' when the file was deleted/moved
699
662
  // between panel open and send.
700
663
  function safeReadOrNull(p) {
701
- const prdStore = require('./prd-store');
702
- if (prdStore.parsePrdPath(p)) {
703
- const prd = prdStore.readPrd(p);
704
- return prd == null ? null : JSON.stringify(prd, null, 2);
705
- }
706
664
  try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
707
665
  }
708
666
 
709
667
  function safeReadDir(dir) {
710
- const resolved = path.resolve(dir);
711
- const prdRoot = path.resolve(MINIONS_DIR, 'prd');
712
- const prdArchive = path.join(prdRoot, 'archive');
713
- if (resolved === prdRoot || resolved === prdArchive) {
714
- const archived = resolved === prdArchive;
715
- return require('./prd-store').listPrdRows()
716
- .filter(row => row.archived === archived)
717
- .map(row => row.filename);
718
- }
719
668
  try { return fs.readdirSync(dir); } catch { return []; }
720
669
  }
721
670
 
@@ -845,125 +794,9 @@ function isSourcePlanContentStale(plansDir, plan) {
845
794
  return out;
846
795
  }
847
796
 
848
- // ── SQL-routing shim for migrated state files ──────────────────────────────
849
- //
850
- // Phase 9 (post-Phase 8 cleanup): every state file that has a SQL backing
851
- // (dispatches, work-items, pull-requests, metrics, watches, schedule-runs,
852
- // pipeline-runs, managed-processes, worktree-pool, qa-runs, qa-sessions)
853
- // becomes SQL-only at the read API surface. The JSON sidecar files stop
854
- // being mirror-written; this shim makes existing `safeJson*` callers
855
- // transparently read from SQL so we don't have to convert ~40 call sites
856
- // in engine.js / dashboard.js / cleanup.js / etc.
857
- //
858
- // Matching is by path suffix rather than full-prefix against MINIONS_DIR,
859
- // because tests using MINIONS_TEST_DIR re-resolve the dir but use the same
860
- // suffix pattern. Returns `{value}` on hit, `null` on miss; callers fall
861
- // through to ordinary file-backed JSON handling.
862
- //
863
- // Per-project work-items / pull-requests need scope extraction from the
864
- // path. `<MINIONS_DIR>/projects/<name>/work-items.json` → scope `<name>`;
865
- // `<MINIONS_DIR>/work-items.json` → scope `central`.
866
- function _routeJsonReadToSql(p) {
867
- if (!p || typeof p !== 'string') return null;
868
- const norm = p.replace(/\\/g, '/');
869
- // Only route reads from paths inside MINIONS_DIR. Tests that use
870
- // createTmpDir() write to ad-hoc temp paths that shouldn't be hijacked
871
- // by the SQL stores (the path-suffix patterns below would otherwise
872
- // match e.g. `<tmp>/pull-requests.json` and return empty SQL data).
873
- const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
874
- if (!norm.startsWith(minionsNorm + '/') && norm !== minionsNorm) return null;
875
- let m;
876
- try {
877
- if (norm.endsWith('/engine/dispatch.json')) {
878
- const store = require('./dispatch-store');
879
- return { value: store.readDispatchSectioned() };
880
- }
881
- if (norm.endsWith('/engine/metrics.json')) {
882
- const store = require('./metrics-store');
883
- return { value: store.readMetrics() };
884
- }
885
- if (norm.endsWith('/engine/watches.json')) {
886
- const store = require('./watches-store');
887
- return { value: store.readWatches() };
888
- }
889
- if (norm.endsWith('/engine/schedule-runs.json')) {
890
- const store = require('./small-state-store');
891
- return { value: store.readScheduleRuns() };
892
- }
893
- if (norm.endsWith('/engine/pipeline-runs.json')) {
894
- const store = require('./small-state-store');
895
- return { value: store.readPipelineRuns() };
896
- }
897
- if (norm.endsWith('/engine/managed-processes.json')) {
898
- const store = require('./small-state-store');
899
- return { value: store.readManagedProcesses() };
900
- }
901
- if (norm.endsWith('/engine/worktree-pool.json')) {
902
- const store = require('./small-state-store');
903
- return { value: store.readWorktreePool() };
904
- }
905
- if (norm.endsWith('/engine/qa-runs.json')) {
906
- const store = require('./small-state-store');
907
- return { value: store.readQaRuns() };
908
- }
909
- if (norm.endsWith('/engine/qa-sessions.json')) {
910
- const store = require('./small-state-store');
911
- return { value: store.readQaSessions() };
912
- }
913
- if (norm.endsWith('/engine/pr-links.json')) {
914
- const store = require('./small-state-store');
915
- return { value: store.readPrLinks() };
916
- }
917
- if (norm.endsWith('/engine/cooldowns.json')) {
918
- const store = require('./small-state-store');
919
- return { value: store.readCooldowns() };
920
- }
921
- if (norm.endsWith('/engine/pending-rebases.json')) {
922
- const store = require('./small-state-store');
923
- return { value: store.readPendingRebases() };
924
- }
925
- if (norm.endsWith('/engine/cc-sessions.json')) {
926
- const store = require('./small-state-store');
927
- return { value: store.readCcSessions() };
928
- }
929
- if (norm.endsWith('/engine/doc-sessions.json')) {
930
- const store = require('./small-state-store');
931
- return { value: store.readDocSessions() };
932
- }
933
- // Per-project work-items.json — the path is now only a scope identifier.
934
- if ((m = norm.match(/\/projects\/([^/]+)\/work-items\.json$/))) {
935
- const store = require('./work-items-store');
936
- return { value: store.readWorkItemsForScope(m[1]) };
937
- }
938
- // Per-project pull-requests.json
939
- if ((m = norm.match(/\/projects\/([^/]+)\/pull-requests\.json$/))) {
940
- const store = require('./pull-requests-store');
941
- return { value: store.readPullRequestsForScope(m[1]) };
942
- }
943
- // Central work-items.json — `<MINIONS_DIR>/work-items.json`. Must not
944
- // collide with `/projects/<name>/work-items.json` (handled above).
945
- if (/(^|\/)work-items\.json$/.test(norm) && !/\/projects\//.test(norm)) {
946
- const store = require('./work-items-store');
947
- return { value: store.readWorkItemsForScope('central') };
948
- }
949
- // Central pull-requests.json
950
- if (/(^|\/)pull-requests\.json$/.test(norm) && !/\/projects\//.test(norm)) {
951
- const store = require('./pull-requests-store');
952
- return { value: store.readPullRequestsForScope('central') };
953
- }
954
- } catch (e) {
955
- // Phase 9.4: store/load failures (not SQLite-unavailable — the CLI shim
956
- // in bin/minions.js guarantees node:sqlite is loadable) propagate up so
957
- // the caller can decide whether to retry or surface.
958
- throw e;
959
- }
960
- return null;
961
- }
962
-
963
797
  /**
964
798
  * Read a JSON file with **automatic restore from `.backup` sidecar** on
965
- * missing/corrupt primary. Intended for live, mutable state files
966
- * (work-items.json, dispatch.json, pull-requests.json, etc.) that are paired
799
+ * missing/corrupt primary. Intended for intentionally file-backed data paired
967
800
  * with a `.backup` sidecar written by `safeWrite`. Returns the parsed JSON,
968
801
  * or null when both primary and backup are missing/unparseable.
969
802
  *
@@ -972,45 +805,12 @@ function _routeJsonReadToSql(p) {
972
805
  * to the primary path (best-effort). This protects live state from torn
973
806
  * writes / interrupted saves.
974
807
  *
975
- * **SQL routing (Phase 9):** when the path matches a migrated state file
976
- * (work-items, pull-requests, dispatch, metrics, watches, schedule-runs,
977
- * pipeline-runs, managed-processes, worktree-pool, qa-runs, qa-sessions,
978
- * pr-links), the read is served from SQLite via `_routeJsonReadToSql` — the
979
- * JSON file on disk is no longer authoritative.
980
- *
981
808
  * Counterpart: `safeJsonNoRestore` for terminal artifacts and "missing == gone"
982
809
  * reads (cooldowns, archived PRDs, ephemeral session state) where reviving a
983
810
  * stale `.backup` is actively harmful. See its JSDoc for selection guidance.
984
811
  */
985
812
 
986
- // PL-prd-no-backup (W-mqub65ez0004b3bd) — PRD `.json` files must NOT auto-restore
987
- // from a stale `.backup` sidecar: a removed/archived PRD coming back and
988
- // re-dispatching work is the original resurrection footgun (W-mouptdh1000h9f39).
989
- // Phase 10 step 4.3 — BROADENED from prd/archive/ to ALL prd/*.json (root +
990
- // archive). The root-level backup lifecycle previously survived ONLY to recover
991
- // the enforceDeclaredPlanProject rename-race (a concurrent sweep deleting a
992
- // just-renamed PRD); that rename is now retired and SQL is the canonical store,
993
- // so the sidecar is pure resurrection fuel with no remaining benefit:
994
- // - prd/*.json AND prd/archive/*.json → no backup write + no safeJson restore.
995
- // Matches a single filename segment under prd/ (optionally one archive/ level),
996
- // so nested non-PRD paths and plans/ (which are .md) are unaffected.
997
- const _NO_BACKUP_JSON_RE = /(?:^|[\\/])prd[\\/](?:archive[\\/])?[^\\/]+\.json$/i;
998
- function _isNoBackupJsonPath(p) {
999
- return typeof p === 'string' && _NO_BACKUP_JSON_RE.test(p);
1000
- }
1001
-
1002
813
  function safeJson(p) {
1003
- // Internal opt-out (positional second arg from mutateJsonFileLocked):
1004
- // when truthy, skip the SQL-routing shim and do a raw disk read. Used
1005
- // when the caller already holds the file lock and needs the on-disk
1006
- // bytes as the source of truth (so the read+write pair is atomic).
1007
- const skipSqlRouting = arguments.length > 1 && arguments[1] && arguments[1].skipSqlRouting;
1008
- if (!skipSqlRouting) {
1009
- const prdStore = require('./prd-store');
1010
- if (prdStore.parsePrdPath(p)) return prdStore.readPrd(p);
1011
- const routed = _routeJsonReadToSql(p);
1012
- if (routed) return routed.value;
1013
- }
1014
814
  // Split the read from the parse so we can distinguish "file missing" (normal
1015
815
  // pre-create state — silent) from "file present but corrupt JSON" (real
1016
816
  // integrity failure — must log). Without this split a `JSON.parse(read)` in
@@ -1034,8 +834,6 @@ function safeJson(p) {
1034
834
  console.error(`[safeJson] parse failure for ${path.basename(p)}: ${parseErr.message}`);
1035
835
  }
1036
836
  }
1037
- // Archived PRDs (prd/archive/) are permanently gone — skip .backup restore.
1038
- if (_isNoBackupJsonPath(p)) return null;
1039
837
  // Primary missing or corrupted — try restoring from .backup sidecar.
1040
838
  const backupPath = p + '.backup';
1041
839
  try {
@@ -1070,23 +868,16 @@ function safeJsonArr(p) { return safeJson(p) || []; }
1070
868
 
1071
869
  /**
1072
870
  * Sibling of safeJson for terminal-artifact and "missing == gone" reads
1073
- * (PRDs in `prd/`, archived plans, cooldowns, ephemeral session state
1074
- * anything where a missing primary should NOT auto-restore from a stale
871
+ * (archived plans and any other data where a missing primary should not
872
+ * auto-restore from a stale
1075
873
  * `.backup` sidecar). Returns the parsed JSON on success, or `defaultValue`
1076
874
  * (default `null`) on **any** failure: missing file, unparseable JSON, or
1077
875
  * IO error. The `.backup` sidecar is never consulted.
1078
876
  *
1079
877
  * Why a separate primitive: safeJson's restore-on-miss is correct for live
1080
- * state files (work-items.json, dispatch.json, pull-requests.json, etc.) but
878
+ * file-backed data, but
1081
879
  * actively harmful for terminal artifacts. Examples of misuse and the bugs
1082
880
  * they hide:
1083
- * - Archived PRDs leave a `.backup` sidecar in `prd/`; reading the active
1084
- * path with safeJson silently restores it and the dashboard sees a
1085
- * phantom "active" PRD (W-mouptdh1000h9f39). PRDs are end-state — no
1086
- * resurrection.
1087
- * - Cooldowns are time-bounded ephemeral state (24h TTL). Restoring a
1088
- * stale `cooldowns.json.backup` could resurrect expired entries that
1089
- * should already have been pruned, suppressing legitimate dispatches.
1090
881
  * - Restoring corrupt-primary scenarios from `.backup` masks the underlying
1091
882
  * write integrity failure and breaks State Integrity tests.
1092
883
  *
@@ -1105,10 +896,6 @@ function safeJsonArr(p) { return safeJson(p) || []; }
1105
896
  * @returns {*} Parsed JSON on success, otherwise `defaultValue`.
1106
897
  */
1107
898
  function safeJsonNoRestore(p, defaultValue = null) {
1108
- const prdStore = require('./prd-store');
1109
- if (prdStore.parsePrdPath(p)) return prdStore.readPrd(p) ?? defaultValue;
1110
- const routed = _routeJsonReadToSql(p);
1111
- if (routed) return routed.value ?? defaultValue;
1112
899
  let raw;
1113
900
  try {
1114
901
  raw = fs.readFileSync(p, 'utf8');
@@ -1135,12 +922,6 @@ function safeJsonNoRestore(p, defaultValue = null) {
1135
922
  let _tmpCounter = 0;
1136
923
 
1137
924
  function safeWrite(p, data) {
1138
- const prdStore = require('./prd-store');
1139
- if (prdStore.parsePrdPath(p)) {
1140
- if (typeof data === 'string') data = JSON.parse(data);
1141
- prdStore.writePrd(p, data);
1142
- return;
1143
- }
1144
925
  const dir = path.dirname(p);
1145
926
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1146
927
  const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
@@ -1235,7 +1016,7 @@ function createDispatchTmpDir(dispatchId) {
1235
1016
  }
1236
1017
 
1237
1018
  // Validate a tmpDir path before destructive ops (rmSync). Defends against a
1238
- // corrupted/poisoned dispatch.json `entry.tmpDir` field pointing at an
1019
+ // corrupted/poisoned dispatch `entry.tmpDir` field pointing at an
1239
1020
  // arbitrary directory: must resolve under <MINIONS_DIR>/engine/tmp/, basename
1240
1021
  // must match `dispatch-*`, and lstat must be a real directory (not a symlink).
1241
1022
  function validateDispatchTmpDir(dirPath) {
@@ -1348,42 +1129,10 @@ function forEachPidFile(callback) {
1348
1129
  }
1349
1130
  }
1350
1131
 
1351
- function neutralizeJsonBackupSidecar(filePath, inertData = { status: 'archived' }) {
1352
- const backupPath = filePath + '.backup';
1353
- try {
1354
- // Retry the unlink — on Windows a transient EPERM/EBUSY (AV scanner, lagging
1355
- // OS handle, indexer) otherwise leaves the .backup sidecar in place, and the
1356
- // next by-name safeJson() resurrects the just-deleted PRD from it. _retryFsOp
1357
- // rethrows non-retryable codes (e.g. ENOENT) immediately, so the absent path
1358
- // below still fires for an already-missing sidecar.
1359
- _retryFsOp(() => fs.unlinkSync(backupPath), `neutralize backup ${path.basename(backupPath)}`);
1360
- return { ok: true, action: 'removed', backupPath };
1361
- } catch (unlinkErr) {
1362
- if (unlinkErr.code === 'ENOENT') return { ok: true, action: 'absent', backupPath };
1363
- try {
1364
- safeWrite(backupPath, inertData);
1365
- return {
1366
- ok: true,
1367
- action: 'neutralized',
1368
- backupPath,
1369
- unlinkError: unlinkErr.message,
1370
- };
1371
- } catch (writeErr) {
1372
- return {
1373
- ok: false,
1374
- action: 'failed',
1375
- backupPath,
1376
- unlinkError: unlinkErr.message,
1377
- writeError: writeErr.message,
1378
- };
1379
- }
1380
- }
1381
- }
1382
-
1383
1132
  // ── Dispatch Prompt Sidecar (#1167) ─────────────────────────────────────────
1384
1133
  // Large prompts (PR diffs, build error logs, coalesced human feedback) inlined
1385
- // into dispatch.json caused hundreds-of-MB bloat per entry and eventual V8 OOM
1386
- // at startup. Sidecar files keep dispatch.json small while preserving full
1134
+ // into dispatch state caused hundreds-of-MB bloat per entry and eventual V8 OOM.
1135
+ // Sidecar files keep dispatch rows small while preserving full
1387
1136
  // content for the agent at spawn time.
1388
1137
 
1389
1138
  // Resolve lazily so MINIONS_TEST_DIR overrides work in tests.
@@ -1441,7 +1190,7 @@ function sidecarDispatchPrompt(item, thresholdBytes) {
1441
1190
  /**
1442
1191
  * Read the effective prompt for a dispatch item. Prefers the sidecar file when
1443
1192
  * `_promptFile` is set so spawnAgent always sees the full prompt even though
1444
- * dispatch.json only stores a small stub.
1193
+ * dispatch state only stores a small stub.
1445
1194
  */
1446
1195
  function resolveDispatchPrompt(item) {
1447
1196
  if (!item) return '';
@@ -1472,13 +1221,6 @@ function deleteDispatchPromptSidecar(item) {
1472
1221
  for (const p of paths) safeUnlink(p);
1473
1222
  }
1474
1223
 
1475
- /**
1476
- * Startup guard: throw a clear error when a state file has grown past
1477
- * maxStateFileBytes. Without this the dashboard silently OOMs on JSON.parse
1478
- * (seen on a 491 MB dispatch.json + 509 MB cooldowns.json — #1167).
1479
- * The thrown error points at the bloated file so operators can act instead
1480
- * of chasing V8 heap traces.
1481
- */
1482
1224
  // ── Stop-intent flag (engine/stop-intent.json) ──────────────────────────────
1483
1225
  //
1484
1226
  // File-presence signal used by the external supervisor (engine/supervisor.js)
@@ -1517,25 +1259,6 @@ function isStopIntentSet() {
1517
1259
  return fs.existsSync(STOP_INTENT_PATH);
1518
1260
  }
1519
1261
 
1520
- function assertStateFileSize(filePath, maxBytes) {
1521
- const limit = Number(maxBytes) > 0 ? Number(maxBytes) : ENGINE_DEFAULTS.maxStateFileBytes;
1522
- try {
1523
- const stat = fs.statSync(filePath);
1524
- if (stat.size > limit) {
1525
- throw new Error(
1526
- `State file too large: ${filePath} is ${Math.round(stat.size / (1024 * 1024))} MB ` +
1527
- `(limit ${Math.round(limit / (1024 * 1024))} MB). ` +
1528
- `This usually means dispatch prompts or cooldown contexts were inlined and not sidecarred. ` +
1529
- `Inspect/trim the file manually, then restart. See engine/contexts/ for sidecar files.`
1530
- );
1531
- }
1532
- } catch (e) {
1533
- if (e.code === 'ENOENT') return; // file absent is fine
1534
- if (e.message && e.message.startsWith('State file too large:')) throw e;
1535
- // Other stat errors (permission etc.) — do not block startup
1536
- }
1537
- }
1538
-
1539
1262
  function sleepMs(ms) {
1540
1263
  try {
1541
1264
  const ab = new SharedArrayBuffer(4);
@@ -1799,127 +1522,28 @@ function withFileLock(lockPath, fn, {
1799
1522
  throw lastErr;
1800
1523
  }
1801
1524
 
1802
- // Route table for small-state files migrated to SQL stores. Each entry
1803
- // declares the SQL store function to invoke and the expected default JSON
1804
- // shape (for the "ensure file exists" fallback below).
1805
- const _STATE_MUTATE_ROUTES = {
1806
- 'dispatch.json': { module: './dispatch-store', fn: 'applyDispatchMutation' },
1807
- 'metrics.json': { module: './metrics-store', fn: 'applyMetricsMutation' },
1808
- 'watches.json': { module: './watches-store', fn: 'applyWatchesMutation' },
1809
- 'schedule-runs.json': { module: './small-state-store', fn: 'applyScheduleRunsMutation' },
1810
- 'pipeline-runs.json': { module: './small-state-store', fn: 'applyPipelineRunsMutation' },
1811
- 'managed-processes.json': { module: './small-state-store', fn: 'applyManagedProcessesMutation' },
1812
- 'worktree-pool.json': { module: './small-state-store', fn: 'applyWorktreePoolMutation' },
1813
- 'qa-runs.json': { module: './small-state-store', fn: 'applyQaRunsMutation' },
1814
- 'qa-sessions.json': { module: './small-state-store', fn: 'applyQaSessionsMutation' },
1815
- 'cooldowns.json': { module: './small-state-store', fn: 'applyCooldownsMutation' },
1816
- 'pending-rebases.json': { module: './small-state-store', fn: 'applyPendingRebasesMutation' },
1817
- 'cc-sessions.json': { module: './small-state-store', fn: 'applyCcSessionsMutation' },
1818
- 'doc-sessions.json': { module: './small-state-store', fn: 'applyDocSessionsMutation' },
1819
- 'pr-links.json': { module: './small-state-store', fn: 'applyPrLinksMutation' },
1820
- };
1821
-
1822
- function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
1823
- const baseName = path.basename(filePath);
1824
- const stateRoute = _STATE_MUTATE_ROUTES[baseName];
1825
- if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json' && !stateRoute) return null;
1826
- const fpNorm = String(filePath).replace(/\\/g, '/');
1827
- const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
1828
- const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/' + baseName;
1829
- if (!insideMinionsDir) return null;
1830
-
1831
- // Small-state files live exclusively under <MINIONS_DIR>/engine/<baseName>.
1832
- // Don't hijack writes to ad-hoc paths that happen to share the basename.
1833
- if (stateRoute) {
1834
- if (!fpNorm.endsWith('/engine/' + baseName)) return null;
1835
- const store = require(stateRoute.module);
1836
- try {
1837
- require('./db').getDb();
1838
- } catch (e) {
1839
- throw new Error(`small-state-store: SQLite unavailable — ${e.message}`);
1840
- }
1841
- const out = store[stateRoute.fn]((data) => {
1842
- const next = mutateFn(data);
1843
- if (onWrote) onWrote();
1844
- return next;
1845
- });
1846
- const result = out && Object.prototype.hasOwnProperty.call(out, 'result') ? out.result : undefined;
1847
- return { routed: result };
1848
- }
1849
-
1850
- let result;
1851
- if (baseName === 'work-items.json') {
1852
- result = mutateWorkItems(filePath, (arr) => {
1853
- const out = mutateFn(arr);
1854
- if (onWrote) onWrote();
1855
- return out;
1856
- });
1857
- } else {
1858
- // pull-requests.json: preserve legacy-ID normalization the JSON path
1859
- // applied before invoking the mutator (parity with mutateJsonFileLocked
1860
- // body at the pull-requests.json normalization site).
1861
- const project = resolveProjectForPrPath(filePath);
1862
- result = mutatePullRequests(filePath, (arr) => {
1863
- if (Array.isArray(arr)) normalizePrRecords(arr, project);
1864
- const out = mutateFn(arr);
1865
- if (onWrote) onWrote();
1866
- return out;
1867
- });
1868
- }
1869
- return { routed: result };
1870
- }
1871
-
1872
1525
  function mutateJsonFileLocked(filePath, mutateFn, {
1873
1526
  defaultValue = {},
1874
1527
  lockRetries,
1875
1528
  lockRetryBackoffMs,
1876
1529
  skipWriteIfUnchanged = false,
1877
1530
  onWrote = null,
1878
- _skipSqlRouting = false,
1879
1531
  } = {}) {
1880
1532
  const lockPath = `${filePath}.lock`;
1881
1533
  const retries = lockRetries ?? ENGINE_DEFAULTS.lockRetries;
1882
1534
  const retryBackoffMs = lockRetryBackoffMs ?? ENGINE_DEFAULTS.lockRetryBackoffMs;
1883
- const prdStore = require('./prd-store');
1884
- if (!_skipSqlRouting && prdStore.parsePrdPath(filePath)) {
1885
- return prdStore.mutatePrd(filePath, mutateFn, { defaultValue });
1886
- }
1887
- // Route migrated path-shaped state APIs to SQL.
1888
- if (!_skipSqlRouting) {
1889
- const routed = _tryRouteMutateToSql(filePath, mutateFn, onWrote);
1890
- if (routed) return routed.routed;
1891
- }
1892
1535
  return withFileLock(lockPath, () => {
1893
1536
  const fileExists = fs.existsSync(filePath);
1894
- let data = safeJson(filePath, { skipSqlRouting: true });
1537
+ let data = safeJson(filePath);
1895
1538
  const parsedInvalid = fileExists && data === null;
1896
1539
  if (data === null || typeof data !== 'object') data = Array.isArray(defaultValue) ? [...defaultValue] : { ...defaultValue };
1897
- // Normalize BEFORE taking the baseline snapshot so that both `beforeSerialized`
1898
- // and the post-mutator snapshot reflect post-normalize state. Capturing the
1899
- // baseline before normalize breaks the `skipWriteIfUnchanged` optimization for
1900
- // pull-requests.json files: a no-op mutator on a denormalized file would
1901
- // always trip the write path because normalization itself shifted serialized
1902
- // bytes between the two snapshots (P-bfa1c-skipwrite-timing). The trade-off
1903
- // is intentional: when normalization is the ONLY change, we deliberately
1904
- // leave the on-disk file denormalized — readers re-run normalizePrRecords on
1905
- // load (see getPrLinks, engine/queries.js:670-674), so the in-memory contract
1906
- // is preserved without the per-poll mtime bump.
1907
- if (path.basename(filePath) === 'pull-requests.json' && Array.isArray(data)) {
1908
- normalizePrRecords(data, resolveProjectForPrPath(filePath));
1909
- }
1910
1540
  const beforeSerialized = skipWriteIfUnchanged ? JSON.stringify(data) : null;
1911
1541
  const next = mutateFn(data);
1912
1542
  const finalData = next === undefined ? data : next;
1913
1543
  const shouldWrite = !skipWriteIfUnchanged || parsedInvalid || JSON.stringify(finalData) !== beforeSerialized;
1914
1544
  if (shouldWrite) {
1915
- // Back up last-known-good state before mutation (best-effort). SKIP for
1916
- // archived PRDs under prd/archive/: a .backup there is resurrection fuel
1917
- // (PL-prd-no-backup, W-mqub65ez0004b3bd). Root-level prd/*.json retains
1918
- // the backup lifecycle — concurrent-sweep loss must be recoverable.
1919
- if (!_isNoBackupJsonPath(filePath)) {
1920
- const backupPath = filePath + '.backup';
1921
- try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1922
- }
1545
+ const backupPath = filePath + '.backup';
1546
+ try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1923
1547
  safeWrite(filePath, finalData);
1924
1548
  // Side-effect hook fired only when an actual write happened. Callers
1925
1549
  // use this to emit cache-invalidation signals (events table row) so
@@ -2020,33 +1644,34 @@ function recordEngineRespawn(source) {
2020
1644
  return { count, crashLoop };
2021
1645
  }
2022
1646
 
2023
- // W-mp60tw0u000j3931: Lock-safe read-modify-write for engine/state.json — the
2024
- // persistent cross-restart engine-level state file (migration markers,
2025
- // one-shot reconciliation versions, etc.). Mirrors mutateControl's shape.
2026
1647
  function mutateEngineState(mutator) {
2027
- return mutateJsonFileLocked(ENGINE_STATE_PATH, (data) => {
1648
+ const store = require('./small-state-store');
1649
+ const { result } = store.applyEngineStateMutation((data) => {
2028
1650
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
2029
1651
  return mutator(data) || data;
2030
- }, { defaultValue: {}, skipWriteIfUnchanged: true });
1652
+ });
1653
+ return result;
2031
1654
  }
2032
1655
 
2033
1656
  function readEngineState() {
2034
- const data = safeJson(ENGINE_STATE_PATH);
1657
+ const data = require('./small-state-store').readEngineState();
2035
1658
  if (!data || typeof data !== 'object' || Array.isArray(data)) return {};
2036
1659
  return data;
2037
1660
  }
2038
1661
 
2039
1662
  function mutateCooldowns(mutator) {
2040
- return mutateJsonFileLocked(COOLDOWNS_PATH, (data) => {
1663
+ const store = require('./small-state-store');
1664
+ const { wrote, result } = store.applyCooldownsMutation((data) => {
2041
1665
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
2042
1666
  return mutator(data) || data;
2043
- }, { defaultValue: {}, skipWriteIfUnchanged: true });
1667
+ });
1668
+ if (wrote) {
1669
+ try { require('./db-events').emitStateEvent('cooldowns'); } catch { /* optional */ }
1670
+ }
1671
+ return result;
2044
1672
  }
2045
1673
 
2046
- // Phase 9.3 (W-mp48wxqw / SQL-canonicalization prep): lock-safe RMW wrappers
2047
- // for the small JSON state files that were still going through raw safeWrite.
2048
- // Pattern mirrors mutateControl / mutateEngineState / mutateCooldowns — so a
2049
- // future SQL migration is a mechanical _SMALL_STATE_MUTATE_ROUTES addition.
1674
+ // Lock-safe wrappers for intentionally file-backed coordination state.
2050
1675
 
2051
1676
  // KB classification checkpoint counter (consolidation.js post-classify).
2052
1677
  function mutateKbCheckpoint(mutator) {
@@ -3351,8 +2976,8 @@ const ENGINE_DEFAULTS = {
3351
2976
  // shared.resolvePollFlag is intentionally NOT used here because no
3352
2977
  // legacy macro bundle ever covered these phases.
3353
2978
  prDiscoveryEnabled: true, // discoverFromPrs (per-project PR-driven fix/review/test discovery)
3354
- workItemsDiscoveryEnabled: true, // discoverFromWorkItems (per-project work-items.json scan)
3355
- centralWorkDiscoveryEnabled: true, // discoverCentralWorkItems (top-level work-items.json scan)
2979
+ workItemsDiscoveryEnabled: true, // discoverFromWorkItems (project SQL scope)
2980
+ centralWorkDiscoveryEnabled: true, // discoverCentralWorkItems (central SQL scope)
3356
2981
  scheduledWorkDiscoveryEnabled: true, // discoverScheduledWork (cron-style scheduled tasks + meetings)
3357
2982
  planMaterializationEnabled: true, // reconcilePrdStatuses + materializePlansAsWorkItems pair
3358
2983
  prPollStatusEvery: 72, // poll PR build/review/merge status every N ticks for both ADO and GitHub (~12 min at default 10s tick)
@@ -3394,8 +3019,8 @@ const ENGINE_DEFAULTS = {
3394
3019
  prAbandonConfirmCount: 3,
3395
3020
  // W-mp60tw0u000j3931: One-shot startup reconciliation pass for `abandoned` PRs runs
3396
3021
  // exactly once per bump of this constant. The engine compares this value against
3397
- // engine/state.json:lastAbandonedReconciliationVersion at boot; if the on-disk version
3398
- // is lower, every project's pull-requests.json is scanned, every abandoned PR is
3022
+ // the persisted lastAbandonedReconciliationVersion at boot; if the version
3023
+ // is lower, every project's PR scope is scanned, every abandoned PR is
3399
3024
  // re-probed, and any false-flipped PRs (e.g. transient 404 victims from before the
3400
3025
  // hardening in W-mp5trwh60008386d shipped) are flipped back to active/merged/closed
3401
3026
  // based on their live API state. Bump this when reconciliation logic itself changes
@@ -3459,7 +3084,7 @@ const ENGINE_DEFAULTS = {
3459
3084
  // re-queued into review again.
3460
3085
  preDispatchEvalMaxRejections: 5,
3461
3086
  completionNonceRequired: false, // P-d2a8f6c1 (agent trust boundary F8): when true, a missing `nonce` field in the completion JSON hard-fails the dispatch with failure_class:'completion-nonce-mismatch'. Default false for one release so older agents/runtime caches that haven't picked up the prompt change degrade with a warning instead of breaking. Mismatched nonces hard-fail regardless of this flag. See docs/completion-reports.md → "Trust boundary".
3462
- autoApplyReviewVote: false, // W-mpea9fyb0010febf / P-f7splitgate: master gate for ALL engine platform vote mutations. When true, the engine (a) mirrors live platform vote state into local pull-requests.json reviewStatus, (b) re-applies its own ADO +10 approval when ADO resets votes on target-branch advance (pollPrStatus), (c) reconciles its own prior negative on a verdict flip to APPROVE (ADO resetReviewerNegativeVote / GitHub dismissPriorViewerChangesRequestedReviews), and (d) instructs ADO review agents to cast `az repos pr set-vote`. When false (default), the verdict is recorded in pull-requests.json reviewStatus from the agent's completion ONLY — informational — and Minions never cast, re-cast, or clear a platform vote/review (ADO agents post the verdict as a comment only, parity with GitHub self-approval block).
3087
+ autoApplyReviewVote: false, // Master gate for platform vote mutations; local verdicts remain informational when false.
3463
3088
 
3464
3089
  // ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
3465
3090
  // Single source of truth for which CLI runtime + model every spawn uses.
@@ -3473,7 +3098,7 @@ const ENGINE_DEFAULTS = {
3473
3098
  claudeBareMode: false, // Claude --bare: suppress CLAUDE.md auto-discovery (per-agent override: agents.<id>.bareMode)
3474
3099
  claudeFallbackModel: undefined,// Claude --fallback-model — Claude CLI honors this on rate-limit (429) only; engine retry on FAILURE_CLASS.MODEL_UNAVAILABLE keeps the flag passed so the CLI can swap internally
3475
3100
  copilotFallbackModel: undefined,// W-mpg6isvy000xca4d: Copilot has no --fallback-model flag; engine retry on FAILURE_CLASS.MODEL_UNAVAILABLE OVERRIDES --model directly with this value (separate knob from claudeFallbackModel because model namespaces differ across runtimes)
3476
- copilotDisableBuiltinMcps: true, // Copilot --disable-builtin-mcps: keep github-mcp-server out so it can't bypass pull-requests.json tracking
3101
+ copilotDisableBuiltinMcps: true, // Keep github-mcp-server from bypassing Minions PR tracking.
3477
3102
  // P-7d31a06b — Claude pre-approves workspace .mcp.json servers in ~/.claude.json on every spawn into a fresh
3478
3103
  // worktree cwd. Without this, the first Claude invocation in a brand-new worktree shows a project-scoped MCP
3479
3104
  // "trust this server?" prompt that blocks until a human accepts — invisible behind --dangerously-skip-permissions
@@ -3489,13 +3114,6 @@ const ENGINE_DEFAULTS = {
3489
3114
  agentUseWorkerPool: false, // P-9b3d5f61: mirrors ccUseWorkerPool's shape but for the fleet agent-dispatch path (engine/agent-worker-pool.js). Opt-in, default OFF — see resolveAgentUseWorkerPool in engine/shared.js for the full priority chain and rationale.
3490
3115
  maxBudgetUsd: undefined, // fleet USD ceiling for --max-budget-usd (per-agent override: agents.<id>.maxBudgetUsd). Honors 0 via ?? so a literal cap of $0 works
3491
3116
  disableModelDiscovery: false, // skip runtime.listModels() REST calls fleet-wide (settings UI falls back to free-text)
3492
- // Phase 8 (qa-runs.json + qa-sessions.json → SQL). When true, every QA
3493
- // mutation also writes the JSON sidecar at engine/qa-runs.json and
3494
- // engine/qa-sessions.json for back-compat with external tooling. SQL is
3495
- // always the source of truth — flip this OFF once operators trust the
3496
- // SQL store and want to drop the dual-write cost. Sunset tracked in
3497
- // docs/deprecated.json (qa-json-sidecars).
3498
- qaDualWriteJson: true,
3499
3117
  // W-mpmwxkrw000872ec — dashboard global font-size scale. Drives the
3500
3118
  // [data-font-size] attribute on <html> via the inline bootstrap script in
3501
3119
  // layout.html (localStorage fast path) and is reconciled from the server
@@ -3503,10 +3121,9 @@ const ENGINE_DEFAULTS = {
3503
3121
  // 'xlarge'. Allowlist validation lives in handleSettingsUpdate so invalid
3504
3122
  // values are clamped rather than silently breaking the bootstrap.
3505
3123
  fontSize: 'small',
3506
- maxPendingContexts: 20, // cap pendingContexts arrays in cooldowns.json to prevent unbounded growth
3507
- maxPendingContextEntryBytes: 256 * 1024, // 256 KB — cap each pendingContexts entry to prevent huge PR comments from bloating cooldowns.json
3508
- maxDispatchPromptBytes: 1024 * 1024, // 1 MB — dispatch items with prompts larger than this sidecar to engine/contexts/ to prevent dispatch.json OOM (#1167)
3509
- maxStateFileBytes: 100 * 1024 * 1024, // 100 MB — fail startup with a clear error when dispatch.json / cooldowns.json exceed this, rather than silently OOMing on JSON.parse (#1167)
3124
+ maxPendingContexts: 20, // cap pendingContexts arrays to prevent unbounded growth
3125
+ maxPendingContextEntryBytes: 256 * 1024, // cap each pending context entry
3126
+ maxDispatchPromptBytes: 1024 * 1024, // sidecar oversized prompts under engine/contexts/
3510
3127
  mainBranchCacheTtlMs: 300000, // 5min — cache git default-branch detection, then prune expired entries
3511
3128
  mainBranchCacheMaxEntries: 100, // bound repo/branch detection cache in long-lived dashboard/engine processes
3512
3129
  removeWorktreeFailureTtlMs: 24 * 60 * 60 * 1000, // stale failed paths are forgotten after a day
@@ -3565,7 +3182,7 @@ const ENGINE_DEFAULTS = {
3565
3182
  ccTurnTimeoutMs: 300000, // W-mpmwxni2000c25c7-b/-d: 5min per-turn no-progress watchdog. The window resets on every liveness signal — token chunk, tool-call notification, tool-update — so an actively-streaming CC/doc-chat turn (long shell command, deep search, sub-agent loop) survives indefinitely up to the outer CC_CALL_TIMEOUT_MS (~1h) ceiling. Only true silence past this window with no progress fires the cancel: the in-flight LLM call is aborted and the handler surfaces `{code:'cc-turn-timeout', retryable:true}` via the typed error envelope so the UI can stop the spinner and offer Retry. Clamped to [10000, 3600000] in the settings POST handler. Independent of CC_CALL_TIMEOUT_MS. Non-streaming doc-chat is the lone wall-clock exception (no progress hooks); see _raceCcDocChatTimeout in dashboard.js for the dual factory/promise shape.
3566
3183
  docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity (LRU; sessions are non-expiring otherwise)
3567
3184
  ccLiveStreamMaxAgeMs: 30 * 60 * 1000, // hard cap reconnect buffers if abort/cleanup stalls
3568
- metricsFlushIntervalMs: 10000, // batch trackEngineUsage writes to metrics.json — flushed every 10s instead of per-call to cut lock contention and dashboard mtime churn
3185
+ metricsFlushIntervalMs: 10000, // batch trackEngineUsage SQL writes
3569
3186
  maxLlmRawBytes: 256 * 1024, // keep only a bounded stdout tail from direct Claude calls
3570
3187
  maxLlmStderrBytes: 64 * 1024, // keep only a bounded stderr tail from direct Claude calls
3571
3188
  maxLlmLineBufferBytes: 128 * 1024, // cap the incremental JSON line buffer to avoid malformed-stream OOMs
@@ -4402,7 +4019,7 @@ const PR_PENDING_REASON = {
4402
4019
  MISSING_BRANCH: 'missing_pr_branch',
4403
4020
  };
4404
4021
  // PR build-status enum — single source of truth for the literal strings written to
4405
- // pull-requests.json `buildStatus`. Previously drifted across engine/ado.js,
4022
+ // PR `buildStatus`. Previously drifted across engine/ado.js,
4406
4023
  // engine/github.js, engine/lifecycle.js, engine/watches.js, engine/queries.js, engine/cli.js
4407
4024
  // (P-bfa3d-constants-eslint, audit items #68-#82).
4408
4025
  const BUILD_STATUS = {
@@ -4412,7 +4029,7 @@ const BUILD_STATUS = {
4412
4029
  NONE: 'none',
4413
4030
  };
4414
4031
  // PR review-status enum — single source of truth for the literal strings written to
4415
- // pull-requests.json `reviewStatus`. Previously drifted across engine/ado.js,
4032
+ // PR `reviewStatus`. Previously drifted across engine/ado.js,
4416
4033
  // engine/github.js, engine/lifecycle.js, engine/watches.js, engine/queries.js, engine/cli.js
4417
4034
  // (P-bfa3d-constants-eslint, audit items #68-#82).
4418
4035
  const REVIEW_STATUS = {
@@ -4666,14 +4283,10 @@ function trackReviewMetric(pr, newReviewStatus, config) {
4666
4283
  * just a boolean caused the toast to render "queued undefined" (issue #2375).
4667
4284
  */
4668
4285
  function queuePlanToPrd({ planFile, prdFile, title, description, project, createdBy, extra }) {
4669
- // Use MINIONS_DIR (honors MINIONS_TEST_DIR override) instead of resolving from
4670
- // __dirname — otherwise tests that exercise this helper leak work items into
4671
- // the real package-root work-items.json even after createTestMinionsDir().
4672
- const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
4673
4286
  let queued = false;
4674
4287
  let id = null;
4675
4288
  let item = null;
4676
- mutateJsonFileLocked(centralWiPath, items => {
4289
+ mutateWorkItems('central', items => {
4677
4290
  if (!Array.isArray(items)) items = [];
4678
4291
  const existing = items.find(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === planFile && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED));
4679
4292
  if (existing) {
@@ -4820,7 +4433,7 @@ const FAILURE_CLASS = {
4820
4433
  WORKSPACE_MANIFEST_URL: 'workspace-manifest-url-forbidden', // W-mq07avbk000m5543: out-of-scope external URL fetch. Non-retryable as-is.
4821
4434
  SPAWN_PHASE_STALL: 'spawn-phase-stall', // W-mq0e2dae000a003d: process spawned and ran startup-only events (MCP init / hooks) but never emitted real task progress; CPU usage stayed below threshold past the grace window. Engine kills the wedged child and treats this as retryable (fresh-session) so a re-spawn can clear a transient MCP wedge.
4822
4435
  OUTPUT_TRUNCATED: 'output-truncated', // P-8e4c2a17: the agent streamed more stdout than the engine's hard capture cap (engine.js AGENT_OUTPUT_CAP_BYTES, 1MB) BEFORE the terminal `result` event arrived. The result (session id, completion block, final text) lives at the END of the stream, so it fell outside the captured window and parseOutput found nothing — the dispatch would otherwise fail as an opaque UNKNOWN and retry to death. Surfaced loudly with an actionable message; non-retryable (mechanical retry just reproduces the overflow — the agent must reduce output volume or the task must be split).
4823
- VERIFY_MISSING_PR: 'verify-missing-pr', // W-mqsk1ip00006cbae: a verify WI exited done but no PR was attached (neither _prUrl/_pr on the item nor a matching pull-requests.json entry for the plan). Flipped to failed so the plan doesn't silently advance without an E2E PR. Retryable — agent may have phantom-crashed before pushing the branch.
4436
+ VERIFY_MISSING_PR: 'verify-missing-pr', // Verify completed without an attached or tracked PR.
4824
4437
  PROJECT_NOT_FOUND: 'project_not_found', // W-mqv2wsy30002e090: a work item's `project` field names a project that is not configured (resolveConfiguredProject / formatUnknownProjectError → `Project "<name>" not found. Known projects: …`). Stamped at discovery time so the dashboard PR-column renderer surfaces the red failReason snippet like other non-retryable failures instead of burying it in the Agent column. Non-retryable — operator must fix the WI's project field or configure the project.
4825
4438
  PRE_DISPATCH_EVAL_STUCK: 'pre-dispatch-eval-stuck', // W-mrcrh5hj0017d22f: pre-dispatch-eval rejected the SAME (unchanged) description ENGINE_DEFAULTS.preDispatchEvalMaxRejections times in a row without the WI status ever leaving pending — previously this burned an LLM validation call every discovery tick forever with nothing surfaced to the dashboard (constant-bug-bash's file-fixes stage wedged 16+ consecutive rejections, one/minute, until a human manually cancelled it). Engine now flips the WI to failed with this class so the stalled item is visible and stops silently re-evaluating; non-retryable until the description changes.
4826
4439
  UNKNOWN: 'unknown', // Unclassified failure
@@ -4857,9 +4470,8 @@ const DEFAULT_CLAUDE = {
4857
4470
  // engine/cleanup.js:1200-1213 _engine.legacyStatusMigrations.
4858
4471
  function _bumpPruneDefaultClaudeStrip() {
4859
4472
  try {
4860
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
4861
4473
  const dateKey = new Date().toISOString().slice(0, 10);
4862
- mutateJsonFileLocked(metricsPath, (metrics) => {
4474
+ mutateMetrics((metrics) => {
4863
4475
  metrics = metrics || {};
4864
4476
  if (!metrics._engine) metrics._engine = {};
4865
4477
  if (!metrics._engine.pruneDefaultClaudeConfigStrips) metrics._engine.pruneDefaultClaudeConfigStrips = {};
@@ -5168,18 +4780,9 @@ function resolveConfiguredProject(projectName, projectsOrConfig, options = {}) {
5168
4780
  return { project: null, explicit: false, value: '' };
5169
4781
  }
5170
4782
 
5171
- function centralWorkItemsPath(minionsDir = MINIONS_DIR) {
5172
- return path.join(minionsDir, 'work-items.json');
5173
- }
5174
-
5175
- function centralPullRequestsPath(minionsDir = MINIONS_DIR) {
5176
- return path.join(minionsDir, 'pull-requests.json');
5177
- }
5178
-
5179
4783
  function _projectSourceRawValue(source) {
5180
4784
  if (source && typeof source === 'object') {
5181
4785
  return source.name ?? source.project ?? source._project ?? source.source ?? source._source ??
5182
- source.wiPath ?? source.workItemsPath ?? source.prPath ?? source.pullRequestsPath ??
5183
4786
  source.stateDir ?? source.path ?? source.localPath ?? '';
5184
4787
  }
5185
4788
  return source;
@@ -5193,18 +4796,15 @@ function _sameSourcePath(value, targetPath, minionsDir = MINIONS_DIR) {
5193
4796
  }
5194
4797
 
5195
4798
  function _projectSourceDescriptor(project, value, explicit, minionsDir = MINIONS_DIR) {
5196
- const wiPath = project ? projectWorkItemsPath(project) : centralWorkItemsPath(minionsDir);
5197
- const prPath = project ? projectPrPath(project) : centralPullRequestsPath(minionsDir);
5198
4799
  const stateDir = project ? projectStateDir(project) : minionsDir;
5199
4800
  return {
5200
4801
  project: project || null,
5201
4802
  explicit: !!explicit,
5202
4803
  value: value || '',
5203
4804
  sourceName: project?.name || 'central',
4805
+ scope: project?.name || 'central',
5204
4806
  isCentral: !project,
5205
4807
  stateDir,
5206
- wiPath,
5207
- prPath,
5208
4808
  };
5209
4809
  }
5210
4810
 
@@ -5221,17 +4821,13 @@ function resolveProjectSource(source, projectsOrConfig, options = {}) {
5221
4821
  return _projectSourceDescriptor(projects[0], '', false, minionsDir);
5222
4822
  }
5223
4823
  if (allowCentral) return _projectSourceDescriptor(null, '', false, minionsDir);
5224
- return { project: null, explicit: false, value: '', sourceName: '', isCentral: false, wiPath: null, prPath: null, stateDir: null };
4824
+ return { project: null, explicit: false, value: '', sourceName: '', isCentral: false, scope: null, stateDir: null };
5225
4825
  }
5226
4826
 
5227
- const centralWorkPath = centralWorkItemsPath(minionsDir);
5228
- const centralPrPath = centralPullRequestsPath(minionsDir);
5229
4827
  const centralNames = new Set(['central', 'root']);
5230
4828
  const lowerValue = value.toLowerCase();
5231
4829
  const isCentral = centralNames.has(lowerValue) ||
5232
- _sameSourcePath(value, minionsDir, minionsDir) ||
5233
- _sameSourcePath(value, centralWorkPath, minionsDir) ||
5234
- _sameSourcePath(value, centralPrPath, minionsDir);
4830
+ _sameSourcePath(value, minionsDir, minionsDir);
5235
4831
  if (isCentral) {
5236
4832
  if (allowCentral) return _projectSourceDescriptor(null, value, true, minionsDir);
5237
4833
  return {
@@ -5240,8 +4836,7 @@ function resolveProjectSource(source, projectsOrConfig, options = {}) {
5240
4836
  value,
5241
4837
  sourceName: '',
5242
4838
  isCentral: false,
5243
- wiPath: null,
5244
- prPath: null,
4839
+ scope: null,
5245
4840
  stateDir: null,
5246
4841
  error: 'central source is not allowed here',
5247
4842
  };
@@ -5255,8 +4850,6 @@ function resolveProjectSource(source, projectsOrConfig, options = {}) {
5255
4850
  const candidates = [
5256
4851
  project.localPath,
5257
4852
  projectStateDir(project),
5258
- projectWorkItemsPath(project),
5259
- projectPrPath(project),
5260
4853
  ].filter(Boolean);
5261
4854
  if (candidates.some(candidate => _sameSourcePath(value, candidate, minionsDir))) {
5262
4855
  return _projectSourceDescriptor(project, value, explicit, minionsDir);
@@ -5269,8 +4862,7 @@ function resolveProjectSource(source, projectsOrConfig, options = {}) {
5269
4862
  value,
5270
4863
  sourceName: '',
5271
4864
  isCentral: false,
5272
- wiPath: null,
5273
- prPath: null,
4865
+ scope: null,
5274
4866
  stateDir: null,
5275
4867
  error: formatUnknownProjectError(value, projects),
5276
4868
  };
@@ -5280,15 +4872,8 @@ function projectRoot(project) {
5280
4872
  return path.resolve(project.localPath);
5281
4873
  }
5282
4874
 
5283
- // All project state files live centrally in .minions/projects/{name}/
5284
- // No state files in project repos — avoids worktree/git interference.
5285
- //
5286
- // projectStateDir is path-only (no fs side effects) — safe to call with stale
5287
- // project references after `removeProject` archived the data dir. Write paths
5288
- // (safeWrite, withFileLock, mutateJsonFileLocked) already mkdir the parent dir
5289
- // at write time, so the dir is created lazily only when something is actually
5290
- // written. Use projectStateDirEnsure() when a caller specifically needs the
5291
- // directory to exist before doing its own fs ops.
4875
+ // Project-local definitions and artifacts live under projects/{name}/.
4876
+ // Runtime records use the project's SQL scope.
5292
4877
  function projectStateDir(project) {
5293
4878
  const name = project.name || path.basename(project.localPath);
5294
4879
  return path.join(MINIONS_DIR, 'projects', name);
@@ -5302,14 +4887,6 @@ function projectStateDirEnsure(project) {
5302
4887
  return dir;
5303
4888
  }
5304
4889
 
5305
- function projectWorkItemsPath(project) {
5306
- return path.join(projectStateDir(project), 'work-items.json');
5307
- }
5308
-
5309
- function projectPrPath(project) {
5310
- return path.join(projectStateDir(project), 'pull-requests.json');
5311
- }
5312
-
5313
4890
  function sameResolvedPath(a, b) {
5314
4891
  if (!a || !b) return false;
5315
4892
  try {
@@ -5321,7 +4898,7 @@ function sameResolvedPath(a, b) {
5321
4898
  }
5322
4899
  }
5323
4900
 
5324
- function ensureProjectStateFiles(project) {
4901
+ function initializeProjectState(project) {
5325
4902
  const result = { created: [] };
5326
4903
  projectStateDirEnsure(project);
5327
4904
  // SQL scopes need no empty sentinel rows. Touch both stores so migrations
@@ -5355,33 +4932,6 @@ function realPathForComparison(filePath) {
5355
4932
  }
5356
4933
  }
5357
4934
 
5358
- function prPathComparisonCandidates(filePath) {
5359
- const candidates = new Set();
5360
- const addCandidate = (candidate) => {
5361
- const resolved = path.resolve(candidate);
5362
- candidates.add(resolved);
5363
- candidates.add(realPathForComparison(resolved));
5364
- };
5365
-
5366
- addCandidate(filePath);
5367
- if (!path.isAbsolute(filePath)) {
5368
- addCandidate(path.resolve(MINIONS_DIR, filePath));
5369
- }
5370
- return candidates;
5371
- }
5372
-
5373
- function resolveProjectForPrPath(filePath, config = null) {
5374
- const fileCandidates = prPathComparisonCandidates(filePath);
5375
- const projects = getProjects(config);
5376
- for (const project of projects) {
5377
- for (const projectPath of prPathComparisonCandidates(projectPrPath(project))) {
5378
- if (fileCandidates.has(projectPath)) return project;
5379
- }
5380
- }
5381
- if (projects.length === 1) return projects[0];
5382
- return null;
5383
- }
5384
-
5385
4935
  // ── ID Generation ────────────────────────────────────────────────────────────
5386
4936
 
5387
4937
  function nextWorkItemId(items, prefix) {
@@ -5421,7 +4971,7 @@ function getAdoOrgBase(project) {
5421
4971
  *
5422
4972
  * - "basenames": exact relative paths under the live root (engine.js, dashboard.js,
5423
4973
  * minions.js, config.json — and the runtime state files engine/control.json
5424
- * and engine/dispatch.json).
4974
+ * and engine/control.json).
5425
4975
  * - "globs": direct-child JS files under protected live directories
5426
4976
  * (engine/*.js, bin/*.js).
5427
4977
  * - "prefixes": relative directory prefixes whose entire subtree is read-only
@@ -5443,7 +4993,6 @@ const _CC_PROTECTED_BASENAMES = Object.freeze([
5443
4993
  'minions.js',
5444
4994
  'config.json',
5445
4995
  'engine/control.json',
5446
- 'engine/dispatch.json',
5447
4996
  ]);
5448
4997
  const _CC_PROTECTED_FILE_GLOBS = Object.freeze([
5449
4998
  'engine/*.js',
@@ -6581,8 +6130,8 @@ function extractStructuredWorkItemPrRef(item) {
6581
6130
  // W-mq1j85cj00055a8f — when an inbox note (notes/inbox/<name>) leaves the
6582
6131
  // inbox (persisted to notes.md, promoted to knowledge/, or auto-archived by
6583
6132
  // consolidation), any work-item reference pointing at the old inbox path
6584
- // turns into a broken link. Rewrite those references across every project's
6585
- // work-items.json + the central one to the new canonical location.
6133
+ // turns into a broken link. Rewrite those references across every work-item
6134
+ // scope to the new canonical location.
6586
6135
  //
6587
6136
  // Match semantics (intentionally narrow per the WI scope):
6588
6137
  // - item.references[]: string 'notes/inbox/<inboxName>' (anchored on start
@@ -6600,15 +6149,14 @@ function extractStructuredWorkItemPrRef(item) {
6600
6149
  // - Trailing `?query` or `#fragment` is tolerated; substring overlaps in
6601
6150
  // unrelated path segments (e.g. .../notes/inbox/foobar for foo.md) are
6602
6151
  // NOT rewritten.
6603
- // - Archived work-items files are skipped — only live work-items.json paths
6604
- // surfaced by getProjects() + the central path are touched.
6152
+ // - Archived work items are skipped.
6605
6153
  //
6606
6154
  // Idempotent: re-running with the same inboxName after the rewrite is a
6607
6155
  // no-op (the references already read newLocation, which won't match the
6608
6156
  // notes/inbox/<inboxName> regex).
6609
6157
  //
6610
- // Returns the count of references rewritten across all files (for logging).
6611
- // Each file's mutate is wrapped in try/catch so one corrupt project can't
6158
+ // Returns the count of references rewritten across all scopes (for logging).
6159
+ // Each scope's mutate is wrapped in try/catch so one corrupt project can't
6612
6160
  // block the others.
6613
6161
  //
6614
6162
  // `opts._mutate` is an undocumented test seam — production callers always
@@ -6645,14 +6193,14 @@ function rewriteInboxRefsAcrossProjects(inboxName, newLocation, opts = {}) {
6645
6193
  const candidates = [];
6646
6194
  try {
6647
6195
  for (const project of getProjects(config)) {
6648
- try { candidates.push(projectWorkItemsPath(project)); } catch { /* skip bad project entry */ }
6196
+ if (project?.name) candidates.push(project);
6649
6197
  }
6650
6198
  } catch { /* getProjects failed — fall back to central only */ }
6651
- candidates.push(centralWorkItemsPath());
6199
+ candidates.push('central');
6652
6200
 
6653
- for (const wiPath of candidates) {
6201
+ for (const scope of candidates) {
6654
6202
  try {
6655
- _mutate(wiPath, items => {
6203
+ _mutate(scope, items => {
6656
6204
  if (!Array.isArray(items)) return items;
6657
6205
  for (const item of items) {
6658
6206
  if (!item) continue;
@@ -6709,7 +6257,7 @@ function rewriteInboxRefsAcrossProjects(inboxName, newLocation, opts = {}) {
6709
6257
  return items;
6710
6258
  });
6711
6259
  } catch (e) {
6712
- try { console.warn(`rewriteInboxRefsAcrossProjects(${wiPath}): ${e.message}`); } catch { /* logging best-effort */ }
6260
+ try { console.warn(`rewriteInboxRefsAcrossProjects(${stateScope(scope)}): ${e.message}`); } catch { /* logging best-effort */ }
6713
6261
  }
6714
6262
  }
6715
6263
  return rewritten;
@@ -7347,18 +6895,14 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
7347
6895
  links[effectivePrId] = [...mergedCurrent];
7348
6896
  return links;
7349
6897
  };
7350
- // Phase 9.4 + W-mpz7lbb600012d4f: pr-links is SQL-canonical via small-state-store;
7351
- // the JSON file is a write-only mirror. Route through mutateJsonFileLocked so
7352
- // _tryRouteMutateToSql serializes the SQL apply + JSON mirror under the same
7353
- // cross-process file lock every other small-state mutation uses.
7354
- mutateJsonFileLocked(PR_LINKS_PATH, mutator, { defaultValue: {} });
6898
+ const { wrote } = require('./small-state-store').applyPrLinksMutation(mutator);
6899
+ if (wrote) {
6900
+ try { require('./db-events').emitStateEvent('pr_links'); } catch { /* optional */ }
6901
+ }
7355
6902
 
7356
6903
  if (!project) return;
7357
- const prPath = projectPrPath(project);
7358
6904
  const effectivePrNumber = getPrNumber(prNumber ?? effectivePrId);
7359
- // Phase 9 SQL routing: mutatePullRequests writes through pull-requests-store
7360
- // so SQL stays canonical; the JSON mirror is regenerated by the store.
7361
- mutatePullRequests(prPath, (prs) => {
6905
+ mutatePullRequests(project, (prs) => {
7362
6906
  if (!Array.isArray(prs) || prs.length === 0) return prs;
7363
6907
  normalizePrRecords(prs, project);
7364
6908
  const existingPr = prs.find(pr =>
@@ -7428,13 +6972,13 @@ function _mergeDuplicatePrInto(winner, loser) {
7428
6972
  * • scalar fields: filled only when the winner's field is empty
7429
6973
  * • prdItems: union-merged
7430
6974
  *
7431
- * @param {string} prPath Path to the pull-requests.json file.
6975
+ * @param {object|string|null} source Project, scope name, or central.
7432
6976
  * @param {{ project?: object|null }} opts Optional project config for normalization.
7433
6977
  * @returns {{ collapsed: number }} Number of duplicate records removed.
7434
6978
  */
7435
- function collapseDuplicatePrRecords(prPath, { project = null } = {}) {
6979
+ function collapseDuplicatePrRecords(source, { project = null } = {}) {
7436
6980
  let collapsed = 0;
7437
- mutatePullRequests(prPath, (prs) => {
6981
+ mutatePullRequests(source, (prs) => {
7438
6982
  normalizePrRecords(prs, project);
7439
6983
  // PR numbers are only unique within a repository. Grouping by number alone
7440
6984
  // merges unrelated GitHub/ADO repositories and can discard a tombstone.
@@ -7476,12 +7020,12 @@ function _clearPrTombstonesForIdentity(identity) {
7476
7020
  ? projectsByName.get(pr._scope) || null
7477
7021
  : null;
7478
7022
  if (getPrIdentityKey(pr, project) !== identity) continue;
7479
- const prPath = store._filePathForScope(pr._scope || 'central');
7480
- scopes.set(path.resolve(prPath), { prPath, project });
7023
+ const scope = pr._scope || 'central';
7024
+ scopes.set(scope, { scope, project });
7481
7025
  }
7482
7026
  let cleared = 0;
7483
- for (const { prPath, project } of scopes.values()) {
7484
- mutatePullRequests(prPath, (prs) => {
7027
+ for (const { scope, project } of scopes.values()) {
7028
+ mutatePullRequests(scope, (prs) => {
7485
7029
  for (const pr of prs) {
7486
7030
  if (pr?.userDeleted !== true || getPrIdentityKey(pr, project) !== identity) continue;
7487
7031
  delete pr.userDeleted;
@@ -7523,13 +7067,10 @@ function shouldAdoptPlatformCreated(storedCreated, platformCreated) {
7523
7067
  /**
7524
7068
  * Canonical PR-producing work contract helper.
7525
7069
  *
7526
- * Dashboard rendering derives work-item PR columns from PR.prdItems (with
7527
- * engine/pr-links.json as a compatibility fallback). Any path that discovers or
7528
- * manually records a PR for a work item must use this helper so the PR record
7529
- * and the canonical work-item attachment are created together and idempotently.
7070
+ * Any path that discovers or manually records a PR for a work item must use
7071
+ * this helper so the PR record and work-item attachment stay consistent.
7530
7072
  */
7531
- function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null, itemIds = null, beforeInsert = null, allowResurrect = false } = {}) {
7532
- if (!prPath) throw new Error('prPath required');
7073
+ function upsertPullRequestRecord(source, entry, { project = null, itemId = null, itemIds = null, beforeInsert = null, allowResurrect = false } = {}) {
7533
7074
  if (!entry || typeof entry !== 'object') throw new Error('entry required');
7534
7075
 
7535
7076
  const linkedItemIds = normalizePrLinkItems([
@@ -7553,9 +7094,7 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7553
7094
  prNumber: prNumber ?? entry.prNumber ?? null,
7554
7095
  prdItems: linkedItemIds,
7555
7096
  };
7556
- // Issue #348: backfill `project` from the project option when the entry
7557
- // doesn't carry one. The file path already encodes the project name (e.g.
7558
- // projects/<name>/pull-requests.json), so it is always deterministic.
7097
+ // Backfill `project` from the resolved project when the entry omits it.
7559
7098
  // Without this, PRs linked via /api/pull-requests/link had project:null on
7560
7099
  // the record, which broke any downstream path that reads pr.project to
7561
7100
  // resolve the owning project.
@@ -7572,7 +7111,7 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7572
7111
  let resurrected = allowResurrect && _clearPrTombstonesForIdentity(canonicalId) > 0;
7573
7112
  let record = null;
7574
7113
 
7575
- mutatePullRequests(prPath, (prs) => {
7114
+ mutatePullRequests(source, (prs) => {
7576
7115
  normalizePrRecords(prs, project);
7577
7116
  const identityMatches = prs.filter(pr => getPrIdentityKey(pr, project) === canonicalId);
7578
7117
  let target = identityMatches.length > 0 ? _pickBestPrRecord(identityMatches) : null;
@@ -7736,7 +7275,7 @@ function classifyPrRefForVerification(prRef, project = null) {
7736
7275
  return null;
7737
7276
  }
7738
7277
 
7739
- // W-mq5wfh1v000e0da9 — Auto-enroll a PR into pull-requests.json when a
7278
+ // W-mq5wfh1v000e0da9 — Auto-enroll a PR into tracked state when a
7740
7279
  // `type: fix` work item is created with a structured PR pointer. Without
7741
7280
  // this, fix WIs against untracked PRs bypass the polling / review / build
7742
7281
  // pipelines (the engine `pr_not_found` gate blocks dispatch entirely, and
@@ -7754,8 +7293,8 @@ function classifyPrRefForVerification(prRef, project = null) {
7754
7293
  //
7755
7294
  // Returns:
7756
7295
  // { skipped: true, reason } — not a pr-requiring type / no ref / no URL / upsert error
7757
- // { alreadyEnrolled: true, id } — PR already in pull-requests.json
7758
- // { enrolled: true, id, prPath } — newly enrolled
7296
+ // { alreadyEnrolled: true, id } — PR already tracked
7297
+ // { enrolled: true, id, scope } — newly enrolled
7759
7298
  const _PR_REQUIRING_TYPES = new Set([WORK_TYPE.FIX, WORK_TYPE.REVIEW, WORK_TYPE.TEST]);
7760
7299
  function autoEnrollPrFromWorkItem(item, project, minionsDir) {
7761
7300
  if (!item || !_PR_REQUIRING_TYPES.has(item.type)) return { skipped: true, reason: 'not-pr-requiring-type' };
@@ -7763,8 +7302,8 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
7763
7302
  if (!prRef) return { skipped: true, reason: 'no-structured-ref' };
7764
7303
  const url = deriveUrlForPrRef(prRef, project);
7765
7304
  if (!url) return { skipped: true, reason: 'no-url' };
7766
- const prPath = project ? projectPrPath(project) : centralPullRequestsPath(minionsDir);
7767
- const existing = safeJsonArr(prPath);
7305
+ const source = project || 'central';
7306
+ const existing = readPullRequests(source);
7768
7307
  if (findPrRecord(existing, prRef, project)) {
7769
7308
  return { alreadyEnrolled: true, id: getCanonicalPrId(project, prRef, url) };
7770
7309
  }
@@ -7772,7 +7311,7 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
7772
7311
  const prNum = parsedUrl ? parsedUrl.prNumber : null;
7773
7312
  const prId = getCanonicalPrId(project, prRef, url);
7774
7313
  try {
7775
- const result = upsertPullRequestRecord(prPath, {
7314
+ const result = upsertPullRequestRecord(source, {
7776
7315
  id: prId,
7777
7316
  prNumber: prNum,
7778
7317
  title: `PR #${prNum != null ? prNum : '?'} (polling...)`,
@@ -7786,7 +7325,7 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
7786
7325
  contextOnly: false,
7787
7326
  }, { project, itemId: item.id });
7788
7327
  return result.created
7789
- ? { enrolled: true, id: result.id, prPath }
7328
+ ? { enrolled: true, id: result.id, scope: stateScope(source) }
7790
7329
  : { alreadyEnrolled: true, id: result.id };
7791
7330
  } catch (e) {
7792
7331
  log('warn', `autoEnrollPrFromWorkItem ${item.id}: ${e.message}`);
@@ -7820,47 +7359,35 @@ function clearWorktreeFailureCache(wtPath) {
7820
7359
 
7821
7360
  // ─── Work Items & Pull Requests Mutation Helpers ────────────────────────────
7822
7361
 
7823
- /**
7824
- * Atomic read-modify-write for work-items JSON files.
7825
- * Wraps mutateJsonFileLocked with defaultValue of [].
7826
- * @param {string} filePath - Path to the work-items JSON file
7827
- * @param {Function} mutator - Receives the array, mutates in place or returns new value
7828
- */
7829
- function mutateWorkItems(filePath, mutator) {
7830
- // Phase 9.4 SQL-only. Route through work-items-store; SQL is the canonical
7831
- // (and only) store. The legacy JSON path below remains ONLY for ad-hoc
7832
- // file paths outside the MINIONS_DIR layout (e.g. tests using
7833
- // createTmpDir()) that can't be mapped to a stable scope. Production
7834
- // callers always use shared.projectWorkItemsPath(p) / MINIONS_DIR/work-items.json.
7835
- const fpNorm = String(filePath).replace(/\\/g, '/');
7836
- const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
7837
- const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/work-items.json';
7838
- if (insideMinionsDir) {
7839
- const store = require('./work-items-store');
7840
- const scope = store.scopeForFilePath(filePath);
7841
- const { wrote, result } = store.applyWorkItemsMutation(scope, (items) => {
7842
- if (!Array.isArray(items)) items = [];
7843
- return mutator(items) || items;
7844
- });
7845
- if (wrote) {
7846
- try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
7847
- try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
7848
- }
7849
- return result;
7362
+ function stateScope(source) {
7363
+ if (source == null || source === '') return 'central';
7364
+ if (source && typeof source === 'object') {
7365
+ if (source.isCentral) return 'central';
7366
+ source = source.scope ?? source.name ?? source.project;
7367
+ }
7368
+ if (typeof source !== 'string') throw new TypeError('state scope requires a project, scope name, or null');
7369
+ const scope = source.trim();
7370
+ if (!scope || scope === 'central') return 'central';
7371
+ if (/[\\/]/.test(scope) || /\.json$/i.test(scope)) {
7372
+ throw new TypeError('state scope must be a project name, not a file path');
7850
7373
  }
7374
+ return scope;
7375
+ }
7851
7376
 
7852
- const result = mutateJsonFileLocked(filePath, (data) => {
7853
- if (!Array.isArray(data)) data = [];
7854
- return mutator(data) || data;
7855
- }, {
7856
- defaultValue: [],
7857
- skipWriteIfUnchanged: true,
7858
- _skipSqlRouting: true,
7859
- onWrote: () => {
7860
- try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
7861
- try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
7862
- },
7377
+ function readWorkItems(source = null) {
7378
+ return require('./work-items-store').readWorkItemsForScope(stateScope(source));
7379
+ }
7380
+
7381
+ function mutateWorkItems(source, mutator) {
7382
+ const store = require('./work-items-store');
7383
+ const { wrote, result } = store.applyWorkItemsMutation(stateScope(source), (items) => {
7384
+ if (!Array.isArray(items)) items = [];
7385
+ return mutator(items) || items;
7863
7386
  });
7387
+ if (wrote) {
7388
+ try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
7389
+ try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
7390
+ }
7864
7391
  return result;
7865
7392
  }
7866
7393
 
@@ -7877,43 +7404,20 @@ function reopenWorkItem(wi) {
7877
7404
  wi._retryCount = 0;
7878
7405
  }
7879
7406
 
7880
- /**
7881
- * Atomic read-modify-write for pull-requests JSON files.
7882
- * Wraps mutateJsonFileLocked with defaultValue of [].
7883
- * @param {string} filePath - Path to the pull-requests JSON file
7884
- * @param {Function} mutator - Receives the array, mutates in place or returns new value
7885
- */
7886
- function mutatePullRequests(filePath, mutator) {
7887
- // Phase 9.4 SQL-only. Route through pull-requests-store when filePath sits
7888
- // under MINIONS_DIR. Ad-hoc tmp paths (legacy tests using createTmpDir)
7889
- // still fall through to the JSON path.
7890
- const fpNorm = String(filePath).replace(/\\/g, '/');
7891
- const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
7892
- const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/pull-requests.json';
7893
- if (insideMinionsDir) {
7894
- const store = require('./pull-requests-store');
7895
- const scope = store.scopeForFilePath(filePath);
7896
- const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
7897
- if (!Array.isArray(prs)) prs = [];
7898
- return mutator(prs) || prs;
7899
- });
7900
- if (wrote) {
7901
- try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
7902
- }
7903
- return result;
7904
- }
7407
+ function readPullRequests(source = null) {
7408
+ return require('./pull-requests-store').readPullRequestsForScope(stateScope(source));
7409
+ }
7905
7410
 
7906
- return mutateJsonFileLocked(filePath, (data) => {
7907
- if (!Array.isArray(data)) data = [];
7908
- return mutator(data) || data;
7909
- }, {
7910
- defaultValue: [],
7911
- skipWriteIfUnchanged: true,
7912
- _skipSqlRouting: true,
7913
- onWrote: () => {
7914
- try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
7915
- },
7411
+ function mutatePullRequests(source, mutator) {
7412
+ const store = require('./pull-requests-store');
7413
+ const { wrote, result } = store.applyPullRequestsMutation(stateScope(source), (prs) => {
7414
+ if (!Array.isArray(prs)) prs = [];
7415
+ return mutator(prs) || prs;
7916
7416
  });
7417
+ if (wrote) {
7418
+ try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
7419
+ }
7420
+ return result;
7917
7421
  }
7918
7422
 
7919
7423
  /**
@@ -8818,11 +8322,7 @@ module.exports = {
8818
8322
  writeStopIntent,
8819
8323
  clearStopIntent,
8820
8324
  isStopIntentSet,
8821
- COOLDOWNS_PATH,
8822
- ENGINE_STATE_PATH, // W-mp60tw0u000j3931
8823
- PR_LINKS_PATH,
8824
8325
  PINNED_ITEMS_PATH,
8825
- LOG_PATH,
8826
8326
  CONSTELLATION_BRIDGE_MARKER_PATH,
8827
8327
  CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
8828
8328
  DEFAULT_DASHBOARD_PORT,
@@ -8860,14 +8360,12 @@ module.exports = {
8860
8360
  forEachPidFile,
8861
8361
  resolveMinionsHome,
8862
8362
  saveMinionsRootPointer,
8863
- neutralizeJsonBackupSidecar,
8864
8363
  PROMPT_CONTEXTS_DIR,
8865
8364
  dispatchPromptSidecarPath,
8866
8365
  dispatchCompletionReportPath,
8867
8366
  sidecarDispatchPrompt,
8868
8367
  resolveDispatchPrompt,
8869
8368
  deleteDispatchPromptSidecar,
8870
- assertStateFileSize,
8871
8369
  withFileLock,
8872
8370
  mutateJsonFileLocked,
8873
8371
  mutateControl, recordEngineRespawn,
@@ -8878,8 +8376,11 @@ module.exports = {
8878
8376
  mutateKbSwept, // Phase 9.3
8879
8377
  mutateKbSweepState, // Phase 9.3
8880
8378
  mutateTestResults, // Phase 9.3
8379
+ stateScope,
8380
+ readWorkItems,
8881
8381
  mutateWorkItems,
8882
8382
  reopenWorkItem,
8383
+ readPullRequests,
8883
8384
  mutatePullRequests,
8884
8385
  uid,
8885
8386
  uniquePath,
@@ -8950,17 +8451,11 @@ module.exports = {
8950
8451
  resolveConfiguredProject,
8951
8452
  resolveProjectSource,
8952
8453
  projectRoot,
8953
- centralWorkItemsPath,
8954
- centralPullRequestsPath,
8955
8454
  projectStateDir,
8956
8455
  projectStateDirEnsure,
8957
- projectWorkItemsPath,
8958
- projectPrPath,
8959
- ensureProjectStateFiles,
8456
+ initializeProjectState,
8960
8457
  sameResolvedPath,
8961
8458
  realPathForComparison, // exported for testing
8962
- prPathComparisonCandidates, // exported for testing
8963
- resolveProjectForPrPath, // exported for testing
8964
8459
  getPrLinks,
8965
8460
  addPrLink,
8966
8461
  normalizePrScopeSegment, // exported for testing