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.
Files changed (95) 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/harness-audit.md +1 -1
  10. package/.agent/mindforge/orch-add-feature.md +1 -1
  11. package/.agent/mindforge/orch-build-mvp.md +1 -1
  12. package/.agent/mindforge/orch-change-feature.md +1 -1
  13. package/.agent/mindforge/orch-fix-defect.md +1 -1
  14. package/.agent/mindforge/orch-refine-code.md +1 -1
  15. package/.agent/mindforge/qa.md +2 -2
  16. package/.claude/CLAUDE.md +37 -13
  17. package/.claude/commands/mindforge/browse.md +2 -2
  18. package/.claude/commands/mindforge/checkpoint.md +1 -1
  19. package/.claude/commands/mindforge/harness-audit.md +1 -1
  20. package/.claude/commands/mindforge/orch-add-feature.md +1 -1
  21. package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
  22. package/.claude/commands/mindforge/orch-change-feature.md +1 -1
  23. package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
  24. package/.claude/commands/mindforge/orch-refine-code.md +1 -1
  25. package/.claude/commands/mindforge/qa.md +2 -2
  26. package/.mindforge/MINDFORGE-SCHEMA.json +1 -1
  27. package/.mindforge/config.json +3 -3
  28. package/.mindforge/engine/autonomous/headless-adapter.md +9 -2
  29. package/.mindforge/engine/temporal-protocol.md +2 -2
  30. package/.mindforge/governance/change-classifier.md +20 -4
  31. package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
  32. package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
  33. package/CHANGELOG.md +357 -0
  34. package/MINDFORGE.md +13 -6
  35. package/README.md +49 -30
  36. package/RELEASENOTES.md +65 -2
  37. package/SECURITY.md +22 -3
  38. package/bin/autonomous/auto-runner.js +65 -2
  39. package/bin/change-classifier.js +151 -16
  40. package/bin/dashboard/api-router.js +18 -38
  41. package/bin/dashboard/frontend/app.js +429 -0
  42. package/bin/dashboard/frontend/index.html +13 -406
  43. package/bin/dashboard/metrics-aggregator.js +52 -23
  44. package/bin/dashboard/server.js +160 -1
  45. package/bin/dashboard/sse-bridge.js +11 -8
  46. package/bin/engine/sre-manager.js +1 -1
  47. package/bin/engine/temporal-cli.js +56 -6
  48. package/bin/engine/verification-runner.js +134 -17
  49. package/bin/engine/verify-cli.js +25 -7
  50. package/bin/governance/approval-record.js +147 -0
  51. package/bin/governance/approve.js +24 -8
  52. package/bin/governance/policy-engine.js +33 -3
  53. package/bin/governance/policy-gate-hardened.js +36 -1
  54. package/bin/governance/verify-approvals.js +175 -0
  55. package/bin/harness-audit.js +224 -10
  56. package/bin/hooks/instinct-capture-hook.js +12 -4
  57. package/bin/install.js +63 -3
  58. package/bin/installer/harness-adapter-compliance.js +339 -28
  59. package/bin/installer/hook-registration.js +547 -0
  60. package/bin/installer-core.js +481 -65
  61. package/bin/learning/instinct-cli.js +7 -0
  62. package/bin/memory/vector-hub.js +196 -13
  63. package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
  64. package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
  65. package/bin/mindforge-cli.js +67 -6
  66. package/bin/models/cost-tracker.js +104 -6
  67. package/bin/models/model-client.js +6 -1
  68. package/bin/revops/debt-monitor.js +57 -13
  69. package/bin/security/trust-gate-hook.js +50 -6
  70. package/bin/skill-validator.js +6 -1
  71. package/bin/skills-builder/skill-scorer.js +46 -6
  72. package/bin/updater/self-update.js +6 -1
  73. package/bin/updater/version-comparator.js +21 -1
  74. package/bin/utils/mindforge-version.js +99 -0
  75. package/bin/utils/redact-secrets.js +106 -0
  76. package/bin/validate-config.js +42 -2
  77. package/bin/wizard/setup-wizard.js +4 -1
  78. package/bin/wizard/theme.js +9 -1
  79. package/changelogs/index.json +11 -9
  80. package/changelogs/v11.9.3.md +204 -0
  81. package/changelogs/v11.9.4.md +155 -0
  82. package/docs/References/config-reference.md +5 -2
  83. package/docs/References/sdk-api.md +1 -1
  84. package/docs/Templates/Codebase/architecture.md +1 -1
  85. package/docs/commands-reference.md +4 -5
  86. package/docs/faq.md +25 -5
  87. package/docs/getting-started.md +10 -4
  88. package/docs/sdk-reference.md +15 -7
  89. package/docs/troubleshooting.md +65 -6
  90. package/docs/user-guide.md +14 -14
  91. package/examples/sdk-integration/README.md +1 -1
  92. package/package.json +8 -3
  93. package/subagents/.claude-plugin/marketplace.json +1 -1
  94. package/.mindforge/memory/sync-manifest.json +0 -6
  95. 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 ──────────────────────────────────────────────
@@ -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.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
  }
@@ -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
  }
@@ -7,28 +7,114 @@ const fs = require('fs');
7
7
  const MAX_OUTPUT_LENGTH = 2000;
8
8
 
9
9
  /**
10
- * Stage definitions each maps a stage name to its command and optional skip condition.
11
- * The tests stage guards against recursion: if NODE_ENV=test (set by run-all.js) or
12
- * MINDFORGE_VERIFICATION_ACTIVE=1 (set by this runner), we skip to prevent infinite nesting.
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
- process.env.NODE_ENV === 'test',
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
- command: 'npx eslint . --max-warnings=0',
23
- skipIf: null,
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
- skipIf: null,
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) => !fs.existsSync(path.join(cwd, 'tsconfig.json')),
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
- if (def.skipIf && def.skipIf(cwd)) {
46
- return { name, status: 'skip', durationMs: 0, output: '' };
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
- lines.push(`| ${stage.name} | ${emoji} ${stage.status} | ${duration} |`);
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
- module.exports = { runVerification, formatReport };
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 };
@@ -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
- * the formatted report to .planning/VERIFICATION.md.
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 planningDir = path.join(CWD, '.planning');
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
- fs.writeFileSync(path.join(planningDir, 'VERIFICATION.md'), report);
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
  }