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.
- package/.agent/CLAUDE.md +37 -13
- package/.agent/hooks/mindforge-block-no-verify.js +61 -13
- package/.agent/hooks/mindforge-config-protection.js +82 -3
- package/.agent/hooks/mindforge-context-monitor.js +1 -1
- package/.agent/hooks/mindforge-workflow-guard.js +2 -2
- package/.agent/hooks/run-with-flags.js +190 -20
- package/.agent/mindforge/browse.md +2 -2
- package/.agent/mindforge/checkpoint.md +1 -1
- package/.agent/mindforge/consult.md +1 -1
- package/.agent/mindforge/cost-report.md +1 -1
- package/.agent/mindforge/harness-audit.md +1 -1
- package/.agent/mindforge/orch-add-feature.md +1 -1
- package/.agent/mindforge/orch-build-mvp.md +1 -1
- package/.agent/mindforge/orch-change-feature.md +1 -1
- package/.agent/mindforge/orch-fix-defect.md +1 -1
- package/.agent/mindforge/orch-refine-code.md +1 -1
- package/.agent/mindforge/qa.md +2 -2
- package/.claude/CLAUDE.md +37 -13
- package/.claude/commands/mindforge/browse.md +2 -2
- package/.claude/commands/mindforge/checkpoint.md +1 -1
- package/.claude/commands/mindforge/consult.md +1 -1
- package/.claude/commands/mindforge/cost-report.md +1 -1
- package/.claude/commands/mindforge/harness-audit.md +1 -1
- package/.claude/commands/mindforge/orch-add-feature.md +1 -1
- package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
- package/.claude/commands/mindforge/orch-change-feature.md +1 -1
- package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
- package/.claude/commands/mindforge/orch-refine-code.md +1 -1
- package/.claude/commands/mindforge/qa.md +2 -2
- package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
- package/.mindforge/config.json +4 -4
- package/.mindforge/engine/autonomous/headless-adapter.md +2 -2
- package/.mindforge/engine/cost-tracking/router.md +1 -1
- package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
- package/.mindforge/engine/temporal-protocol.md +2 -2
- package/.mindforge/governance/change-classifier.md +20 -4
- package/.mindforge/memory/sync-manifest.json +1 -1
- package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
- package/.mindforge/personas/cost-optimizer.md +2 -2
- package/.mindforge/personas/multi-model-bridge.md +1 -1
- package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
- package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
- package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
- package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
- package/CHANGELOG.md +402 -0
- package/MINDFORGE.md +13 -6
- package/README.md +51 -2
- package/RELEASENOTES.md +55 -2
- package/SECURITY.md +22 -3
- package/bin/autonomous/audit-writer.js +48 -33
- package/bin/autonomous/auto-runner.js +65 -2
- package/bin/change-classifier.js +151 -16
- package/bin/dashboard/api-router.js +28 -47
- package/bin/dashboard/error-response.js +44 -0
- package/bin/dashboard/frontend/app.js +429 -0
- package/bin/dashboard/frontend/index.html +14 -390
- package/bin/dashboard/metrics-aggregator.js +75 -30
- package/bin/dashboard/revops-api.js +12 -2
- package/bin/dashboard/server.js +245 -6
- package/bin/dashboard/sse-bridge.js +11 -8
- package/bin/dashboard/temporal-api.js +11 -5
- package/bin/engine/remediation-engine.js +12 -1
- package/bin/engine/sre-manager.js +1 -1
- package/bin/engine/temporal-cli.js +56 -6
- package/bin/engine/temporal-hub.js +41 -9
- package/bin/engine/verification-runner.js +134 -17
- package/bin/engine/verify-cli.js +25 -7
- package/bin/eval/eval-harness.js +212 -1
- package/bin/eval/golden-set-retrieval.json +9 -0
- package/bin/governance/approval-record.js +147 -0
- package/bin/governance/approve.js +12 -7
- package/bin/governance/policy-engine.js +41 -3
- package/bin/governance/policy-gate-hardened.js +36 -1
- package/bin/governance/verify-approvals.js +163 -0
- package/bin/harness-audit.js +224 -10
- package/bin/hindsight-injector.js +8 -2
- package/bin/hooks/instinct-capture-hook.js +19 -5
- package/bin/install.js +63 -3
- package/bin/installer/harness-adapter-compliance.js +339 -28
- package/bin/installer/hook-registration.js +504 -0
- package/bin/installer-core.js +451 -63
- package/bin/learning/instinct-cli.js +14 -24
- package/bin/memory/knowledge-capture.js +23 -3
- package/bin/memory/knowledge-graph.js +70 -31
- package/bin/memory/vector-hub.js +500 -44
- package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
- package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
- package/bin/mindforge-cli.js +110 -17
- package/bin/models/cost-tracker.js +126 -29
- package/bin/models/model-client.js +6 -1
- package/bin/models/model-router.js +28 -7
- package/bin/models/usage-record.js +71 -0
- package/bin/revops/debt-monitor.js +57 -13
- package/bin/security/trust-gate-hook.js +50 -6
- package/bin/skill-validator.js +6 -1
- package/bin/skills-builder/skill-scorer.js +46 -6
- package/bin/updater/self-update.js +6 -1
- package/bin/updater/version-comparator.js +21 -1
- package/bin/utils/file-lock.js +106 -0
- package/bin/utils/mindforge-params.js +124 -0
- package/bin/utils/mindforge-version.js +99 -0
- package/bin/utils/redact-secrets.js +106 -0
- package/bin/validate-config.js +75 -17
- package/bin/wizard/setup-wizard.js +4 -1
- package/bin/wizard/theme.js +9 -1
- package/changelogs/index.json +11 -9
- package/changelogs/v11.9.2.md +209 -0
- package/changelogs/v11.9.3.md +195 -0
- package/docs/References/config-reference.md +76 -14
- package/docs/References/sdk-api.md +1 -1
- package/docs/Templates/Codebase/architecture.md +1 -1
- package/docs/commands-reference.md +4 -5
- package/docs/faq.md +25 -5
- package/docs/getting-started.md +3 -3
- package/docs/sdk-reference.md +15 -7
- package/docs/troubleshooting.md +10 -6
- package/docs/user-guide.md +14 -14
- package/examples/sdk-integration/README.md +1 -1
- package/package.json +10 -4
- package/subagents/.claude-plugin/marketplace.json +1 -1
- package/bin/dashboard/approval-handler.js +0 -136
package/bin/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
235
|
-
//
|
|
236
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
135
|
-
|
|
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');
|