@yemi33/minions 0.1.2369 → 0.1.2371

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 (79) hide show
  1. package/bin/minions.js +38 -11
  2. package/dashboard/js/memory-search.js +112 -0
  3. package/dashboard/js/render-kb.js +15 -0
  4. package/dashboard/js/render-other.js +2 -30
  5. package/dashboard/js/render-work-items.js +0 -34
  6. package/dashboard/js/settings.js +27 -27
  7. package/dashboard/pages/inbox.html +1 -1
  8. package/dashboard/pages/tools.html +1 -1
  9. package/dashboard.js +148 -258
  10. package/docs/README.md +2 -3
  11. package/docs/architecture.excalidraw +2 -2
  12. package/docs/auto-discovery.md +3 -3
  13. package/docs/completion-reports.md +0 -121
  14. package/docs/copilot-cli-schema.md +9 -17
  15. package/docs/deprecated.json +0 -51
  16. package/docs/design-state-storage.md +4 -4
  17. package/docs/harness-propagation.md +33 -263
  18. package/docs/human-vs-automated.md +1 -1
  19. package/docs/named-agents.md +0 -2
  20. package/docs/plan-lifecycle.md +5 -6
  21. package/docs/qa-runbook-lifecycle.md +10 -25
  22. package/docs/shared-lifecycle-module-map.md +2 -10
  23. package/docs/team-memory.md +18 -4
  24. package/engine/ado-comment.js +5 -11
  25. package/engine/ado.js +10 -5
  26. package/engine/cleanup.js +16 -36
  27. package/engine/cli.js +13 -175
  28. package/engine/comment-format.js +8 -182
  29. package/engine/consolidation.js +72 -1
  30. package/engine/db/index.js +60 -10
  31. package/engine/db/migrations/017-agent-memory.js +208 -0
  32. package/engine/db/migrations/018-sql-only-cutover.js +56 -0
  33. package/engine/dispatch-store.js +0 -114
  34. package/engine/dispatch.js +1 -6
  35. package/engine/features.js +6 -0
  36. package/engine/gh-comment.js +2 -10
  37. package/engine/github.js +8 -6
  38. package/engine/lifecycle.js +141 -173
  39. package/engine/llm.js +9 -11
  40. package/engine/managed-spawn.js +1 -6
  41. package/engine/memory-retrieval.js +165 -0
  42. package/engine/memory-store.js +367 -0
  43. package/engine/metrics-store.js +4 -128
  44. package/engine/pipeline.js +4 -7
  45. package/engine/playbook.js +64 -198
  46. package/engine/prd-store.js +115 -141
  47. package/engine/preflight.js +8 -55
  48. package/engine/projects.js +34 -41
  49. package/engine/pull-requests-store.js +9 -234
  50. package/engine/qa-runs.js +4 -15
  51. package/engine/qa-sessions.js +5 -17
  52. package/engine/queries.js +23 -103
  53. package/engine/routing.js +1 -1
  54. package/engine/runtimes/claude.js +1 -2
  55. package/engine/runtimes/codex.js +0 -2
  56. package/engine/runtimes/copilot.js +1 -4
  57. package/engine/shared-branch-pr-reconcile.js +2 -5
  58. package/engine/shared.js +179 -533
  59. package/engine/small-state-store.js +12 -647
  60. package/engine/spawn-agent.js +5 -96
  61. package/engine/state-operations.js +166 -0
  62. package/engine/supervisor.js +0 -1
  63. package/engine/watch-actions.js +2 -3
  64. package/engine/watches-store.js +2 -128
  65. package/engine/work-items-store.js +3 -254
  66. package/engine.js +102 -334
  67. package/package.json +2 -2
  68. package/playbooks/build-fix-complex.md +0 -4
  69. package/playbooks/fix.md +0 -4
  70. package/playbooks/implement-shared.md +0 -4
  71. package/playbooks/implement.md +0 -4
  72. package/playbooks/plan-to-prd.md +16 -11
  73. package/playbooks/plan.md +0 -4
  74. package/playbooks/review.md +0 -6
  75. package/playbooks/shared-rules.md +0 -65
  76. package/docs/harness-transparency.md +0 -199
  77. package/docs/project-skills.md +0 -193
  78. package/engine/discover-project-skills.js +0 -673
  79. package/engine/playbook-intents.js +0 -76
package/engine/shared.js CHANGED
@@ -471,17 +471,6 @@ function log(level, msg, meta = {}) {
471
471
  // Console output remains immediate (also redacted)
472
472
  console.log(`[${logTs()}] [${level}] ${safeMsg}`);
473
473
 
474
- // Capture the resolved log file path AT WRITE TIME (not flush time).
475
- // Stops test pollution: a test sets MINIONS_TEST_DIR, calls log(), the test
476
- // ends and clears MINIONS_TEST_DIR, then the buffer flushes — without
477
- // capture-at-write-time the entry would land in the production log.json.
478
- // Stripped before persisting (see _flushLogBuffer).
479
- Object.defineProperty(entry, '_logPath', {
480
- value: _currentLogPath(),
481
- enumerable: false,
482
- writable: true,
483
- configurable: true,
484
- });
485
474
  // Capture the SQL state.db path too — same write-time semantics so a
486
475
  // deferred buffer flush after the test ends still routes the entry to
487
476
  // the test's state.db (not the production one). Path resolution mirrors
@@ -534,22 +523,7 @@ function log(level, msg, meta = {}) {
534
523
  * Lazy resolution honors the *current* env at flush time, so even unbussed
535
524
  * dependents write to the test dir while the test owns it.
536
525
  */
537
- function _currentLogPath() {
538
- if (process.env.MINIONS_TEST_DIR) {
539
- return path.join(path.resolve(process.env.MINIONS_TEST_DIR), 'engine', 'log.json');
540
- }
541
- if (process.env.MINIONS_LOG_PATH) {
542
- return path.resolve(process.env.MINIONS_LOG_PATH);
543
- }
544
- const root = process.env.MINIONS_HOME ? path.resolve(process.env.MINIONS_HOME) : path.resolve(__dirname, '..');
545
- return path.join(root, 'engine', 'log.json');
546
- }
547
-
548
- // Resolve the SQL state.db path at write time — same isolation contract
549
- // as _currentLogPath. MINIONS_LOG_PATH only redirects the JSON sidecar;
550
- // the SQL store stays attached to MINIONS_TEST_DIR / MINIONS_HOME because
551
- // it shares the file with the rest of the engine state (dispatches,
552
- // work_items, pull_requests).
526
+ // Resolve the SQL state.db path at write time.
553
527
  function _currentLogDbPath() {
554
528
  if (process.env.MINIONS_TEST_DIR) {
555
529
  return path.join(path.resolve(process.env.MINIONS_TEST_DIR), 'engine', 'state.db');
@@ -673,21 +647,15 @@ function _flushLogBuffer() {
673
647
  // has been cleared by the time we flush. Entries without captured paths
674
648
  // fall back to the current resolved values (eg. direct _logBuffer.push()
675
649
  // from tests).
676
- const fallbackJsonPath = _currentLogPath();
677
650
  const fallbackDbPath = _currentLogDbPath();
678
- const byJsonPath = new Map();
679
651
  const byDbPath = new Map();
680
652
  for (const raw of drained) {
681
- const jsonTarget = raw._logPath || fallbackJsonPath;
682
653
  const dbTarget = raw._dbPath || fallbackDbPath;
683
654
  // SEC-09 defense-in-depth: redact again at flush time so any direct
684
655
  // `_logBuffer.push(entry)` callers (tests, future paths) can't leak secrets.
685
656
  const entry = redactSecrets(raw);
686
657
  // Strip the routing-only metadata before persisting.
687
- delete entry._logPath;
688
658
  delete entry._dbPath;
689
- if (!byJsonPath.has(jsonTarget)) byJsonPath.set(jsonTarget, []);
690
- byJsonPath.get(jsonTarget).push(entry);
691
659
  if (!byDbPath.has(dbTarget)) byDbPath.set(dbTarget, []);
692
660
  byDbPath.get(dbTarget).push(entry);
693
661
  }
@@ -702,19 +670,6 @@ function _flushLogBuffer() {
702
670
  }
703
671
  } catch { /* logs-store unavailable (node<22.5) — JSON mirror still works */ }
704
672
 
705
- // JSON mirror — kept for backward compat with test fixtures that
706
- // safeJson(log.json) directly, and the legacy getEngineLog fallback.
707
- // Phase 4.5 will retire this once readers move to readRecentLogs().
708
- for (const [target, entries] of byJsonPath) {
709
- try {
710
- mutateJsonFileLocked(target, (logData) => {
711
- if (!Array.isArray(logData)) logData = logData?.entries || [];
712
- logData.push(...entries);
713
- if (logData.length >= 2500) logData.splice(0, logData.length - 2000);
714
- return logData;
715
- }, { defaultValue: [] });
716
- } catch { /* logging should never crash the caller */ }
717
- }
718
673
  }
719
674
 
720
675
  /** Flush buffered log entries to disk. Call during graceful shutdown to drain the buffer. */
@@ -729,6 +684,11 @@ function flushLogs() {
729
684
  // ── File I/O ─────────────────────────────────────────────────────────────────
730
685
 
731
686
  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
+ }
732
692
  try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
733
693
  }
734
694
 
@@ -738,10 +698,24 @@ function safeRead(p) {
738
698
  // the client-sent document body with '' when the file was deleted/moved
739
699
  // between panel open and send.
740
700
  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
+ }
741
706
  try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
742
707
  }
743
708
 
744
709
  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
+ }
745
719
  try { return fs.readdirSync(dir); } catch { return []; }
746
720
  }
747
721
 
@@ -884,7 +858,7 @@ function isSourcePlanContentStale(plansDir, plan) {
884
858
  // Matching is by path suffix rather than full-prefix against MINIONS_DIR,
885
859
  // because tests using MINIONS_TEST_DIR re-resolve the dir but use the same
886
860
  // suffix pattern. Returns `{value}` on hit, `null` on miss; callers fall
887
- // through to the legacy disk read.
861
+ // through to ordinary file-backed JSON handling.
888
862
  //
889
863
  // Per-project work-items / pull-requests need scope extraction from the
890
864
  // path. `<MINIONS_DIR>/projects/<name>/work-items.json` → scope `<name>`;
@@ -956,23 +930,15 @@ function _routeJsonReadToSql(p) {
956
930
  const store = require('./small-state-store');
957
931
  return { value: store.readDocSessions() };
958
932
  }
959
- // Per-project work-items.json — match `/projects/<name>/work-items.json`.
960
- // When SQL has no rows for the scope AND the JSON file is absent on
961
- // disk, preserve the legacy "file missing → null" semantic. This guards
962
- // the resurrection-bug regression (engine-helper-coverage.test.js):
963
- // safeJson(path-to-removed-project) must return null, not `[]`.
933
+ // Per-project work-items.json — the path is now only a scope identifier.
964
934
  if ((m = norm.match(/\/projects\/([^/]+)\/work-items\.json$/))) {
965
935
  const store = require('./work-items-store');
966
- const v = store.readWorkItemsForScope(m[1]);
967
- if ((!Array.isArray(v) || v.length === 0) && !fs.existsSync(p)) return null;
968
- return { value: v };
936
+ return { value: store.readWorkItemsForScope(m[1]) };
969
937
  }
970
938
  // Per-project pull-requests.json
971
939
  if ((m = norm.match(/\/projects\/([^/]+)\/pull-requests\.json$/))) {
972
940
  const store = require('./pull-requests-store');
973
- const v = store.readPullRequestsForScope(m[1]);
974
- if ((!Array.isArray(v) || v.length === 0) && !fs.existsSync(p)) return null;
975
- return { value: v };
941
+ return { value: store.readPullRequestsForScope(m[1]) };
976
942
  }
977
943
  // Central work-items.json — `<MINIONS_DIR>/work-items.json`. Must not
978
944
  // collide with `/projects/<name>/work-items.json` (handled above).
@@ -1040,6 +1006,8 @@ function safeJson(p) {
1040
1006
  // bytes as the source of truth (so the read+write pair is atomic).
1041
1007
  const skipSqlRouting = arguments.length > 1 && arguments[1] && arguments[1].skipSqlRouting;
1042
1008
  if (!skipSqlRouting) {
1009
+ const prdStore = require('./prd-store');
1010
+ if (prdStore.parsePrdPath(p)) return prdStore.readPrd(p);
1043
1011
  const routed = _routeJsonReadToSql(p);
1044
1012
  if (routed) return routed.value;
1045
1013
  }
@@ -1137,6 +1105,10 @@ function safeJsonArr(p) { return safeJson(p) || []; }
1137
1105
  * @returns {*} Parsed JSON on success, otherwise `defaultValue`.
1138
1106
  */
1139
1107
  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;
1140
1112
  let raw;
1141
1113
  try {
1142
1114
  raw = fs.readFileSync(p, 'utf8');
@@ -1163,6 +1135,12 @@ function safeJsonNoRestore(p, defaultValue = null) {
1163
1135
  let _tmpCounter = 0;
1164
1136
 
1165
1137
  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
+ }
1166
1144
  const dir = path.dirname(p);
1167
1145
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1168
1146
  const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
@@ -1824,18 +1802,27 @@ function withFileLock(lockPath, fn, {
1824
1802
  // Route table for small-state files migrated to SQL stores. Each entry
1825
1803
  // declares the SQL store function to invoke and the expected default JSON
1826
1804
  // shape (for the "ensure file exists" fallback below).
1827
- const _SMALL_STATE_MUTATE_ROUTES = {
1828
- 'cooldowns.json': { fn: 'applyCooldownsMutation', mirror: '_mirrorCooldownsJson', defaultShape: 'object' },
1829
- 'pending-rebases.json': { fn: 'applyPendingRebasesMutation', mirror: '_mirrorPendingRebasesJson', defaultShape: 'array' },
1830
- 'cc-sessions.json': { fn: 'applyCcSessionsMutation', mirror: '_mirrorCcSessionsJson', defaultShape: 'array' },
1831
- 'doc-sessions.json': { fn: 'applyDocSessionsMutation', mirror: '_mirrorDocSessionsJson', defaultShape: 'object' },
1832
- 'pr-links.json': { fn: 'applyPrLinksMutation', mirror: '_mirrorPrLinksJson', defaultShape: 'object' },
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' },
1833
1820
  };
1834
1821
 
1835
1822
  function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
1836
1823
  const baseName = path.basename(filePath);
1837
- const smallRoute = _SMALL_STATE_MUTATE_ROUTES[baseName];
1838
- if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json' && !smallRoute) return null;
1824
+ const stateRoute = _STATE_MUTATE_ROUTES[baseName];
1825
+ if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json' && !stateRoute) return null;
1839
1826
  const fpNorm = String(filePath).replace(/\\/g, '/');
1840
1827
  const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
1841
1828
  const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/' + baseName;
@@ -1843,57 +1830,21 @@ function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
1843
1830
 
1844
1831
  // Small-state files live exclusively under <MINIONS_DIR>/engine/<baseName>.
1845
1832
  // Don't hijack writes to ad-hoc paths that happen to share the basename.
1846
- if (smallRoute) {
1833
+ if (stateRoute) {
1847
1834
  if (!fpNorm.endsWith('/engine/' + baseName)) return null;
1848
- const store = require('./small-state-store');
1849
- // Pre-warm the SQLite DB BEFORE acquiring the file lock. On the very first
1850
- // call from a process (or after a test-isolation MINIONS_TEST_DIR swap),
1851
- // getDb() opens the DB file and runs migrations — which can take tens of
1852
- // milliseconds on a loaded box. When 5+ concurrent processes all hit this
1853
- // path simultaneously, whichever one wins the file lock holds it for the
1854
- // entire DB-init duration. The other 4 processes each have only a 5-second
1855
- // timeout window to acquire the lock; if DB init exceeds that, they throw
1856
- // "Lock timeout" and exit 1 (observed in CI after opg#552 backport which
1857
- // added a new test that increases system load during parallel runs).
1858
- // Pre-warming here means getDb() returns the cached _db instantly when
1859
- // called again inside the lock, so the lock is held only for the fast
1860
- // SQL transaction + mirror write — not for DB initialization.
1835
+ const store = require(stateRoute.module);
1861
1836
  try {
1862
1837
  require('./db').getDb();
1863
1838
  } catch (e) {
1864
1839
  throw new Error(`small-state-store: SQLite unavailable — ${e.message}`);
1865
1840
  }
1866
- // Hold the JSON file lock across the SQL transaction AND the mirror
1867
- // write. The SQL transaction itself is cross-process serialized via
1868
- // BEGIN IMMEDIATE inside the SQL store, but the JSON mirror is a
1869
- // separate read-from-SQL → atomic-rename that can race across
1870
- // processes: an earlier mirror's stale snapshot can land AFTER a
1871
- // later mirror's complete snapshot and lose committed rows from the
1872
- // on-disk JSON file. Pre-Phase-9.4 this was masked by the JSON
1873
- // fallback (concurrent writers serialized through the same lock at
1874
- // the bottom of mutateJsonFileLocked); after the fallback removal,
1875
- // the bare mirror races. Locking around the SQL+mirror block puts
1876
- // those two operations back into one cross-process critical section.
1877
- const lockPath = `${filePath}.lock`;
1878
- return withFileLock(lockPath, () => {
1879
- const out = store[smallRoute.fn]((data) => {
1880
- const next = mutateFn(data);
1881
- if (onWrote) onWrote();
1882
- return next;
1883
- });
1884
- const result = out && Object.prototype.hasOwnProperty.call(out, 'result') ? out.result : undefined;
1885
- try { store[smallRoute.mirror](filePath); } catch { /* mirror best-effort */ }
1886
- if (!fs.existsSync(filePath)) {
1887
- try {
1888
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
1889
- const fallback = smallRoute.defaultShape === 'array'
1890
- ? (Array.isArray(result) ? result : [])
1891
- : (result && typeof result === 'object' && !Array.isArray(result) ? result : {});
1892
- safeWrite(filePath, fallback);
1893
- } catch { /* best-effort */ }
1894
- }
1895
- return { routed: result };
1841
+ const out = store[stateRoute.fn]((data) => {
1842
+ const next = mutateFn(data);
1843
+ if (onWrote) onWrote();
1844
+ return next;
1896
1845
  });
1846
+ const result = out && Object.prototype.hasOwnProperty.call(out, 'result') ? out.result : undefined;
1847
+ return { routed: result };
1897
1848
  }
1898
1849
 
1899
1850
  let result;
@@ -1915,15 +1866,6 @@ function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
1915
1866
  return out;
1916
1867
  });
1917
1868
  }
1918
- // SQL-store mirror is gated on `wrote`; an empty-no-op mutator leaves the
1919
- // JSON file uncreated. The JSON path always wrote (including for empty
1920
- // arrays via ensureProjectStateFiles), so guarantee a JSON file exists.
1921
- if (!fs.existsSync(filePath)) {
1922
- try {
1923
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
1924
- safeWrite(filePath, Array.isArray(result) ? result : []);
1925
- } catch { /* best-effort */ }
1926
- }
1927
1869
  return { routed: result };
1928
1870
  }
1929
1871
 
@@ -1938,12 +1880,11 @@ function mutateJsonFileLocked(filePath, mutateFn, {
1938
1880
  const lockPath = `${filePath}.lock`;
1939
1881
  const retries = lockRetries ?? ENGINE_DEFAULTS.lockRetries;
1940
1882
  const retryBackoffMs = lockRetryBackoffMs ?? ENGINE_DEFAULTS.lockRetryBackoffMs;
1941
- // Phase 9 SQL-routing: when targeting work-items.json or pull-requests.json
1942
- // under MINIONS_DIR, delegate to the SQL-store wrappers so SQL stays
1943
- // canonical. Without this, JSON-only edits leak past the SQL mirror and
1944
- // divergence-detection short-circuits leave SQL readers stale.
1945
- // `_skipSqlRouting: true` is the internal opt-out used by the SQL store's
1946
- // own SQLite-failure fallback to avoid recursion.
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.
1947
1888
  if (!_skipSqlRouting) {
1948
1889
  const routed = _tryRouteMutateToSql(filePath, mutateFn, onWrote);
1949
1890
  if (routed) return routed.routed;
@@ -1980,17 +1921,6 @@ function mutateJsonFileLocked(filePath, mutateFn, {
1980
1921
  try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1981
1922
  }
1982
1923
  safeWrite(filePath, finalData);
1983
- // Phase 10 step 2 — dual-write PRD content to SQL. The single chokepoint:
1984
- // every prd/*.json mutation through mutateJsonFileLocked mirrors into the
1985
- // prds/prd_items/prd_verify_prs tables. JSON stays canonical (nothing
1986
- // reads SQL yet); this keeps the mirror trustworthy for the read-flip.
1987
- // BEST-EFFORT: parsePrdPath fast-rejects non-PRD paths, and a SQLite
1988
- // failure must never break the JSON write. mirrorPrdToSql swallows
1989
- // internally; the require is guarded for the (engine-agnostic) load order.
1990
- try {
1991
- const prdStore = require('./prd-store');
1992
- if (prdStore.parsePrdPath(filePath)) prdStore.mirrorPrdToSql(filePath, finalData);
1993
- } catch { /* mirror is best-effort — never break the canonical JSON write */ }
1994
1924
  // Side-effect hook fired only when an actual write happened. Callers
1995
1925
  // use this to emit cache-invalidation signals (events table row) so
1996
1926
  // skip-write-unchanged paths (dedup, idempotent no-op POSTs) don't
@@ -3533,46 +3463,18 @@ const ENGINE_DEFAULTS = {
3533
3463
  // Engine-scoped per the CLAUDE.md rule against `runtime.name === ...` branches at call sites — the runtime check
3534
3464
  // lives inside the helper (engine/spawn-agent.js#preApproveWorkspaceMcps) which no-ops for non-Claude runtimes.
3535
3465
  claudePreApproveWorkspaceMcps: true,
3536
- // P-08b62d49 — propagate project-local-on-main harness dirs (uncommitted
3537
- // <repo>/.claude/skills, <repo>/.copilot/commands, etc.) into the worktree
3538
- // dispatch via additional `--add-dir` entries. Defaults TRUE so the
3539
- // worktree-uncommitted footgun described in docs/harness-propagation.md is
3540
- // closed by default. Set to false to fall back to the legacy
3541
- // "only committed assets are visible" behavior.
3542
- harnessPropagateProjectLocal: true,
3543
- // P-7c1a9e42 — cap on how many skill packs / command files
3544
- // engine/discover-project-skills.js reads per surface (root `.claude/skills`,
3545
- // each `<area>/.claude/skills`, etc.). Raised well above the module-level
3546
- // DEFAULTS.maxFilesPerSurface fallback (50) so large monorepo sub-projects (e.g.
3547
- // office/src/ocm/.claude/skills with ~80 packages) don't silently drop the
3548
- // most-relevant skills past the cap. engine/playbook.js threads this into the
3549
- // discovery `opts`; when no override is supplied the module falls back to its
3550
- // own DEFAULTS.maxFilesPerSurface. See docs/project-skills.md.
3551
- projectSkillMaxFilesPerSurface: 200,
3552
- // P-49e1c8b7 — hermetic harness opt-out. When TRUE the spawned agent runs
3553
- // with a known-empty harness surface around the worktree:
3554
- // - `--add-dir` is exactly `[minionsDir]` (user-scope skill/command/MCP
3555
- // roots from `runtime.getUserAssetDirs(...)` are dropped);
3556
- // - project-local-on-main harness propagation (`harnessPropagateProjectLocal`)
3557
- // is skipped — no `--project-harness-dir` flags emitted;
3558
- // - Claude workspace `.mcp.json` pre-approval (`claudePreApproveWorkspaceMcps`)
3559
- // is skipped.
3560
- // Independent of `copilotDisableBuiltinMcps` and `copilotSuppressAgentsMd` —
3561
- // those keep their existing semantics so operators can mix-and-match. Use
3562
- // per-agent override `agent.hermeticHarness` for targeted runs. Resolved
3563
- // through `shared.resolveAgentHermeticHarness(agent, engine)` (mirrors
3564
- // `resolveAgentBareMode`).
3565
- hermeticHarness: false,
3566
- copilotSuppressAgentsMd: true, // Copilot --no-custom-instructions: stop AGENTS.md auto-load from fighting Minions playbook prompts
3567
3466
  copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
3568
3467
  copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
3569
- // P-mcp-storm Copilot SPAWNS every server in its `mcp-config.json` on every
3570
- // process start and authenticates it. `--disable-mcp-server <name>` only hides
3571
- // a server's TOOLS from the model; it does NOT stop the server process (proven
3572
- // empirically agents spawned azmcp/workiq even with the flag set), so any
3573
- // auth-requiring server pops a Microsoft sign-in window on EVERY agent spawn.
3574
- // The ONLY real lever is the CONFIG: a server absent from the config can't be
3575
- // spawned. The engine therefore points agents at a private COPILOT_HOME
3468
+ ccUseWorkerPool: false, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. Off by default — opt-in feature flag. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
3469
+ // PR #321 "kill auth-popup storm". Copilot spawns and authenticates EVERY
3470
+ // MCP server in `$COPILOT_HOME/mcp-config.json` on every process start, even
3471
+ // when the corresponding tool is hidden from the model via
3472
+ // `--disable-mcp-server`. If left unfiltered, every auth-requiring user MCP
3473
+ // (azure-kusto/azmcp, DevBox, workiq, loop, teams, …) boots and pops a
3474
+ // Microsoft sign-in window on EVERY agent spawn — at minions' continuous
3475
+ // spawn cadence that is an infinite popup storm.
3476
+ //
3477
+ // The engine therefore points agents at a private COPILOT_HOME
3576
3478
  // (`shared.resolveAgentCopilotHome`) whose mcp-config is a copy of the operator's
3577
3479
  // `~/.copilot/mcp-config.json` MINUS the names in this list (see engine.js
3578
3480
  // `_applyAgentCopilotHome`). Default [] = inherit ALL of the operator's MCP
@@ -3581,7 +3483,6 @@ const ENGINE_DEFAULTS = {
3581
3483
  // `agent.copilotAgentDisabledMcpServers`. Resolved through
3582
3484
  // `shared.resolveCopilotAgentDisabledMcpServers(agent, engine)`.
3583
3485
  copilotAgentDisabledMcpServers: [],
3584
- ccUseWorkerPool: false, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. Off by default — opt-in feature flag. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
3585
3486
  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
3586
3487
  disableModelDiscovery: false, // skip runtime.listModels() REST calls fleet-wide (settings UI falls back to free-text)
3587
3488
  // Phase 8 (qa-runs.json + qa-sessions.json → SQL). When true, every QA
@@ -3683,6 +3584,11 @@ const ENGINE_DEFAULTS = {
3683
3584
  agentMemorySummaryEnabled: false, // opt-in: when true, eviction batches go through an LLM-compressed summary before being dropped. Default off to mirror the conservative gating on the existing reconcile pass (LLM cost + test stability). Operators flip via engine.agentMemorySummaryEnabled.
3684
3585
  agentMemorySummaryThreshold: 30, // batch window: when summary is enabled and a prune evicts entries, fold at least this many oldest sections into one summary. Means "summary every ~30 entries" in steady state (the original PRD intent).
3685
3586
  agentMemorySummaryDays: 30, // age trigger: when the oldest section is older than this and >= agentMemorySummaryThreshold entries exist, summarize the oldest window even if the file is under the entry cap.
3587
+ memoryRetrievalShadowMode: true, // compute + measure FTS5 retrieval but preserve legacy prompt injection until explicitly disabled
3588
+ memoryRetrievalTopK: 8, // maximum structured memory records injected into one dispatch
3589
+ memoryRetrievalMaxBytes: 8 * 1024, // hard byte budget for the Relevant Memory prompt appendix
3590
+ memoryRetrievalCandidateLimit: 50, // bounded FTS5 candidate pool before deterministic re-ranking
3591
+ memoryEpisodicCapture: false, // opt-in capture of compact task outcomes; never stores transcripts or chain-of-thought
3686
3592
  untrustedFenceMaxBytes: 64 * 1024, // F5 (W-mpeklod3000we69c): per-block cap for `<UNTRUSTED-INPUT>` fences in engine/untrusted-fence.js. 64KB is long enough for realistic PR comments / pinned notes / agent memory sections, short enough that a megabyte-bomb comment cannot blow up the prompt. Content above the cap is truncated INSIDE the fence with a `[truncated N more bytes]` marker so the agent still sees the provenance attribute.
3687
3593
  maxMeetingPromptBytes: 16 * 1024, // cap meeting findings/debate context injected into prompts
3688
3594
  maxMeetingHumanNotesBytes: 2 * 1024, // cap human note bullet lists injected into meeting prompts
@@ -4041,31 +3947,6 @@ function resolveCopilotAgentDisabledMcpServers(agent, engine) {
4041
3947
  return [];
4042
3948
  }
4043
3949
 
4044
- /**
4045
- * P-49e1c8b7 — Resolve whether this agent should run with a hermetic harness.
4046
- * Priority (mirrors `resolveAgentBareMode`):
4047
- * 1. `agent.hermeticHarness` — per-agent override (boolean)
4048
- * 2. `engine.hermeticHarness` — fleet default
4049
- * 3. `false` — hardcoded fallback
4050
- *
4051
- * Strict undefined/null check (not falsy) so a per-agent `false` correctly
4052
- * overrides an engine `true`. Truthy/falsy non-bool values are coerced via
4053
- * `!!` for config tolerance.
4054
- *
4055
- * When TRUE, engine.js (a) skips Claude workspace .mcp.json pre-approval,
4056
- * (b) skips project-local-on-main `--project-harness-dir` propagation, and
4057
- * (c) forwards `--hermetic-harness` to spawn-agent.js so `computeAddDirs`
4058
- * returns `[minionsDir]` only (user-scope skill/command/MCP roots stripped).
4059
- * Independent of `copilotDisableBuiltinMcps` and `copilotSuppressAgentsMd`.
4060
- */
4061
- function resolveAgentHermeticHarness(agent, engine) {
4062
- const a = agent ? agent.hermeticHarness : undefined;
4063
- if (a !== undefined && a !== null) return !!a;
4064
- const e = engine ? engine.hermeticHarness : undefined;
4065
- if (e !== undefined && e !== null) return !!e;
4066
- return false;
4067
- }
4068
-
4069
3950
 
4070
3951
  // ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
4071
3952
  //
@@ -4648,12 +4529,11 @@ const WATCH_ACTION_TYPE = {
4648
4529
  * propagate; the CLI shim in bin/minions.js guarantees `node:sqlite` is
4649
4530
  * loadable on every supported Node version.
4650
4531
  */
4651
- function _smallStateMutator({ filePath, applyMutation, mirror, topic }) {
4532
+ function _smallStateMutator({ applyMutation, topic }) {
4652
4533
  return (mutator) => {
4653
4534
  const store = require('./small-state-store');
4654
4535
  const { wrote, result } = store[applyMutation]((obj) => mutator(obj) || obj);
4655
4536
  if (wrote) {
4656
- try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
4657
4537
  try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
4658
4538
  }
4659
4539
  return result;
@@ -4661,84 +4541,51 @@ function _smallStateMutator({ filePath, applyMutation, mirror, topic }) {
4661
4541
  }
4662
4542
 
4663
4543
  const mutateScheduleRuns = _smallStateMutator({
4664
- filePath: path.join(MINIONS_DIR, 'engine', 'schedule-runs.json'),
4665
4544
  applyMutation: 'applyScheduleRunsMutation',
4666
- mirror: '_mirrorScheduleRunsJson',
4667
4545
  topic: 'schedule_runs',
4668
4546
  defaultValue: () => ({}),
4669
4547
  });
4670
4548
 
4671
4549
  const mutatePipelineRuns = _smallStateMutator({
4672
- filePath: path.join(MINIONS_DIR, 'engine', 'pipeline-runs.json'),
4673
4550
  applyMutation: 'applyPipelineRunsMutation',
4674
- mirror: '_mirrorPipelineRunsJson',
4675
4551
  topic: 'pipeline_runs',
4676
4552
  defaultValue: () => ({}),
4677
4553
  });
4678
4554
 
4679
4555
  const mutateManagedProcesses = _smallStateMutator({
4680
- filePath: path.join(MINIONS_DIR, 'engine', 'managed-processes.json'),
4681
4556
  applyMutation: 'applyManagedProcessesMutation',
4682
- mirror: '_mirrorManagedProcessesJson',
4683
4557
  topic: 'managed_processes',
4684
4558
  defaultValue: () => ({ specs: [] }),
4685
4559
  });
4686
4560
 
4687
4561
  const mutateWorktreePool = _smallStateMutator({
4688
- filePath: path.join(MINIONS_DIR, 'engine', 'worktree-pool.json'),
4689
4562
  applyMutation: 'applyWorktreePoolMutation',
4690
- mirror: '_mirrorWorktreePoolJson',
4691
4563
  topic: 'worktree_pool',
4692
4564
  defaultValue: () => ({ entries: [] }),
4693
4565
  });
4694
4566
 
4695
- /**
4696
- * Phase 8 QA state mutators. Both qa-runs and qa-sessions are top-level
4697
- * JSON arrays (mutator receives the array; mutates in place or returns a
4698
- * replacement). The store diffs by `id`. Falls back to mutateJsonFileLocked
4699
- * on SQLite failure so a node:sqlite-broken install keeps recording QA
4700
- * state. The JSON mirror is gated by `engine.qaDualWriteJson` (default true)
4701
- * — turn off once operators trust SQL as the source of truth.
4702
- */
4703
- function _qaDualWriteEnabled() {
4704
- try {
4705
- const cfg = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
4706
- const flag = cfg && cfg.engine && cfg.engine.qaDualWriteJson;
4707
- if (flag === false) return false;
4708
- return true;
4709
- } catch { return true; }
4710
- }
4711
-
4712
- function _qaMutator({ filePath, applyMutation, mirror, topic }) {
4567
+ /** Route QA array mutations through their SQL stores. */
4568
+ function _qaMutator({ applyMutation, topic }) {
4713
4569
  return (mutator) => {
4714
- return withFileLock(filePath + '.lock', () => {
4715
- const store = require('./small-state-store');
4716
- const { wrote, result } = store[applyMutation]((arr) => {
4717
- if (!Array.isArray(arr)) arr = [];
4718
- return mutator(arr) || arr;
4719
- });
4720
- if (wrote) {
4721
- if (_qaDualWriteEnabled()) {
4722
- try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
4723
- }
4724
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
4725
- }
4726
- return result;
4727
- }, { timeoutMs: 5000, retries: 3 });
4570
+ const store = require('./small-state-store');
4571
+ const { wrote, result } = store[applyMutation]((arr) => {
4572
+ if (!Array.isArray(arr)) arr = [];
4573
+ return mutator(arr) || arr;
4574
+ });
4575
+ if (wrote) {
4576
+ try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
4577
+ }
4578
+ return result;
4728
4579
  };
4729
4580
  }
4730
4581
 
4731
4582
  const mutateQaRuns = _qaMutator({
4732
- filePath: path.join(MINIONS_DIR, 'engine', 'qa-runs.json'),
4733
4583
  applyMutation: 'applyQaRunsMutation',
4734
- mirror: '_mirrorQaRunsJson',
4735
4584
  topic: 'qa_runs',
4736
4585
  });
4737
4586
 
4738
4587
  const mutateQaSessions = _qaMutator({
4739
- filePath: path.join(MINIONS_DIR, 'engine', 'qa-sessions.json'),
4740
4588
  applyMutation: 'applyQaSessionsMutation',
4741
- mirror: '_mirrorQaSessionsJson',
4742
4589
  topic: 'qa_sessions',
4743
4590
  });
4744
4591
 
@@ -4749,41 +4596,24 @@ const mutateQaSessions = _qaMutator({
4749
4596
  * diffs by id. SQL-canonical (Phase 9.4); SQLite failures propagate.
4750
4597
  */
4751
4598
  function mutateWatches(mutator) {
4752
- const watchesPath = path.join(MINIONS_DIR, 'engine', 'watches.json');
4753
4599
  const store = require('./watches-store');
4754
4600
  const { wrote, result } = store.applyWatchesMutation((arr) => {
4755
4601
  if (!Array.isArray(arr)) arr = [];
4756
4602
  return mutator(arr) || arr;
4757
4603
  });
4758
4604
  if (wrote) {
4759
- try { store._mirrorJsonFromSql(watchesPath); } catch { /* mirror best-effort */ }
4760
4605
  try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
4761
4606
  }
4762
4607
  return result;
4763
4608
  }
4764
4609
 
4765
- /**
4766
- * Route a metrics mutation through the SQL store with a JSON dual-write
4767
- * mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
4768
- * receives the full legacy-shape metrics object, mutates in place or
4769
- * returns a replacement, and the store diffs by (kind, key) row.
4770
- * SQL-canonical (Phase 9.4); SQLite failures propagate.
4771
- */
4610
+ /** Route a metrics object mutation through the SQL store. */
4772
4611
  function mutateMetrics(mutator) {
4773
- const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
4774
4612
  const store = require('./metrics-store');
4775
- // W-mradg74d000rfd45 — the JSON mirror is now written INSIDE
4776
- // applyMetricsMutation's SQL transaction (before commit), not here
4777
- // after the fact. Mirroring post-commit left a cross-process window
4778
- // where a sibling process (dashboard vs. engine — separate Node
4779
- // processes, each with its own in-memory mirror-hash cache) could
4780
- // observe the newly committed SQL rows before the JSON file caught up
4781
- // and read stale/missing metrics off the sidecar. See
4782
- // metrics-store.js#applyMetricsMutation for the full rationale.
4783
4613
  const { wrote, result } = store.applyMetricsMutation((m) => {
4784
4614
  if (!m || typeof m !== 'object') m = {};
4785
4615
  return mutator(m) || m;
4786
- }, { mirrorPath: metricsPath });
4616
+ });
4787
4617
  if (wrote) {
4788
4618
  try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
4789
4619
  }
@@ -4976,132 +4806,6 @@ const FAILURE_CLASS = {
4976
4806
  // Structured completion protocol — fields agents must produce in ```completion blocks
4977
4807
  const COMPLETION_FIELDS = ['status', 'summary', 'files_changed', 'tests', 'pr', 'not_changed', 'failure_class', 'retryable', 'needs_rerun', 'verdict', 'artifacts', 'nonce', 'securityFlags'];
4978
4808
 
4979
- // ── Harness transparency (P-a8f3c2d1) ──────────────────────────────────────
4980
- // Canonical vocabulary for harness-usage observability. Both the agent
4981
- // self-report (completionReport.harnessUsed) and the engine-side grounding
4982
- // manifest (_harnessPropagated) speak this shape. Each kind declares the only
4983
- // fields that survive normalization plus the single field that MUST be present
4984
- // for an entry to be kept:
4985
- // skills: { name, source, path } (required: name)
4986
- // mcpServers: { name, scope } (required: name)
4987
- // commands: { name, scope } (required: name)
4988
- // docs: { path, why } (required: path)
4989
- const HARNESS_USED_KINDS = {
4990
- skills: { fields: ['name', 'source', 'path'], required: 'name' },
4991
- mcpServers: { fields: ['name', 'scope'], required: 'name' },
4992
- commands: { fields: ['name', 'scope'], required: 'name' },
4993
- docs: { fields: ['path', 'why'], required: 'path' },
4994
- };
4995
- const HARNESS_USED_MAX_ENTRIES = 50; // per-kind array cap (anti-spam / anti-DoS)
4996
- const HARNESS_USED_MAX_FIELD_LEN = 512; // per-field string clamp
4997
-
4998
- // normalizeHarnessUsed(raw) — validate/clamp an untrusted harnessUsed-shaped
4999
- // object into the canonical { skills, mcpServers, commands, docs } form.
5000
- // Drops malformed entries (non-objects, or missing the kind's required field),
5001
- // keeps only whitelisted string fields (trimmed + clamped), and caps each array
5002
- // at HARNESS_USED_MAX_ENTRIES. Returns null when nothing survives so callers can
5003
- // treat "no harness data" as a single falsy case. When non-null, all four kind
5004
- // keys are always present (empty arrays for kinds with no entries) for a stable
5005
- // downstream shape.
5006
- function normalizeHarnessUsed(raw) {
5007
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
5008
- const out = {};
5009
- let any = false;
5010
- for (const [kind, spec] of Object.entries(HARNESS_USED_KINDS)) {
5011
- const arr = Array.isArray(raw[kind]) ? raw[kind] : [];
5012
- const normed = [];
5013
- for (const entry of arr) {
5014
- if (normed.length >= HARNESS_USED_MAX_ENTRIES) break;
5015
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
5016
- const clean = {};
5017
- for (const field of spec.fields) {
5018
- const val = entry[field];
5019
- if (typeof val !== 'string') continue;
5020
- const trimmed = val.trim();
5021
- if (!trimmed) continue;
5022
- clean[field] = trimmed.slice(0, HARNESS_USED_MAX_FIELD_LEN);
5023
- }
5024
- if (!clean[spec.required]) continue; // required field missing/empty → drop
5025
- normed.push(clean);
5026
- }
5027
- out[kind] = normed;
5028
- if (normed.length) any = true;
5029
- }
5030
- return any ? out : null;
5031
- }
5032
-
5033
- // groundHarnessUsed(harnessUsed, propagated) — Stage 2 grounding cross-check
5034
- // (P-c6f5e8b3). Normalizes an agent's self-reported `harnessUsed` and annotates
5035
- // every surviving entry with a strict-boolean `grounded` field by cross-checking
5036
- // it against the engine-captured `_harnessPropagated` manifest persisted at
5037
- // spawn time:
5038
- // { skills:[{name,source,path}], mcpServers:[{name,scope}],
5039
- // commands:[{name,scope}], addDirs:[...] }
5040
- //
5041
- // Grounding is intentionally NON-DESTRUCTIVE: a `grounded:false` entry is KEPT
5042
- // and flagged — it may be a genuine out-of-band tool, a stale path, or a
5043
- // hallucination, and a human (not the engine) adjudicates. Returns null when
5044
- // the self-report normalizes to nothing (same falsy contract as
5045
- // normalizeHarnessUsed) so callers treat "no harness data" as one case.
5046
- //
5047
- // Matching signals per entry (OR'd to grounded:true):
5048
- // - identity: the entry's `name` equals a propagated entry's name of the same
5049
- // kind, OR (skills/docs) the entry's `path` is lexically contained under a
5050
- // propagated skill root or `--add-dir`, OR basename(path) equals a
5051
- // propagated skill name.
5052
- // - scope: the entry's scope (skills use `source`) matches a propagated
5053
- // entry's scope of the same kind. The capture manifest records harness
5054
- // ROOTS (skill/command dirs, MCP config files) rather than individual
5055
- // affordances, so a per-affordance self-report often can't match by name;
5056
- // scope membership is the coarse-but-honest "the engine exposed this
5057
- // surface" signal the manifest can attest. `docs` carry no scope, so path
5058
- // containment is their only signal.
5059
- function groundHarnessUsed(harnessUsed, propagated) {
5060
- const normalized = normalizeHarnessUsed(harnessUsed);
5061
- if (!normalized) return null;
5062
- const m = (propagated && typeof propagated === 'object' && !Array.isArray(propagated)) ? propagated : {};
5063
- const arr = (v) => (Array.isArray(v) ? v : []);
5064
- const lc = (v) => (typeof v === 'string' ? v.trim().toLowerCase() : '');
5065
- const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
5066
- const basename = (p) => normPath(p).split('/').filter(Boolean).pop() || '';
5067
-
5068
- // Container paths an affordance may legitimately live under: every propagated
5069
- // skill root + every propagated `--add-dir`.
5070
- const containers = [
5071
- ...arr(m.skills).map(s => s && s.path).filter(Boolean),
5072
- ...arr(m.addDirs).filter(p => typeof p === 'string' && p),
5073
- ].map(normPath).filter(Boolean);
5074
- const isUnder = (p) => {
5075
- const np = normPath(p);
5076
- if (!np) return false;
5077
- return containers.some(c => np === c || np.startsWith(c + '/'));
5078
- };
5079
-
5080
- const setOf = (list, key) => new Set(arr(list).map(e => e && lc(e[key])).filter(Boolean));
5081
- const skillNames = setOf(m.skills, 'name');
5082
- const skillSources = setOf(m.skills, 'source');
5083
- const cmdNames = setOf(m.commands, 'name');
5084
- const cmdScopes = setOf(m.commands, 'scope');
5085
- const mcpNames = setOf(m.mcpServers, 'name');
5086
- const mcpScopes = setOf(m.mcpServers, 'scope');
5087
-
5088
- const groundSkill = (e) => !!(
5089
- (e.name && skillNames.has(lc(e.name)))
5090
- || (e.path && (isUnder(e.path) || skillNames.has(basename(e.path))))
5091
- || (e.source && skillSources.has(lc(e.source)))
5092
- );
5093
- const groundCommand = (e) => !!((e.name && cmdNames.has(lc(e.name))) || (e.scope && cmdScopes.has(lc(e.scope))));
5094
- const groundMcp = (e) => !!((e.name && mcpNames.has(lc(e.name))) || (e.scope && mcpScopes.has(lc(e.scope))));
5095
- const groundDoc = (e) => !!(e.path && isUnder(e.path));
5096
-
5097
- return {
5098
- skills: normalized.skills.map(e => ({ ...e, grounded: groundSkill(e) })),
5099
- mcpServers: normalized.mcpServers.map(e => ({ ...e, grounded: groundMcp(e) })),
5100
- commands: normalized.commands.map(e => ({ ...e, grounded: groundCommand(e) })),
5101
- docs: normalized.docs.map(e => ({ ...e, grounded: groundDoc(e) })),
5102
- };
5103
- }
5104
-
5105
4809
  const DEFAULT_AGENT_METRICS = {
5106
4810
  tasksCompleted: 0, tasksErrored: 0,
5107
4811
  prsCreated: 0, prsApproved: 0, prsRejected: 0, prsMerged: 0,
@@ -5595,19 +5299,12 @@ function sameResolvedPath(a, b) {
5595
5299
  }
5596
5300
 
5597
5301
  function ensureProjectStateFiles(project) {
5598
- const files = [
5599
- { name: 'pull-requests.json', centralPath: projectPrPath(project) },
5600
- { name: 'work-items.json', centralPath: projectWorkItemsPath(project) },
5601
- ];
5602
5302
  const result = { created: [] };
5603
-
5604
5303
  projectStateDirEnsure(project);
5605
- for (const file of files) {
5606
- if (!fs.existsSync(file.centralPath)) {
5607
- mutateJsonFileLocked(file.centralPath, data => Array.isArray(data) ? data : [], { defaultValue: [] });
5608
- result.created.push(file.name);
5609
- }
5610
- }
5304
+ // SQL scopes need no empty sentinel rows. Touch both stores so migrations
5305
+ // and schema validation still happen at project setup time.
5306
+ require('./work-items-store').readWorkItemsForScope(project.name);
5307
+ require('./pull-requests-store').readPullRequestsForScope(project.name);
5611
5308
  return result;
5612
5309
  }
5613
5310
 
@@ -6257,50 +5954,6 @@ function resolveProjectRootDir(localPath, minionsDir) {
6257
5954
  return fallback;
6258
5955
  }
6259
5956
 
6260
- /**
6261
- * Resolve a stable, repo-EXTERNAL COPILOT_HOME for spawned copilot agents.
6262
- *
6263
- * Copilot loads `$COPILOT_HOME/mcp-config.json` and SPAWNS every server in it on
6264
- * every process start. `--disable-mcp-server <name>` only hides a server's tools
6265
- * from the model — it does NOT stop the server PROCESS from launching, so any
6266
- * server that does interactive auth (azure-kusto/azmcp, DevBox, workiq, loop, …)
6267
- * still pops a Microsoft sign-in window on EVERY agent spawn. With minions
6268
- * spawning agents continuously that is an unbounded popup storm.
6269
- *
6270
- * The only effective lever is to give agents a COPILOT_HOME whose mcp-config has
6271
- * NO user servers (the engine seeds `{"mcpServers":{}}` there). Copilot auth is
6272
- * unaffected — it uses GH_TOKEN / the OS credential store, not files under the
6273
- * home — and the operator's real `~/.copilot` (interactive copilot) is untouched.
6274
- *
6275
- * Placed OUTSIDE the repo so concurrent git branch switches in the working tree
6276
- * can never delete or dirty it, on the MINIONS_DIR volume (copilot's session-store
6277
- * can grow to GBs, so keep it off a possibly-full system drive). Stable path so
6278
- * copilot session resume (`--resume`) stays consistent across spawns. Falls back
6279
- * to the user home when no minionsDir is given.
6280
- *
6281
- * Base selection is cross-platform (W-copilot-home-posix-root): on Windows the
6282
- * MINIONS_DIR drive root (`C:\`, `D:\`, or a UNC share root) is user-writable, so
6283
- * we anchor there. On POSIX the filesystem root (`/`) is NOT user-writable — a
6284
- * home placed there can never be created (`mkdir` EACCES), `ensureAgentCopilotHome`
6285
- * fail-opens and returns the uncreatable path anyway, and every spawned
6286
- * `copilot --acp` / copilot agent then inherits a `COPILOT_HOME` it can't use and
6287
- * exits code 1 silently (surfaced in CC as "Load failed"). So when the drive root
6288
- * is the bare POSIX root we fall back to the user home — repo-external, writable,
6289
- * and on the user's own volume.
6290
- */
6291
- function resolveAgentCopilotHome(minionsDir) {
6292
- let base;
6293
- if (minionsDir) {
6294
- const root = path.parse(path.resolve(String(minionsDir))).root;
6295
- // The POSIX filesystem root ('/') is not user-writable; Windows drive/UNC
6296
- // roots are. Anchor at the user home in the unwritable-root case.
6297
- base = (root === '/' || root === path.sep) ? os.homedir() : root;
6298
- } else {
6299
- base = os.homedir();
6300
- }
6301
- return path.join(base, '.minions-agent-copilot-home');
6302
- }
6303
-
6304
5957
  /**
6305
5958
  * Derive a scratch/temp base dir on the same volume agent work already lives on
6306
5959
  * — the configured worktree location (engine.worktreeRoot, default
@@ -6325,6 +5978,55 @@ function resolveAgentTempBaseDir(project, engine, minionsDir) {
6325
5978
  return path.join(anchor, '.agent-temp');
6326
5979
  }
6327
5980
 
5981
+ /**
5982
+ * Resolve the stable, repo-external COPILOT_HOME used to isolate spawned
5983
+ * Copilot agents from the operator's interactive `~/.copilot` MCP servers
5984
+ * (W-copilot-auth-popup-storm / PR #321 — "give copilot agents an MCP-free
5985
+ * COPILOT_HOME (kill auth-popup storm)"). Copilot spawns and authenticates
5986
+ * EVERY server in `$COPILOT_HOME/mcp-config.json` on every process start;
5987
+ * `--disable-mcp-server <name>` only hides that server's tools from the model,
5988
+ * it does NOT stop the server process from launching (verified live: agents
5989
+ * spawned azmcp.exe/workiq.exe despite those servers being in
5990
+ * `copilotAgentDisabledMcpServers`). The only effective lever is to point
5991
+ * agents at a COPILOT_HOME whose mcp-config has NO disabled-list servers — see
5992
+ * `ensureAgentCopilotHome` below, which the engine seeds at boot and re-stamps
5993
+ * per dispatch (`_applyAgentCopilotHome` in engine.js).
5994
+ *
5995
+ * `COPILOT_HOME` overrides `$HOME/.copilot`; copilot auth is unaffected (it
5996
+ * uses GH_TOKEN / the OS credential store, not files under the home), and the
5997
+ * operator's real `~/.copilot` — i.e. their interactive copilot — is
5998
+ * completely untouched.
5999
+ *
6000
+ * Placed OUTSIDE the repo so concurrent git branch switches in the working
6001
+ * tree can never delete or dirty it, on the MINIONS_DIR volume (copilot's
6002
+ * session-store can grow to GBs, so keep it off a possibly-full system
6003
+ * drive). Stable path so copilot session resume (`--resume`) stays
6004
+ * consistent across spawns. Falls back to the user home when no minionsDir
6005
+ * is given.
6006
+ *
6007
+ * Base selection is cross-platform (W-copilot-home-posix-root): on Windows
6008
+ * the MINIONS_DIR drive root (`C:\`, `D:\`, or a UNC share root) is
6009
+ * user-writable, so we anchor there. On POSIX the filesystem root (`/`) is
6010
+ * NOT user-writable — a home placed there can never be created (`mkdir`
6011
+ * EACCES), `ensureAgentCopilotHome` fail-opens and returns the uncreatable
6012
+ * path anyway, and every spawned `copilot --acp` / copilot agent then
6013
+ * inherits a `COPILOT_HOME` it can't use and exits code 1 silently. So when
6014
+ * the drive root is the bare POSIX root we fall back to the user home —
6015
+ * repo-external, writable, and on the user's own volume.
6016
+ */
6017
+ function resolveAgentCopilotHome(minionsDir) {
6018
+ let base;
6019
+ if (minionsDir) {
6020
+ const root = path.parse(path.resolve(String(minionsDir))).root;
6021
+ // The POSIX filesystem root ('/') is not user-writable; Windows drive/UNC
6022
+ // roots are. Anchor at the user home in the unwritable-root case.
6023
+ base = (root === '/' || root === path.sep) ? os.homedir() : root;
6024
+ } else {
6025
+ base = os.homedir();
6026
+ }
6027
+ return path.join(base, '.minions-agent-copilot-home');
6028
+ }
6029
+
6328
6030
  // Seed the agent COPILOT_HOME's mcp-config (copy of `~/.copilot/mcp-config.json`
6329
6031
  // MINUS `engine.copilotAgentDisabledMcpServers`) and return the home path. This
6330
6032
  // is the source of truth for "what MCP servers may an agent-dispatch copilot
@@ -6498,13 +6200,7 @@ function resolveSpawnPaths(project, type, minionsDir, options) {
6498
6200
  // (project.localPath for read-only / live; worktree root for mutating).
6499
6201
  // When set, the engine lands the agent at <base>/<workdir> so monorepo
6500
6202
  // subpackage dispatches can use the runtime CLI's native cwd-rooted skill
6501
- // discovery (`<workdir>/.claude/skills/<name>/SKILL.md`) without engine
6502
- // awareness of which package is which. Cross-package skill aggregation is
6503
- // explicitly out of scope (per PRD open_question) — when workdir is set,
6504
- // the harness propagation block in engine.js also CLIPS its candidate
6505
- // `--project-harness-dir` set to dirs that live under the workdir mirror
6506
- // in `project.localPath`, so a `packages/foo` dispatch never sees
6507
- // `packages/bar/.claude/skills/...`.
6203
+ // discovery without engine awareness of which package is which.
6508
6204
  //
6509
6205
  // Three pure helpers, all unit-tested in test/unit/work-item-workdir.test.js:
6510
6206
  //
@@ -6525,13 +6221,6 @@ function resolveSpawnPaths(project, type, minionsDir, options) {
6525
6221
  // already rejects bare `..` / absolute strings). `base` MUST be
6526
6222
  // absolute. Empty/null workdir returns the base unchanged.
6527
6223
  //
6528
- // 3. filterProjectHarnessDirsForWorkdir(dirs, opts) — clips the
6529
- // candidate `--project-harness-dir` set to entries under the resolved
6530
- // cwd anchor in `project.localPath`, excluding entries under the
6531
- // worktree mirror (already discoverable via the agent's cwd). When
6532
- // `workdir` is null/empty the behavior matches P-08b62d49 unchanged
6533
- // (filter to project.localPath, exclude worktree).
6534
- //
6535
6224
  // The private `_applyWorkdirOrThrow(base, workdir)` is the throw-on-error
6536
6225
  // adapter used inside resolveSpawnPaths so live/read-only callers don't
6537
6226
  // need to forward the `error` field.
@@ -6617,48 +6306,6 @@ function applyWorkdir(base, workdir) {
6617
6306
  return { cwd: joined };
6618
6307
  }
6619
6308
 
6620
- function filterProjectHarnessDirsForWorkdir(dirs, opts) {
6621
- if (!Array.isArray(dirs) || dirs.length === 0) return [];
6622
- const projectLocalPath = opts && opts.projectLocalPath;
6623
- const worktreePath = opts && opts.worktreePath;
6624
- const workdir = opts && opts.workdir;
6625
- if (!projectLocalPath || !worktreePath) return [];
6626
-
6627
- const projectLocalAbs = path.resolve(String(projectLocalPath));
6628
- const worktreeAbs = path.resolve(String(worktreePath));
6629
-
6630
- // When workdir is set, the harness propagation anchors shift to the
6631
- // subpath mirror in BOTH the project root and the worktree root, so a
6632
- // `packages/foo` dispatch only sees `<projectLocal>/packages/foo/...`
6633
- // harness dirs (its actual cwd in the operator's main checkout) and
6634
- // excludes `<worktree>/packages/foo/...` (already discoverable via cwd).
6635
- let projectAnchor = projectLocalAbs;
6636
- let worktreeAnchor = worktreeAbs;
6637
- if (workdir && typeof workdir === 'string' && workdir) {
6638
- const pr = applyWorkdir(projectLocalAbs, workdir);
6639
- const wr = applyWorkdir(worktreeAbs, workdir);
6640
- // If either join fails containment, drop everything — better empty than wrong.
6641
- if (pr.error || wr.error) return [];
6642
- projectAnchor = pr.cwd;
6643
- worktreeAnchor = wr.cwd;
6644
- }
6645
-
6646
- const seen = new Set();
6647
- const out = [];
6648
- for (const dir of dirs) {
6649
- if (typeof dir !== 'string' || !dir) continue;
6650
- const abs = path.resolve(dir);
6651
- if (seen.has(abs)) continue;
6652
- seen.add(abs);
6653
- const insideProject = abs === projectAnchor || abs.startsWith(projectAnchor + path.sep);
6654
- if (!insideProject) continue;
6655
- const insideWorktree = abs === worktreeAnchor || abs.startsWith(worktreeAnchor + path.sep);
6656
- if (insideWorktree) continue;
6657
- out.push(abs);
6658
- }
6659
- return out;
6660
- }
6661
-
6662
6309
  // ── HTTP Origin Allowlist & Security Headers ─────────────────────────────────
6663
6310
  // Pure helpers used by dashboard.js to gate mutating requests against an
6664
6311
  // explicit allowlist of local origins and to attach uniform security response
@@ -7705,18 +7352,12 @@ function getPrLinks() {
7705
7352
  mergePrLinkItems(links, pr.id, pr.prdItems || []);
7706
7353
  }
7707
7354
  } catch { /* SQL unavailable — skip primary source */ }
7708
- // Fallback: pr_links SQL store (was static pr-links.json file). Phase 9.1
7709
- // routes both reads and writes through small-state-store; the JSON file is
7710
- // kept as a dual-write mirror. SQL failures fall through to the JSON file.
7355
+ // Static PR links are stored in SQLite.
7711
7356
  let static_ = null;
7712
7357
  try {
7713
7358
  const store = require('./small-state-store');
7714
7359
  static_ = store.readPrLinks();
7715
7360
  } catch { static_ = null; }
7716
- if (!static_ || Object.keys(static_).length === 0) {
7717
- try { static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8')); }
7718
- catch { static_ = null; }
7719
- }
7720
7361
  if (static_ && typeof static_ === 'object' && !Array.isArray(static_)) {
7721
7362
  for (const [k, v] of Object.entries(static_)) {
7722
7363
  const canonical = parseCanonicalPrId(k);
@@ -7915,6 +7556,32 @@ function _clearPrTombstonesForIdentity(identity) {
7915
7556
  return cleared;
7916
7557
  }
7917
7558
 
7559
+ /**
7560
+ * W-mrezh0yb0007f733 — decide whether a poll should adopt the platform's real
7561
+ * PR creation timestamp over the currently-stored `created` value. The platform
7562
+ * (GitHub `created_at` / ADO `creationDate`) is the source of truth for when a
7563
+ * PR was opened; the stored value may be missing, a legacy date-only stamp, or
7564
+ * a link/attach-time `now()` default (the bug this fixes). Returns true when the
7565
+ * stored value should be replaced.
7566
+ *
7567
+ * Rules (fail-safe — never trades a real timestamp for a worse one):
7568
+ * - platform absent/unparseable → false (keep whatever we have).
7569
+ * - stored absent/empty → true (nothing to lose; backfill).
7570
+ * - stored unparseable → true (replace garbage with the real instant).
7571
+ * - both parse → replace only when they denote a DIFFERENT instant, so two
7572
+ * equivalent representations (e.g. `…T00:00:00Z` vs `…T00:00:00.000Z`) don't
7573
+ * churn a rewrite every poll.
7574
+ */
7575
+ function shouldAdoptPlatformCreated(storedCreated, platformCreated) {
7576
+ if (!platformCreated) return false;
7577
+ const p = Date.parse(platformCreated);
7578
+ if (!Number.isFinite(p)) return false;
7579
+ if (!storedCreated) return true;
7580
+ const s = Date.parse(storedCreated);
7581
+ if (!Number.isFinite(s)) return true;
7582
+ return s !== p;
7583
+ }
7584
+
7918
7585
  /**
7919
7586
  * Canonical PR-producing work contract helper.
7920
7587
  *
@@ -8233,19 +7900,10 @@ function mutateWorkItems(filePath, mutator) {
8233
7900
  if (insideMinionsDir) {
8234
7901
  const store = require('./work-items-store');
8235
7902
  const scope = store.scopeForFilePath(filePath);
8236
- // W-mr9vcxvy000cdb4e — the JSON mirror is now written INSIDE
8237
- // applyWorkItemsMutation's SQL transaction (before commit), not here
8238
- // after the fact. Mirroring post-commit left a cross-process window
8239
- // where a sibling process (dashboard vs. engine — separate Node
8240
- // processes, each with its own in-memory mirror-hash cache) could
8241
- // observe the new SQL row before the JSON file caught up and
8242
- // destructively rehydrate the scope from that stale file, dropping
8243
- // the just-created row (the "phantom work item" dispatch race). See
8244
- // work-items-store.js#applyWorkItemsMutation for the full rationale.
8245
7903
  const { wrote, result } = store.applyWorkItemsMutation(scope, (items) => {
8246
7904
  if (!Array.isArray(items)) items = [];
8247
7905
  return mutator(items) || items;
8248
- }, { mirrorPath: filePath });
7906
+ });
8249
7907
  if (wrote) {
8250
7908
  try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
8251
7909
  try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
@@ -8297,20 +7955,10 @@ function mutatePullRequests(filePath, mutator) {
8297
7955
  if (insideMinionsDir) {
8298
7956
  const store = require('./pull-requests-store');
8299
7957
  const scope = store.scopeForFilePath(filePath);
8300
- // W-mrcg7j9k000ja233 (#759) — the JSON mirror is now written INSIDE
8301
- // applyPullRequestsMutation's SQL transaction (before commit), not here
8302
- // after the fact. Mirroring post-commit left a cross-process window
8303
- // where a sibling process (dashboard vs. engine — separate Node
8304
- // processes) could observe the new SQL row before the JSON file caught
8305
- // up and destructively rehydrate the scope from that stale file,
8306
- // dropping the just-written row. See
8307
- // pull-requests-store.js#applyPullRequestsMutation for the full
8308
- // rationale (mirrors the identical work-items-store.js fix,
8309
- // W-mr9vcxvy000cdb4e).
8310
7958
  const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
8311
7959
  if (!Array.isArray(prs)) prs = [];
8312
7960
  return mutator(prs) || prs;
8313
- }, { mirrorPath: filePath });
7961
+ });
8314
7962
  if (wrote) {
8315
7963
  try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
8316
7964
  }
@@ -9336,7 +8984,6 @@ module.exports = {
9336
8984
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
9337
8985
  resolveAgentMaxBudget, resolveAgentBareMode,
9338
8986
  resolveCopilotAgentDisabledMcpServers,
9339
- resolveAgentHermeticHarness,
9340
8987
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
9341
8988
  runtimeConfigWarnings,
9342
8989
  projectWorkSourceWarnings,
@@ -9346,7 +8993,6 @@ module.exports = {
9346
8993
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
9347
8994
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
9348
8995
  FAILURE_CLASS, COMPLETION_FIELDS,
9349
- HARNESS_USED_KINDS, HARNESS_USED_MAX_ENTRIES, HARNESS_USED_MAX_FIELD_LEN, normalizeHarnessUsed, groundHarnessUsed,
9350
8996
  DEFAULT_AGENT_METRICS,
9351
8997
  DEFAULT_AGENTS,
9352
8998
  DEFAULT_CLAUDE,
@@ -9414,6 +9060,7 @@ module.exports = {
9414
9060
  normalizePrLinkItems, // exported for testing
9415
9061
  mergePrLinkItems, // exported for testing
9416
9062
  isContextOnlyPrRecord,
9063
+ shouldAdoptPlatformCreated, // W-mrezh0yb0007f733 — platform creationDate authority
9417
9064
  upsertPullRequestRecord,
9418
9065
  collapseDuplicatePrRecords, // P-e9f0a2b4 — one-time repair helper
9419
9066
  isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
@@ -9448,7 +9095,6 @@ module.exports = {
9448
9095
  resolveSpawnPaths,
9449
9096
  validateWorkItemWorkdir,
9450
9097
  applyWorkdir,
9451
- filterProjectHarnessDirsForWorkdir,
9452
9098
  READ_ONLY_ROOT_TASK_TYPES,
9453
9099
  isLiveCommandCenterPath,
9454
9100
  describeCcProtectedPaths,