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
@@ -37,6 +37,7 @@ const SSE = require('./sse-bridge');
37
37
  const API = require('./api-router');
38
38
  const TemporalAPI = require('./temporal-api');
39
39
  const RevOpsAPI = require('./revops-api');
40
+ const { newCorrelationId } = require('./error-response');
40
41
 
41
42
  // ── Express app ───────────────────────────────────────────────────────────────
42
43
  const app = express();
@@ -190,6 +191,73 @@ app.post('/api/v1/token/refresh', requireAuth, (req, res) => {
190
191
  // ── Register API routes ───────────────────────────────────────────────────────
191
192
  API.register(app);
192
193
  app.use('/api/temporal', TemporalAPI);
194
+ // RevOpsAPI was required at the top of this file but never mounted, so /api/revops
195
+ // returned 404 while the AgRevOps dashboard panels and docs described it as live.
196
+ app.use('/api/revops', RevOpsAPI);
197
+
198
+ // ── Terminal error handler (LEAK-01) ─────────────────────────────────────────
199
+ // MUST stay last, after every route, and MUST keep its 4-arg signature or express
200
+ // will treat it as ordinary middleware. Without it, express's default handler
201
+ // renders err.stack into the response body whenever NODE_ENV !== 'production':
202
+ // a single unauthenticated malformed-JSON POST (express.json() is mounted BEFORE
203
+ // requireAuth, and requireAuth exempts GET anyway) returned the absolute paths of
204
+ // node_modules and the repo — the operator's username and home directory — to any
205
+ // local caller. Log server-side; return a generic body plus a correlation id.
206
+ app.use((err, req, res, next) => {
207
+ if (res.headersSent) return next(err); // e.g. an SSE stream already flushed headers
208
+ const status = Number.isInteger(err && err.status) && err.status >= 400 && err.status < 600
209
+ ? err.status
210
+ : 500;
211
+ const correlationId = newCorrelationId();
212
+ console.error(
213
+ `[dashboard] unhandled ${req.method} ${req.originalUrl} -> ${status} [cid=${correlationId}]:`,
214
+ err && err.stack ? err.stack : err
215
+ );
216
+ res.status(status).json({
217
+ error: status >= 500 ? 'Internal server error' : 'Invalid request',
218
+ correlation_id: correlationId
219
+ });
220
+ });
221
+
222
+ // ── Crash guards ──────────────────────────────────────────────────────────────
223
+ // Both guards log and exit. That is deliberate and symmetric.
224
+ //
225
+ // unhandledRejection: an escaped rejection is the ONLY reliable signal that an
226
+ // async call was left un-awaited — the ASYNC-01 class this release fixes. Measured
227
+ // on v11.9.1: an un-awaited rollbackTo() rejected, and by the time the rejection
228
+ // surfaced a hash-chained `hindsight_injected` entry had already been fsync'd and
229
+ // auto-state.json flipped to awaiting_regeneration for a rollback that never
230
+ // happened; the client got ECONNRESET at ~16ms and the process exited 1. The
231
+ // durable damage is committed before any handler can run, so a 500-and-continue
232
+ // response would answer the request while leaving the audit chain asserting an
233
+ // event that did not occur.
234
+ // Keeping it survivable is also not free: express 4.22.1 does not route async
235
+ // handler rejections to its error middleware (measured: a 4-arity app.use never
236
+ // fires), so log-and-continue leaves the client socket open until the CLIENT gives
237
+ // up — 2.5s, 4s and 8s clients all timed out with no response — versus a ~15ms
238
+ // connection reset when the process exits. A silent hang is worse than a restart.
239
+ // Cost accepted: one faulting request takes the observability surface down. The
240
+ // dashboard is a 127.0.0.1-only single-operator tool, and an audit chain that
241
+ // verifies as valid while recording events that did not happen is not a survivable
242
+ // state to keep serving from.
243
+ process.on('unhandledRejection', (reason) => {
244
+ console.error('[Dashboard] Unhandled rejection — exiting:',
245
+ reason instanceof Error ? reason.stack : reason);
246
+ process.exit(1);
247
+ });
248
+
249
+ // uncaughtException MUST exit. After an uncaught throw the process state is
250
+ // undefined, and log-and-continue actively broke shutdown: a throw anywhere in the
251
+ // first half of shutdown() (SSE.stop(), or unlinking the token file) was swallowed,
252
+ // so server.close() was never reached and the forced-exit timer was never armed.
253
+ // The result was a dashboard that IGNORED SIGTERM while still serving the
254
+ // token-authenticated mutation endpoints, with the bearer token left on disk and
255
+ // still valid in memory — i.e. the operator's documented stop command silently
256
+ // failed while reporting success. Only SIGKILL stopped it.
257
+ process.on('uncaughtException', (err) => {
258
+ console.error('[Dashboard] Uncaught exception — exiting:', err && err.stack ? err.stack : err);
259
+ process.exit(1);
260
+ });
193
261
 
194
262
  // ── Start SSE bridge ──────────────────────────────────────────────────────────
195
263
  SSE.start();
@@ -231,14 +299,26 @@ server.on('error', err => {
231
299
  // ── Graceful shutdown ─────────────────────────────────────────────────────────
232
300
  function shutdown(signal) {
233
301
  console.log(`\n[dashboard] ${signal} received — shutting down`);
234
- SSE.stop();
235
- // Remove sensitive token file on shutdown
236
- if (fs.existsSync(TOKEN_FILE)) fs.unlinkSync(TOKEN_FILE);
302
+
303
+ // Arm the forced exit FIRST. Every step below can throw (permission drift on the
304
+ // token path, a read-only .mindforge, an SSE listener error), and if any of them
305
+ // does before this timer is set, the process would keep serving the authenticated
306
+ // mutation API after the operator asked it to stop.
307
+ const forced = setTimeout(() => process.exit(0), 3000);
308
+ forced.unref();
309
+
310
+ try { SSE.stop(); } catch (err) { console.error('[dashboard] SSE.stop failed:', err.message); }
311
+
312
+ // Destroying the bearer token is a security step, not housekeeping — never let it
313
+ // throw. rmSync with force tolerates a missing path and most permission cases.
314
+ try { fs.rmSync(TOKEN_FILE, { force: true }); } catch (err) {
315
+ console.error('[dashboard] could not remove token file:', err.message);
316
+ }
317
+
237
318
  server.close(() => {
238
- if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
319
+ try { fs.rmSync(PID_FILE, { force: true }); } catch { /* best effort */ }
239
320
  process.exit(0);
240
321
  });
241
- setTimeout(() => process.exit(0), 3000);
242
322
  }
243
323
 
244
324
  process.on('SIGTERM', () => shutdown('SIGTERM'));
@@ -8,6 +8,7 @@ const express = require('express');
8
8
  const router = express.Router();
9
9
  const TemporalHub = require('../engine/temporal-hub');
10
10
  const HindsightInjector = require('../hindsight-injector');
11
+ const { sendServerError } = require('./error-response');
11
12
 
12
13
  /**
13
14
  * GET /api/temporal/history
@@ -18,7 +19,7 @@ router.get('/history', (req, res) => {
18
19
  const history = TemporalHub.getHistory();
19
20
  res.json(history);
20
21
  } catch (err) {
21
- res.status(500).json({ error: 'Failed to retrieve temporal history', detail: err.message });
22
+ sendServerError(res, 'GET /api/temporal/history', err, 'Failed to retrieve temporal history');
22
23
  }
23
24
  });
24
25
 
@@ -37,7 +38,7 @@ router.get('/snapshot/:auditId/:file', (req, res) => {
37
38
 
38
39
  res.send(content);
39
40
  } catch (err) {
40
- res.status(500).json({ error: 'Failed to retrieve snapshot file', detail: err.message });
41
+ sendServerError(res, 'GET /api/temporal/snapshot/:auditId/:file', err, 'Failed to retrieve snapshot file');
41
42
  }
42
43
  });
43
44
 
@@ -52,7 +53,9 @@ router.get('/snapshot/:auditId/meta', (req, res) => {
52
53
  if (!snap) return res.status(404).json({ error: 'Snapshot not found' });
53
54
  res.json(snap);
54
55
  } catch (err) {
55
- res.status(500).json({ error: 'Failed to retrieve snapshot metadata' });
56
+ // Previously swallowed the error entirely no client detail, but no server log
57
+ // either, so a broken history dir was undiagnosable.
58
+ sendServerError(res, 'GET /api/temporal/snapshot/:auditId/meta', err, 'Failed to retrieve snapshot metadata');
56
59
  }
57
60
  });
58
61
 
@@ -72,10 +75,13 @@ router.post('/inject', async (req, res) => {
72
75
  if (result.success) {
73
76
  res.json(result);
74
77
  } else {
75
- res.status(500).json(result);
78
+ // result.error is HindsightInjector's own `err.message` (hindsight-injector.js:59)
79
+ // and can be an fs error carrying an absolute path — never forward it verbatim.
80
+ sendServerError(res, 'POST /api/temporal/inject', result.error, 'Hindsight injection failed',
81
+ { success: false });
76
82
  }
77
83
  } catch (err) {
78
- res.status(500).json({ error: 'Hindsight injection failed', detail: err.message });
84
+ sendServerError(res, 'POST /api/temporal/inject', err, 'Hindsight injection failed');
79
85
  }
80
86
  });
81
87
 
@@ -109,7 +109,18 @@ class RemediationEngine {
109
109
  }
110
110
 
111
111
  await SemanticHub.ensureInit();
112
- const goldenTraces = await SemanticHub.getGoldenTraces({ limit: 3 });
112
+ // SemanticHub.getGoldenTraces(skillFilter = null) takes a STRING filter,
113
+ // not an options object: `{ limit: 3 }` flowed straight through to
114
+ // vectorHub.searchTraces() as the query text. That used to stringify to
115
+ // "[object Object]" and silently search for that literal; FTS-01 makes a
116
+ // non-string query throw a TypeError, so the call shape is corrected here
117
+ // in the same change. The cap the old `{ limit: 3 }` intended is applied
118
+ // locally, because getGoldenTraces() has no limit parameter.
119
+ const GOLDEN_TRACE_LIMIT = 3;
120
+ const allGoldenTraces = await SemanticHub.getGoldenTraces();
121
+ const goldenTraces = Array.isArray(allGoldenTraces)
122
+ ? allGoldenTraces.slice(0, GOLDEN_TRACE_LIMIT)
123
+ : [];
113
124
 
114
125
  if (!goldenTraces || goldenTraces.length === 0) {
115
126
  return { strategy: 'GOLDEN_TRACE_INJECTION', result: 'no_traces_found' };
@@ -32,12 +32,23 @@ class TemporalHub {
32
32
  }
33
33
 
34
34
  static _verifyMetadata(metadata) {
35
- if (!metadata.integrity) return false;
35
+ if (!metadata || typeof metadata.integrity !== 'string') return false;
36
36
  const { integrity, ...rest } = metadata;
37
37
  const expected = crypto.createHmac('sha256', HMAC_KEY)
38
38
  .update(JSON.stringify(rest))
39
39
  .digest('hex');
40
- return crypto.timingSafeEqual(Buffer.from(integrity), Buffer.from(expected));
40
+ // timingSafeEqual throws RangeError on unequal BYTE lengths. A String's `.length`
41
+ // counts UTF-16 code units, NOT bytes, so a 64-unit `integrity` containing any
42
+ // non-ASCII character is 65+ bytes and still produced unequal Buffers and still
43
+ // threw. Materialise both Buffers and compare their real byte lengths.
44
+ // NOTE: this is a CORRECTNESS fix, not a security guarantee — HMAC_KEY is a
45
+ // literal in shipped source, the HMAC covers only metadata and not snapshot file
46
+ // CONTENTS, and an absent SNAPSHOT-META.json still bypasses verification. This
47
+ // was never an authenticity control.
48
+ const actual = Buffer.from(integrity, 'utf8');
49
+ const expectedBuf = Buffer.from(expected, 'utf8');
50
+ if (actual.length !== expectedBuf.length) return false;
51
+ return crypto.timingSafeEqual(actual, expectedBuf);
41
52
  }
42
53
 
43
54
  /**
@@ -124,20 +135,41 @@ class TemporalHub {
124
135
  }
125
136
 
126
137
  const metaPath = path.join(snapshotDir, 'SNAPSHOT-META.json');
138
+ // Read and verify as two SEPARATE stages so only one specific, expected condition
139
+ // (the metadata file genuinely not existing) can reach the tolerant legacy path.
140
+ // The previous single try/catch sniffed err.message for 'integrity verification',
141
+ // so ANY other throw — a JSON.parse SyntaxError, or the crypto RangeError from a
142
+ // non-ASCII `integrity` — fell through to 'proceeding without integrity check' and
143
+ // restored the snapshot anyway. Unexpected throws must fail CLOSED.
144
+ //
145
+ // Error messages deliberately carry no err.message: JSON.parse embeds file content
146
+ // and fs errors embed absolute paths, and these strings reach an HTTP response via
147
+ // hindsight-injector -> temporal-api. Detail goes to the server log only.
148
+ let metaRaw = null;
127
149
  try {
128
- const metaRaw = await fsPromises.readFile(metaPath, 'utf8');
129
- const metaData = JSON.parse(metaRaw);
130
- if (!TemporalHub._verifyMetadata(metaData)) {
131
- throw new Error(`Snapshot ${auditId} failed integrity verification — metadata may be tampered.`);
132
- }
150
+ metaRaw = await fsPromises.readFile(metaPath, 'utf8');
133
151
  } catch (err) {
134
- if (err.message.includes('integrity verification') || err.message.includes('tampered')) {
135
- throw err;
152
+ if (err.code !== 'ENOENT') {
153
+ console.error(`[temporal-hub] metadata read failed for ${auditId}:`, err);
154
+ throw new Error(`Snapshot ${auditId} metadata could not be read (${err.code || err.name}) — refusing to restore.`);
136
155
  }
137
156
  // Missing metadata file on legacy snapshots — allow rollback with warning
138
157
  console.warn(`[temporal-hub] No verifiable metadata for ${auditId}, proceeding without integrity check.`);
139
158
  }
140
159
 
160
+ if (metaRaw !== null) {
161
+ let verified = false;
162
+ try {
163
+ verified = TemporalHub._verifyMetadata(JSON.parse(metaRaw));
164
+ } catch (err) {
165
+ console.error(`[temporal-hub] metadata parse/verify failed for ${auditId}:`, err);
166
+ throw new Error(`Snapshot ${auditId} failed integrity verification — metadata unreadable or malformed (${err.name}).`);
167
+ }
168
+ if (!verified) {
169
+ throw new Error(`Snapshot ${auditId} failed integrity verification — metadata may be tampered.`);
170
+ }
171
+ }
172
+
141
173
  try {
142
174
  const allEntries = await fsPromises.readdir(snapshotDir);
143
175
  const files = allEntries.filter(f => f !== 'SNAPSHOT-META.json');
@@ -1,5 +1,16 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ // Repo root derived from THIS file's location, never from cwd.
8
+ // bin/utils/paths.js resolves PROJECT_ROOT by walking up from process.cwd(),
9
+ // which on a consumer install lands inside node_modules — the wrong root for a
10
+ // corpus scan, and it would silently yield an empty corpus. __dirname is always
11
+ // <root>/bin/eval.
12
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
13
+
3
14
  /**
4
15
  * Recall@K — fraction of relevant items found in the top-k retrieved results.
5
16
  * @param {string[]} retrieved - IDs in ranked order
@@ -79,4 +90,204 @@ async function runEval({ goldenSet, retriever, k }) {
79
90
  return { meanRecallAtK, meanNDCG, perQuery };
80
91
  }
81
92
 
82
- module.exports = { recallAtK, ndcg, runEval };
93
+ // ── Corpus + runnable golden-set gate (FTS-01) ───────────────────────────────
94
+ // Before this block the file had NO require.main guard, so the documented
95
+ // command `node bin/eval/eval-harness.js --set golden-set-retrieval.json`
96
+ // printed nothing and exited 0 — a gate that could not fail, and therefore not
97
+ // a gate. golden-set-retrieval.json and this harness had zero callers.
98
+ //
99
+ // The shipped golden set names documents by BASENAME (`audit-hash`,
100
+ // `model-router`, `stuck-detector`, …). Those ids exist nowhere in
101
+ // .mindforge/celestial.db, so recall measured against the live trace DB is 0.00
102
+ // by construction whatever the query builder does. The corpus the golden set
103
+ // actually describes is the repo's own module/skill documentation — so build it,
104
+ // index it into a THROWAWAY database under os.tmpdir(), and measure that.
105
+
106
+ const CORPUS_ROOTS = [
107
+ { dir: 'bin', exts: ['.js'] },
108
+ { dir: '.mindforge/skills', exts: ['.md'] },
109
+ { dir: '.mindforge/engine', exts: ['.md'] },
110
+ { dir: '.agent/hooks', exts: ['.js'] },
111
+ ];
112
+ const CORPUS_MAX_BYTES = 20000;
113
+ const CORPUS_SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage']);
114
+
115
+ function _walk(dir, exts, out) {
116
+ let entries;
117
+ try {
118
+ entries = fs.readdirSync(dir, { withFileTypes: true });
119
+ } catch {
120
+ return out; // a missing corpus root is reported via corpusSize, not a throw
121
+ }
122
+ for (const entry of entries) {
123
+ const p = path.join(dir, entry.name);
124
+ if (entry.isDirectory()) {
125
+ if (!CORPUS_SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
126
+ _walk(p, exts, out);
127
+ }
128
+ } else if (exts.includes(path.extname(entry.name))) {
129
+ out.push(p);
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Doc id for a corpus file: its basename, except SKILL.md, which is keyed by its
137
+ * containing skill directory (that is the name the golden set uses).
138
+ * @param {string} file - absolute or relative file path
139
+ * @returns {string} document id
140
+ */
141
+ function docIdForFile(file) {
142
+ const base = path.basename(file, path.extname(file));
143
+ return base === 'SKILL' ? path.basename(path.dirname(file)) : base;
144
+ }
145
+
146
+ /**
147
+ * Enumerate the document corpus the golden set's `relevant` ids refer to.
148
+ * @param {string} [root] - project root; defaults to REPO_ROOT
149
+ * @returns {Map<string, {id: string, file: string, content: string}>}
150
+ */
151
+ function buildDocCorpus(root = REPO_ROOT) {
152
+ const corpus = new Map();
153
+ for (const { dir, exts } of CORPUS_ROOTS) {
154
+ for (const file of _walk(path.join(root, dir), exts, [])) {
155
+ const id = docIdForFile(file);
156
+ if (corpus.has(id)) continue; // first wins; basename collisions are rare
157
+ let text = '';
158
+ try {
159
+ text = fs.readFileSync(file, 'utf8').slice(0, CORPUS_MAX_BYTES);
160
+ } catch {
161
+ continue;
162
+ }
163
+ const rel = path.relative(root, file);
164
+ corpus.set(id, { id, file: rel, content: `${id} ${rel}\n${text}` });
165
+ }
166
+ }
167
+ return corpus;
168
+ }
169
+
170
+ /**
171
+ * Index the doc corpus into a throwaway VectorHub and score the golden set.
172
+ * NEVER touches .mindforge/celestial.db — the database lives in os.tmpdir() and
173
+ * is deleted again before this resolves.
174
+ * @param {Object} [opts]
175
+ * @param {string} [opts.goldenSetPath] - defaults to ./golden-set-retrieval.json
176
+ * @param {string} [opts.root] - corpus root; defaults to REPO_ROOT
177
+ * @param {number} [opts.k] - cutoff; defaults to 10
178
+ * @returns {Promise<Object>} runEval metrics plus corpus/coverage diagnostics
179
+ */
180
+ async function runGoldenSetEval(opts = {}) {
181
+ const goldenSetPath = opts.goldenSetPath || path.join(__dirname, 'golden-set-retrieval.json');
182
+ const root = opts.root || REPO_ROOT;
183
+ const k = opts.k || 10;
184
+
185
+ const golden = JSON.parse(fs.readFileSync(goldenSetPath, 'utf8'));
186
+ const goldenSet = golden.queries || [];
187
+ const corpus = buildDocCorpus(root);
188
+
189
+ const relevantIds = [...new Set(goldenSet.flatMap(q => q.relevant || []))];
190
+ const unresolved = relevantIds.filter(id => !corpus.has(id));
191
+
192
+ const { VectorHub } = require('../memory/vector-hub');
193
+ const dbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mindforge-eval-'));
194
+ const hub = new VectorHub(path.join(dbDir, 'eval-corpus.db'));
195
+ await hub.init();
196
+ try {
197
+ // The hub is throwaway, so raise the autosave batch size: the default of 10
198
+ // exports the whole growing database to disk every 10 documents, which turns
199
+ // a 0.5 s index build into a quadratic one for no durability benefit here.
200
+ hub._batchSize = Number.MAX_SAFE_INTEGER;
201
+ for (const doc of corpus.values()) {
202
+ await hub.saveKnowledge({ id: doc.id, type: 'doc', content: doc.content, source: doc.file });
203
+ }
204
+ const retriever = async (query) =>
205
+ (await hub.searchKnowledge(query, { limit: k })).map(r => r.id);
206
+ const metrics = await runEval({ goldenSet, retriever, k });
207
+ return {
208
+ ...metrics,
209
+ k,
210
+ corpusSize: corpus.size,
211
+ relevantIdCount: relevantIds.length,
212
+ unresolvedRelevantIds: unresolved,
213
+ baseline: golden.baseline || null,
214
+ };
215
+ } finally {
216
+ await hub.close();
217
+ fs.rmSync(dbDir, { recursive: true, force: true });
218
+ }
219
+ }
220
+
221
+ module.exports = {
222
+ recallAtK,
223
+ ndcg,
224
+ runEval,
225
+ buildDocCorpus,
226
+ docIdForFile,
227
+ runGoldenSetEval,
228
+ };
229
+
230
+ // ── CLI ──────────────────────────────────────────────────────────────────────
231
+ // node bin/eval/eval-harness.js [--set golden-set-retrieval.json] [--k 10]
232
+ // [--min-recall 0.55] [--json]
233
+ // Exits 1 when mean recall@k is below --min-recall, or when any golden
234
+ // `relevant` id no longer resolves to a real document (golden-set drift).
235
+ // `npm run eval:retrieval` runs this with the committed baseline floor.
236
+ if (require.main === module) {
237
+ const argv = process.argv.slice(2);
238
+ const flag = (name, fallback) => {
239
+ const i = argv.indexOf(name);
240
+ return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : fallback;
241
+ };
242
+ const setArg = flag('--set', 'golden-set-retrieval.json');
243
+ const goldenSetPath = path.isAbsolute(setArg)
244
+ ? setArg
245
+ : path.join(__dirname, path.basename(setArg));
246
+ const num = (name, fallback) => {
247
+ const raw = flag(name, String(fallback));
248
+ const n = Number(raw);
249
+ // Fail closed: an unparseable threshold must never silently become 0.
250
+ if (!Number.isFinite(n) || n < 0) {
251
+ console.error(`[eval] ERROR: ${name} must be a non-negative number, got ${JSON.stringify(raw)}`);
252
+ process.exit(1);
253
+ }
254
+ return n;
255
+ };
256
+ const k = Math.max(1, Math.trunc(num('--k', 10)));
257
+ const minRecall = num('--min-recall', 0);
258
+
259
+ runGoldenSetEval({ goldenSetPath, k })
260
+ .then((res) => {
261
+ if (argv.includes('--json')) {
262
+ console.log(JSON.stringify(res, null, 2));
263
+ } else {
264
+ console.log(`[eval] corpus: ${res.corpusSize} docs · golden queries: ${res.perQuery.length} · k=${res.k}`);
265
+ for (const q of res.perQuery) {
266
+ console.log(` recall=${q.recall.toFixed(3)} nDCG=${q.ndcg.toFixed(3)} hits=${q.retrieved.length} ${q.query}`);
267
+ }
268
+ console.log(`[eval] mean recall@${res.k} = ${res.meanRecallAtK.toFixed(4)}`);
269
+ console.log(`[eval] mean nDCG@${res.k} = ${res.meanNDCG.toFixed(4)}`);
270
+ if (res.baseline && typeof res.baseline.meanRecallAtK === 'number') {
271
+ // Round BEFORE choosing the sign, so a difference smaller than the
272
+ // printed precision reads as +0.0000 rather than a bogus -0.0000.
273
+ const delta = Number((res.meanRecallAtK - res.baseline.meanRecallAtK).toFixed(4));
274
+ console.log(`[eval] committed baseline recall@${res.baseline.k || res.k} = ${res.baseline.meanRecallAtK.toFixed(4)} (delta ${delta >= 0 ? '+' : ''}${delta.toFixed(4)})`);
275
+ }
276
+ }
277
+ if (res.unresolvedRelevantIds.length > 0) {
278
+ console.error(`[eval] FAIL: ${res.unresolvedRelevantIds.length} golden id(s) no longer resolve to a document: ${res.unresolvedRelevantIds.join(', ')}`);
279
+ process.exit(1);
280
+ }
281
+ if (res.meanRecallAtK < minRecall) {
282
+ console.error(`[eval] FAIL: mean recall@${res.k} ${res.meanRecallAtK.toFixed(4)} < --min-recall ${minRecall}`);
283
+ process.exit(1);
284
+ }
285
+ })
286
+ .catch((err) => {
287
+ // sql.js throws bare strings for some binding errors, so an Error-shaped
288
+ // formatter alone would print "undefined" and hide the real failure.
289
+ const detail = (err && (err.stack || err.message)) || String(err);
290
+ console.error(`[eval] ERROR: ${detail}`);
291
+ process.exit(1);
292
+ });
293
+ }
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "description": "Golden set for retrieval quality evaluation. Each entry has a natural-language query and the IDs of documents that SHOULD be retrieved.",
3
3
  "version": "1.0.0",
4
+ "baseline": {
5
+ "note": "Committed measurement so a retrieval regression is detectable. Reproduce with `npm run eval:retrieval`. Corpus = repo docs enumerated by buildDocCorpus() in bin/eval/eval-harness.js; retriever = VectorHub.searchKnowledge (OR-joined terms, ranked by tf-idf over FTS4 matchinfo('pcnx')). Before FTS-01 both figures were 0.0000 because the whole query was one FTS phrase.",
6
+ "k": 10,
7
+ "meanRecallAtK": 0.6417,
8
+ "meanNDCG": 0.5698,
9
+ "corpusSize": 517,
10
+ "gateMinRecall": 0.55,
11
+ "measuredOn": "v11.9.2 (fix/v11.9.2-ship-blockers), sql.js 1.14.1 / SQLite 3.49.1"
12
+ },
4
13
  "queries": [
5
14
  {
6
15
  "query": "how does the audit hash chain work",
@@ -165,6 +165,12 @@ class PolicyEngine {
165
165
  }
166
166
 
167
167
  logAudit(intent, impactScore, verdict) {
168
+ // LOCK-01: AuditWriter.write -> appendAuditEntrySync now takes a FAIL-CLOSED lock,
169
+ // so this can reject under contention. It is intentionally not awaited (the verdict
170
+ // path is synchronous), so catch here — otherwise the rejection escapes to a global
171
+ // unhandledRejection handler and the lost audit record is invisible at this site.
172
+ // The verdict is still returned: promoting an audit-write failure to an implicit
173
+ // DENY is a behaviour change for v12, not a patch.
168
174
  this._auditWriter.write({
169
175
  timestamp: new Date().toISOString(),
170
176
  requestId: verdict.requestId,
@@ -175,6 +181,8 @@ class PolicyEngine {
175
181
  impactScore,
176
182
  verdict: verdict.verdict,
177
183
  reason: verdict.reason
184
+ }).catch(err => {
185
+ console.error(`[APO-AUDIT-FAIL] [${verdict.requestId}] RISK-AUDIT append failed — decision NOT recorded: ${err.message}`);
178
186
  });
179
187
  }
180
188
 
@@ -18,8 +18,14 @@ class HindsightInjector {
18
18
  console.log(`[hindsight] Injecting fix at ${auditId}: "${fixDescription}"`);
19
19
 
20
20
  try {
21
- // 1. Rollback .planning directory
22
- TemporalHub.rollbackTo(auditId);
21
+ // 1. Rollback .planning directory.
22
+ // MUST be awaited: rollbackTo is async (engine/temporal-hub.js), so without
23
+ // await its rejection escapes this try/catch entirely, the process dies on an
24
+ // unhandled rejection, AND execution still falls through to steps 2-3 — which
25
+ // fsync a hash-chained `hindsight_injected` entry and flip auto-state.json for
26
+ // a rollback that never happened. The chain then verifies as valid but records
27
+ // an event that did not occur.
28
+ await TemporalHub.rollbackTo(auditId);
23
29
 
24
30
  // 2. Append the "Hindsight" event to AUDIT.jsonl via the unified, hash-chained,
25
31
  // durable append (UC-04b) so this entry links into the single verifiable chain.
@@ -189,7 +189,13 @@ function main() {
189
189
  if (!fs.existsSync(storeDir)) {
190
190
  fs.mkdirSync(storeDir, { recursive: true });
191
191
  }
192
- fs.appendFileSync(storePath, JSON.stringify(entry) + '\n');
192
+ // LOCK-01: instinct-cli's prune/import does read-modify-atomic-rename under this
193
+ // same lock, so an unlocked append here can be clobbered by that rename. Low
194
+ // maxTries because this is a hook fast path — the catch below keeps it non-fatal.
195
+ const { withFileLock } = require('../utils/file-lock');
196
+ withFileLock(storePath, () => {
197
+ fs.appendFileSync(storePath, JSON.stringify(entry) + '\n');
198
+ }, { maxTries: 10, label: 'instinct-store' });
193
199
  incrementSessionCount();
194
200
  } catch {
195
201
  // Non-fatal — hooks must not block
@@ -24,6 +24,7 @@ const path = require('path');
24
24
 
25
25
  const guard = require('./lib/ssrf-guard');
26
26
  const { detectProject } = require('../hooks/lib/detect-project');
27
+ const { withFileLock } = require('../utils/file-lock');
27
28
 
28
29
  const CONFIG_PATH = path.join(process.cwd(), '.mindforge', 'config.json');
29
30
 
@@ -71,33 +72,15 @@ function writeStoreAtomic(p, entries) {
71
72
  }
72
73
 
73
74
  /**
74
- * Advisory lock (Node has no fcntl): exclusive lockfile create, spin-with-timeout,
75
- * stale-break after 10s by mtime. Runs fn() while held, releases in finally.
75
+ * Advisory lock now a thin delegate to the shared fail-closed lock (LOCK-01).
76
+ * The implementation moved to bin/utils/file-lock.js so the audit chain and the
77
+ * knowledge graph use the SAME lock semantics as the instinct store. Behaviour is
78
+ * unchanged: O_EXCL create, 50 tries, ~20ms waits, 10s stale reclaim, unlink in
79
+ * finally, THROW (never write anyway) when unacquirable — same error text.
76
80
  * Read the store INSIDE this so a prune/import rewrite can't race a hook append.
77
81
  */
78
82
  function withStoreLock(p, fn) {
79
- const lock = `${p}.lock`;
80
- const maxTries = 50, waitMs = 20, staleMs = 10000;
81
- let held = false;
82
- for (let i = 0; i < maxTries && !held; i++) {
83
- try {
84
- const fd = fs.openSync(lock, 'wx');
85
- fs.closeSync(fd);
86
- held = true;
87
- } catch (err) {
88
- if (err.code !== 'EEXIST') throw err;
89
- // stale-break: if the lockfile is older than staleMs, remove it.
90
- try {
91
- const age = Date.now() - fs.statSync(lock).mtimeMs;
92
- if (age > staleMs) { fs.unlinkSync(lock); continue; }
93
- } catch { /* lock vanished — retry */ }
94
- const until = Date.now() + waitMs;
95
- while (Date.now() < until) { /* busy-wait (short) */ }
96
- }
97
- }
98
- if (!held) throw new Error(`could not acquire instinct-store lock: ${lock}`);
99
- try { return fn(); }
100
- finally { try { fs.unlinkSync(lock); } catch { /* already gone */ } }
83
+ return withFileLock(p, fn, { label: 'instinct-store' });
101
84
  }
102
85
 
103
86
  function currentProjectId() {
@@ -133,7 +133,14 @@ function createCausalEdges(bugId, allEntries, vectors) {
133
133
  weight: sim,
134
134
  reason: `Bug pattern potentially caused by code pattern (sim: ${sim.toFixed(3)})`,
135
135
  });
136
- } catch { /* skip duplicates or self-refs */ }
136
+ } catch (err) {
137
+ // LOCK-01: this catch predates the graph-edges lock. Without the guard below,
138
+ // an edge LOST to lock contention is reported as a skipped duplicate — a
139
+ // silent write loss wearing the label of a no-op.
140
+ if (/could not acquire/.test(err.message)) {
141
+ console.warn(`[KnowledgeCapture] edge NOT written — graph-edges lock unavailable: ${err.message}`);
142
+ }
143
+ }
137
144
  }
138
145
  }
139
146
  }
@@ -165,7 +172,14 @@ function createInformsEdges(decisionId, allEntries, vectors) {
165
172
  weight: sim,
166
173
  reason: `Decision informs domain knowledge (sim: ${sim.toFixed(3)})`,
167
174
  });
168
- } catch { /* skip duplicates or self-refs */ }
175
+ } catch (err) {
176
+ // LOCK-01: this catch predates the graph-edges lock. Without the guard below,
177
+ // an edge LOST to lock contention is reported as a skipped duplicate — a
178
+ // silent write loss wearing the label of a no-op.
179
+ if (/could not acquire/.test(err.message)) {
180
+ console.warn(`[KnowledgeCapture] edge NOT written — graph-edges lock unavailable: ${err.message}`);
181
+ }
182
+ }
169
183
  }
170
184
  }
171
185
  }
@@ -180,7 +194,13 @@ function reinforceRelatedEdges(nodeId) {
180
194
  for (const edge of edges.slice(0, 3)) { // Top 3 edges only
181
195
  Graph.reinforceEdge(edge.id);
182
196
  }
183
- } catch { /* non-critical */ }
197
+ } catch (err) {
198
+ // LOCK-01: reinforceEdge now takes the graph-edges lock, so "non-critical" would
199
+ // also absorb a contention failure. Surface that one; keep the rest quiet.
200
+ if (/could not acquire/.test(err.message)) {
201
+ console.warn(`[KnowledgeCapture] reinforcement NOT recorded — graph-edges lock unavailable: ${err.message}`);
202
+ }
203
+ }
184
204
  }
185
205
 
186
206
  // ── Event-specific capture functions ─────────────────────────────────────────