mindforge-cc 11.9.2 → 11.9.4
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/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/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 +1 -1
- package/.mindforge/config.json +3 -3
- package/.mindforge/engine/autonomous/headless-adapter.md +9 -2
- package/.mindforge/engine/temporal-protocol.md +2 -2
- package/.mindforge/governance/change-classifier.md +20 -4
- package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
- package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
- package/CHANGELOG.md +357 -0
- package/MINDFORGE.md +13 -6
- package/README.md +49 -30
- package/RELEASENOTES.md +65 -2
- package/SECURITY.md +22 -3
- package/bin/autonomous/auto-runner.js +65 -2
- package/bin/change-classifier.js +151 -16
- package/bin/dashboard/api-router.js +18 -38
- package/bin/dashboard/frontend/app.js +429 -0
- package/bin/dashboard/frontend/index.html +13 -406
- package/bin/dashboard/metrics-aggregator.js +52 -23
- package/bin/dashboard/server.js +160 -1
- package/bin/dashboard/sse-bridge.js +11 -8
- package/bin/engine/sre-manager.js +1 -1
- package/bin/engine/temporal-cli.js +56 -6
- package/bin/engine/verification-runner.js +134 -17
- package/bin/engine/verify-cli.js +25 -7
- package/bin/governance/approval-record.js +147 -0
- package/bin/governance/approve.js +24 -8
- package/bin/governance/policy-engine.js +33 -3
- package/bin/governance/policy-gate-hardened.js +36 -1
- package/bin/governance/verify-approvals.js +175 -0
- package/bin/harness-audit.js +224 -10
- package/bin/hooks/instinct-capture-hook.js +12 -4
- package/bin/install.js +63 -3
- package/bin/installer/harness-adapter-compliance.js +339 -28
- package/bin/installer/hook-registration.js +547 -0
- package/bin/installer-core.js +481 -65
- package/bin/learning/instinct-cli.js +7 -0
- package/bin/memory/vector-hub.js +196 -13
- 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 +67 -6
- package/bin/models/cost-tracker.js +104 -6
- package/bin/models/model-client.js +6 -1
- 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/mindforge-version.js +99 -0
- package/bin/utils/redact-secrets.js +106 -0
- package/bin/validate-config.js +42 -2
- 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.3.md +204 -0
- package/changelogs/v11.9.4.md +155 -0
- package/docs/References/config-reference.md +5 -2
- 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 +10 -4
- package/docs/sdk-reference.md +15 -7
- package/docs/troubleshooting.md +65 -6
- package/docs/user-guide.md +14 -14
- package/examples/sdk-integration/README.md +1 -1
- package/package.json +8 -3
- package/subagents/.claude-plugin/marketplace.json +1 -1
- package/.mindforge/memory/sync-manifest.json +0 -6
- 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 ──────────────────────────────────────────────
|
|
@@ -179,6 +316,28 @@ app.get('/', (req, res) => {
|
|
|
179
316
|
res.sendFile(FRONTEND);
|
|
180
317
|
});
|
|
181
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
|
+
|
|
182
341
|
// ── Token refresh endpoint (requires valid existing token) ───────────────────
|
|
183
342
|
app.post('/api/v1/token/refresh', requireAuth, (req, res) => {
|
|
184
343
|
const newToken = crypto.randomBytes(32).toString('hex');
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -7,28 +7,114 @@ const fs = require('fs');
|
|
|
7
7
|
const MAX_OUTPUT_LENGTH = 2000;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Detect an ESLint configuration. Flat config (eslint 9+) and the legacy .eslintrc family both
|
|
11
|
+
* count, as does an `eslintConfig` key in package.json, because `npx eslint .` succeeds with any
|
|
12
|
+
* of them and fails with none.
|
|
13
|
+
*/
|
|
14
|
+
function hasEslintConfig(cwd) {
|
|
15
|
+
const named = [
|
|
16
|
+
'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs',
|
|
17
|
+
'eslint.config.ts', 'eslint.config.mts', 'eslint.config.cts',
|
|
18
|
+
'.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json',
|
|
19
|
+
'.eslintrc.yml', '.eslintrc.yaml',
|
|
20
|
+
];
|
|
21
|
+
if (named.some((f) => fs.existsSync(path.join(cwd, f)))) return true;
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
24
|
+
return Boolean(pkg.eslintConfig);
|
|
25
|
+
} catch { return false; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stage definitions — each maps a stage name to its command and an optional availability check.
|
|
30
|
+
*
|
|
31
|
+
* `skipIf(cwd)` returns a REASON STRING to skip, or false to run. A bare `true` still skips, for
|
|
32
|
+
* compatibility, but a reason is strongly preferred: an unexplained skip in the report is nearly as
|
|
33
|
+
* unhelpful as an unexplained failure.
|
|
34
|
+
*
|
|
35
|
+
* WHY EVERY STAGE NEEDS ONE. Only `typecheck` had an availability check, so `mindforge verify` on a
|
|
36
|
+
* freshly installed, entirely healthy consumer project reported:
|
|
37
|
+
*
|
|
38
|
+
* | tests | ❌ fail |
|
|
39
|
+
* | lint | ❌ fail |
|
|
40
|
+
* | audit | ❌ fail |
|
|
41
|
+
* | typecheck | ⏭️ skip |
|
|
42
|
+
* **Summary:** 0 passed, 3 failed, 1 skipped exit 1
|
|
43
|
+
*
|
|
44
|
+
* Measured on `node bin/install.js --claude --local` into an empty project: 1,836 files installed,
|
|
45
|
+
* and no `tests/`, no ESLint config and no `bin/verify-audit.js` — because the installer ships none
|
|
46
|
+
* of them, which is correct. So all three stages were reporting the ABSENCE of a tool as the
|
|
47
|
+
* FAILURE of the thing it would have checked. The audit stage's "result" was a raw Node module
|
|
48
|
+
* loader stack trace.
|
|
49
|
+
*
|
|
50
|
+
* "I cannot check this here" and "this is broken" are opposite statements, and a verifier that
|
|
51
|
+
* conflates them is worse than one that runs nothing: it trains its user to ignore red.
|
|
52
|
+
*
|
|
53
|
+
* The recursion guard on `tests` is unchanged and separate — NODE_ENV=test is set by run-all.js and
|
|
54
|
+
* MINDFORGE_VERIFICATION_ACTIVE=1 by this runner, and skipping prevents infinite nesting.
|
|
13
55
|
*/
|
|
14
56
|
const STAGE_DEFS = {
|
|
15
57
|
tests: {
|
|
16
58
|
command: 'node tests/run-all.js',
|
|
17
|
-
skipIf: () =>
|
|
18
|
-
process.env.MINDFORGE_VERIFICATION_ACTIVE === '1' ||
|
|
19
|
-
|
|
59
|
+
skipIf: (cwd) => {
|
|
60
|
+
if (process.env.MINDFORGE_VERIFICATION_ACTIVE === '1' || process.env.NODE_ENV === 'test') {
|
|
61
|
+
return 'already inside a verification run (recursion guard)';
|
|
62
|
+
}
|
|
63
|
+
if (!fs.existsSync(path.join(cwd, 'tests', 'run-all.js'))) {
|
|
64
|
+
return 'no tests/run-all.js here — the package does not install a test suite';
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
},
|
|
20
68
|
},
|
|
21
69
|
lint: {
|
|
22
|
-
|
|
23
|
-
|
|
70
|
+
// `--max-warnings=0` used to be here, which made this stage IMPOSSIBLE to pass in the repo it
|
|
71
|
+
// ships from: measured, `npx eslint .` reports 199 problems / 0 errors / 199 warnings. So
|
|
72
|
+
// `mindforge verify` reported a lint FAILURE on a tree whose lint is green by the project's own
|
|
73
|
+
// definition — a confidently wrong answer, which is worse than no answer.
|
|
74
|
+
//
|
|
75
|
+
// Aligned with the project's own contract rather than a stricter threshold invented here:
|
|
76
|
+
// package.json "lint": "eslint ." (warnings tolerated)
|
|
77
|
+
// CI eslint . --max-warnings=9999 (warnings tolerated)
|
|
78
|
+
// sdk CI eslint src/ --max-warnings 0 (strict, but only over sdk/src)
|
|
79
|
+
// Two of the three tolerate warnings and the third is scoped to a different tree, so a
|
|
80
|
+
// repo-wide zero-warning gate was this file's opinion alone. Errors still fail the stage,
|
|
81
|
+
// because eslint exits non-zero on an error regardless of the warning threshold.
|
|
82
|
+
command: 'npx eslint .',
|
|
83
|
+
skipIf: (cwd) => (hasEslintConfig(cwd)
|
|
84
|
+
? false
|
|
85
|
+
: 'no ESLint configuration here — nothing defines what lint means for this project'),
|
|
24
86
|
},
|
|
25
87
|
audit: {
|
|
26
88
|
command: 'node bin/verify-audit.js',
|
|
27
|
-
|
|
89
|
+
// BOTH the verifier AND the log — and the comment that used to sit here asserted the opposite of
|
|
90
|
+
// measured behaviour, which is how the remaining gap survived review.
|
|
91
|
+
//
|
|
92
|
+
// It claimed "an absent AUDIT.jsonl is a legitimate empty chain that verify-audit.js reports on
|
|
93
|
+
// correctly". Measured in an empty project: `node bin/verify-audit.js` prints
|
|
94
|
+
// "❌ audit chain BROKEN at entry 0: unreadable: ENOENT: no such file or directory" and exits 1.
|
|
95
|
+
// It does not report an empty chain, it reports corruption. So wherever the script IS present and
|
|
96
|
+
// the log is not — a --with-utils install, a fresh checkout — this stage still produced a red for
|
|
97
|
+
// a project that has simply never written an audit entry. That is the same
|
|
98
|
+
// absence-reported-as-failure defect the rest of this change exists to remove.
|
|
99
|
+
//
|
|
100
|
+
// The script being missing is the state of every ordinary consumer install (the installer does not
|
|
101
|
+
// copy bin/verify-audit.js), and there the stage died in Node's module loader and printed the
|
|
102
|
+
// stack trace as its verification result.
|
|
103
|
+
skipIf: (cwd) => {
|
|
104
|
+
if (!fs.existsSync(path.join(cwd, 'bin', 'verify-audit.js'))) {
|
|
105
|
+
return 'bin/verify-audit.js is not installed here, so the audit chain cannot be verified';
|
|
106
|
+
}
|
|
107
|
+
if (!fs.existsSync(path.join(cwd, '.planning', 'AUDIT.jsonl'))) {
|
|
108
|
+
return 'no .planning/AUDIT.jsonl yet — nothing has been audited, which is not a broken chain';
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
},
|
|
28
112
|
},
|
|
29
113
|
typecheck: {
|
|
30
114
|
command: 'npx tsc --noEmit',
|
|
31
|
-
skipIf: (cwd) =>
|
|
115
|
+
skipIf: (cwd) => (fs.existsSync(path.join(cwd, 'tsconfig.json'))
|
|
116
|
+
? false
|
|
117
|
+
: 'no tsconfig.json here — nothing to typecheck'),
|
|
32
118
|
},
|
|
33
119
|
};
|
|
34
120
|
|
|
@@ -41,9 +127,18 @@ function executeStage(name, cwd) {
|
|
|
41
127
|
return { name, status: 'skip', durationMs: 0, output: `Unknown stage: ${name}` };
|
|
42
128
|
}
|
|
43
129
|
|
|
44
|
-
// Check skip condition
|
|
45
|
-
|
|
46
|
-
|
|
130
|
+
// Check availability / skip condition. A reason string is carried through to the report, because
|
|
131
|
+
// an unexplained skip leaves the reader unable to tell "not applicable here" from "silently
|
|
132
|
+
// broken" — the same ambiguity that conflating absence with failure created.
|
|
133
|
+
const skip = def.skipIf && def.skipIf(cwd);
|
|
134
|
+
if (skip) {
|
|
135
|
+
return {
|
|
136
|
+
name,
|
|
137
|
+
status: 'skip',
|
|
138
|
+
durationMs: 0,
|
|
139
|
+
output: typeof skip === 'string' ? skip : '',
|
|
140
|
+
reason: typeof skip === 'string' ? skip : undefined,
|
|
141
|
+
};
|
|
47
142
|
}
|
|
48
143
|
|
|
49
144
|
const start = Date.now();
|
|
@@ -112,20 +207,42 @@ function formatReport(result) {
|
|
|
112
207
|
lines.push('');
|
|
113
208
|
lines.push(`**Timestamp:** ${result.timestamp}`);
|
|
114
209
|
lines.push('');
|
|
115
|
-
lines.push('| Stage | Status | Duration |');
|
|
116
|
-
lines.push('
|
|
210
|
+
lines.push('| Stage | Status | Duration | Note |');
|
|
211
|
+
lines.push('|-------|--------|----------|------|');
|
|
117
212
|
|
|
118
213
|
for (const stage of result.stages) {
|
|
119
214
|
const emoji = statusEmoji[stage.status] || '?';
|
|
120
215
|
const duration = stage.durationMs > 0 ? `${stage.durationMs}ms` : '-';
|
|
121
|
-
|
|
216
|
+
// A skip without its reason is unreadable: this table used to show a bare `⏭️ skip` and left the
|
|
217
|
+
// reader to guess whether the stage was inapplicable or quietly broken. Pipes are escaped so a
|
|
218
|
+
// reason can never break the markdown table.
|
|
219
|
+
const note = stage.status === 'skip' && stage.reason
|
|
220
|
+
? String(stage.reason).replace(/\|/g, '\\|')
|
|
221
|
+
: '';
|
|
222
|
+
lines.push(`| ${stage.name} | ${emoji} ${stage.status} | ${duration} | ${note} |`);
|
|
122
223
|
}
|
|
123
224
|
|
|
124
225
|
lines.push('');
|
|
125
226
|
lines.push(`**Summary:** ${result.summary.passed} passed, ${result.summary.failed} failed, ${result.summary.skipped} skipped (${result.summary.totalDurationMs}ms total)`);
|
|
227
|
+
|
|
228
|
+
// Say it outright when NOTHING ran. Adding availability checks fixed the false failures, but it
|
|
229
|
+
// moved a consumer install from "3 failed, exit 1" to "0 failed, exit 0" — and bin/engine/
|
|
230
|
+
// verify-cli.js exits on `failed > 0`, so a script reading $? now sees success for a run that
|
|
231
|
+
// verified nothing. Whether an all-skipped run should exit non-zero is an open maintainer
|
|
232
|
+
// decision and is deliberately NOT pre-empted here; what is not optional is that the report
|
|
233
|
+
// refuses to look like a pass.
|
|
234
|
+
if (result.summary.passed === 0 && result.summary.failed === 0 && result.summary.skipped > 0) {
|
|
235
|
+
lines.push('');
|
|
236
|
+
lines.push('> **NOTHING WAS VERIFIED.** Every stage was skipped, so this report is not '
|
|
237
|
+
+ 'evidence that anything works — see the Note column for what was unavailable. A verifier '
|
|
238
|
+
+ 'that ran no checks must not be read as a passing verifier.');
|
|
239
|
+
}
|
|
126
240
|
lines.push('');
|
|
127
241
|
|
|
128
242
|
return lines.join('\n');
|
|
129
243
|
}
|
|
130
244
|
|
|
131
|
-
|
|
245
|
+
// STAGE_DEFS is exported so a test can compare the lint stage's threshold against the project's own
|
|
246
|
+
// lint script rather than hardcoding what it expects to find. Read-only by contract: mutating it
|
|
247
|
+
// would change every subsequent run in the same process.
|
|
248
|
+
module.exports = { runVerification, formatReport, STAGE_DEFS };
|
package/bin/engine/verify-cli.js
CHANGED
|
@@ -3,8 +3,19 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* verify-cli.js — Entrypoint for the `verify` CLI command.
|
|
6
|
-
* Calls the unified verification runner across all stages and writes
|
|
7
|
-
*
|
|
6
|
+
* Calls the unified verification runner across all stages, prints the formatted report, and writes it
|
|
7
|
+
* to .planning/VERIFICATION.md only when asked with --write.
|
|
8
|
+
*
|
|
9
|
+
* WHY --write RATHER THAN ALWAYS. `.planning/VERIFICATION.md` is TRACKED (`git ls-files` returns it),
|
|
10
|
+
* and this file used to overwrite it on every run — so a contributor who ran the project's own
|
|
11
|
+
* verification dirtied a tracked file and had to remember to discard it. Nothing reads the root
|
|
12
|
+
* report: the only other reference is tests/e2e.test.js:399, and that reads
|
|
13
|
+
* `.planning/phases/1/VERIFICATION-1.md`, a per-phase fixture in a generated project, not this file.
|
|
14
|
+
* The report was already printed to stdout, so gating the file loses no information.
|
|
15
|
+
*
|
|
16
|
+
* Untracking the file is the other half and belongs to the operator (`git rm --cached` plus a
|
|
17
|
+
* .gitignore entry). This change is correct either way: with the file untracked, --write becomes a
|
|
18
|
+
* convenience; while it stays tracked, --write is what stops the dirtying.
|
|
8
19
|
*/
|
|
9
20
|
|
|
10
21
|
const path = require('path');
|
|
@@ -13,17 +24,24 @@ const { runVerification, formatReport } = require('./verification-runner');
|
|
|
13
24
|
|
|
14
25
|
const STAGES = ['tests', 'lint', 'audit', 'typecheck'];
|
|
15
26
|
const CWD = process.env.MINDFORGE_ROOT || path.resolve(__dirname, '../..');
|
|
27
|
+
const REPORT_NAME = 'VERIFICATION.md';
|
|
16
28
|
|
|
17
29
|
async function main() {
|
|
18
|
-
const
|
|
19
|
-
if (!fs.existsSync(planningDir)) {
|
|
20
|
-
fs.mkdirSync(planningDir, { recursive: true });
|
|
21
|
-
}
|
|
30
|
+
const shouldWrite = process.argv.slice(2).includes('--write');
|
|
22
31
|
|
|
23
32
|
const result = await runVerification({ cwd: CWD, stages: STAGES });
|
|
24
33
|
const report = formatReport(result);
|
|
25
34
|
|
|
26
|
-
|
|
35
|
+
if (shouldWrite) {
|
|
36
|
+
const planningDir = path.join(CWD, '.planning');
|
|
37
|
+
if (!fs.existsSync(planningDir)) {
|
|
38
|
+
fs.mkdirSync(planningDir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
const reportPath = path.join(planningDir, REPORT_NAME);
|
|
41
|
+
fs.writeFileSync(reportPath, report);
|
|
42
|
+
process.stdout.write(`Report written to ${reportPath}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
process.stdout.write(report + '\n');
|
|
28
46
|
process.exit(result.summary.failed > 0 ? 1 : 0);
|
|
29
47
|
}
|