@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/dashboard.js CHANGED
@@ -65,6 +65,7 @@ const prTrack = require('./engine/pr-track');
65
65
  const features = require('./engine/features');
66
66
  const ccWorkerPool = require('./engine/cc-worker-pool');
67
67
  const diagnosticsMemory = require('./engine/diagnostics-memory');
68
+ const memoryStore = require('./engine/memory-store');
68
69
  const os = require('os');
69
70
 
70
71
  const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
@@ -81,24 +82,6 @@ const IS_DEV_MODE = fs.existsSync(path.join(MINIONS_DIR, '.git'));
81
82
  const FAVICON_EMOJI = IS_DEV_MODE ? '🚧' : '👽';
82
83
  const TITLE_SUFFIX = IS_DEV_MODE ? ' [DEV]' : '';
83
84
 
84
- // Startup size guard (#1167): fail fast with a clear error when dispatch.json /
85
- // cooldowns.json have ballooned past ENGINE_DEFAULTS.maxStateFileBytes. Without
86
- // this, V8 silently OOMs on JSON.parse(~1 GB) and the operator has no hint as to
87
- // which file is bloated. The thrown error names the file and directs to
88
- // engine/contexts/ where sidecars live.
89
- (() => {
90
- const stateFiles = [
91
- DISPATCH_PATH,
92
- path.join(ENGINE_DIR, 'cooldowns.json'),
93
- ];
94
- for (const fp of stateFiles) {
95
- try { shared.assertStateFileSize(fp); } catch (e) {
96
- console.error('\n[dashboard] STARTUP ABORTED — ' + e.message + '\n');
97
- process.exit(78); // 78 = configuration error; consistent with spawn-agent.js
98
- }
99
- }
100
- })();
101
-
102
85
  let CONFIG = queries.getConfig();
103
86
  let PROJECTS = _getProjects(CONFIG);
104
87
  // PORT resolution chain (W-mq5nwl9l): explicit CLI arg (argv[2]) > env
@@ -468,6 +451,18 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
468
451
  // `worktreeMode`. Drop any stale legacy key on every settings save so a
469
452
  // migrated project never carries both fields.
470
453
  delete currentProject.worktreeMode;
454
+ // W-mrchrfe100067d3a — mirror the per-project additionalRepoScopes
455
+ // allowlist the same way: present on candidate → copy; absent → clear so
456
+ // the endpoint falls back to single-scope matching. Without this branch
457
+ // the validated update (set/deleted on `candidate` in
458
+ // handleSettingsUpdate) is silently dropped before reaching disk — the
459
+ // endpoint would return 200 but persist nothing (issue: fix before
460
+ // configuring minions-opg to poll the secondary yemi33/minions scope).
461
+ if (Object.prototype.hasOwnProperty.call(candidateProject, 'additionalRepoScopes')) {
462
+ currentProject.additionalRepoScopes = candidateProject.additionalRepoScopes;
463
+ } else {
464
+ delete currentProject.additionalRepoScopes;
465
+ }
471
466
  }
472
467
  }
473
468
  shared.pruneDefaultClaudeConfig(current);
@@ -578,6 +573,9 @@ function normalizePrMetadata(metadata) {
578
573
  description: typeof metadata.description === 'string' ? metadata.description : '',
579
574
  branch: typeof metadata.branch === 'string' ? metadata.branch.trim().replace(/^refs\/heads\//i, '') : '',
580
575
  author: typeof metadata.author === 'string' ? metadata.author.trim() : '',
576
+ // W-mrezh0yb0007f733 — carry the platform's real creation timestamp through
577
+ // so the manual-link path can seed `created` with it instead of now().
578
+ created: typeof metadata.created === 'string' ? metadata.created.trim() : '',
581
579
  };
582
580
  }
583
581
 
@@ -1364,7 +1362,12 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1364
1362
  branch: metadata?.branch || '',
1365
1363
  reviewStatus: 'pending',
1366
1364
  status: 'active',
1367
- created: new Date().toISOString(),
1365
+ // W-mrezh0yb0007f733 — prefer the platform's real creationDate (fetched into
1366
+ // metadata for ADO before linking) over link-time now(). When it isn't
1367
+ // available yet (e.g. GitHub, where enrichment runs async after this insert,
1368
+ // or an ADO metadata-fetch failure), fall back to now() — the next PR poll
1369
+ // corrects it from the platform via shared.shouldAdoptPlatformCreated.
1370
+ created: metadata?.created || new Date().toISOString(),
1368
1371
  url,
1369
1372
  prdItems: linkedWorkItemId ? [linkedWorkItemId] : [],
1370
1373
  contextOnly: resolvedContextOnly,
@@ -1498,161 +1501,10 @@ function _resolveSkillReadPath({ file, dir, source, config, skillFiles } = {}) {
1498
1501
  return null;
1499
1502
  }
1500
1503
 
1501
- // ── Harness propagation diagnostic (P-c601f9a2) ──────────────────────────────
1502
- // Per-runtime view of where the CLI looks for skills/commands/MCPs, what the
1503
- // engine actually attaches via --add-dir, which assets are explicitly
1504
- // suppressed (e.g. AGENTS.md auto-load, Copilot's bundled github-mcp-server),
1505
- // and the canonical footgun: per-project assets that live as uncommitted files
1506
- // in the operator's main checkout but won't propagate to fresh worktrees.
1507
- //
1508
- // Surfaced via GET /api/harness/diagnostics and rendered on the Tools page so
1509
- // operators can spot a missing or unpropagated harness asset without leaving
1510
- // the dashboard. See docs/harness-propagation.md for the full contract.
1511
- const HARNESS_FOOTGUN_DIRS = [
1512
- '.claude/skills',
1513
- '.claude/commands',
1514
- '.copilot/skills',
1515
- '.copilot/commands',
1516
- '.agents/skills',
1517
- '.agents/commands',
1518
- ];
1519
- const HARNESS_FOOTGUN_FILES = [
1520
- '.claude/.mcp.json',
1521
- '.copilot/.mcp.json',
1522
- '.mcp.json',
1523
- ];
1524
-
1525
- const _PROJECT_LOCAL_FOOTGUN_WARNING =
1526
- 'These files exist in your main checkout but are uncommitted or untracked in ' +
1527
- 'git. A fresh `git worktree add` for a mutating dispatch will NOT see them, so ' +
1528
- 'the dispatched agent silently underperforms. Fix: commit them, move them to ' +
1529
- 'user scope (~/.claude/skills/..., ~/.copilot/skills/...), or flip the project ' +
1530
- 'to live-checkout mode (checkoutMode: live).';
1531
-
1532
- function _walkUncommittedHarnessAssets(absStart, rootDir, projectName) {
1533
- const results = [];
1534
- const stack = [absStart];
1535
- let count = 0;
1536
- while (stack.length > 0 && count < 500) {
1537
- const cur = stack.pop();
1538
- let entries;
1539
- try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
1540
- catch { continue; }
1541
- for (const ent of entries) {
1542
- if (ent.name === '.git' || ent.name === 'node_modules') continue;
1543
- const full = path.join(cur, ent.name);
1544
- if (ent.isDirectory()) { stack.push(full); continue; }
1545
- if (!ent.isFile()) continue;
1546
- const rel = path.relative(rootDir, full);
1547
- const normalized = rel.replace(/\\/g, '/');
1548
- let kind = null;
1549
- if (
1550
- normalized.startsWith('.claude/skills/') ||
1551
- normalized.startsWith('.copilot/skills/') ||
1552
- normalized.startsWith('.agents/skills/')
1553
- ) kind = 'skill';
1554
- else if (
1555
- normalized.startsWith('.claude/commands/') ||
1556
- normalized.startsWith('.copilot/commands/') ||
1557
- normalized.startsWith('.agents/commands/')
1558
- ) kind = 'command';
1559
- else if (
1560
- normalized === '.mcp.json' ||
1561
- normalized === '.claude/.mcp.json' ||
1562
- normalized === '.copilot/.mcp.json'
1563
- ) kind = 'mcp';
1564
- if (!kind) continue;
1565
- results.push({ file: rel, kind, scope: `project:${projectName}` });
1566
- count++;
1567
- if (count >= 500) break;
1568
- }
1569
- }
1570
- return results;
1571
- }
1572
-
1573
- function _scanProjectLocalHarnessFootgun(project) {
1574
- const out = {
1575
- project: project && project.name ? project.name : '',
1576
- localPath: project && project.localPath ? project.localPath : '',
1577
- uncommittedAssets: [],
1578
- footgunWarning: _PROJECT_LOCAL_FOOTGUN_WARNING,
1579
- };
1580
- if (!project || !project.localPath) return out;
1581
- // Skip the synchronous `git status` for UNC / WSL-from-Windows paths
1582
- // (\\wsl.localhost\..., \\wsl$\..., or any \\server\share). git/stat over a
1583
- // WSL UNC mount can hang for minutes; even capped at the 10s timeout below it
1584
- // hard-blocks the dashboard event loop on every poll, freezing the whole
1585
- // dashboard. Such projects can't carry uncommitted project-local harness
1586
- // assets the engine would propagate anyway, so returning the empty shell here
1587
- // is correct, not lossy. (Load-reduction audit 2026-06-24.)
1588
- const _lp = String(project.localPath);
1589
- if (_lp.startsWith('\\\\') || _lp.startsWith('//')) return out;
1590
- let raw = '';
1591
- try {
1592
- const { execFileSync } = require('child_process');
1593
- raw = execFileSync('git', ['status', '--porcelain', '-z'], {
1594
- cwd: project.localPath,
1595
- encoding: 'utf8',
1596
- stdio: ['ignore', 'pipe', 'ignore'],
1597
- timeout: 10000,
1598
- // W-mqecdoot — MUST set windowsHide. This runs per-project on every
1599
- // GET /api/harness/diagnostics hit (Tools/Harness page, polled), and a
1600
- // detached dashboard has no console — so without this each call pops a
1601
- // visible git.exe console window per project ("infinite git windows").
1602
- // This was the only git spawn in the codebase missing windowsHide.
1603
- windowsHide: true,
1604
- });
1605
- } catch { return out; }
1606
- if (!raw) return out;
1607
- const records = raw.split('\0').filter(Boolean);
1608
- const seen = new Set();
1609
- for (const rec of records) {
1610
- if (rec.length < 4) continue;
1611
- const filePath = rec.slice(3);
1612
- const normalized = filePath.replace(/\\/g, '/');
1613
- const isDirRecord = normalized.endsWith('/');
1614
- const trimmed = isDirRecord ? normalized.slice(0, -1) : normalized;
1615
- let overlaps = false;
1616
- for (const dir of HARNESS_FOOTGUN_DIRS) {
1617
- if (
1618
- trimmed === dir ||
1619
- trimmed.startsWith(dir + '/') ||
1620
- dir.startsWith(trimmed + '/')
1621
- ) { overlaps = true; break; }
1622
- }
1623
- if (!overlaps) {
1624
- for (const file of HARNESS_FOOTGUN_FILES) {
1625
- if (trimmed === file || file.startsWith(trimmed + '/')) { overlaps = true; break; }
1626
- }
1627
- }
1628
- if (!overlaps) continue;
1629
-
1630
- const absStart = path.join(project.localPath, filePath);
1631
- let stat;
1632
- try { stat = fs.statSync(absStart); } catch { continue; }
1633
- if (stat.isDirectory()) {
1634
- for (const asset of _walkUncommittedHarnessAssets(absStart, project.localPath, out.project)) {
1635
- if (seen.has(asset.file)) continue;
1636
- seen.add(asset.file);
1637
- out.uncommittedAssets.push(asset);
1638
- }
1639
- } else {
1640
- let kind = 'other';
1641
- if (normalized.includes('/skills/')) kind = 'skill';
1642
- else if (normalized.includes('/commands/')) kind = 'command';
1643
- else if (normalized.endsWith('.mcp.json')) kind = 'mcp';
1644
- if (seen.has(filePath)) continue;
1645
- seen.add(filePath);
1646
- out.uncommittedAssets.push({ file: filePath, kind, scope: `project:${out.project}` });
1647
- }
1648
- }
1649
- return out;
1650
- }
1651
-
1652
- // TTL cache for the harness diagnostic. The build runs a synchronous `git
1653
- // status` + directory walks per project; the Tools page polls it while open,
1654
- // so without a cache it re-runs the whole sweep every 4s and blocks the event
1655
- // loop each time. The diagnostic is rare-change, so a short TTL is ample.
1504
+ // ── Runtime harness inventory ─────────────────────────────────────────────────
1505
+ // Read-only view of native skill, command, and MCP locations declared by each
1506
+ // adapter. It does not influence spawning or attest what a runtime loaded.
1507
+ // The inventory is rare-change, so a short TTL is ample.
1656
1508
  // Bypassed whenever explicit opts are supplied (unit tests pass homeDir/
1657
1509
  // projects/engineConfig) so test determinism is unaffected.
1658
1510
  // (Load-reduction audit 2026-06-24.)
@@ -1729,29 +1581,7 @@ function _buildHarnessDiagnosticsUncached(opts = {}) {
1729
1581
  collectMissing(mcpConfigPaths, runtimeName, 'mcp');
1730
1582
  }
1731
1583
 
1732
- let addDirSnapshot = [];
1733
- if (preflight && registry) {
1734
- let fleetRuntime = null;
1735
- try { fleetRuntime = registry.resolveRuntime(fleetDefaultCli); }
1736
- catch { fleetRuntime = null; }
1737
- if (fleetRuntime) {
1738
- const snapshot = preflight._computeAddDirSnapshot(fleetRuntime, {
1739
- minionsHome: MINIONS_DIR,
1740
- homeDir,
1741
- existsFn,
1742
- });
1743
- if (Array.isArray(snapshot)) addDirSnapshot = snapshot.map(slimRow);
1744
- }
1745
- }
1746
-
1747
1584
  const suppressed = [];
1748
- if (engineConfig.copilotSuppressAgentsMd !== false) {
1749
- suppressed.push({
1750
- flag: 'copilotSuppressAgentsMd',
1751
- value: true,
1752
- effect: 'Copilot --no-custom-instructions: in-tree AGENTS.md is NOT auto-loaded, so Minions playbook prompts are not overridden by repo-local instructions.',
1753
- });
1754
- }
1755
1585
  if (engineConfig.copilotDisableBuiltinMcps !== false) {
1756
1586
  suppressed.push({
1757
1587
  flag: 'copilotDisableBuiltinMcps',
@@ -1759,22 +1589,10 @@ function _buildHarnessDiagnosticsUncached(opts = {}) {
1759
1589
  effect: 'Copilot --disable-builtin-mcps: bundled github-mcp-server is stripped so dispatched agents do not open a parallel PR alongside the Minions PR.',
1760
1590
  });
1761
1591
  }
1762
- if (engineConfig.hermeticHarness === true) {
1763
- suppressed.push({
1764
- flag: 'hermeticHarness',
1765
- value: true,
1766
- effect: 'Hermetic harness mode: only the engine playbook + system prompt are surfaced; user-scope skills, commands, and MCP configs are NOT propagated to this dispatch.',
1767
- });
1768
- }
1769
-
1770
- const projectLocalOnMain = projects.map(_scanProjectLocalHarnessFootgun);
1771
-
1772
1592
  return {
1773
1593
  fleetDefaultCli,
1774
1594
  runtimes,
1775
- addDirSnapshot,
1776
1595
  suppressed,
1777
- projectLocalOnMain,
1778
1596
  missingDirs,
1779
1597
  };
1780
1598
  }
@@ -1840,6 +1658,11 @@ let _plansCacheTs = 0;
1840
1658
  const PLANS_CACHE_TTL_MS = 5000;
1841
1659
  function invalidatePlansCache() { _plansCache = null; _plansCacheTs = 0; }
1842
1660
 
1661
+ function statePathExists(filePath) {
1662
+ if (prdStore.parsePrdPath(filePath)) return prdStore.readPrd(filePath) != null;
1663
+ return fs.existsSync(filePath);
1664
+ }
1665
+
1843
1666
  // Resolve a plan/PRD file path: .json files live in prd/, .md files in plans/
1844
1667
  // Validates that the file stays within the expected directory to prevent path traversal.
1845
1668
  function resolvePlanPath(file) {
@@ -1847,11 +1670,12 @@ function resolvePlanPath(file) {
1847
1670
  // Validate against both prd/ and prd/archive/
1848
1671
  shared.sanitizePath(file, PRD_DIR);
1849
1672
  const active = path.join(PRD_DIR, file);
1850
- if (fs.existsSync(active)) return active;
1673
+ if (prdStore.readPrd(active)) return active;
1851
1674
  const archived = path.join(PRD_DIR, 'archive', file);
1852
- if (fs.existsSync(archived)) return archived;
1675
+ if (prdStore.readPrd(archived)) return archived;
1853
1676
  return active;
1854
1677
  }
1678
+
1855
1679
  // Validate against both plans/ and plans/archive/
1856
1680
  shared.sanitizePath(file, PLANS_DIR);
1857
1681
  const active = path.join(PLANS_DIR, file);
@@ -1880,7 +1704,7 @@ function _archivePrdPostProcess({ planFile, archivePath, planPath, plansDir, _mu
1880
1704
 
1881
1705
  // (a) Flip the archived FLAG + status + archivedAt. Phase 10 step 4.2b:
1882
1706
  // archived-ness is the flag now (not the directory), so set `archived:true`
1883
- // explicitly. The mutate dual-writes the change into SQL via the chokepoint.
1707
+ // explicitly. The path-shaped mutator routes PRDs into SQL.
1884
1708
  // When archivePath === planPath (in-place archive) this flags the PRD where it
1885
1709
  // sits; when they differ (.md-cascade / legacy move) it flags the moved copy.
1886
1710
  try {
@@ -3244,7 +3068,8 @@ function serveFreshJson(req, res, opts) {
3244
3068
  return;
3245
3069
  }
3246
3070
  const mtime = _maxInputMtimeMs(inputs);
3247
- const etag = '"' + tag + '-' + mtime + '"';
3071
+ const eventVersion = _getCurrentEventVersion();
3072
+ const etag = '"' + tag + '-' + mtime + '-' + eventVersion + '"';
3248
3073
  res.setHeader('ETag', etag);
3249
3074
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
3250
3075
  if (req && req.headers && req.headers['if-none-match'] === etag) {
@@ -6253,11 +6078,11 @@ const server = http.createServer(async (req, res) => {
6253
6078
  const prdDir = path.join(MINIONS_DIR, 'prd');
6254
6079
  let prdPath = path.join(prdDir, body.file);
6255
6080
  let fromArchive = false;
6256
- if (!fs.existsSync(prdPath)) {
6081
+ if (!statePathExists(prdPath)) {
6257
6082
  prdPath = path.join(prdDir, 'archive', body.file);
6258
6083
  fromArchive = true;
6259
6084
  }
6260
- if (!fs.existsSync(prdPath)) return jsonReply(res, 404, { error: 'PRD not found' });
6085
+ if (!statePathExists(prdPath)) return jsonReply(res, 404, { error: 'PRD not found' });
6261
6086
 
6262
6087
  // If archived, temporarily restore to active so checkPlanCompletion can find it
6263
6088
  const activePath = path.join(prdDir, body.file);
@@ -7217,14 +7042,9 @@ const server = http.createServer(async (req, res) => {
7217
7042
  const manualPrd = buildManualPrdItemPlan(body);
7218
7043
  if (manualPrd.error) return jsonReply(res, 400, { error: manualPrd.error });
7219
7044
 
7220
- if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
7221
-
7222
7045
  const planFile = 'manual-' + shared.uid() + '.json';
7223
7046
  const manualPrdPath = path.join(PRD_DIR, planFile);
7224
7047
  safeWrite(manualPrdPath, manualPrd.plan);
7225
- // Phase 10 step 2 — this create uses safeWrite (not mutateJsonFileLocked),
7226
- // so mirror to SQL explicitly. Best-effort.
7227
- prdStore.mirrorPrdToSql(manualPrdPath, manualPrd.plan);
7228
7048
  invalidatePlansCache();
7229
7049
  return jsonReply(res, 200, { ok: true, id: manualPrd.id, file: planFile });
7230
7050
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
@@ -7238,7 +7058,7 @@ const server = http.createServer(async (req, res) => {
7238
7058
  // the file with JSON, destroying source plans (see /api/plans/approve trap).
7239
7059
  if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename in `source` (got `' + body.source + '`). Pass prd/<plan>.json, not plans/<plan>.md.' });
7240
7060
  const planPath = resolvePlanPath(body.source);
7241
- if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
7061
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
7242
7062
  // Pre-check: verify item exists before taking the lock
7243
7063
  const preCheck = safeJsonObj(planPath);
7244
7064
  const preItem = (preCheck.missing_features || []).find(f => f.id === body.itemId);
@@ -7295,7 +7115,7 @@ const server = http.createServer(async (req, res) => {
7295
7115
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
7296
7116
  if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename in `source` (got `' + body.source + '`). Pass prd/<plan>.json, not plans/<plan>.md.' });
7297
7117
  const planPath = resolvePlanPath(body.source);
7298
- if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
7118
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
7299
7119
  let removed = false;
7300
7120
  mutateJsonFileLocked(planPath, (plan) => {
7301
7121
  if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = { missing_features: [] };
@@ -7896,6 +7716,46 @@ const server = http.createServer(async (req, res) => {
7896
7716
  return jsonReply(res, 200, result);
7897
7717
  }
7898
7718
 
7719
+ function handleMemoryRecordsSearch(req, res) {
7720
+ const url = new URL(req.url, 'http://localhost');
7721
+ const query = String(url.searchParams.get('q') || '').trim();
7722
+ if (!query) return jsonReply(res, 400, { error: 'q is required' });
7723
+ if (query.length > 500) return jsonReply(res, 400, { error: 'q must be 500 characters or fewer' });
7724
+ const limitRaw = Number.parseInt(url.searchParams.get('limit') || '20', 10);
7725
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(100, limitRaw)) : 20;
7726
+ const status = url.searchParams.get('status') || 'active';
7727
+ if (!memoryStore.STATUSES.has(status)) return jsonReply(res, 400, { error: 'invalid status' });
7728
+ try {
7729
+ const records = memoryStore.searchMemoryRecords(query, {
7730
+ allScopes: true,
7731
+ statuses: [status],
7732
+ limit,
7733
+ });
7734
+ const boundedRecords = records.map(record => ({
7735
+ ...record,
7736
+ body: String(record.body || '').slice(0, 4000),
7737
+ bodyTruncated: String(record.body || '').length > 4000,
7738
+ }));
7739
+ return jsonReply(res, 200, { query, count: boundedRecords.length, records: boundedRecords });
7740
+ } catch (e) {
7741
+ return jsonReply(res, 400, { error: e.message });
7742
+ }
7743
+ }
7744
+
7745
+ async function handleMemoryRecordAction(req, res, match) {
7746
+ const id = decodeURIComponent((match && match[1]) || '').trim();
7747
+ if (!id) return jsonReply(res, 400, { error: 'memory record id is required' });
7748
+ try {
7749
+ const body = await readBody(req);
7750
+ const record = memoryStore.updateMemoryRecordState(id, String(body.action || '').trim());
7751
+ if (!record) return jsonReply(res, 404, { error: 'memory record not found' });
7752
+ invalidateStatusCache();
7753
+ return jsonReply(res, 200, { ok: true, record });
7754
+ } catch (e) {
7755
+ return jsonReply(res, 400, { error: e.message });
7756
+ }
7757
+ }
7758
+
7899
7759
  async function handleKnowledgeRead(req, res, match) {
7900
7760
  const cat = match[1];
7901
7761
  // P-bfa2b-kb-path-traversal — whitelist the category param BEFORE any
@@ -8000,9 +7860,9 @@ const server = http.createServer(async (req, res) => {
8000
7860
  { dir: PRD_DIR, archived: false },
8001
7861
  { dir: path.join(PRD_DIR, 'archive'), archived: true },
8002
7862
  ];
8003
- // Load work items to check for completed plan-to-prd conversions.
8004
- // safeJsonArr is sync but reads a single small file — leave as is.
8005
- const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
7863
+ // Load central work items from the SQL-backed store to check for completed
7864
+ // plan-to-prd conversions.
7865
+ const centralWi = require('./engine/work-items-store').readWorkItemsForScope('central');
8006
7866
  const completedPrdFiles = new Set(
8007
7867
  centralWi.filter(w => w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile)
8008
7868
  .map(w => w.planFile)
@@ -8386,8 +8246,8 @@ const server = http.createServer(async (req, res) => {
8386
8246
  if (!body.file.endsWith('.md')) return jsonReply(res, 400, { error: 'only .md plans can be executed' });
8387
8247
  shared.sanitizePath(body.file, PLANS_DIR);
8388
8248
  const planPath = path.join(MINIONS_DIR, 'plans', body.file);
8389
- if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
8390
- const planContent = fs.readFileSync(planPath, 'utf8');
8249
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
8250
+ const planContent = safeRead(planPath);
8391
8251
  const declaredProject = shared.extractPlanDeclaredProject(planContent);
8392
8252
 
8393
8253
  const feedback = (body.feedback || '').toString().trim();
@@ -8444,7 +8304,7 @@ const server = http.createServer(async (req, res) => {
8444
8304
  const body = await readBody(req);
8445
8305
  if (!body.source) return jsonReply(res, 400, { error: 'source required' });
8446
8306
  const planPath = resolvePlanPath(body.source);
8447
- if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
8307
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
8448
8308
  const plan = safeJsonObj(planPath);
8449
8309
  const planItems = plan.missing_features || [];
8450
8310
 
@@ -8540,7 +8400,7 @@ const server = http.createServer(async (req, res) => {
8540
8400
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
8541
8401
  shared.sanitizePath(body.file, body.file.endsWith('.json') ? PRD_DIR : PLANS_DIR);
8542
8402
  const planPath = resolvePlanPath(body.file);
8543
- if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8403
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8544
8404
  // Read plan content before deleting — needed for worktree cleanup and source_plan
8545
8405
  let planObj = null;
8546
8406
  let prdSourcePlan = null;
@@ -8550,13 +8410,13 @@ const server = http.createServer(async (req, res) => {
8550
8410
  // Clean up worktrees before deleting work items (needs branch info from work items)
8551
8411
  try {
8552
8412
  const { cleanupPlanWorktrees } = require('./engine/lifecycle');
8553
- cleanupPlanWorktrees(body.file, planObj || {}, PROJECTS, getConfig());
8413
+ cleanupPlanWorktrees(body.file, planObj || {}, PROJECTS, queries.getConfig());
8554
8414
  } catch (e) { console.error('plan worktree cleanup:', e.message); }
8555
8415
  safeUnlink(planPath);
8556
8416
  // Phase 10 step 2 — a deleted PRD must also leave SQL, or the read-flip
8557
8417
  // (step 3) would resurrect it from a stale mirror row. Best-effort; no-op
8558
8418
  // for non-PRD paths (parsePrdPath returns null for plan .md deletes).
8559
- if (body.file.endsWith('.json')) prdStore.removePrdFromSql(planPath);
8419
+ if (body.file.endsWith('.json')) prdStore.deletePrd(planPath);
8560
8420
  // Neutralize the `.backup` sidecar so `safeJson` auto-restore can't
8561
8421
  // RESURRECT the PRD we just deleted (the live file is gone, but a stray
8562
8422
  // `prd/<plan>.json.backup` would be auto-restored on the next safeJson read
@@ -8626,7 +8486,7 @@ const server = http.createServer(async (req, res) => {
8626
8486
  const isPrd = body.file.endsWith('.json');
8627
8487
  shared.sanitizePath(body.file, isPrd ? PRD_DIR : PLANS_DIR);
8628
8488
  const planPath = resolvePlanPath(body.file);
8629
- if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8489
+ if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
8630
8490
 
8631
8491
  // Phase 10 step 4.2b: a PRD is archived IN PLACE (flag flip via
8632
8492
  // _archivePrdPostProcess — the json stays in prd/, no move, so the
@@ -8669,19 +8529,7 @@ const server = http.createServer(async (req, res) => {
8669
8529
  // shipped for the PRD branch in W-mqa13ulk0002def5 (PR #3222) — the
8670
8530
  // helper's source-plan move is a safe no-op here because the outer
8671
8531
  // handler already renamed body.file into plans/archive/.
8672
- let prdFiles = [];
8673
- try {
8674
- prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
8675
- } catch (e) {
8676
- // ENOENT on prd/ is expected for projects without a PRD dir yet —
8677
- // don't surface as a user-visible warning. Other errors (EACCES,
8678
- // EIO) get logged + warned.
8679
- if (e.code !== 'ENOENT') {
8680
- const warning = `Archive could not enumerate ${PRD_DIR}: ${e.message}`;
8681
- archiveWarnings.push(warning);
8682
- console.warn(warning);
8683
- }
8684
- }
8532
+ const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
8685
8533
  for (const prdFile of prdFiles) {
8686
8534
  const prdLivePath = path.join(PRD_DIR, prdFile);
8687
8535
  let prd = null;
@@ -8736,7 +8584,7 @@ const server = http.createServer(async (req, res) => {
8736
8584
 
8737
8585
  try {
8738
8586
  const { cleanupPlanWorktrees } = require('./engine/lifecycle');
8739
- cleanupPlanWorktrees(body.file, plan, PROJECTS, getConfig());
8587
+ cleanupPlanWorktrees(body.file, plan, PROJECTS, queries.getConfig());
8740
8588
  } catch (e) { console.error('plan worktree cleanup:', e.message); }
8741
8589
 
8742
8590
  invalidateStatusCache();
@@ -8763,12 +8611,23 @@ const server = http.createServer(async (req, res) => {
8763
8611
  // (physically in <dir>/archive/) or in-place (in <dir>/ with the archived
8764
8612
  // flag set). Restore from whichever it is.
8765
8613
  let liveDest;
8766
- if (fs.existsSync(archivePath)) {
8614
+ // Existence probes only — NEVER used as the write payload. The actual
8615
+ // restore below (prdStore.restorePrdFromArchive) re-reads the archived
8616
+ // row fresh inside its own transaction immediately before writing, so a
8617
+ // concurrent engine write to that row between this probe and the write
8618
+ // can't be clobbered by a stale in-memory snapshot (W-mrenu075000a94c9).
8619
+ const archivedPrdExists = isJson ? prdStore.readPrd(archivePath) != null : false;
8620
+ const activePrdData = isJson ? prdStore.readPrd(livePath) : null;
8621
+ if (isJson && archivedPrdExists) {
8622
+ const restored = prdStore.restorePrdFromArchive(body.file);
8623
+ if (!restored) return jsonReply(res, 404, { error: 'File not found in archive' });
8624
+ liveDest = livePath;
8625
+ } else if (fs.existsSync(archivePath)) {
8767
8626
  // DATA-LOSS GUARD: restoring must not clobber a LIVE plan/PRD of the same
8768
8627
  // basename (renameSync overwrites). moveFileNoClobber bumps the restored
8769
8628
  // name instead so the existing live file survives.
8770
8629
  liveDest = shared.moveFileNoClobber(archivePath, targetDir, body.file);
8771
- } else if (isJson && fs.existsSync(livePath) && shared.isPrdArchived(safeJsonNoRestore(livePath))) {
8630
+ } else if (isJson && activePrdData && shared.isPrdArchived(activePrdData)) {
8772
8631
  liveDest = livePath; // in-place flag-archived — already in prd/, just clear the flag below
8773
8632
  } else {
8774
8633
  return jsonReply(res, 404, { error: 'File not found in archive' });
@@ -11360,6 +11219,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11360
11219
  // 1 floor (sequential fallback) and 20 ceiling (above this the LLM
11361
11220
  // provider's per-second rate limits dominate; throughput gains taper).
11362
11221
  preDispatchEvalConcurrency: [1, 20],
11222
+ memoryRetrievalTopK: [1, 30],
11223
+ memoryRetrievalMaxBytes: [1024, 65536],
11224
+ memoryRetrievalCandidateLimit: [5, 200],
11363
11225
  };
11364
11226
  for (const [key, [min, max]] of Object.entries(numericFields)) {
11365
11227
  if (e[key] !== undefined) {
@@ -13566,10 +13428,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13566
13428
  },
13567
13429
  });
13568
13430
  }},
13569
- { method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with schedule-runs.json overlay (_lastRun/_lastResult/_lastCompletedAt)', handler: (req, res) => {
13431
+ { method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with SQL schedule-run state (_lastRun/_lastResult/_lastCompletedAt)', handler: (req, res) => {
13570
13432
  return serveFreshJson(req, res, {
13571
13433
  tag: 'schedules',
13572
- inputs: [CONFIG_PATH, path.join(ENGINE_DIR, 'schedule-runs.json')],
13434
+ inputs: [CONFIG_PATH],
13573
13435
  builder: () => {
13574
13436
  // Read config.json directly via queries.getConfig() rather than
13575
13437
  // calling reloadConfig() — the latter cascades into
@@ -13581,7 +13443,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13581
13443
  // (Round-2 review finding #3.)
13582
13444
  const cfg = queries.getConfig() || {};
13583
13445
  const scheds = cfg.schedules || [];
13584
- const runs = shared.safeJsonObj(path.join(ENGINE_DIR, 'schedule-runs.json'));
13446
+ const runs = require('./engine/small-state-store').readScheduleRuns();
13585
13447
  return scheds.map(s => {
13586
13448
  const runEntry = runs[s.id];
13587
13449
  const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
@@ -13598,12 +13460,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13598
13460
  },
13599
13461
  });
13600
13462
  }},
13601
- { method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 runs each from pipeline-runs.json', handler: (req, res) => {
13463
+ { method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 SQL-backed runs', handler: (req, res) => {
13602
13464
  return serveFreshJson(req, res, {
13603
13465
  tag: 'pipelines',
13604
13466
  inputs: [
13605
13467
  path.join(MINIONS_DIR, 'pipelines'),
13606
- path.join(ENGINE_DIR, 'pipeline-runs.json'),
13607
13468
  ],
13608
13469
  builder: () => {
13609
13470
  try {
@@ -13618,14 +13479,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13618
13479
  { method: 'GET', path: '/api/qa-runs-summary', desc: 'Sidebar-counter summary {total, sig} for QA runs (sidebar activity-dot signal)', handler: (req, res) => {
13619
13480
  return serveFreshJson(req, res, {
13620
13481
  tag: 'qa-runs-summary',
13621
- inputs: [path.join(ENGINE_DIR, 'qa-runs.json')],
13482
+ inputs: [],
13622
13483
  builder: () => qaRunsMod.summarizeRunsForStatus(),
13623
13484
  });
13624
13485
  }},
13625
13486
  { method: 'GET', path: '/api/qa-sessions-summary', desc: 'Sidebar-counter summary {active, sig} for non-terminal QA sessions (drives the QA nav badge — issue #717)', handler: (req, res) => {
13626
13487
  return serveFreshJson(req, res, {
13627
13488
  tag: 'qa-sessions-summary',
13628
- inputs: [qaSessionsMod.qaSessionsPath()],
13489
+ inputs: [],
13629
13490
  builder: () => qaSessionsMod.summarizeActiveSessionsForStatus(),
13630
13491
  });
13631
13492
  }},
@@ -13912,7 +13773,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13912
13773
  if (token) ghOpts.env = { ...process.env, GH_TOKEN: token };
13913
13774
  const result = await shared.shellSafeGh(['api', `repos/${shared.validateGhSlug(slug)}/pulls/${String(prNum)}`], ghOpts);
13914
13775
  const d = JSON.parse(result);
13915
- prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login };
13776
+ prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login, created: d.created_at };
13916
13777
  } else if (adoTarget && !initialPrData) {
13917
13778
  try {
13918
13779
  prData = await ado.fetchAdoPrMetadata(adoTarget.prNum, adoTarget.adoOrg, adoTarget.adoProj, adoTarget.adoRepo);
@@ -13939,6 +13800,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13939
13800
  }
13940
13801
  }
13941
13802
  if (pr.agent === 'human' && prData.author) pr.agent = prData.author;
13803
+ // W-mrezh0yb0007f733 — for GitHub (and ADO metadata-fetch-retry)
13804
+ // links, `created` was seeded with link-time now(); adopt the
13805
+ // platform's real creation timestamp now that enrichment fetched it.
13806
+ if (prData.created && shared.shouldAdoptPlatformCreated(pr.created, prData.created)) {
13807
+ pr.created = prData.created;
13808
+ }
13942
13809
  return prs;
13943
13810
  }, { defaultValue: [] });
13944
13811
  invalidateStatusCache();
@@ -14560,7 +14427,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14560
14427
  } },
14561
14428
 
14562
14429
  // Knowledge base
14430
+ { method: 'GET', path: '/assets/memory-search.js', desc: 'Serve the lazy-loaded structured memory search UI', handler: (req, res) => {
14431
+ const content = safeRead(path.join(MINIONS_DIR, 'dashboard', 'js', 'memory-search.js'));
14432
+ if (content == null) { res.statusCode = 404; return res.end('not found'); }
14433
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
14434
+ res.setHeader('Cache-Control', 'no-cache');
14435
+ res.end(content);
14436
+ } },
14563
14437
  { method: 'GET', path: '/api/knowledge', desc: 'List all knowledge base entries grouped by category', handler: handleKnowledgeList },
14438
+ { method: 'GET', path: /^\/api\/memory-records\/search(?:\?.*)?$/, template: '/api/memory-records/search', desc: 'Search structured memory records with provenance and lifecycle status', params: 'q, status?, limit?', handler: handleMemoryRecordsSearch },
14439
+ { method: 'POST', path: /^\/api\/memory-records\/([^/?]+)\/action$/, template: '/api/memory-records/<id>/action', desc: 'Pin, retract, or restore a structured memory record', params: 'action (pin|retract|restore)', handler: handleMemoryRecordAction },
14564
14440
  { method: 'POST', path: '/api/knowledge', desc: 'Create a knowledge base entry', params: 'category, title, content', handler: async (req, res) => {
14565
14441
  const body = await readBody(req);
14566
14442
  const { category, title, content } = body;
@@ -14572,7 +14448,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14572
14448
  const dir = path.dirname(filePath);
14573
14449
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
14574
14450
  const header = '# ' + title + '\n\n*Created by human teammate on ' + new Date().toISOString().slice(0, 10) + '*\n\n';
14575
- safeWrite(filePath, header + content);
14451
+ const fullContent = header + content;
14452
+ safeWrite(filePath, fullContent);
14453
+ memoryStore.upsertMemoryRecord({
14454
+ memoryType: 'semantic',
14455
+ scopeType: 'global',
14456
+ title,
14457
+ body: fullContent,
14458
+ tags: [category],
14459
+ sourceType: 'knowledge-base',
14460
+ sourcePath: path.relative(MINIONS_DIR, filePath).replace(/\\/g, '/'),
14461
+ sourceRef: path.relative(MINIONS_DIR, filePath).replace(/\\/g, '/'),
14462
+ trust: 'human',
14463
+ importance: 0.8,
14464
+ confidence: 1,
14465
+ });
14576
14466
  queries.invalidateKnowledgeBaseCache();
14577
14467
  invalidateStatusCache();
14578
14468
  recordCcTurnIfPresent(req, { kind: 'knowledge', title, path: filePath });
@@ -14596,8 +14486,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14596
14486
  // Skills
14597
14487
  { method: 'GET', path: '/api/skill', desc: 'Read a skill file', params: 'file, source?, dir?', handler: handleSkillRead },
14598
14488
 
14599
- // Harness propagation diagnostic (P-c601f9a2)
14600
- { method: 'GET', path: '/api/harness/diagnostics', desc: 'Per-runtime harness diagnostic (add-dirs, suppressed flags, project-local-on-main footgun, missing dirs)', handler: handleHarnessDiagnostics },
14489
+ // Runtime harness inventory
14490
+ { method: 'GET', path: '/api/harness/diagnostics', desc: 'Read-only inventory of runtime-declared harness locations and explicit orchestration policies', handler: handleHarnessDiagnostics },
14601
14491
 
14602
14492
  // Projects
14603
14493
  { method: 'POST', path: '/api/projects/browse', desc: 'Open folder picker dialog, return selected path', handler: handleProjectsBrowse },