mindforge-cc 11.9.1 → 11.9.2

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 (48) hide show
  1. package/.agent/mindforge/consult.md +1 -1
  2. package/.agent/mindforge/cost-report.md +1 -1
  3. package/.claude/commands/mindforge/consult.md +1 -1
  4. package/.claude/commands/mindforge/cost-report.md +1 -1
  5. package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
  6. package/.mindforge/config.json +3 -3
  7. package/.mindforge/engine/cost-tracking/router.md +1 -1
  8. package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
  9. package/.mindforge/memory/sync-manifest.json +1 -1
  10. package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
  11. package/.mindforge/personas/cost-optimizer.md +2 -2
  12. package/.mindforge/personas/multi-model-bridge.md +1 -1
  13. package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
  14. package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
  15. package/CHANGELOG.md +208 -0
  16. package/MINDFORGE.md +3 -3
  17. package/README.md +50 -2
  18. package/RELEASENOTES.md +53 -0
  19. package/bin/autonomous/audit-writer.js +48 -33
  20. package/bin/dashboard/api-router.js +11 -10
  21. package/bin/dashboard/error-response.js +44 -0
  22. package/bin/dashboard/frontend/index.html +20 -3
  23. package/bin/dashboard/metrics-aggregator.js +29 -8
  24. package/bin/dashboard/revops-api.js +12 -2
  25. package/bin/dashboard/server.js +85 -5
  26. package/bin/dashboard/temporal-api.js +11 -5
  27. package/bin/engine/remediation-engine.js +12 -1
  28. package/bin/engine/temporal-hub.js +41 -9
  29. package/bin/eval/eval-harness.js +212 -1
  30. package/bin/eval/golden-set-retrieval.json +9 -0
  31. package/bin/governance/policy-engine.js +8 -0
  32. package/bin/hindsight-injector.js +8 -2
  33. package/bin/hooks/instinct-capture-hook.js +7 -1
  34. package/bin/learning/instinct-cli.js +7 -24
  35. package/bin/memory/knowledge-capture.js +23 -3
  36. package/bin/memory/knowledge-graph.js +70 -31
  37. package/bin/memory/vector-hub.js +304 -31
  38. package/bin/mindforge-cli.js +43 -11
  39. package/bin/models/cost-tracker.js +22 -23
  40. package/bin/models/model-router.js +28 -7
  41. package/bin/models/usage-record.js +71 -0
  42. package/bin/utils/file-lock.js +106 -0
  43. package/bin/utils/mindforge-params.js +124 -0
  44. package/bin/validate-config.js +34 -16
  45. package/changelogs/v11.9.2.md +209 -0
  46. package/docs/References/config-reference.md +73 -14
  47. package/docs/sdk-reference.md +1 -1
  48. package/package.json +4 -2
@@ -17,6 +17,7 @@ const crypto = require('crypto');
17
17
  const Store = require('./knowledge-store');
18
18
  const Embedder = require('./embedding-engine');
19
19
  const EISClient = require('./eis-client');
20
+ const { withFileLock } = require('../utils/file-lock');
20
21
 
21
22
  // ── Edge Types ────────────────────────────────────────────────────────────────
22
23
  const EDGE_TYPES = Object.freeze({
@@ -59,6 +60,19 @@ function ensureDir(dir) {
59
60
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
60
61
  }
61
62
 
63
+ /**
64
+ * LOCK-01: runs fn() holding the exclusive lock on graph-edges.jsonl.
65
+ * Every edge write below is a READ-modify-APPEND (readAllEdges -> mutate -> append),
66
+ * so the read MUST be inside the same critical section as the append or concurrent
67
+ * writers lose updates. Measured over 4 runs of 8 processes x 20 reinforceEdge calls:
68
+ * unlocked lost 93-129 of 160 increments (final traversal_count 31-67); locked lost 0
69
+ * of 160 in every run.
70
+ * Re-entrant: applyDecay() holds this lock and calls deprecateEdge(), which re-takes it.
71
+ */
72
+ function withEdgesLock(fn, opts = {}) {
73
+ return withFileLock(getPaths().EDGES_PATH, fn, { maxTries: 150, label: 'graph-edges', ...opts });
74
+ }
75
+
62
76
  // ── Edge CRUD ─────────────────────────────────────────────────────────────────
63
77
 
64
78
  /**
@@ -108,8 +122,12 @@ function addEdge(edge) {
108
122
  const payload = JSON.stringify({ ...record, checksum: '' });
109
123
  record.checksum = crypto.createHash('sha256').update(payload).digest('hex');
110
124
 
111
- fs.appendFileSync(paths.EDGES_PATH, JSON.stringify(record) + '\n');
112
- invalidateAdjacencyCache();
125
+ // LOCK-01: serialise against the read-modify-append paths below, whose readAllEdges()
126
+ // snapshot would otherwise go stale if this append landed inside their window.
127
+ withEdgesLock(() => {
128
+ fs.appendFileSync(paths.EDGES_PATH, JSON.stringify(record) + '\n');
129
+ invalidateAdjacencyCache();
130
+ });
113
131
  return id;
114
132
  }
115
133
 
@@ -144,19 +162,23 @@ function readAllEdges() {
144
162
  */
145
163
  function deprecateEdge(edgeId, reason) {
146
164
  const paths = getPaths();
147
- const edges = readAllEdges();
148
- const edge = edges.find(e => e.id === edgeId);
149
- if (!edge) return;
150
-
151
- const deprecated = {
152
- ...edge,
153
- deprecated: true,
154
- deprecated_reason: reason,
155
- deprecated_at: new Date().toISOString(),
156
- };
157
-
158
- fs.appendFileSync(paths.EDGES_PATH, JSON.stringify(deprecated) + '\n');
159
- invalidateAdjacencyCache();
165
+ // LOCK-01: read-modify-append is ONE critical section — the readAllEdges() snapshot
166
+ // must not be able to go stale before our append lands.
167
+ return withEdgesLock(() => {
168
+ const edges = readAllEdges();
169
+ const edge = edges.find(e => e.id === edgeId);
170
+ if (!edge) return;
171
+
172
+ const deprecated = {
173
+ ...edge,
174
+ deprecated: true,
175
+ deprecated_reason: reason,
176
+ deprecated_at: new Date().toISOString(),
177
+ };
178
+
179
+ fs.appendFileSync(paths.EDGES_PATH, JSON.stringify(deprecated) + '\n');
180
+ invalidateAdjacencyCache();
181
+ });
160
182
  }
161
183
 
162
184
  /**
@@ -165,22 +187,26 @@ function deprecateEdge(edgeId, reason) {
165
187
  */
166
188
  function reinforceEdge(edgeId) {
167
189
  const paths = getPaths();
168
- const edges = readAllEdges();
169
- const edge = edges.find(e => e.id === edgeId);
170
- if (!edge) return;
171
-
172
- const reinforced = {
173
- ...edge,
174
- weight: Math.min(2.0, edge.weight + 0.1),
175
- last_traversed: new Date().toISOString(),
176
- traversal_count: (edge.traversal_count || 0) + 1,
177
- };
178
-
179
- // Recompute checksum
180
- const payload = JSON.stringify({ ...reinforced, checksum: '' });
181
- reinforced.checksum = crypto.createHash('sha256').update(payload).digest('hex');
182
-
183
- fs.appendFileSync(paths.EDGES_PATH, JSON.stringify(reinforced) + '\n');
190
+ // LOCK-01: weight += 0.1 and traversal_count += 1 are read-modify-writes. Unlocked,
191
+ // 8 concurrent processes x 20 reinforcements lost 93-129 of 160 increments (4 runs).
192
+ return withEdgesLock(() => {
193
+ const edges = readAllEdges();
194
+ const edge = edges.find(e => e.id === edgeId);
195
+ if (!edge) return;
196
+
197
+ const reinforced = {
198
+ ...edge,
199
+ weight: Math.min(2.0, edge.weight + 0.1),
200
+ last_traversed: new Date().toISOString(),
201
+ traversal_count: (edge.traversal_count || 0) + 1,
202
+ };
203
+
204
+ // Recompute checksum
205
+ const payload = JSON.stringify({ ...reinforced, checksum: '' });
206
+ reinforced.checksum = crypto.createHash('sha256').update(payload).digest('hex');
207
+
208
+ fs.appendFileSync(paths.EDGES_PATH, JSON.stringify(reinforced) + '\n');
209
+ });
184
210
  }
185
211
 
186
212
  // ── Adjacency Index (with persistent cache) ─────────────────────────────────
@@ -489,6 +515,19 @@ function autoCreateEdges(entryId, vectors) {
489
515
  * @returns {{ decayed: number, pruned: number }}
490
516
  */
491
517
  function applyDecay() {
518
+ // LOCK-01: the ENTIRE pass is one read-modify-write — one readAllEdges() snapshot
519
+ // drives appends at the bottom of this loop AND nested deprecateEdge() calls. The
520
+ // nested calls re-take this same lock; withFileLock is re-entrant for that reason.
521
+ // staleMs is raised far above the 10s module default. This pass is O(n^2) — the
522
+ // nested deprecateEdge() re-reads the whole edges file per prune — and was measured
523
+ // holding this lock 9.1s at 2,000 edges and 14.4s at 2,500. Past STALE_MS another
524
+ // process classifies this LIVE lock as orphaned, unlinks it, and enters the critical
525
+ // section alongside us, so mutual exclusion would silently vanish on any real-sized
526
+ // graph — the same "looks guarded, isn't" failure this item exists to remove.
527
+ return withEdgesLock(() => applyDecayLocked(), { staleMs: 900000 });
528
+ }
529
+
530
+ function applyDecayLocked() {
492
531
  const edges = readAllEdges();
493
532
  const now = Date.now();
494
533
  let decayed = 0;
@@ -9,6 +9,135 @@ const crypto = require('crypto');
9
9
  const path = require('path');
10
10
  const fs = require('fs');
11
11
 
12
+ // ── FTS retrieval (FTS-01) ───────────────────────────────────────────────────
13
+ // A MATCH argument is an FTS *query expression*, not a literal. Wrapping the
14
+ // whole user query in double quotes made it ONE phrase, so a multi-word query
15
+ // only matched documents holding those exact ADJACENT words. Default behaviour
16
+ // is now search-box semantics: tokenise, drop FTS operator keywords, quote each
17
+ // term (which neutralises every FTS metacharacter) and OR the results together.
18
+ // Callers that genuinely need adjacency pass { phrase: true }.
19
+ //
20
+ // The OR alone is NOT the fix. FTS4 has no ranker, so the first `limit` rows
21
+ // are docid order over documents containing ANY term ("how", "the", "work") and
22
+ // measured mean recall@10 stays 0.0000. Ranking is what recovers it: tf-idf
23
+ // scored in JS from FTS4's matchinfo('pcnx') blob. Measured via
24
+ // `npm run eval:retrieval` on the repo doc corpus (517 docs, 10 golden
25
+ // queries): recall@10 0.0000 -> 0.6417, nDCG@10 0.0000 -> 0.5698.
26
+ const FTS_OPERATOR_WORDS = new Set(['and', 'or', 'not', 'near']);
27
+ const FTS_MAX_TERMS = 32;
28
+ const FTS_DEFAULT_LIMIT = 10;
29
+ const FTS_MAX_LIMIT = 100;
30
+ // Candidate rows pulled PER TERM before ranking. Bounds the work done for a very
31
+ // common term; see _rankedFtsSearch for why the pool is per term and not per
32
+ // query.
33
+ const FTS_RANK_POOL = 2000;
34
+ // BM25-style term-frequency saturation. matchinfo('pcnx') carries no document
35
+ // length, so a raw term count would systematically favour long documents;
36
+ // saturating tf at tf/(tf + k) keeps a repeated term ranked above a single
37
+ // occurrence without letting file size dominate the score.
38
+ const FTS_TF_SATURATION = 1.2;
39
+ // The only tables a ranked search may touch, with each FTS table's per-column
40
+ // relevance weights in DECLARED column order. _rankedFtsSearch interpolates
41
+ // these identifiers into SQL (SQLite cannot bind an identifier), so they are
42
+ // allowlisted here rather than trusted — no caller string can reach that SQL.
43
+ // A hit in the identifier column is a title match and is worth more than one in
44
+ // the body; traces_search declares `id` notindexed so it can never produce a hit
45
+ // at all, and weight 0 documents that rather than implying otherwise.
46
+ const FTS_SEARCH_TABLES = {
47
+ traces: { ftsTable: 'traces_search', columnWeights: [0, 1, 1, 1] }, // id, trace_id, content, agent
48
+ knowledge: { ftsTable: 'knowledge_search', columnWeights: [3, 1, 1] }, // id, content, tags
49
+ };
50
+
51
+ /**
52
+ * Tokenise a raw user query into individual FTS4 MATCH expressions.
53
+ * @param {string} rawQuery - MUST be a string; anything else is a caller bug
54
+ * @param {{phrase?: boolean}} [opts] - phrase:true yields a single
55
+ * exact-adjacency phrase expression instead of one expression per term
56
+ * @returns {string[]} MATCH expressions; empty when the query holds no term
57
+ * @throws {TypeError} when rawQuery is not a string
58
+ */
59
+ function buildFtsTerms(rawQuery, opts = {}) {
60
+ // Reject a non-string LOUDLY. Stringifying would turn an accidentally-passed
61
+ // options object into "[object Object]" and silently search for that literal —
62
+ // a wrong answer that looks like a working search. bin/engine/
63
+ // remediation-engine.js did exactly that. Failing at the boundary is honest.
64
+ if (typeof rawQuery !== 'string') {
65
+ const got = rawQuery === null ? 'null' : typeof rawQuery;
66
+ throw new TypeError(`[VectorHub] search query must be a string, received ${got}`);
67
+ }
68
+ if (opts.phrase) {
69
+ // FTS query syntax has no escape for a double quote inside a phrase, so
70
+ // strip them rather than emit an unparseable expression.
71
+ const phrase = rawQuery.replace(/"/g, ' ').trim();
72
+ return phrase ? [`"${phrase}"`] : [];
73
+ }
74
+ const terms = [];
75
+ const seen = new Set();
76
+ // Split on every character that is not a letter, digit or underscore: the
77
+ // resulting tokens cannot contain an FTS metacharacter, so quoting is total.
78
+ for (const token of rawQuery.split(/[^\p{L}\p{N}_]+/u)) {
79
+ if (!token) continue;
80
+ const lower = token.toLowerCase();
81
+ if (FTS_OPERATOR_WORDS.has(lower) || seen.has(lower)) continue;
82
+ seen.add(lower);
83
+ terms.push(`"${token}"`);
84
+ if (terms.length >= FTS_MAX_TERMS) break;
85
+ }
86
+ return terms;
87
+ }
88
+
89
+ /**
90
+ * Clamp a caller-supplied row limit into a sane bounded integer.
91
+ * @param {*} limit
92
+ * @returns {number} an integer in [1, FTS_MAX_LIMIT]
93
+ */
94
+ function clampFtsLimit(limit) {
95
+ const n = parseInt(limit, 10);
96
+ if (!Number.isFinite(n) || n < 1) return FTS_DEFAULT_LIMIT;
97
+ return Math.min(n, FTS_MAX_LIMIT);
98
+ }
99
+
100
+ /**
101
+ * Score one FTS4 matchinfo('pcnx') blob as a weighted tf-idf sum over every
102
+ * phrase/column pair.
103
+ *
104
+ * Blob layout (little-endian uint32): [p, c, n, then 3 values per (phrase,
105
+ * column) pair — hits in THIS row, hits in ALL rows, number of documents with at
106
+ * least one hit]. tf is saturated (see FTS_TF_SATURATION) because 'pcnx' carries
107
+ * no document length to normalise by; idf is the BM25 form clamped at 0, so a
108
+ * term present in nearly every document can never drive a score negative.
109
+ * @param {Uint8Array|Buffer|null} blob - value of matchinfo(<table>, 'pcnx')
110
+ * @param {number[]} [columnWeights] - per-column multipliers in declared column
111
+ * order; missing entries default to 1
112
+ * @returns {number} non-negative relevance score; 0 when the blob is unusable
113
+ */
114
+ function scoreMatchInfo(blob, columnWeights) {
115
+ if (!blob || typeof blob.length !== 'number' || blob.length < 12) return 0;
116
+ const buf = Buffer.from(blob);
117
+ const u32 = (i) => buf.readUInt32LE(i * 4);
118
+ const phrases = u32(0);
119
+ const columns = u32(1);
120
+ const totalRows = u32(2);
121
+ if (!phrases || !columns) return 0;
122
+ let score = 0;
123
+ for (let p = 0; p < phrases; p++) {
124
+ for (let c = 0; c < columns; c++) {
125
+ const base = 3 + 3 * (p * columns + c);
126
+ // Truncated blob: return what was scored rather than read past the end.
127
+ if ((base + 3) * 4 > buf.length) return score;
128
+ const hitsThisRow = u32(base);
129
+ if (!hitsThisRow) continue;
130
+ const weight = (columnWeights && columnWeights[c] !== undefined) ? columnWeights[c] : 1;
131
+ if (weight === 0) continue;
132
+ const docsWithHits = u32(base + 2);
133
+ const tf = hitsThisRow / (hitsThisRow + FTS_TF_SATURATION);
134
+ const idf = Math.max(0, Math.log((totalRows - docsWithHits + 0.5) / (docsWithHits + 0.5)));
135
+ score += weight * tf * idf;
136
+ }
137
+ }
138
+ return score;
139
+ }
140
+
12
141
  /**
13
142
  * VectorHub — Unified Persistence Layer
14
143
  * Traces, remediations, skills, knowledge, and graph edges.
@@ -203,10 +332,22 @@ class VectorHub {
203
332
 
204
333
  // ── FTS4 Virtual Tables (FTS4 is available in all sql.js builds) ────────
205
334
 
206
- this._db.run(`
207
- CREATE VIRTUAL TABLE IF NOT EXISTS traces_search
208
- USING fts4(trace_id, content, agent, tokenize=porter)
209
- `);
335
+ // FTS-01: traces_search must be keyed on the traces PRIMARY KEY (id), not
336
+ // trace_id. With a trace_id key every new span in a trace DELETEd its
337
+ // siblings' index rows, so only the last span per trace stayed searchable
338
+ // (live DB: 5,082 content-bearing traces, 2,827 index rows, 2,255 = 44.4%
339
+ // unsearchable). CREATE VIRTUAL TABLE IF NOT EXISTS cannot add a column to a
340
+ // database created before this fix, so detect the old signature and rebuild.
341
+ // The index is 100% derivable from `traces`, so the rebuild is lossless.
342
+ const ftsMigration = this._ensureTracesSearchSchema();
343
+ // Never let the rebuild be silent: it DROPS and recreates the index table, and a
344
+ // parity shortfall means rows silently stopped being searchable. Both are reported.
345
+ if (ftsMigration && ftsMigration.migrated && ftsMigration.expected > 0) {
346
+ console.log(`[VectorHub] traces_search migrated to the id-keyed schema; rebuilt ${ftsMigration.indexed}/${ftsMigration.expected} rows`);
347
+ }
348
+ if (ftsMigration && ftsMigration.migrated && ftsMigration.indexed !== ftsMigration.expected) {
349
+ console.warn(`[VectorHub] traces_search parity BROKEN after rebuild: indexed=${ftsMigration.indexed} expected=${ftsMigration.expected}`);
350
+ }
210
351
 
211
352
  this._db.run(`
212
353
  CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_search
@@ -228,6 +369,120 @@ class VectorHub {
228
369
  console.log(`[VectorHub] Initialized WASM SQLite persistence at ${this.dbPath}`);
229
370
  }
230
371
 
372
+ /**
373
+ * Create traces_search with the correct (id-keyed) schema, migrating and
374
+ * repopulating an older trace_id-keyed table in place when one exists.
375
+ * Idempotent — the sqlite_master signature check makes a re-run a no-op.
376
+ * `id` is declared notindexed: it is an opaque UUID, contributes no useful
377
+ * search term, and leaving it unindexed keeps MATCH semantics identical to the
378
+ * pre-fix table (content, agent and trace_id all remain searchable).
379
+ * @returns {{migrated: boolean, indexed: number|null, expected: number|null}}
380
+ */
381
+ _ensureTracesSearchSchema() {
382
+ const existing = this.query(
383
+ 'SELECT sql FROM sqlite_master WHERE type = ? AND name = ?',
384
+ ['table', 'traces_search']
385
+ );
386
+ const currentSql = existing.length ? String(existing[0].sql || '') : null;
387
+ if (currentSql !== null && /fts4\s*\(\s*id\s*,/i.test(currentSql)) {
388
+ return { migrated: false, indexed: null, expected: null };
389
+ }
390
+
391
+ if (currentSql !== null) {
392
+ this._db.run('DROP TABLE traces_search');
393
+ }
394
+ this._db.run(`
395
+ CREATE VIRTUAL TABLE traces_search
396
+ USING fts4(id, trace_id, content, agent, notindexed=id, tokenize=porter)
397
+ `);
398
+ return { migrated: true, ...this.rebuildTracesSearch() };
399
+ }
400
+
401
+ /**
402
+ * Rebuild traces_search from the `traces` base table.
403
+ *
404
+ * Explicitly re-runnable and idempotent: every index row is derivable from
405
+ * `traces`, so this clears the index and re-inserts exactly one row per
406
+ * content-bearing trace. Running it twice yields the same counts. This is the
407
+ * backfill that recovers rows lost to the old trace_id-keyed DELETE; no schema
408
+ * migration is required to run it once the schema is id-keyed.
409
+ * @returns {{indexed: number, expected: number}} post-rebuild row counts —
410
+ * equal when the index is complete
411
+ */
412
+ rebuildTracesSearch() {
413
+ this._db.run('DELETE FROM traces_search');
414
+ this._db.run(
415
+ `INSERT INTO traces_search (id, trace_id, content, agent)
416
+ SELECT id, trace_id, content, agent
417
+ FROM traces
418
+ WHERE content IS NOT NULL AND content <> ?`,
419
+ ['']
420
+ );
421
+ const indexed = this.query('SELECT COUNT(*) AS c FROM traces_search')[0].c;
422
+ const expected = this.query(
423
+ 'SELECT COUNT(*) AS c FROM traces WHERE content IS NOT NULL AND content <> ?',
424
+ ['']
425
+ )[0].c;
426
+ return { indexed, expected };
427
+ }
428
+
429
+ /**
430
+ * Run an FTS MATCH per term, then return the top `limit` base-table rows ranked
431
+ * by summed tf-idf.
432
+ *
433
+ * One MATCH per term rather than a single OR-joined MATCH: an OR-join returns
434
+ * its rows in docid order, so LIMIT cuts the candidate pool at the OLDEST
435
+ * FTS_RANK_POOL matches. Measured on the 5,082-row live trace index, a query of
436
+ * "celestial" (2,888 matches) plus a unique token could not retrieve the
437
+ * uniquely-matching document AT ALL, because 2,000 older rows filled the pool
438
+ * first — and since traces are append-only, the newest rows are always the
439
+ * first to be dropped. Scored per term the arithmetic is identical (matchinfo
440
+ * sums over phrases either way), but the pool can only be exhausted by a term's
441
+ * OWN document frequency, so a rare, discriminating term never loses.
442
+ * @param {string} baseTable - key of FTS_SEARCH_TABLES
443
+ * @param {string[]} terms - MATCH expressions from buildFtsTerms()
444
+ * @param {number} limit - clamped row limit
445
+ * @returns {Array<Object>} base-table rows, most relevant first
446
+ */
447
+ _rankedFtsSearch(baseTable, terms, limit) {
448
+ const config = FTS_SEARCH_TABLES[baseTable];
449
+ if (!config) {
450
+ throw new Error(`[VectorHub] refusing to search unknown table: ${baseTable}`);
451
+ }
452
+ if (terms.length === 0) return [];
453
+
454
+ // id -> { score, order }; `order` is the first-seen position, used as an
455
+ // explicit tiebreak so the ranking never depends on sort stability.
456
+ const scored = new Map();
457
+ for (const term of terms) {
458
+ const rows = this.query(
459
+ `SELECT id, matchinfo(${config.ftsTable}, 'pcnx') AS mi
460
+ FROM ${config.ftsTable}
461
+ WHERE ${config.ftsTable} MATCH ?
462
+ LIMIT ?`,
463
+ [term, FTS_RANK_POOL]
464
+ );
465
+ for (const row of rows) {
466
+ const add = scoreMatchInfo(row.mi, config.columnWeights);
467
+ const prev = scored.get(row.id);
468
+ scored.set(row.id, prev
469
+ ? { score: prev.score + add, order: prev.order }
470
+ : { score: add, order: scored.size });
471
+ }
472
+ }
473
+
474
+ const ids = [...scored.entries()]
475
+ .sort((a, b) => (b[1].score - a[1].score) || (a[1].order - b[1].order))
476
+ .slice(0, limit)
477
+ .map(([id]) => id);
478
+ if (ids.length === 0) return [];
479
+
480
+ const placeholders = ids.map(() => '?').join(', ');
481
+ const rows = this.query(`SELECT * FROM ${baseTable} WHERE id IN (${placeholders})`, ids);
482
+ const byId = new Map(rows.map(r => [r.id, r]));
483
+ return ids.map(id => byId.get(id)).filter(Boolean);
484
+ }
485
+
231
486
  /**
232
487
  * Persist the in-memory database to disk (UC-09).
233
488
  *
@@ -396,12 +651,14 @@ class VectorHub {
396
651
  [entry.id, entry.trace_id, entry.span_id, entry.event, entry.timestamp, entry.agent, entry.content, entry.metadata, entry.drift_score, entry.mesh_node_id]
397
652
  );
398
653
 
399
- // Update FTS index if content exists
654
+ // Update the FTS index if content exists. Keyed on entry.id (the traces
655
+ // PRIMARY KEY) — keying the DELETE on trace_id wiped every sibling span's
656
+ // index row, which is what made 44.4% of trace content unsearchable (FTS-01).
400
657
  if (entry.content) {
401
- this._db.run('DELETE FROM traces_search WHERE trace_id = ?', [entry.trace_id]);
658
+ this._db.run('DELETE FROM traces_search WHERE id = ?', [entry.id]);
402
659
  this._db.run(
403
- 'INSERT INTO traces_search (trace_id, content, agent) VALUES (?, ?, ?)',
404
- [entry.trace_id, entry.content, entry.agent]
660
+ 'INSERT INTO traces_search (id, trace_id, content, agent) VALUES (?, ?, ?, ?)',
661
+ [entry.id, entry.trace_id, entry.content, entry.agent]
405
662
  );
406
663
  }
407
664
 
@@ -436,26 +693,34 @@ class VectorHub {
436
693
  }
437
694
 
438
695
  /**
439
- * Full-text search for traces.
696
+ * Full-text search for traces, ranked by tf-idf.
697
+ *
698
+ * Multi-word queries are ORed across terms and ranked (see _rankedFtsSearch).
699
+ * FTS4 has no built-in ranker, so without the ranking step the result is docid
700
+ * order and mean recall@10 measures 0.0000.
701
+ * @param {string} rawQuery - the user's query text
702
+ * @param {{phrase?: boolean, limit?: number}} [opts]
703
+ * phrase:true searches for the exact adjacent word sequence (old behaviour);
704
+ * limit is clamped to [1, 100] and defaults to 10.
705
+ * @returns {Promise<Array<Object>>} trace rows, most relevant first
706
+ * @throws {TypeError} when rawQuery is not a string
440
707
  */
441
- async searchTraces(rawQuery) {
442
- const escaped = rawQuery.replace(/"/g, '""');
443
- const ftsQuery = `"${escaped}"`;
444
- return this.query(
445
- `SELECT t.*
446
- FROM traces t
447
- JOIN traces_search ts ON t.trace_id = ts.trace_id
448
- WHERE traces_search MATCH ?
449
- LIMIT 10`,
450
- [ftsQuery]
708
+ async searchTraces(rawQuery, opts = {}) {
709
+ return this._rankedFtsSearch(
710
+ 'traces',
711
+ buildFtsTerms(rawQuery, opts),
712
+ clampFtsLimit(opts.limit)
451
713
  );
452
714
  }
453
715
 
454
716
  /**
455
717
  * Full-text search for traces (alias for backward compat).
718
+ * @param {string} rawQuery
719
+ * @param {{phrase?: boolean, limit?: number}} [opts]
720
+ * @returns {Promise<Array<Object>>}
456
721
  */
457
- async searchFTS(rawQuery) {
458
- return this.searchTraces(rawQuery);
722
+ async searchFTS(rawQuery, opts = {}) {
723
+ return this.searchTraces(rawQuery, opts);
459
724
  }
460
725
 
461
726
  /**
@@ -512,16 +777,22 @@ class VectorHub {
512
777
  return record.id;
513
778
  }
514
779
 
515
- async searchKnowledge(rawQuery, limit = 10) {
516
- const escaped = rawQuery.replace(/"/g, '""');
517
- const ftsQuery = `"${escaped}"`;
518
- return this.query(
519
- `SELECT k.*
520
- FROM knowledge k
521
- JOIN knowledge_search ks ON k.id = ks.id
522
- WHERE knowledge_search MATCH ?
523
- LIMIT ?`,
524
- [ftsQuery, limit]
780
+ /**
781
+ * Full-text search for knowledge entries, ranked by tf-idf (see searchTraces).
782
+ * @param {string} rawQuery
783
+ * @param {number|{phrase?: boolean, limit?: number}} [limitOrOpts] - a bare
784
+ * number is still accepted for backward compatibility
785
+ * @returns {Promise<Array<Object>>} knowledge rows, most relevant first
786
+ * @throws {TypeError} when rawQuery is not a string
787
+ */
788
+ async searchKnowledge(rawQuery, limitOrOpts = {}) {
789
+ const opts = (limitOrOpts !== null && typeof limitOrOpts === 'object')
790
+ ? limitOrOpts
791
+ : { limit: limitOrOpts };
792
+ return this._rankedFtsSearch(
793
+ 'knowledge',
794
+ buildFtsTerms(rawQuery, opts),
795
+ clampFtsLimit(opts.limit)
525
796
  );
526
797
  }
527
798
 
@@ -611,6 +882,7 @@ const lazyHub = new Proxy({}, {
611
882
  get(_, prop) {
612
883
  if (prop === 'VectorHub') return VectorHub;
613
884
  if (prop === 'createVectorHub') return createVectorHub;
885
+ if (prop === 'buildFtsTerms') return buildFtsTerms;
614
886
  if (!_instance) _instance = new VectorHub();
615
887
  return typeof _instance[prop] === 'function'
616
888
  ? _instance[prop].bind(_instance)
@@ -621,3 +893,4 @@ const lazyHub = new Proxy({}, {
621
893
  module.exports = lazyHub;
622
894
  module.exports.VectorHub = VectorHub;
623
895
  module.exports.createVectorHub = createVectorHub;
896
+ module.exports.buildFtsTerms = buildFtsTerms;
@@ -25,8 +25,10 @@ const ROOT = path.resolve(__dirname, '..');
25
25
  const COMMANDS = {
26
26
  'security-scan': {
27
27
  script: 'bin/validate-config.js',
28
- description: 'Validate configuration and run security checks',
29
- defaultArgs: ['MINDFORGE.md']
28
+ description: 'Validate configuration and run security checks'
29
+ // No defaultArgs: validate-config.js already defaults to MINDFORGE.md
30
+ // (bin/validate-config.js:13). Declaring it here would prepend a positional
31
+ // that shadows a user-supplied config path.
30
32
  },
31
33
  'health': {
32
34
  script: 'bin/installer-core.js',
@@ -57,20 +59,31 @@ const COMMANDS = {
57
59
  script: 'bin/skill-validator.js',
58
60
  description: 'Run Level 1 & 2 validation on a SKILL.md file'
59
61
  },
62
+ // NOTE: install-skill / register-skill / audit-skill deliberately carry NO
63
+ // defaultArgs. Supplying their subcommand token here reaches skill-registry's
64
+ // write paths, which perform no existence or validation checks:
65
+ // - audit-skill <name> <ver> <tier> appended a hash-chained
66
+ // {event:'skill_installed', validation_passed:true} entry for a skill that
67
+ // does not exist, and exited 0. For a product whose central claim is a
68
+ // tamper-evident audit chain, a CLI that mints authentic-looking false
69
+ // entries on request is worse than one that refuses.
70
+ // - register-skill <name> <ver> 1 wrote a malformed 5-column row above the
71
+ // table header of .mindforge/org/skills/MANIFEST.md, which ships in the
72
+ // tarball, and exited 0.
73
+ // Without defaultArgs these refuse with "Invalid or missing action" (exit 1),
74
+ // which is the pre-11.9.2 behaviour. Re-enable only once skill-registry gates
75
+ // on skill existence and fixes its table-separator match.
60
76
  'install-skill': {
61
77
  script: 'bin/skill-registry.js',
62
- description: 'Install a skill to the correct tier folder (Tier 1/2/3)',
63
- defaultArgs: ['install']
78
+ description: 'Install a skill to the correct tier folder (pass the "install" action explicitly)'
64
79
  },
65
80
  'register-skill': {
66
81
  script: 'bin/skill-registry.js',
67
- description: 'Register a skill in MANIFEST.md',
68
- defaultArgs: ['register']
82
+ description: 'Register a skill in MANIFEST.md (pass the "register" action explicitly)'
69
83
  },
70
84
  'audit-skill': {
71
85
  script: 'bin/skill-registry.js',
72
- description: 'Record skill life cycle events in audit log',
73
- defaultArgs: ['audit']
86
+ description: 'Record skill life cycle events in audit log (pass the "audit" action explicitly)'
74
87
  },
75
88
  'remember': {
76
89
  script: 'bin/memory/cli.js',
@@ -98,6 +111,15 @@ const COMMANDS = {
98
111
  description: 'Invoke a specialized identity from /agents/',
99
112
  defaultArgs: ['identity']
100
113
  },
114
+ // spawn-agent.js reads MODE from ARGS[0] and supports spawn|identity|subagent.
115
+ // 'subagent' had no router entry, so it was reached as `mindforge spawn subagent
116
+ // <name>` — which prepending 'spawn' now shadows (MODE='spawn', TARGET='subagent').
117
+ // Promoting it to a first-class command keeps the only implemented mode reachable.
118
+ 'subagent': {
119
+ script: 'bin/spawn-agent.js',
120
+ description: 'Invoke a subagent definition from /subagents/',
121
+ defaultArgs: ['subagent']
122
+ },
101
123
  'temporal': {
102
124
  script: 'bin/engine/temporal-cli.js',
103
125
  description: 'Manage time-travel debugging and state history'
@@ -129,10 +151,14 @@ const COMMANDS = {
129
151
  script: 'bin/engine/learning-manager.js',
130
152
  description: 'Consult or initialize the project agentic learning memory'
131
153
  },
154
+ // No defaultArgs: 'record' reaches a writer that resolves its target relative to
155
+ // the spawn cwd, which is still ROOT (deferred to v12). In a consumer install that
156
+ // writes inside node_modules/mindforge-cc/ and is destroyed by the next npm ci.
157
+ // Activating a writer while its path resolution is known-wrong is worse than
158
+ // leaving it inert; re-enable together with the cwd fix.
132
159
  'record-learning': {
133
160
  script: 'bin/engine/learning-manager.js',
134
- description: 'Append a new Learning Entry to the Evolution Log',
135
- defaultArgs: ['record']
161
+ description: 'Append a new Learning Entry to the Evolution Log (pass the "record" action explicitly)'
136
162
  },
137
163
  'verify': {
138
164
  script: 'bin/engine/verify-cli.js',
@@ -177,7 +203,13 @@ if (!target) {
177
203
  }
178
204
 
179
205
  const scriptPath = path.join(ROOT, target.script);
180
- const finalArgs = COMMAND_ARGS.length > 0 ? COMMAND_ARGS : (target.defaultArgs || []);
206
+
207
+ // defaultArgs are PREPENDED, never replaced. They carry the subcommand token or
208
+ // mode flag the child script requires (e.g. 'inject' for hindsight, '--check' for
209
+ // health), so dropping them when the user supplies an argument silently changes
210
+ // which code path runs — `health --force` used to lose '--check' and fall through
211
+ // to installer-core's install() path.
212
+ const finalArgs = [...(target.defaultArgs || []), ...COMMAND_ARGS];
181
213
 
182
214
  console.log(`🚀 Executing: ${COMMAND} (${target.description})`);
183
215