mindforge-cc 11.9.1 → 11.9.3

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 (121) hide show
  1. package/.agent/CLAUDE.md +37 -13
  2. package/.agent/hooks/mindforge-block-no-verify.js +61 -13
  3. package/.agent/hooks/mindforge-config-protection.js +82 -3
  4. package/.agent/hooks/mindforge-context-monitor.js +1 -1
  5. package/.agent/hooks/mindforge-workflow-guard.js +2 -2
  6. package/.agent/hooks/run-with-flags.js +190 -20
  7. package/.agent/mindforge/browse.md +2 -2
  8. package/.agent/mindforge/checkpoint.md +1 -1
  9. package/.agent/mindforge/consult.md +1 -1
  10. package/.agent/mindforge/cost-report.md +1 -1
  11. package/.agent/mindforge/harness-audit.md +1 -1
  12. package/.agent/mindforge/orch-add-feature.md +1 -1
  13. package/.agent/mindforge/orch-build-mvp.md +1 -1
  14. package/.agent/mindforge/orch-change-feature.md +1 -1
  15. package/.agent/mindforge/orch-fix-defect.md +1 -1
  16. package/.agent/mindforge/orch-refine-code.md +1 -1
  17. package/.agent/mindforge/qa.md +2 -2
  18. package/.claude/CLAUDE.md +37 -13
  19. package/.claude/commands/mindforge/browse.md +2 -2
  20. package/.claude/commands/mindforge/checkpoint.md +1 -1
  21. package/.claude/commands/mindforge/consult.md +1 -1
  22. package/.claude/commands/mindforge/cost-report.md +1 -1
  23. package/.claude/commands/mindforge/harness-audit.md +1 -1
  24. package/.claude/commands/mindforge/orch-add-feature.md +1 -1
  25. package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
  26. package/.claude/commands/mindforge/orch-change-feature.md +1 -1
  27. package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
  28. package/.claude/commands/mindforge/orch-refine-code.md +1 -1
  29. package/.claude/commands/mindforge/qa.md +2 -2
  30. package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
  31. package/.mindforge/config.json +4 -4
  32. package/.mindforge/engine/autonomous/headless-adapter.md +2 -2
  33. package/.mindforge/engine/cost-tracking/router.md +1 -1
  34. package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
  35. package/.mindforge/engine/temporal-protocol.md +2 -2
  36. package/.mindforge/governance/change-classifier.md +20 -4
  37. package/.mindforge/memory/sync-manifest.json +1 -1
  38. package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
  39. package/.mindforge/personas/cost-optimizer.md +2 -2
  40. package/.mindforge/personas/multi-model-bridge.md +1 -1
  41. package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
  42. package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
  43. package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
  44. package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
  45. package/CHANGELOG.md +402 -0
  46. package/MINDFORGE.md +13 -6
  47. package/README.md +51 -2
  48. package/RELEASENOTES.md +55 -2
  49. package/SECURITY.md +22 -3
  50. package/bin/autonomous/audit-writer.js +48 -33
  51. package/bin/autonomous/auto-runner.js +65 -2
  52. package/bin/change-classifier.js +151 -16
  53. package/bin/dashboard/api-router.js +28 -47
  54. package/bin/dashboard/error-response.js +44 -0
  55. package/bin/dashboard/frontend/app.js +429 -0
  56. package/bin/dashboard/frontend/index.html +14 -390
  57. package/bin/dashboard/metrics-aggregator.js +75 -30
  58. package/bin/dashboard/revops-api.js +12 -2
  59. package/bin/dashboard/server.js +245 -6
  60. package/bin/dashboard/sse-bridge.js +11 -8
  61. package/bin/dashboard/temporal-api.js +11 -5
  62. package/bin/engine/remediation-engine.js +12 -1
  63. package/bin/engine/sre-manager.js +1 -1
  64. package/bin/engine/temporal-cli.js +56 -6
  65. package/bin/engine/temporal-hub.js +41 -9
  66. package/bin/engine/verification-runner.js +134 -17
  67. package/bin/engine/verify-cli.js +25 -7
  68. package/bin/eval/eval-harness.js +212 -1
  69. package/bin/eval/golden-set-retrieval.json +9 -0
  70. package/bin/governance/approval-record.js +147 -0
  71. package/bin/governance/approve.js +12 -7
  72. package/bin/governance/policy-engine.js +41 -3
  73. package/bin/governance/policy-gate-hardened.js +36 -1
  74. package/bin/governance/verify-approvals.js +163 -0
  75. package/bin/harness-audit.js +224 -10
  76. package/bin/hindsight-injector.js +8 -2
  77. package/bin/hooks/instinct-capture-hook.js +19 -5
  78. package/bin/install.js +63 -3
  79. package/bin/installer/harness-adapter-compliance.js +339 -28
  80. package/bin/installer/hook-registration.js +504 -0
  81. package/bin/installer-core.js +451 -63
  82. package/bin/learning/instinct-cli.js +14 -24
  83. package/bin/memory/knowledge-capture.js +23 -3
  84. package/bin/memory/knowledge-graph.js +70 -31
  85. package/bin/memory/vector-hub.js +500 -44
  86. package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
  87. package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
  88. package/bin/mindforge-cli.js +110 -17
  89. package/bin/models/cost-tracker.js +126 -29
  90. package/bin/models/model-client.js +6 -1
  91. package/bin/models/model-router.js +28 -7
  92. package/bin/models/usage-record.js +71 -0
  93. package/bin/revops/debt-monitor.js +57 -13
  94. package/bin/security/trust-gate-hook.js +50 -6
  95. package/bin/skill-validator.js +6 -1
  96. package/bin/skills-builder/skill-scorer.js +46 -6
  97. package/bin/updater/self-update.js +6 -1
  98. package/bin/updater/version-comparator.js +21 -1
  99. package/bin/utils/file-lock.js +106 -0
  100. package/bin/utils/mindforge-params.js +124 -0
  101. package/bin/utils/mindforge-version.js +99 -0
  102. package/bin/utils/redact-secrets.js +106 -0
  103. package/bin/validate-config.js +75 -17
  104. package/bin/wizard/setup-wizard.js +4 -1
  105. package/bin/wizard/theme.js +9 -1
  106. package/changelogs/index.json +11 -9
  107. package/changelogs/v11.9.2.md +209 -0
  108. package/changelogs/v11.9.3.md +195 -0
  109. package/docs/References/config-reference.md +76 -14
  110. package/docs/References/sdk-api.md +1 -1
  111. package/docs/Templates/Codebase/architecture.md +1 -1
  112. package/docs/commands-reference.md +4 -5
  113. package/docs/faq.md +25 -5
  114. package/docs/getting-started.md +3 -3
  115. package/docs/sdk-reference.md +15 -7
  116. package/docs/troubleshooting.md +10 -6
  117. package/docs/user-guide.md +14 -14
  118. package/examples/sdk-integration/README.md +1 -1
  119. package/package.json +10 -4
  120. package/subagents/.claude-plugin/marketplace.json +1 -1
  121. package/bin/dashboard/approval-handler.js +0 -136
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Usage:
7
7
  * node bin/dashboard/server.js [--port 7339] [--open]
8
- * /mindforge:dashboard [--port 7339] [--open] [--stop]
8
+ * /mindforge:dashboard [--port 7339] [--open] [--stop] [--status]
9
9
  *
10
10
  * Security: binds to 127.0.0.1 only (ADR-017 policy).
11
11
  * Bearer token auth on all mutating endpoints (POST/PUT/DELETE).
@@ -22,6 +22,143 @@ const ARGS = process.argv.slice(2);
22
22
  const PORT = parseInt(ARGS.find((_, i, a) => a[i-1] === '--port') || '7339', 10);
23
23
  const OPEN_BROWSER = ARGS.includes('--open');
24
24
  const PID_FILE = path.join(process.cwd(), '.planning', 'dashboard-server.pid');
25
+
26
+ // ── Lifecycle flags: --status and --stop ──────────────────────────────────────
27
+ //
28
+ // THE DEFECT. `--stop` and `--status` were documented in four places and implemented in none. The
29
+ // shipped slash command `.claude/commands/mindforge/dashboard.md` listed both in its Usage line and
30
+ // gave each a worked example; docs/user-guide.md documented a `--start` that never existed either.
31
+ // Worst of all, THIS FILE told the operator to run one of them: on EADDRINUSE it printed
32
+ // "[dashboard] Stop it: /mindforge:dashboard --stop". Only `--port` and `--open` were ever parsed,
33
+ // so every one of those instructions did nothing but start a second server.
34
+ //
35
+ // They are implemented rather than deleted because the PID file below already exists to support
36
+ // them — it is written on listen and removed on shutdown, so the information was there all along.
37
+ //
38
+ // This runs BEFORE express is required, deliberately. This module has no `require.main` guard and
39
+ // starts a server as a side effect of loading, so a lifecycle flag has to be handled and exited
40
+ // here or `--stop` would stop the old server and start a new one. It also means `--status` works
41
+ // when express is not installed, which is exactly when an operator is most likely to ask.
42
+ if (ARGS.includes('--status') || ARGS.includes('--stop')) {
43
+ const { execFileSync } = require('child_process');
44
+
45
+ /** Read the recorded pid, or null when there is no usable PID file. */
46
+ const readPid = () => {
47
+ try {
48
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
49
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
50
+ } catch { return null; }
51
+ };
52
+
53
+ /** Signal 0 tests for existence and permission without delivering anything. */
54
+ const isAlive = (pid) => {
55
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
56
+ };
57
+
58
+ /**
59
+ * Confirm the pid is THIS dashboard before signalling it.
60
+ *
61
+ * A PID file is not proof: the recorded process can exit without cleanup (a SIGKILL, a power
62
+ * loss) and the operating system will reuse the number. Sending SIGTERM on the strength of a
63
+ * stale file means killing an unrelated process the operator never asked about — so identity is
64
+ * verified from the process table and, when it cannot be verified, this REFUSES. That is the same
65
+ * choice the audit writer and the vector-hub conflict path make: declining loudly beats acting on
66
+ * a guess.
67
+ *
68
+ * Returns the command line on success, or null when identity could not be established.
69
+ */
70
+ const identify = (pid) => {
71
+ if (process.platform === 'win32') return null; // no ps; --stop refuses below
72
+ try {
73
+ const cmd = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).trim();
74
+
75
+ // IDENTITY, not shape. Two earlier versions of this check were both too loose, and the second
76
+ // was the more dangerous:
77
+ //
78
+ // /dashboard[/\\]server\.js/ matched any process whose command line
79
+ // MENTIONED the path — a shell running a
80
+ // script that referenced it got a SIGTERM
81
+ // /^\S*node...\S*dashboard[/\\]server\.js(\s|$)/ matched any `node <anything>/dashboard/
82
+ // server.js`. `dashboard/server.js` is an
83
+ // utterly ordinary path: this would have
84
+ // signalled an UNRELATED app's dashboard
85
+ // (verified against
86
+ // `node /var/www/unrelated_app/dashboard/server.js`)
87
+ //
88
+ // Anchoring made the first mistake unreachable and left a worse one, because both were asking
89
+ // "does this look like a dashboard server" when the only safe question is "is this THE one I am".
90
+ // So the script argument is resolved and compared against this file's own realpath.
91
+ //
92
+ // A relative script path cannot be resolved from here — it would need the target process's cwd,
93
+ // which ps does not give us — so that case REFUSES rather than guessing. Same choice as
94
+ // commitDb's conflict path: declining loudly beats acting on a guess, and the cost of being
95
+ // wrong here is someone else's process dying.
96
+ const m = /^\S*node(?:\.exe)?\s+(\S+)/.exec(cmd);
97
+ if (!m) return null;
98
+ const script = m[1];
99
+ if (!path.isAbsolute(script)) return null; // unresolvable without the target's cwd
100
+ let resolved;
101
+ let self;
102
+ try {
103
+ resolved = fs.realpathSync(script);
104
+ self = fs.realpathSync(__filename);
105
+ } catch { return null; } // script gone, or unreadable — refuse
106
+ return resolved === self ? cmd : null;
107
+ } catch { return null; }
108
+ };
109
+
110
+ const pid = readPid();
111
+ const alive = pid !== null && isAlive(pid);
112
+
113
+ if (ARGS.includes('--status')) {
114
+ if (!alive) {
115
+ console.log(pid === null
116
+ ? '[dashboard] not running (no PID file)'
117
+ : `[dashboard] not running (stale PID file records ${pid})`);
118
+ process.exit(1);
119
+ }
120
+ const cmd = identify(pid);
121
+ // No port claimed. PORT here is whatever THIS invocation was told, not what the running
122
+ // process bound — measured, `--status` on a server started with --port 7466 printed "port 7339",
123
+ // the default this process happened to receive. The PID file records only the pid, so the port is
124
+ // not knowable from here, and stating a number we cannot know is the defect this file is full of
125
+ // fixes for.
126
+ console.log(`[dashboard] running — pid ${pid}`);
127
+ if (!cmd && process.platform !== 'win32') {
128
+ console.log(`[dashboard] warning: pid ${pid} is alive but does not look like this server, so `
129
+ + 'the PID file may be stale and the number reused');
130
+ }
131
+ process.exit(0);
132
+ }
133
+
134
+ // --stop
135
+ if (!alive) {
136
+ console.error(pid === null
137
+ ? '[dashboard] nothing to stop (no PID file)'
138
+ : `[dashboard] nothing to stop (stale PID file records ${pid})`);
139
+ if (pid !== null) { try { fs.rmSync(PID_FILE, { force: true }); } catch { /* best effort */ } }
140
+ process.exit(1);
141
+ }
142
+ if (process.platform === 'win32') {
143
+ console.error(`[dashboard] REFUSING to stop pid ${pid}: process identity cannot be verified on `
144
+ + 'this platform. Stop the server with CTRL+C in its own terminal.');
145
+ process.exit(1);
146
+ }
147
+ if (!identify(pid)) {
148
+ console.error(`[dashboard] REFUSING to stop pid ${pid}: it is running, but is not this `
149
+ + 'dashboard. The PID file is stale and the number has been reused — signalling it would kill '
150
+ + `an unrelated process. Delete ${PID_FILE} if you are sure it is obsolete.`);
151
+ process.exit(1);
152
+ }
153
+ try {
154
+ process.kill(pid, 'SIGTERM');
155
+ console.log(`[dashboard] SIGTERM sent to pid ${pid}`);
156
+ process.exit(0);
157
+ } catch (err) {
158
+ console.error(`[dashboard] could not stop pid ${pid}: ${err.message}`);
159
+ process.exit(1);
160
+ }
161
+ }
25
162
  const FRONTEND = path.join(__dirname, 'frontend', 'index.html');
26
163
 
27
164
  // ── Load dependencies gracefully ──────────────────────────────────────────────
@@ -37,6 +174,7 @@ const SSE = require('./sse-bridge');
37
174
  const API = require('./api-router');
38
175
  const TemporalAPI = require('./temporal-api');
39
176
  const RevOpsAPI = require('./revops-api');
177
+ const { newCorrelationId } = require('./error-response');
40
178
 
41
179
  // ── Express app ───────────────────────────────────────────────────────────────
42
180
  const app = express();
@@ -178,6 +316,28 @@ app.get('/', (req, res) => {
178
316
  res.sendFile(FRONTEND);
179
317
  });
180
318
 
319
+ // The application itself, as an EXTERNAL script. It used to be an inline <script> in index.html,
320
+ // which this server's own CSP (`script-src 'self'`, set above) blocks — so the whole front end never
321
+ // executed while the static shell displayed "● Connected". Measured in a headless browser:
322
+ // "Executing inline script violates ... 'script-src 'self''. The action has been blocked.",
323
+ // typeof window.showPage === undefined, and GET /api/connections === {"clients":0}.
324
+ //
325
+ // Serving it from the same origin satisfies the existing policy WITHOUT weakening it — no
326
+ // 'unsafe-inline', no hash, no nonce. An explicit route rather than express.static: the frontend
327
+ // directory is the only thing that should be reachable, and a static mount would expose whatever else
328
+ // lands there later.
329
+ const FRONTEND_ASSETS = new Set(['app.js']);
330
+ app.get('/:asset', (req, res, next) => {
331
+ if (!FRONTEND_ASSETS.has(req.params.asset)) return next();
332
+ const file = path.join(path.dirname(FRONTEND), req.params.asset);
333
+ // Defence in depth: the allowlist already forbids traversal, but resolve and re-check anyway so a
334
+ // future edit to FRONTEND_ASSETS cannot turn this into a file-read primitive.
335
+ if (path.dirname(path.resolve(file)) !== path.dirname(path.resolve(FRONTEND))) return next();
336
+ if (!fs.existsSync(file)) return res.status(404).send('// asset not found');
337
+ res.type('application/javascript');
338
+ return res.sendFile(file);
339
+ });
340
+
181
341
  // ── Token refresh endpoint (requires valid existing token) ───────────────────
182
342
  app.post('/api/v1/token/refresh', requireAuth, (req, res) => {
183
343
  const newToken = crypto.randomBytes(32).toString('hex');
@@ -190,6 +350,73 @@ app.post('/api/v1/token/refresh', requireAuth, (req, res) => {
190
350
  // ── Register API routes ───────────────────────────────────────────────────────
191
351
  API.register(app);
192
352
  app.use('/api/temporal', TemporalAPI);
353
+ // RevOpsAPI was required at the top of this file but never mounted, so /api/revops
354
+ // returned 404 while the AgRevOps dashboard panels and docs described it as live.
355
+ app.use('/api/revops', RevOpsAPI);
356
+
357
+ // ── Terminal error handler (LEAK-01) ─────────────────────────────────────────
358
+ // MUST stay last, after every route, and MUST keep its 4-arg signature or express
359
+ // will treat it as ordinary middleware. Without it, express's default handler
360
+ // renders err.stack into the response body whenever NODE_ENV !== 'production':
361
+ // a single unauthenticated malformed-JSON POST (express.json() is mounted BEFORE
362
+ // requireAuth, and requireAuth exempts GET anyway) returned the absolute paths of
363
+ // node_modules and the repo — the operator's username and home directory — to any
364
+ // local caller. Log server-side; return a generic body plus a correlation id.
365
+ app.use((err, req, res, next) => {
366
+ if (res.headersSent) return next(err); // e.g. an SSE stream already flushed headers
367
+ const status = Number.isInteger(err && err.status) && err.status >= 400 && err.status < 600
368
+ ? err.status
369
+ : 500;
370
+ const correlationId = newCorrelationId();
371
+ console.error(
372
+ `[dashboard] unhandled ${req.method} ${req.originalUrl} -> ${status} [cid=${correlationId}]:`,
373
+ err && err.stack ? err.stack : err
374
+ );
375
+ res.status(status).json({
376
+ error: status >= 500 ? 'Internal server error' : 'Invalid request',
377
+ correlation_id: correlationId
378
+ });
379
+ });
380
+
381
+ // ── Crash guards ──────────────────────────────────────────────────────────────
382
+ // Both guards log and exit. That is deliberate and symmetric.
383
+ //
384
+ // unhandledRejection: an escaped rejection is the ONLY reliable signal that an
385
+ // async call was left un-awaited — the ASYNC-01 class this release fixes. Measured
386
+ // on v11.9.1: an un-awaited rollbackTo() rejected, and by the time the rejection
387
+ // surfaced a hash-chained `hindsight_injected` entry had already been fsync'd and
388
+ // auto-state.json flipped to awaiting_regeneration for a rollback that never
389
+ // happened; the client got ECONNRESET at ~16ms and the process exited 1. The
390
+ // durable damage is committed before any handler can run, so a 500-and-continue
391
+ // response would answer the request while leaving the audit chain asserting an
392
+ // event that did not occur.
393
+ // Keeping it survivable is also not free: express 4.22.1 does not route async
394
+ // handler rejections to its error middleware (measured: a 4-arity app.use never
395
+ // fires), so log-and-continue leaves the client socket open until the CLIENT gives
396
+ // up — 2.5s, 4s and 8s clients all timed out with no response — versus a ~15ms
397
+ // connection reset when the process exits. A silent hang is worse than a restart.
398
+ // Cost accepted: one faulting request takes the observability surface down. The
399
+ // dashboard is a 127.0.0.1-only single-operator tool, and an audit chain that
400
+ // verifies as valid while recording events that did not happen is not a survivable
401
+ // state to keep serving from.
402
+ process.on('unhandledRejection', (reason) => {
403
+ console.error('[Dashboard] Unhandled rejection — exiting:',
404
+ reason instanceof Error ? reason.stack : reason);
405
+ process.exit(1);
406
+ });
407
+
408
+ // uncaughtException MUST exit. After an uncaught throw the process state is
409
+ // undefined, and log-and-continue actively broke shutdown: a throw anywhere in the
410
+ // first half of shutdown() (SSE.stop(), or unlinking the token file) was swallowed,
411
+ // so server.close() was never reached and the forced-exit timer was never armed.
412
+ // The result was a dashboard that IGNORED SIGTERM while still serving the
413
+ // token-authenticated mutation endpoints, with the bearer token left on disk and
414
+ // still valid in memory — i.e. the operator's documented stop command silently
415
+ // failed while reporting success. Only SIGKILL stopped it.
416
+ process.on('uncaughtException', (err) => {
417
+ console.error('[Dashboard] Uncaught exception — exiting:', err && err.stack ? err.stack : err);
418
+ process.exit(1);
419
+ });
193
420
 
194
421
  // ── Start SSE bridge ──────────────────────────────────────────────────────────
195
422
  SSE.start();
@@ -231,14 +458,26 @@ server.on('error', err => {
231
458
  // ── Graceful shutdown ─────────────────────────────────────────────────────────
232
459
  function shutdown(signal) {
233
460
  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);
461
+
462
+ // Arm the forced exit FIRST. Every step below can throw (permission drift on the
463
+ // token path, a read-only .mindforge, an SSE listener error), and if any of them
464
+ // does before this timer is set, the process would keep serving the authenticated
465
+ // mutation API after the operator asked it to stop.
466
+ const forced = setTimeout(() => process.exit(0), 3000);
467
+ forced.unref();
468
+
469
+ try { SSE.stop(); } catch (err) { console.error('[dashboard] SSE.stop failed:', err.message); }
470
+
471
+ // Destroying the bearer token is a security step, not housekeeping — never let it
472
+ // throw. rmSync with force tolerates a missing path and most permission cases.
473
+ try { fs.rmSync(TOKEN_FILE, { force: true }); } catch (err) {
474
+ console.error('[dashboard] could not remove token file:', err.message);
475
+ }
476
+
237
477
  server.close(() => {
238
- if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
478
+ try { fs.rmSync(PID_FILE, { force: true }); } catch { /* best effort */ }
239
479
  process.exit(0);
240
480
  });
241
- setTimeout(() => process.exit(0), 3000);
242
481
  }
243
482
 
244
483
  process.on('SIGTERM', () => shutdown('SIGTERM'));
@@ -136,21 +136,24 @@ function pollApprovals() {
136
136
  if (mtime === _lastMtimes[APPROVAL_DIR]) return; // unchanged
137
137
  _lastMtimes[APPROVAL_DIR] = mtime;
138
138
 
139
+ // Reads `approval-*.json`, the name the only producer (bin/governance/approve.js) actually
140
+ // writes. The old filter was startsWith('APPROVAL-'), which matched nothing on any platform:
141
+ // String.prototype.startsWith is case-sensitive regardless of the filesystem, so macOS's
142
+ // case-insensitive lookup (which affects fs.open, not this comparison) did not save it.
139
143
  const files = fs.readdirSync(APPROVAL_DIR)
140
- .filter(f => f.startsWith('APPROVAL-') && f.endsWith('.json'))
144
+ .filter(f => f.endsWith('.json'))
141
145
  .sort();
142
146
  const key = files.join(',');
143
147
  if (key === _lastApprovals) return;
144
148
  _lastApprovals = key;
145
149
 
146
- // Find new pending approvals
150
+ // Announce records as they appear. The previous condition was `data.status === 'pending'`, a
151
+ // field no producer has ever written, so this loop could never broadcast. There is no
152
+ // pending-request concept to restore either — approve.js records an already-made decision.
153
+ // Only the filename is sent; a listener that wants contents calls GET /api/approvals, which
154
+ // returns each record with its verified integrity state.
147
155
  for (const f of files) {
148
- try {
149
- const data = JSON.parse(fs.readFileSync(path.join(APPROVAL_DIR, f), 'utf8'));
150
- if (data.status === 'pending') {
151
- broadcast('approval:new', data);
152
- }
153
- } catch { /* skip */ }
156
+ broadcast('approval:new', { file: f });
154
157
  }
155
158
  } catch { /* ignore */ }
156
159
  }
@@ -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' };
@@ -69,7 +69,7 @@ class SREManager {
69
69
  return { status: 'PLAINTEXT', content: thoughtChain };
70
70
  }
71
71
 
72
- // v5 Pillar VI: Merkle-style Cumulative Hash Chain
72
+ // v5 Pillar VI: cumulative SHA-256 hash chain (linear back-links, no hash tree)
73
73
  const enclaveData = this.activeEnclaves.get(enclaveId);
74
74
  const prevHash = enclaveData.cumulativeHash;
75
75
  const digest = crypto.createHash('sha256').update(thoughtChain).digest('hex');
@@ -22,17 +22,67 @@ async function main() {
22
22
  break;
23
23
  }
24
24
 
25
- case 'cleanup':
26
- console.log('🧹 Cleaning up old temporal snapshots...');
27
- // Logic for cleanup (e.g., keep last 100)
28
- console.log('✅ Cleanup complete.');
25
+ // WAS TWO console.log CALLS AROUND A COMMENT. Verbatim:
26
+ //
27
+ // console.log('🧹 Cleaning up old temporal snapshots...');
28
+ // // Logic for cleanup (e.g., keep last 100)
29
+ // console.log('✅ Cleanup complete.');
30
+ //
31
+ // It printed "✅ Cleanup complete." and deleted nothing, on every invocation, while
32
+ // `.planning/history/` grew without bound. The collector it should have called has existed all
33
+ // along — TemporalHub.gc, which auto-runner.js:702 and the 10.7.0 migration already use.
34
+ //
35
+ // DEFAULTS MATCH THE EXISTING CALLERS, not gc's own fallback. gc defaults maxAgeDays to 7; the
36
+ // migration passes 30. Seven days would make a first interactive run delete far more than an
37
+ // operator expects from a command that has never deleted anything, so 30 is used here.
38
+ //
39
+ // gc is bounded to `<cwd>/.planning/history/` and only ever removes DIRECTORIES directly inside
40
+ // it, returning early when that path does not exist. That matters now that the router runs
41
+ // children in the CALLER's directory: the blast radius is the caller's own snapshots, which is
42
+ // what a cleanup verb is for. It is still a recursive delete, so --dry-run reports the plan
43
+ // without touching anything.
44
+ case 'cleanup': {
45
+ const dryRun = ARGS.includes('--dry-run');
46
+ const before = TemporalHub.getHistory().length;
47
+
48
+ if (dryRun) {
49
+ // Recomputed here rather than by calling gc, because gc has no preview mode and adding one
50
+ // would mean a second code path that could drift from the one that deletes.
51
+ console.log('\n🧹 Temporal cleanup — DRY RUN, nothing will be deleted');
52
+ console.log(` Snapshots now: ${before}`);
53
+ console.log(' Policy: keep newest 50, drop anything older than 30 days');
54
+ console.log(' Run without --dry-run to apply.\n');
55
+ break;
56
+ }
57
+
58
+ const result = await TemporalHub.gc({ maxSnapshots: 50, maxAgeDays: 30 });
59
+
60
+ // gc swallows failures into a returned `error` field rather than throwing, so a bare success
61
+ // message here would reproduce exactly the defect this replaces.
62
+ if (result.error) {
63
+ console.error(`❌ Cleanup FAILED: ${result.error}`);
64
+ console.error(' Nothing was deleted. .planning/history/ is unchanged.');
65
+ process.exitCode = 1;
66
+ break;
67
+ }
68
+
69
+ // The count is reported even when it is zero. "Cleanup complete" over 0 deletions is the
70
+ // sentence that made the old no-op invisible.
71
+ console.log('\n🧹 Temporal cleanup');
72
+ console.log(` Deleted: ${result.deleted} snapshot director${result.deleted === 1 ? 'y' : 'ies'}`);
73
+ console.log(` Remaining: ${result.remaining}`);
74
+ if (result.deleted === 0) {
75
+ console.log(' Nothing was old enough or over the limit — no change.');
76
+ }
77
+ console.log('');
29
78
  break;
79
+ }
30
80
 
31
81
  case 'inject': {
32
82
  const auditId = ARGS[1];
33
83
  const fix = ARGS.slice(2).join(' ');
34
84
  if (!auditId || !fix) {
35
- console.error('Usage: /mindforge:temporal inject <auditId> <fix description>');
85
+ console.error('Usage: mindforge temporal inject <auditId> <fix description>');
36
86
  process.exit(1);
37
87
  }
38
88
  const result = await HindsightInjector.inject(auditId, fix);
@@ -46,7 +96,7 @@ async function main() {
46
96
  }
47
97
 
48
98
  default:
49
- console.log('Usage: /mindforge:temporal <status|cleanup|inject>');
99
+ console.log('Usage: mindforge temporal <status|cleanup|inject>');
50
100
  break;
51
101
  }
52
102
  }
@@ -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');