@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
@@ -1,48 +1,10 @@
1
1
  /**
2
2
  * engine/comment-format.js — platform-neutral PR-comment body fragments.
3
3
  *
4
- * Harness Transparency, Stage 3 readout #1 (P-e4b7a6d5). The other readouts —
5
- * the work-item modal (P-d5a6f7c4) and the notes/inbox digest — live in the
6
- * dashboard renderer and the consolidation path respectively. This module owns
7
- * the *PR-comment* surface.
8
- *
9
- * `buildHarnessUsedSection(harnessUsed)` renders the grounded harness-usage
10
- * record (the canonical `{ skills, mcpServers, commands, docs }` shape produced
11
- * by `shared.groundHarnessUsed` / `shared.normalizeHarnessUsed`; each entry may
12
- * carry `grounded: true | false`) as a collapsible Markdown `<details>` block.
13
- *
14
- * It is deliberately platform-NEUTRAL: the output is plain Markdown with no
15
- * GitHub- or ADO-specific syntax, so the GitHub comment path
16
- * (`engine/gh-comment.js buildMinionsCommentBody`) and the ADO comment path
17
- * (agent-authored via `az repos pr comment` per `playbooks/shared-rules.md`)
18
- * fold in a **byte-identical** section. Both consume this one helper — there is
19
- * no second renderer to drift.
20
- *
21
- * Returns '' when the record is absent, malformed, or has no renderable entries
22
- * so callers can append it unconditionally without emitting an empty section.
4
+ * Owns the platform-neutral Minions marker and brand-link formatting shared by
5
+ * GitHub and Azure DevOps comment transports.
23
6
  */
24
7
 
25
- // Kind metadata mirrors the dashboard work-item modal (P-d5a6f7c4,
26
- // dashboard/js/render-work-items.js): same four kinds, same icons, same
27
- // primary/secondary field mapping, so the PR-comment and modal readouts stay
28
- // visually consistent.
29
- const HARNESS_KINDS = [
30
- { key: 'skills', icon: '\u{1F9E9}', primary: 'name', secondary: 'source' }, // 🧩
31
- { key: 'mcpServers', icon: '\u{1F50C}', primary: 'name', secondary: 'scope' }, // 🔌
32
- { key: 'commands', icon: '\u{2318}', primary: 'name', secondary: 'scope' }, // ⌘
33
- { key: 'docs', icon: '\u{1F4C4}', primary: 'path', secondary: 'why' }, // 📄
34
- ];
35
-
36
- const HARNESS_SUMMARY_ICON = '\u{1F9F0}'; // 🧰
37
- const HARNESS_WARN_ICON = '⚠️'; // ⚠️
38
-
39
- // W-mr287sc0000uc214 — the visible "skipped project skill" callout. The label is
40
- // a stable literal so buildMinionsCommentBody can dedupe (never stack two
41
- // callouts) and tests can assert on it. Clamps mirror the harness field caps.
42
- const SKILL_SKIPPED_LABEL = '**Skipped project skill:**';
43
- const SKILL_SKIPPED_MAX_NAME_LEN = 200;
44
- const SKILL_SKIPPED_MAX_REASON_LEN = 1000;
45
-
46
8
  // Canonical Minions site URL. shared-rules.md ("Brand link (MANDATORY)") requires
47
9
  // the human-readable "Minions" mention in a PR/ADO comment to render as a markdown
48
10
  // hyperlink to this destination. Keep this in sync with the literal in
@@ -76,118 +38,10 @@ function linkifyBrandTrailer(text) {
76
38
  return text.replace(BRAND_TRAILER_RE, `by [Minions](${MINIONS_BRAND_URL})`);
77
39
  }
78
40
 
79
- // Collapse internal whitespace/newlines to single spaces and neutralize
80
- // backticks so each entry renders on one line and can't break the inline-code
81
- // span. Values arrive already trimmed + length-clamped by normalizeHarnessUsed,
82
- // but may still contain internal newlines.
83
- function _clean(value) {
84
- return String(value).replace(/`/g, "'").replace(/\s+/g, ' ').trim();
85
- }
86
-
87
- /**
88
- * Render the grounded harnessUsed record as a collapsible Markdown section.
89
- * @param {object|null} harnessUsed canonical { skills, mcpServers, commands, docs }
90
- * @returns {string} '' when empty, else a `<details>` block (no trailing newline)
91
- */
92
- function buildHarnessUsedSection(harnessUsed) {
93
- if (!harnessUsed || typeof harnessUsed !== 'object' || Array.isArray(harnessUsed)) return '';
94
-
95
- const lines = [];
96
- let anyUngrounded = false;
97
-
98
- for (const kind of HARNESS_KINDS) {
99
- const entries = Array.isArray(harnessUsed[kind.key]) ? harnessUsed[kind.key] : [];
100
- for (const entry of entries) {
101
- if (!entry || typeof entry !== 'object') continue;
102
- const primaryRaw = entry[kind.primary];
103
- if (typeof primaryRaw !== 'string' || !primaryRaw.trim()) continue;
104
- const primary = _clean(primaryRaw);
105
-
106
- const grounded = entry.grounded === true;
107
- if (!grounded) anyUngrounded = true;
108
-
109
- const secondaryRaw = entry[kind.secondary];
110
- const secondary = (typeof secondaryRaw === 'string' && secondaryRaw.trim())
111
- ? _clean(secondaryRaw) : '';
112
-
113
- const tail = secondary ? ` — ${secondary}` : ''; // em dash
114
- const warn = grounded ? '' : ` ${HARNESS_WARN_ICON}`;
115
- lines.push(`- ${kind.icon} \`${primary}\`${tail}${warn}`);
116
- }
117
- }
118
-
119
- if (lines.length === 0) return '';
120
-
121
- const legend = anyUngrounded
122
- ? `\n\n> ${HARNESS_WARN_ICON} agent-reported, not verified by the engine`
123
- : '';
124
-
125
- return `<details>\n<summary>${HARNESS_SUMMARY_ICON} Harnesses used (${lines.length})</summary>\n\n`
126
- + `${lines.join('\n')}${legend}\n\n</details>`;
127
- }
128
-
129
- /**
130
- * Extract a canonical `{ name, reason }` skip record from a self-reported skip
131
- * value. Accepts the raw `{ name, reason }` shape directly, or a `meta` wrapper
132
- * that carries it — `meta.skill` (`{ skipped: { name, reason } }`) or
133
- * `meta.review` (`{ skillSkipped: { name, reason } }`) — so callers can pass the
134
- * completion-report block verbatim. Returns null when no non-empty `name` is
135
- * present so callers treat "no skip" as a single falsy case.
136
- * @param {*} input
137
- * @returns {{name:string, reason:string}|null}
138
- */
139
- function _extractSkillSkipped(input) {
140
- if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
141
- let node = input;
142
- if (input.skipped && typeof input.skipped === 'object' && !Array.isArray(input.skipped)) {
143
- node = input.skipped; // meta.skill shape
144
- } else if (input.skillSkipped && typeof input.skillSkipped === 'object' && !Array.isArray(input.skillSkipped)) {
145
- node = input.skillSkipped; // meta.review shape
146
- }
147
- const name = typeof node.name === 'string' ? node.name.trim() : '';
148
- if (!name) return null;
149
- const reason = typeof node.reason === 'string' ? node.reason.trim() : '';
150
- return { name, reason };
151
- }
152
-
153
- /**
154
- * Render the "skipped project skill" self-report as a VISIBLE Markdown
155
- * blockquote callout (W-mr287sc0000uc214), e.g.:
156
- * > ⚠️ **Skipped project skill:** `FMF-review-context` — small 2-file diff…
157
- *
158
- * A review/fix agent may self-report that it intentionally skipped an available,
159
- * in-scope project (review) skill via `meta.review.skillSkipped` /
160
- * `meta.skill.skipped` (shape `{name, reason}`). That skip used to live ONLY in
161
- * the completion-report JSON — invisible to a human reading the PR before merge.
162
- * This is a sibling of buildHarnessUsedSection (same neutral chokepoint, same
163
- * `_clean` hygiene) but a VISIBLE blockquote rather than a collapsed <details>:
164
- * the whole point is that a human reviewing the PR sees the skip rationale in the
165
- * same comment as the APPROVE / REQUEST_CHANGES verdict, before merge.
166
- *
167
- * Deliberately platform-NEUTRAL Markdown so the GitHub and ADO posters fold in a
168
- * byte-identical callout — there is no second renderer to drift. Accepts either
169
- * the raw `{name, reason}` record or a `meta.skill` / `meta.review` wrapper.
170
- *
171
- * Returns '' when the record is absent/malformed (no skip name), so callers can
172
- * append it unconditionally without emitting a stray callout — reports without
173
- * meta.review.skillSkipped / meta.skill.skipped render identically to today.
174
- *
175
- * @param {object|null} skillSkipped `{name, reason}` or a meta.skill/meta.review wrapper
176
- * @returns {string} '' when empty, else a one-line `> ⚠️ …` blockquote
177
- */
178
- function buildSkippedSkillSection(skillSkipped) {
179
- const parsed = _extractSkillSkipped(skillSkipped);
180
- if (!parsed) return '';
181
- const name = _clean(parsed.name).slice(0, SKILL_SKIPPED_MAX_NAME_LEN);
182
- const reasonClean = parsed.reason ? _clean(parsed.reason).slice(0, SKILL_SKIPPED_MAX_REASON_LEN) : '';
183
- const tail = reasonClean ? ` — ${reasonClean}` : ''; // em dash
184
- return `> ${HARNESS_WARN_ICON} ${SKILL_SKIPPED_LABEL} \`${name}\`${tail}`;
185
- }
186
-
187
41
  // ── Minions marker + neutral comment-body builder ───────────────────────────
188
42
  //
189
43
  // The hidden HTML-comment marker and the body composition (marker + harness
190
- // section + brand link) are platform-NEUTRAL: GitHub (engine/gh-comment.js) and
44
+ // brand link) are platform-NEUTRAL: GitHub (engine/gh-comment.js) and
191
45
  // Azure DevOps (engine/ado-comment.js) both post the byte-identical body built
192
46
  // here, so there is one builder and no drift. The host modules own only the
193
47
  // transport (gh CLI vs ADO REST) and host-specific validation (repo slug vs
@@ -246,42 +100,21 @@ function _buildMarker({ agentId, kind, workItemId }) {
246
100
  }
247
101
 
248
102
  /**
249
- * Compose a minions PR-comment body: prepend the hidden marker, fold in the
250
- * collapsible "Harnesses used" section, and hyperlink the first bare brand
251
- * trailer. Platform-neutral — GitHub and ADO posters both call this.
103
+ * Compose a minions PR-comment body: prepend the hidden marker and hyperlink the
104
+ * first bare brand trailer. Platform-neutral — GitHub and ADO posters both call this.
252
105
  *
253
106
  * Idempotency: if `body` already starts with a minions marker it is returned
254
- * with the harness/brand transforms applied but no second marker prepended.
107
+ * with the brand transform applied but no second marker prepended.
255
108
  *
256
- * @param {{agentId:string, kind:string, workItemId?:string, body?:string, harnessUsed?:object, skillSkipped?:object}} args
109
+ * @param {{agentId:string, kind:string, workItemId?:string, body?:string}} args
257
110
  * @returns {string} the final comment body
258
111
  */
259
- function buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed, skillSkipped }) {
112
+ function buildMinionsCommentBody({ agentId, kind, workItemId, body }) {
260
113
  // Validate the inputs even when the body is pre-marked, so callers can't
261
114
  // silently bypass validation by pre-marking their body.
262
115
  _validateMarkerInputs({ agentId, kind, workItemId });
263
116
  let safeBody = body == null ? '' : String(body);
264
117
 
265
- // Fold in the VISIBLE "skipped project skill" callout (W-mr287sc0000uc214)
266
- // BEFORE the collapsed harness section so a human sees the skip rationale in
267
- // the same comment as the verdict — not buried in a <details> or reachable
268
- // only via a separate API call. Dedup on the stable label so a re-marked body
269
- // already carrying the callout doesn't stack a second one. Absent/malformed
270
- // skip → '' → no-op (byte-identical to today).
271
- const skipSection = buildSkippedSkillSection(skillSkipped);
272
- if (skipSection && !safeBody.includes(SKILL_SKIPPED_LABEL)) {
273
- safeBody = safeBody ? `${safeBody}\n\n${skipSection}` : skipSection;
274
- }
275
-
276
- // Fold the grounded harness-usage digest in as a collapsible <details>
277
- // section. Append at the END so it survives the marker-idempotency path;
278
- // dedup on the summary line so a re-marked body that already carries the
279
- // section doesn't stack a second one.
280
- const harnessSection = buildHarnessUsedSection(harnessUsed);
281
- if (harnessSection && !safeBody.includes('<summary>\u{1F9F0} Harnesses used')) {
282
- safeBody = safeBody ? `${safeBody}\n\n${harnessSection}` : harnessSection;
283
- }
284
-
285
118
  // Brand-link safety net: deterministically hyperlink the first bare
286
119
  // "… by Minions" signature trailer (no-op when the agent already linked it).
287
120
  safeBody = linkifyBrandTrailer(safeBody);
@@ -307,8 +140,6 @@ function parseMinionsMarker(body) {
307
140
  }
308
141
 
309
142
  module.exports = {
310
- buildHarnessUsedSection,
311
- buildSkippedSkillSection,
312
143
  linkifyBrandTrailer,
313
144
  MINIONS_BRAND_URL,
314
145
  // Neutral marker + body builder (consumed by gh-comment.js and ado-comment.js).
@@ -322,9 +153,4 @@ module.exports = {
322
153
  // Internal helpers exported for tests / host posters.
323
154
  _buildMarker,
324
155
  _validateMarkerInputs,
325
- // Exported for tests / advanced callers that want to mirror the kind mapping.
326
- HARNESS_KINDS,
327
- // W-mr287sc0000uc214 — skipped-project-skill callout helpers.
328
- _extractSkillSkipped,
329
- SKILL_SKIPPED_LABEL,
330
156
  };
@@ -149,6 +149,44 @@ function extractInboxAgent(item) {
149
149
  return null;
150
150
  }
151
151
 
152
+ function extractInboxProject(item) {
153
+ const content = String(item?.content || '');
154
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
155
+ if (!fmMatch) return '';
156
+ const projectLine = fmMatch[1].split('\n').find(l => /^project:\s*/i.test(l));
157
+ return projectLine ? projectLine.replace(/^project:\s*/i, '').trim() : '';
158
+ }
159
+
160
+ function _indexMemoryRecord(record) {
161
+ try {
162
+ return require('./memory-store').upsertMemoryRecord(record);
163
+ } catch (err) {
164
+ log('warn', `Structured memory index write failed (${record?.sourceRef || 'unknown'}): ${err.message}`);
165
+ return null;
166
+ }
167
+ }
168
+
169
+ function _indexAgentMemoryItem(item, agent) {
170
+ const content = String(item?.content || '').trim();
171
+ if (!content) return null;
172
+ const titleMatch = content.match(/^#\s+(.+)/m);
173
+ return _indexMemoryRecord({
174
+ memoryType: 'semantic',
175
+ scopeType: 'agent',
176
+ scopeKey: agent,
177
+ title: titleMatch ? titleMatch[1].trim() : String(item.name || 'Agent learning').replace(/\.md$/, ''),
178
+ body: content,
179
+ tags: ['agent-memory', agent],
180
+ sourceType: 'inbox',
181
+ sourcePath: `notes/inbox/${item.name}`,
182
+ sourceRef: String(item.name),
183
+ trust: 'agent',
184
+ importance: 0.6,
185
+ confidence: 0.6,
186
+ metadata: { agent, project: extractInboxProject(item) || null },
187
+ });
188
+ }
189
+
152
190
  /**
153
191
  * Append an inbox item to its author's personal memory file when the agent
154
192
  * is a known team member (must be present in `knownAgents`) and not a
@@ -207,6 +245,7 @@ function appendToAgentMemory(item, knownAgents, config) {
207
245
  const next = pruneAgentMemoryToBudget(existing + entry, agent, _pruneOptsFromConfig(config));
208
246
  safeWrite(memPath, next);
209
247
  });
248
+ _indexAgentMemoryItem(item, agent);
210
249
  return true;
211
250
  } catch (err) {
212
251
  log('warn', `Failed to append to knowledge/agents/${agent}.md: ${err.message}`);
@@ -571,7 +610,22 @@ function reconcileAndAppendToAgentMemory(item, knownAgents, config) {
571
610
  lockErr = err;
572
611
  }
573
612
 
574
- if (reconciled) return true;
613
+ if (reconciled) {
614
+ const indexed = _indexAgentMemoryItem(item, agent);
615
+ if (indexed) {
616
+ try {
617
+ require('./memory-store').supersedeMatchingRecords({
618
+ scopeType: 'agent',
619
+ scopeKey: agent,
620
+ oldTexts: edits.map(edit => edit.old_text),
621
+ supersededBy: indexed.id,
622
+ });
623
+ } catch (err) {
624
+ log('warn', `Structured memory reconcile transition failed for ${agent}: ${err.message}`);
625
+ }
626
+ }
627
+ return true;
628
+ }
575
629
  if (lockErr) log('warn', `agent-memory reconcile: lock/write error for ${agent}: ${lockErr.message} — plain append`);
576
630
  return appendToAgentMemory(item, knownAgents, config);
577
631
  }).catch((err) => {
@@ -1291,6 +1345,22 @@ async function classifyToKnowledgeBase(items, config) {
1291
1345
  const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\nreusable: ${reusable}${isSystemAlert ? `\nalertHash: ${alertHash}` : ''}\n---\n\n`;
1292
1346
  try {
1293
1347
  safeWrite(kbPath, frontmatter + kbBody);
1348
+ const project = extractInboxProject(item);
1349
+ _indexMemoryRecord({
1350
+ memoryType: 'semantic',
1351
+ scopeType: project ? 'project' : 'global',
1352
+ scopeKey: project || '',
1353
+ title: titleMatch ? titleMatch[1].trim() : titleSlug,
1354
+ body: frontmatter + kbBody,
1355
+ tags: [category, agent, reusable ? 'reusable' : 'reference'],
1356
+ sourceType: 'knowledge-base',
1357
+ sourcePath: path.relative(shared.MINIONS_DIR, kbPath).replace(/\\/g, '/'),
1358
+ sourceRef: item.name,
1359
+ trust: 'agent',
1360
+ importance: reusable ? 0.8 : 0.4,
1361
+ confidence: 0.6,
1362
+ metadata: { category, agent, reusable, inbox: item.name },
1363
+ });
1294
1364
  classified++;
1295
1365
  } catch (err) {
1296
1366
  log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
@@ -1430,6 +1500,7 @@ module.exports = {
1430
1500
  checkDuplicateHash,
1431
1501
  // per-agent memory routing
1432
1502
  extractInboxAgent,
1503
+ extractInboxProject,
1433
1504
  appendToAgentMemory,
1434
1505
  reconcileAndAppendToAgentMemory,
1435
1506
  pruneAgentMemoryToBudget,
@@ -21,6 +21,45 @@ let _db = null;
21
21
  let _dbPath = null;
22
22
  let _dbInitError = null;
23
23
  let _exitCheckpointRegistered = false;
24
+ let _cleanedDbPath = null;
25
+
26
+ function _cleanupRetiredJsonFiles(db, dbPath) {
27
+ if (_cleanedDbPath === dbPath) return;
28
+ const marker = db.prepare("SELECT value FROM state_markers WHERE key='sql_only_cutover'").get();
29
+ if (!marker) return;
30
+ const minionsDir = path.dirname(path.dirname(dbPath));
31
+ const engineDir = path.join(minionsDir, 'engine');
32
+ const engineFiles = [
33
+ 'dispatch.json', 'log.json', 'metrics.json', 'watches.json',
34
+ 'schedule-runs.json', 'pipeline-runs.json', 'managed-processes.json',
35
+ 'worktree-pool.json', 'qa-runs.json', 'qa-sessions.json', 'pr-links.json',
36
+ 'cooldowns.json', 'pending-rebases.json', 'cc-sessions.json', 'doc-sessions.json',
37
+ ];
38
+ const targets = engineFiles.map(name => path.join(engineDir, name));
39
+ targets.push(path.join(minionsDir, 'work-items.json'), path.join(minionsDir, 'pull-requests.json'));
40
+ try {
41
+ for (const project of fs.readdirSync(path.join(minionsDir, 'projects'), { withFileTypes: true })) {
42
+ if (!project.isDirectory() || project.name === '.archived') continue;
43
+ targets.push(
44
+ path.join(minionsDir, 'projects', project.name, 'work-items.json'),
45
+ path.join(minionsDir, 'projects', project.name, 'pull-requests.json'),
46
+ );
47
+ }
48
+ } catch { /* projects directory is optional */ }
49
+ for (const dir of [path.join(minionsDir, 'prd'), path.join(minionsDir, 'prd', 'archive')]) {
50
+ try {
51
+ for (const name of fs.readdirSync(dir)) {
52
+ if (name.endsWith('.json')) targets.push(path.join(dir, name));
53
+ }
54
+ } catch { /* PRD directory is optional */ }
55
+ }
56
+ for (const target of targets) {
57
+ for (const suffix of ['', '.backup', '.lock']) {
58
+ try { fs.unlinkSync(target + suffix); } catch (e) { if (e.code !== 'ENOENT') throw e; }
59
+ }
60
+ }
61
+ _cleanedDbPath = dbPath;
62
+ }
24
63
 
25
64
  // DATA-LOSS GUARD: register a one-time process-exit hook that checkpoints +
26
65
  // closes the DB. Registered by getDb() on first successful open so EVERY DB
@@ -68,14 +107,6 @@ function _installExperimentalWarningFilter() {
68
107
  }
69
108
 
70
109
  function getDb() {
71
- // Hard opt-out (issue #3035, option #3): MINIONS_FORCE_JSON=1 makes
72
- // every getDb() call throw, forcing the entire stack onto the JSON
73
- // fallback path. Useful for users who can't enable
74
- // --experimental-sqlite on Node 22.x and for regression tests that
75
- // need to exercise the JSON-fallback branches deterministically.
76
- if (process.env.MINIONS_FORCE_JSON === '1') {
77
- throw new Error('engine/db: SQL disabled via MINIONS_FORCE_JSON=1');
78
- }
79
110
  // Re-resolve the DB path on every call so tests that swap MINIONS_TEST_DIR
80
111
  // (or production-side configuration that changes MINIONS_HOME between
81
112
  // operations) get a fresh connection to the new location instead of
@@ -143,12 +174,13 @@ function getDb() {
143
174
  if (lastErr) throw lastErr;
144
175
  const { runMigrations } = require('./migrate');
145
176
  runMigrations(_db);
177
+ _cleanupRetiredJsonFiles(_db, dbPath);
146
178
  _registerExitCheckpoint();
147
179
  return _db;
148
180
  } catch (e) {
149
181
  const nodeMajor = parseInt(String(process.versions.node).split('.')[0], 10);
150
182
  const flagHint = nodeMajor === 22
151
- ? ` On Node 22.x, node:sqlite is gated behind --experimental-sqlite. Restart with NODE_OPTIONS=--experimental-sqlite, upgrade to Node 24+, or set MINIONS_FORCE_JSON=1 to bypass SQL.`
183
+ ? ` On Node 22.x, node:sqlite is gated behind --experimental-sqlite. Restart with NODE_OPTIONS=--experimental-sqlite or upgrade to Node 24+.`
152
184
  : ` Node 22.5+ (with --experimental-sqlite on 22.x) or 24+ required for built-in 'node:sqlite' support.`;
153
185
  _dbInitError = new Error(`engine/db: failed to open SQLite — ${e.message}.${flagHint}`);
154
186
  throw _dbInitError;
@@ -200,4 +232,22 @@ function withTransaction(db, fn) {
200
232
  }
201
233
  }
202
234
 
203
- module.exports = { getDb, closeDb, withTransaction };
235
+ // Cache compiled statements on the db connection itself so hot, fixed-shape
236
+ // read paths (e.g. FTS5 memory search run repeatedly per dispatch) don't pay
237
+ // SQLite's parse/plan cost on every call — `db.prepare()` recompiles from
238
+ // scratch each time it's invoked, node:sqlite does not dedupe by text. Keyed
239
+ // per-connection (not module-level) so closeDb()/getDb() reopening against a
240
+ // new path (test isolation) naturally starts with an empty cache instead of
241
+ // holding stale statements bound to a closed handle.
242
+ function prepareCached(db, sql) {
243
+ if (!db) throw new Error('engine/db.prepareCached: db is required');
244
+ if (!db._minionsStmtCache) db._minionsStmtCache = new Map();
245
+ let stmt = db._minionsStmtCache.get(sql);
246
+ if (!stmt) {
247
+ stmt = db.prepare(sql);
248
+ db._minionsStmtCache.set(sql, stmt);
249
+ }
250
+ return stmt;
251
+ }
252
+
253
+ module.exports = { getDb, closeDb, withTransaction, prepareCached };
@@ -0,0 +1,208 @@
1
+ // engine/db/migrations/017-agent-memory.js
2
+ //
3
+ // Structured long-term memory for task-aware retrieval. Markdown remains the
4
+ // operator-facing source/mirror during rollout; these rows are the searchable,
5
+ // provenance-preserving representation used by dispatch.
6
+
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const KB_CATEGORIES = [
12
+ 'architecture', 'conventions', 'project-notes', 'build-reports', 'reviews',
13
+ 'learnings', 'decisions', 'incidents', 'api-notes',
14
+ ];
15
+
16
+ function _resolveMinionsDir() {
17
+ return process.env.MINIONS_TEST_DIR
18
+ || process.env.MINIONS_HOME
19
+ || path.resolve(__dirname, '..', '..', '..');
20
+ }
21
+
22
+ function _read(filePath) {
23
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return ''; }
24
+ }
25
+
26
+ function _title(content, fallback) {
27
+ const match = String(content || '').match(/^#\s+(.+)/m);
28
+ return match ? match[1].trim() : fallback;
29
+ }
30
+
31
+ function _recordId(sourceType, sourceRef, content) {
32
+ const hash = crypto.createHash('sha256').update(`${sourceType}\0${sourceRef}\0${content}`).digest('hex');
33
+ return `M-${hash.slice(0, 24)}`;
34
+ }
35
+
36
+ function _contentHash(content) {
37
+ return crypto.createHash('sha256').update(String(content || '')).digest('hex');
38
+ }
39
+
40
+ function _splitAgentMemory(content) {
41
+ const boundary = /(?=\n---\n\n### \d{4}-\d{2}-\d{2}:)/g;
42
+ const parts = String(content || '').split(boundary).map(s => s.trim()).filter(Boolean);
43
+ return parts.length ? parts : [String(content || '')];
44
+ }
45
+
46
+ function _splitTeamNotes(content) {
47
+ const parts = String(content || '').split(/(?=^### \d{4}-\d{2}-\d{2})/m).map(s => s.trim()).filter(Boolean);
48
+ return parts.length ? parts : [String(content || '')];
49
+ }
50
+
51
+ module.exports = {
52
+ version: 17,
53
+ description: 'structured agent memory records, FTS5 retrieval, and retrieval telemetry',
54
+ up(db) {
55
+ db.exec(`
56
+ CREATE TABLE memory_records (
57
+ id TEXT PRIMARY KEY,
58
+ memory_type TEXT NOT NULL,
59
+ scope_type TEXT NOT NULL,
60
+ scope_key TEXT NOT NULL DEFAULT '',
61
+ title TEXT NOT NULL,
62
+ body TEXT NOT NULL,
63
+ tags TEXT NOT NULL DEFAULT '[]',
64
+ source_type TEXT NOT NULL,
65
+ source_path TEXT,
66
+ source_ref TEXT NOT NULL,
67
+ trust TEXT NOT NULL DEFAULT 'agent',
68
+ importance REAL NOT NULL DEFAULT 0.5,
69
+ confidence REAL NOT NULL DEFAULT 0.5,
70
+ status TEXT NOT NULL DEFAULT 'active',
71
+ valid_from INTEGER,
72
+ valid_to INTEGER,
73
+ supersedes_id TEXT,
74
+ superseded_by TEXT,
75
+ content_hash TEXT NOT NULL,
76
+ metadata TEXT NOT NULL DEFAULT '{}',
77
+ created_at INTEGER NOT NULL,
78
+ updated_at INTEGER NOT NULL
79
+ );
80
+ CREATE UNIQUE INDEX idx_memory_source_content
81
+ ON memory_records(source_type, source_ref, content_hash);
82
+ CREATE INDEX idx_memory_scope_status
83
+ ON memory_records(scope_type, scope_key, status);
84
+ CREATE INDEX idx_memory_type_status
85
+ ON memory_records(memory_type, status);
86
+ CREATE INDEX idx_memory_updated
87
+ ON memory_records(updated_at DESC);
88
+ CREATE INDEX idx_memory_supersedes
89
+ ON memory_records(supersedes_id);
90
+
91
+ CREATE VIRTUAL TABLE memory_records_fts USING fts5(
92
+ title,
93
+ body,
94
+ tags,
95
+ source_path,
96
+ content='memory_records',
97
+ content_rowid='rowid'
98
+ );
99
+
100
+ CREATE TRIGGER memory_records_ai AFTER INSERT ON memory_records BEGIN
101
+ INSERT INTO memory_records_fts(rowid, title, body, tags, source_path)
102
+ VALUES (new.rowid, new.title, new.body, new.tags, COALESCE(new.source_path, ''));
103
+ END;
104
+ CREATE TRIGGER memory_records_ad AFTER DELETE ON memory_records BEGIN
105
+ INSERT INTO memory_records_fts(memory_records_fts, rowid, title, body, tags, source_path)
106
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, COALESCE(old.source_path, ''));
107
+ END;
108
+ CREATE TRIGGER memory_records_au AFTER UPDATE OF title, body, tags, source_path ON memory_records BEGIN
109
+ INSERT INTO memory_records_fts(memory_records_fts, rowid, title, body, tags, source_path)
110
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, COALESCE(old.source_path, ''));
111
+ INSERT INTO memory_records_fts(rowid, title, body, tags, source_path)
112
+ VALUES (new.rowid, new.title, new.body, new.tags, COALESCE(new.source_path, ''));
113
+ END;
114
+
115
+ CREATE TABLE memory_retrieval_runs (
116
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
117
+ work_item_id TEXT,
118
+ project TEXT,
119
+ agent TEXT,
120
+ query TEXT NOT NULL,
121
+ candidate_count INTEGER NOT NULL,
122
+ selected_count INTEGER NOT NULL,
123
+ selected_bytes INTEGER NOT NULL,
124
+ duration_ms REAL NOT NULL,
125
+ shadow INTEGER NOT NULL DEFAULT 1,
126
+ created_at INTEGER NOT NULL
127
+ );
128
+ CREATE INDEX idx_memory_retrieval_created
129
+ ON memory_retrieval_runs(created_at DESC);
130
+ `);
131
+
132
+ const minionsDir = _resolveMinionsDir();
133
+ if (!minionsDir) return;
134
+
135
+ const insert = db.prepare(`
136
+ INSERT OR IGNORE INTO memory_records (
137
+ id, memory_type, scope_type, scope_key, title, body, tags,
138
+ source_type, source_path, source_ref, trust, importance, confidence,
139
+ status, valid_from, content_hash, metadata, created_at, updated_at
140
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
141
+ `);
142
+ const now = Date.now();
143
+ const add = ({ type = 'semantic', scopeType = 'global', scopeKey = '', title, body,
144
+ tags = [], sourceType = 'legacy-markdown', sourcePath, sourceRef,
145
+ trust = 'agent', importance = 0.5, metadata = {} }) => {
146
+ if (!body || !String(body).trim()) return;
147
+ let mtime = now;
148
+ try { mtime = Math.round(fs.statSync(path.join(minionsDir, sourcePath)).mtimeMs); } catch {}
149
+ insert.run(
150
+ _recordId(sourceType, sourceRef, body),
151
+ type, scopeType, scopeKey, title || sourceRef, body, JSON.stringify(tags),
152
+ sourceType, sourcePath, sourceRef, trust, importance, 0.5,
153
+ mtime, _contentHash(body), JSON.stringify(metadata), mtime, now,
154
+ );
155
+ };
156
+
157
+ for (const category of KB_CATEGORIES) {
158
+ const dir = path.join(minionsDir, 'knowledge', category);
159
+ let files = [];
160
+ try { files = fs.readdirSync(dir).filter(f => f.endsWith('.md')); } catch {}
161
+ for (const file of files) {
162
+ const sourcePath = path.join('knowledge', category, file).replace(/\\/g, '/');
163
+ const body = _read(path.join(minionsDir, sourcePath));
164
+ add({
165
+ title: _title(body, file.replace(/\.md$/, '')),
166
+ body,
167
+ tags: [category],
168
+ sourceType: 'knowledge-base',
169
+ sourcePath,
170
+ sourceRef: sourcePath,
171
+ metadata: { category },
172
+ });
173
+ }
174
+ }
175
+
176
+ const agentDir = path.join(minionsDir, 'knowledge', 'agents');
177
+ let agentFiles = [];
178
+ try { agentFiles = fs.readdirSync(agentDir).filter(f => f.endsWith('.md') && f !== 'README.md'); } catch {}
179
+ for (const file of agentFiles) {
180
+ const agent = file.replace(/\.md$/, '').toLowerCase();
181
+ const sourcePath = path.join('knowledge', 'agents', file).replace(/\\/g, '/');
182
+ const body = _read(path.join(minionsDir, sourcePath));
183
+ _splitAgentMemory(body).forEach((section, index) => add({
184
+ scopeType: 'agent',
185
+ scopeKey: agent,
186
+ title: _title(section, `${agent} memory ${index + 1}`),
187
+ body: section,
188
+ tags: ['agent-memory', agent],
189
+ sourceType: 'agent-memory',
190
+ sourcePath,
191
+ sourceRef: `${sourcePath}#${index + 1}`,
192
+ metadata: { agent, section: index + 1 },
193
+ }));
194
+ }
195
+
196
+ const notesPath = 'notes.md';
197
+ const notes = _read(path.join(minionsDir, notesPath));
198
+ _splitTeamNotes(notes).forEach((section, index) => add({
199
+ title: _title(section, `Team notes ${index + 1}`),
200
+ body: section,
201
+ tags: ['team-notes'],
202
+ sourceType: 'team-notes',
203
+ sourcePath: notesPath,
204
+ sourceRef: `${notesPath}#${index + 1}`,
205
+ metadata: { section: index + 1 },
206
+ }));
207
+ },
208
+ };