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
@@ -20,6 +20,15 @@
20
20
  * node bin/harness-audit.js --format json # machine-readable
21
21
  * node bin/harness-audit.js --scope security
22
22
  * node bin/harness-audit.js --root /path/to/checkout
23
+ * node bin/harness-audit.js --min-score 70 # exit 1 if below threshold
24
+ * node bin/harness-audit.js --fail-on-findings # exit 1 if ANY check fails
25
+ *
26
+ * GATE FLAGS (opt-in): without --min-score / --fail-on-findings this command
27
+ * ALWAYS exited 0 — even at 0/76 against an empty directory — so wiring it into
28
+ * CI produced a permanently-green required check. The threshold flags are the
29
+ * only way to make it fail; they are opt-in so existing callers (and the
30
+ * `harness:audit` npm script) keep their historical exit-0 behaviour verbatim.
31
+ * `harness:gate` is the CI-facing script that passes a threshold.
23
32
  */
24
33
 
25
34
  const fs = require('fs');
@@ -48,6 +57,22 @@ function normalizeScope(scope) {
48
57
  return value;
49
58
  }
50
59
 
60
+ /**
61
+ * Coerce a --min-score value to a non-negative finite number, or throw.
62
+ *
63
+ * WHY strict instead of defaulting: a silently-ignored threshold (missing value,
64
+ * NaN, negative) would restore the always-green failure mode this flag exists to
65
+ * remove, and would do so invisibly in CI. A bad threshold is a hard error.
66
+ */
67
+ function parseMinScore(raw) {
68
+ const text = raw === undefined || raw === null ? '' : String(raw).trim();
69
+ const value = Number(text);
70
+ if (text === '' || !Number.isFinite(value) || value < 0) {
71
+ throw new Error(`Invalid --min-score: ${raw}. Use a number >= 0 (points, compared against the scope's max_score).`);
72
+ }
73
+ return value;
74
+ }
75
+
51
76
  function parseArgs(argv) {
52
77
  const args = argv.slice(2);
53
78
  const parsed = {
@@ -55,6 +80,10 @@ function parseArgs(argv) {
55
80
  format: 'text',
56
81
  help: false,
57
82
  root: path.resolve(process.env.AUDIT_ROOT || process.cwd()),
83
+ // null (not 0) means "no threshold requested". 0 is a legitimate, if
84
+ // permissive, threshold, so a falsy check here would silently disable it.
85
+ minScore: null,
86
+ failOnFindings: false,
58
87
  };
59
88
 
60
89
  for (let index = 0; index < args.length; index += 1) {
@@ -64,6 +93,9 @@ function parseArgs(argv) {
64
93
  if (arg === '--format') { parsed.format = (args[index + 1] || '').toLowerCase(); index += 1; continue; }
65
94
  if (arg === '--scope') { parsed.scope = normalizeScope(args[index + 1]); index += 1; continue; }
66
95
  if (arg === '--root') { parsed.root = path.resolve(args[index + 1] || process.cwd()); index += 1; continue; }
96
+ if (arg === '--min-score') { parsed.minScore = parseMinScore(args[index + 1]); index += 1; continue; }
97
+ if (arg === '--fail-on-findings') { parsed.failOnFindings = true; continue; }
98
+ if (arg.startsWith('--min-score=')) { parsed.minScore = parseMinScore(arg.slice('--min-score='.length)); continue; }
67
99
  if (arg.startsWith('--format=')) { parsed.format = arg.split('=')[1].toLowerCase(); continue; }
68
100
  if (arg.startsWith('--scope=')) { parsed.scope = normalizeScope(arg.split('=')[1]); continue; }
69
101
  if (arg.startsWith('--root=')) { parsed.root = path.resolve(arg.slice('--root='.length)); continue; }
@@ -82,6 +114,25 @@ function fileExists(rootDir, relativePath) {
82
114
  return fs.existsSync(path.join(rootDir, relativePath));
83
115
  }
84
116
 
117
+ /**
118
+ * True when ANY of `relativePaths` exists under rootDir.
119
+ *
120
+ * Several checks below asked for one hardcoded path — `.agent/hooks/mindforge-context-monitor.js`,
121
+ * `bin/hooks/instinct-capture-hook.js` — which is the SOURCE-REPO layout. The installer copies
122
+ * `.agent/hooks/` to `<localDir>/hooks/`, and `bin/` is not present in a consumer project at all, so
123
+ * an installed tree fails these checks while containing the very same working scripts under a
124
+ * different name. Measured: a fresh `--claude --local` install scores 36/76 largely on checks whose
125
+ * subject it genuinely has.
126
+ *
127
+ * That made the audit unusable as an install-root gate — it under-reported a real install rather
128
+ * than over-reporting it, which is the safer direction but still wrong. Accepting either layout is a
129
+ * fix to the CHECK, not a relaxation: each alternative names a specific file that must exist, and
130
+ * absence of all of them still fails.
131
+ */
132
+ function fileExistsAny(rootDir, relativePaths) {
133
+ return relativePaths.some((rel) => fileExists(rootDir, rel));
134
+ }
135
+
85
136
  function readText(rootDir, relativePath) {
86
137
  return fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
87
138
  }
@@ -95,6 +146,82 @@ function safeParseJson(text) {
95
146
  try { return JSON.parse(text); } catch (_error) { return null; }
96
147
  }
97
148
 
149
+ /**
150
+ * Is a named hook genuinely WIRED in a settings file, relative to rootDir?
151
+ *
152
+ * "Wired" is asserted by three things, not by one substring:
153
+ * 1. the hook id appears in a command registered under PreToolUse,
154
+ * 2. that matcher covers Bash, and
155
+ * 3. every .js path in the command RESOLVES relative to rootDir.
156
+ *
157
+ * (3) is the load-bearing part. The previous check was
158
+ * claudeSettings.includes('trust-gate-hook') && agentSettings.includes('trust-gate-hook')
159
+ * — a substring scan over the raw file text, which cannot distinguish a wired hook from one whose
160
+ * script path resolves to nothing. That distinction is the entire difference between a gate and a
161
+ * silent permit: .agent/hooks/run-with-flags.js:132-136 prints "Script not found", echoes stdin
162
+ * and exits 0, which Claude Code reads as ALLOW. Measured against a real
163
+ * `node bin/install.js --claude --local`, ZERO of the eight registered command paths resolve — yet
164
+ * the substring check scored this category 10/10, because it was reading the REPO's settings text
165
+ * rather than checking anything about where the hooks would run.
166
+ *
167
+ * Consequence worth stating: pointed at an installed project this check now FAILS, truthfully.
168
+ * That is the intended behaviour — it is the honest report of the REG-01 gap, and it is why the
169
+ * check is worth the points it carries.
170
+ *
171
+ * @param {string} rootDir
172
+ * @param {string} settingsRel e.g. '.claude/settings.json'
173
+ * @param {string} hookId substring identifying the hook script, e.g. 'trust-gate-hook'
174
+ * @returns {{ok: boolean, why: string}}
175
+ */
176
+ function hookWired(rootDir, settingsRel, hookId) {
177
+ const parsed = safeParseJson(safeRead(rootDir, settingsRel));
178
+ if (!parsed) return { ok: false, why: `${settingsRel} missing or unparseable` };
179
+
180
+ // Both spellings of the pre-tool event. .claude/settings.json uses Claude Code's PreToolUse;
181
+ // .agent/settings.json is the Gemini/Antigravity mirror and uses BeforeTool — the same
182
+ // difference bin/hooks/mindforge-context-monitor.js switches on when GEMINI_API_KEY is set.
183
+ // Hardcoding PreToolUse made this check report the mirror as unwired, which is a defect in the
184
+ // check rather than in the mirror; the gate caught it.
185
+ const PRE_TOOL_EVENTS = ['PreToolUse', 'BeforeTool'];
186
+ const groups = PRE_TOOL_EVENTS.flatMap((ev) => {
187
+ const g = parsed.hooks && parsed.hooks[ev];
188
+ return Array.isArray(g) ? g : [];
189
+ });
190
+ if (!groups.length) {
191
+ return { ok: false, why: `${settingsRel} registers no ${PRE_TOOL_EVENTS.join('/')} groups` };
192
+ }
193
+
194
+ for (const group of groups) {
195
+ const matcher = String((group && group.matcher) || '');
196
+ if (!/bash/i.test(matcher) && matcher !== '*' && matcher !== '') continue;
197
+ for (const entry of (group && group.hooks) || []) {
198
+ const command = String((entry && entry.command) || '');
199
+ if (!command.includes(hookId)) continue;
200
+ // Strip a leading environment anchor before resolving. A registered command may legitimately
201
+ // be root-anchored rather than cwd-relative — `node "$CLAUDE_PROJECT_DIR/.claude/hooks/..."` —
202
+ // and Claude Code has set CLAUDE_PROJECT_DIR in the hook environment since 1.0.57. Without
203
+ // this, path.join(rootDir, '$CLAUDE_PROJECT_DIR/.claude/hooks/x.js') is checked literally, so
204
+ // an env-anchored registration reads as UNRESOLVED and the check reports a correctly wired
205
+ // hook as permitting. Handles $VAR/, ${VAR}/ and ${VAR:-default}/.
206
+ //
207
+ // Note this only affects the EXISTENCE probe. It is deliberately not a general shell expander:
208
+ // an anchor pointing somewhere other than the audited root cannot be validated from here, and
209
+ // pretending otherwise would be the same class of defect as the literal check it replaces.
210
+ const ENV_ANCHOR = /^\$\{?[A-Z_][A-Z0-9_]*(?::-[^}]*)?\}?\//;
211
+ const scripts = command.split(/\s+/)
212
+ .map((t) => t.replace(/^"|"$/g, ''))
213
+ .map((t) => t.replace(ENV_ANCHOR, ''))
214
+ .filter((t) => t.endsWith('.js'));
215
+ const unresolved = scripts.filter((rel) => !fs.existsSync(path.join(rootDir, rel)));
216
+ if (unresolved.length) {
217
+ return { ok: false, why: `${hookId} registered but ${unresolved.join(', ')} does not exist under ${rootDir} — run-with-flags exits 0 on a miss, so it permits` };
218
+ }
219
+ return { ok: true, why: `${hookId} wired on ${matcher || '*'} with ${scripts.length} resolvable script(s)` };
220
+ }
221
+ }
222
+ return { ok: false, why: `${hookId} is not registered on a Bash PreToolUse matcher in ${settingsRel}` };
223
+ }
224
+
98
225
  function countFiles(rootDir, relativeDir, extension) {
99
226
  const dirPath = path.join(rootDir, relativeDir);
100
227
  if (!fs.existsSync(dirPath)) return 0;
@@ -195,7 +322,9 @@ function getChecks(rootDir) {
195
322
  category: 'Context Efficiency', points: 3, scopes: ['repo', 'hooks'],
196
323
  path: '.agent/hooks/mindforge-context-monitor.js',
197
324
  description: 'Context-monitor hook exists',
198
- pass: fileExists(rootDir, '.agent/hooks/mindforge-context-monitor.js'),
325
+ // Repo layout OR installed layout — the installer copies .agent/hooks/ to <localDir>/hooks/.
326
+ pass: fileExistsAny(rootDir, ['.agent/hooks/mindforge-context-monitor.js',
327
+ '.claude/hooks/mindforge-context-monitor.js']),
199
328
  fix: 'Implement .agent/hooks/mindforge-context-monitor.js for context-pressure tracking.',
200
329
  },
201
330
  {
@@ -255,7 +384,9 @@ function getChecks(rootDir) {
255
384
  category: 'Memory & Learning', points: 3, scopes: ['repo', 'hooks'],
256
385
  path: 'bin/hooks/instinct-capture-hook.js',
257
386
  description: 'Instinct-capture hook exists',
258
- pass: fileExists(rootDir, 'bin/hooks/instinct-capture-hook.js'),
387
+ // bin/ does not exist in a consumer project; the installed copy lands under .claude/hooks/.
388
+ pass: fileExistsAny(rootDir, ['bin/hooks/instinct-capture-hook.js',
389
+ '.claude/hooks/instinct/instinct-capture-hook.js']),
259
390
  fix: 'Add bin/hooks/instinct-capture-hook.js for auto-capture of instincts.',
260
391
  },
261
392
  {
@@ -307,7 +438,13 @@ function getChecks(rootDir) {
307
438
  category: 'Security Guardrails', points: 3, scopes: ['repo', 'hooks', 'security'],
308
439
  path: 'bin/security/trust-gate-hook.js',
309
440
  description: 'TrustGate Bash guard exists',
310
- pass: fileExists(rootDir, 'bin/security/trust-gate-hook.js') && fileExists(rootDir, 'bin/security/trust-boundaries.js'),
441
+ // Both files must be present, in EITHER the repo layout or the installed one. Requiring one
442
+ // from each layout would be satisfiable by a half-copied install, so the pairs are evaluated
443
+ // whole rather than per-file.
444
+ pass: (fileExists(rootDir, 'bin/security/trust-gate-hook.js')
445
+ && fileExists(rootDir, 'bin/security/trust-boundaries.js'))
446
+ || (fileExists(rootDir, '.claude/hooks/security/trust-gate-hook.js')
447
+ && fileExists(rootDir, '.claude/hooks/security/trust-boundaries.js')),
311
448
  fix: 'Restore bin/security/trust-gate-hook.js + trust-boundaries.js.',
312
449
  },
313
450
  {
@@ -315,7 +452,8 @@ function getChecks(rootDir) {
315
452
  category: 'Security Guardrails', points: 2, scopes: ['repo', 'hooks', 'security'],
316
453
  path: '.agent/hooks/mindforge-block-no-verify.js',
317
454
  description: 'Git-hook-bypass guard exists',
318
- pass: fileExists(rootDir, '.agent/hooks/mindforge-block-no-verify.js'),
455
+ pass: fileExistsAny(rootDir, ['.agent/hooks/mindforge-block-no-verify.js',
456
+ '.claude/hooks/mindforge-block-no-verify.js']),
319
457
  fix: 'Add .agent/hooks/mindforge-block-no-verify.js to block --no-verify.',
320
458
  },
321
459
  {
@@ -330,9 +468,19 @@ function getChecks(rootDir) {
330
468
  id: 'security-bash-guard-both',
331
469
  category: 'Security Guardrails', points: 2, scopes: ['repo', 'hooks', 'security'],
332
470
  path: '.agent/settings.json',
333
- description: 'Bash guards wired in BOTH .claude and the .agent Gemini mirror',
334
- pass: claudeSettings.includes('trust-gate-hook') && agentSettings.includes('trust-gate-hook'),
335
- fix: 'Wire trust-gate + block-no-verify into both .claude/settings.json and .agent/settings.json.',
471
+ description: 'Bash guards wired in BOTH .claude and the .agent Gemini mirror (paths resolve)',
472
+ // The .agent mirror is required only WHEN PRESENT. No install channel writes it, and this
473
+ // repo's own spec records its BeforeTool/AfterTool events as never firing — so requiring it
474
+ // unconditionally made this check unpassable on every install root even with Claude Code
475
+ // enforcement fully wired. It measured layout, not enforcement. Absent mirror: .claude
476
+ // alone decides. Present mirror: BOTH must be wired, so a half-configured Gemini setup
477
+ // still fails.
478
+ pass: hookWired(rootDir, '.claude/settings.json', 'trust-gate-hook').ok
479
+ && (hookWired(rootDir, '.agent/settings.json', 'trust-gate-hook').ok
480
+ || !fileExists(rootDir, '.agent/settings.json')),
481
+ fix: 'Wire trust-gate into a Bash PreToolUse matcher in BOTH .claude/settings.json and '
482
+ + '.agent/settings.json, with command paths that RESOLVE from the audited root. A '
483
+ + 'registered command whose script is absent exits 0 and permits — see hookWired().',
336
484
  },
337
485
  {
338
486
  id: 'security-threat-model',
@@ -406,7 +554,7 @@ function getChecks(rootDir) {
406
554
  id: 'gov-audit-trail',
407
555
  category: 'Governance & Identity', points: 2, scopes: ['repo'],
408
556
  path: '.mindforge/audit/',
409
- description: 'Merkle-linked audit trail directory exists',
557
+ description: 'Hash-chained audit trail directory exists',
410
558
  pass: fileExists(rootDir, '.mindforge/audit') || fileExists(rootDir, '.planning'),
411
559
  fix: 'Ensure the audit-trail directory (.mindforge/audit/) is present.',
412
560
  },
@@ -462,7 +610,15 @@ function buildReport(scope, options = {}) {
462
610
  }
463
611
 
464
612
  function printText(report) {
465
- console.log(`MindForge Harness Audit (${report.scope}): ${report.overall_score}/${report.max_score}`);
613
+ // The header names the SCOPE, which is independent of --root. Auditing an installed project with
614
+ // `--root <dir>` therefore used to print "MindForge Harness Audit (repo)" — so an install-root
615
+ // score read as a repo score. Measured: the repo scores 76/76 while a fresh `--claude --local`
616
+ // install of the SAME tree scores 36/76 with Security Guardrails 1/10 and 17 of 31 checks failing.
617
+ // Two very different numbers under one identical label is how the enforcement gap stayed invisible.
618
+ // When the audited root is not the cwd, say so in the header rather than only in the Root: line.
619
+ const auditedElsewhere = path.resolve(report.root_dir) !== path.resolve(process.cwd());
620
+ const where = auditedElsewhere ? `${report.scope}, external root` : report.scope;
621
+ console.log(`MindForge Harness Audit (${where}): ${report.overall_score}/${report.max_score}`);
466
622
  console.log(`Root: ${report.root_dir}`);
467
623
  console.log('');
468
624
 
@@ -488,13 +644,60 @@ function printText(report) {
488
644
  function showHelp(exitCode = 0) {
489
645
  console.log(`
490
646
  Usage: node bin/harness-audit.js [scope] [--scope <${SCOPES.join('|')}>] [--format <text|json>] [--root <path>]
647
+ [--min-score <n>] [--fail-on-findings]
491
648
 
492
649
  Deterministic MindForge harness audit based on explicit file/config checks.
493
650
  Audits the current working directory by default.
651
+
652
+ Gate flags (opt-in — with neither of these the command always exits 0):
653
+ --min-score <n> exit 1 when overall_score < n. n is in POINTS and the max
654
+ is scope-dependent (repo 76, security 13, agents 4), so a
655
+ value above the scope's max_score is rejected as
656
+ unsatisfiable. CI uses: npm run harness:gate
657
+ --fail-on-findings exit 1 when ANY check fails (strictest: every check added
658
+ later becomes blocking the moment it lands)
494
659
  `);
495
660
  process.exit(exitCode);
496
661
  }
497
662
 
663
+ /**
664
+ * Decide whether the report clears the requested gate. Pure: reads only the
665
+ * report's existing `overall_score` / `max_score` / `checks[].pass` fields and
666
+ * returns a NEW object — no parallel scoring path, no mutation of `report`.
667
+ *
668
+ * WHY an unsatisfiable threshold is itself a failure: --min-score is in POINTS
669
+ * and the max differs per scope (repo 76, security 13, agents 4), so
670
+ * `--scope agents --min-score 70` can never pass. Saying so beats a red check
671
+ * nobody can act on — and beats silently passing it.
672
+ */
673
+ function evaluateGate(report, options = {}) {
674
+ const minScore = options.minScore === undefined ? null : options.minScore;
675
+ const failOnFindings = Boolean(options.failOnFindings);
676
+ const failures = [];
677
+
678
+ if (minScore !== null) {
679
+ if (minScore > report.max_score) {
680
+ failures.push(`--min-score ${minScore} exceeds max_score ${report.max_score} for scope "${report.scope}" — unsatisfiable threshold.`);
681
+ } else if (report.overall_score < minScore) {
682
+ failures.push(`score ${report.overall_score}/${report.max_score} is below --min-score ${minScore} (scope "${report.scope}").`);
683
+ }
684
+ }
685
+
686
+ if (failOnFindings) {
687
+ const failing = report.checks.filter(check => !check.pass);
688
+ if (failing.length > 0) {
689
+ failures.push(`--fail-on-findings: ${failing.length} of ${report.checks.length} checks failing (${failing.map(check => check.id).join(', ')}).`);
690
+ }
691
+ }
692
+
693
+ return {
694
+ enforced: minScore !== null || failOnFindings,
695
+ ok: failures.length === 0,
696
+ failures,
697
+ exitCode: failures.length === 0 ? 0 : 1,
698
+ };
699
+ }
700
+
498
701
  function main() {
499
702
  try {
500
703
  const args = parseArgs(process.argv);
@@ -507,6 +710,17 @@ function main() {
507
710
  } else {
508
711
  printText(report);
509
712
  }
713
+
714
+ // Gate LAST and on stderr, so --format json keeps stdout pure JSON and the
715
+ // scorecard stays readable when the gate trips. There is deliberately no
716
+ // explicit process.exit(0) on the success path: falling off main() exits 0
717
+ // naturally, and a hardcoded exit(0) is how gates start lying.
718
+ const gate = evaluateGate(report, { minScore: args.minScore, failOnFindings: args.failOnFindings });
719
+ if (!gate.ok) {
720
+ console.error('');
721
+ for (const failure of gate.failures) { console.error(`Gate FAIL: ${failure}`); }
722
+ process.exit(gate.exitCode);
723
+ }
510
724
  } catch (error) {
511
725
  console.error(`Error: ${error.message}`);
512
726
  process.exit(1);
@@ -517,4 +731,4 @@ if (require.main === module) {
517
731
  main();
518
732
  }
519
733
 
520
- module.exports = { buildReport, parseArgs, getChecks, CATEGORIES, RUBRIC_VERSION };
734
+ module.exports = { buildReport, parseArgs, getChecks, evaluateGate, parseMinScore, hookWired, CATEGORIES, RUBRIC_VERSION };
@@ -15,6 +15,7 @@ const path = require('path');
15
15
  const crypto = require('crypto');
16
16
  const os = require('os');
17
17
  const { detectProject } = require('./lib/detect-project');
18
+ const { redactSecrets } = require('../utils/redact-secrets');
18
19
 
19
20
  // ── Configuration ────────────────────────────────────────────────────────────
20
21
 
@@ -81,18 +82,25 @@ function extractPattern(payload) {
81
82
  if (!command || command.length < 5) return null;
82
83
  // Skip trivial commands
83
84
  if (/^(ls|pwd|echo|cat|cd)\b/.test(command.trim())) return null;
85
+ // Redact BEFORE truncating. Slicing first can cut a credential mid-token, leaving a fragment
86
+ // that no longer matches its own prefix rule — a half-secret is still a secret.
87
+ const safe = redactSecrets(command).slice(0, 200);
84
88
  return {
85
- observation: `Bash command succeeded: ${command.slice(0, 200)}`,
86
- behavior: `Use pattern: ${command.slice(0, 200)}`,
89
+ observation: `Bash command succeeded: ${safe}`,
90
+ behavior: `Use pattern: ${safe}`,
87
91
  };
88
92
  }
89
93
 
90
94
  if (tool === 'task') {
91
95
  const description = payload.description || payload.task_description || payload.name || '';
92
96
  if (!description) return null;
97
+ // The Task branch leaks too, and it is easy to miss because it carries no "command" in its name.
98
+ // Measured: a payload {tool_name:'Task', status:'completed', description:'rotate prod secret
99
+ // using token <40 chars>'} wrote that token verbatim into BOTH fields.
100
+ const safe = redactSecrets(description).slice(0, 200);
93
101
  return {
94
- observation: `Task completed successfully: ${description.slice(0, 200)}`,
95
- behavior: `Reuse approach for similar tasks: ${description.slice(0, 200)}`,
102
+ observation: `Task completed successfully: ${safe}`,
103
+ behavior: `Reuse approach for similar tasks: ${safe}`,
96
104
  };
97
105
  }
98
106
 
package/bin/install.js CHANGED
@@ -20,13 +20,23 @@
20
20
 
21
21
  'use strict';
22
22
 
23
- const VERSION = require('../package.json').version;
23
+ // Resolved by package NAME, not by relative path. This file is copied into the consumer's project
24
+ // by --with-utils, where '../package.json' is THEIR manifest — so a relative read prints their app's
25
+ // version in the banner and in `--version`. resolveMindforgeVersion walks up for a package.json
26
+ // whose name is 'mindforge-cc', then falls back to <cwd>/.mindforge/config.json.
27
+ const VERSION = require('./utils/mindforge-version').resolveMindforgeVersion().version;
24
28
  const ARGS = process.argv.slice(2);
25
29
  const Theme = require('./wizard/theme');
26
30
  const c = Theme.colors;
27
31
 
28
- // Note: Structural integrity check requires the presence of 'verifyInstall'.
29
- // The actual logic is now modularized in ./installer-core.js
32
+ // The installation logic lives in ./installer-core.js; this file is the CLI entry point.
33
+ //
34
+ // A line here used to read "Structural integrity check requires the presence of 'verifyInstall'"
35
+ // — a comment whose only purpose was to contain a string that tests/install.test.js grepped for.
36
+ // The test asserted this file mentioned verifyInstall; the function had moved to installer-core.js
37
+ // and was never called from anywhere. The comment satisfied the test, the test protected the
38
+ // comment, and nothing verified an install. That test now asserts the delegation that actually
39
+ // matters, and installer-core.js calls verifyInstall for real.
30
40
 
31
41
  // ── Minimum Node.js version gate ─────────────────────────────────────────────
32
42
  const NODE_MAJOR = parseInt(process.versions.node.split('.')[0], 10);
@@ -50,6 +60,56 @@ if (ARGS.includes('--help') || ARGS.includes('-h')) {
50
60
  process.exit(0);
51
61
  }
52
62
 
63
+ // ── Reject unknown positional arguments ───────────────────────────────────────
64
+ //
65
+ // This installer has NO subcommands — it only takes flags. It also never looked at positionals, so
66
+ // any bare word was silently ignored and the installer just ran. That mattered because the last
67
+ // thing a successful install printed was, verbatim from bin/wizard/theme.js:
68
+ //
69
+ // Next steps:
70
+ // mindforge-cc init — Initialize your first workspace
71
+ //
72
+ // There is no `init`. Obeying the instruction re-ran the installer against whatever directory the
73
+ // user happened to be standing in: measured in an empty temp dir, `node bin/install.js init` wrote
74
+ // 1,836 files, created .claude/ .mindforge/ .planning/ bin/ plus CLAUDE.md, MINDFORGE.md and
75
+ // AGENTS_LEARNING.md, exited 0 — and re-printed the same instruction, so it loops. The real command
76
+ // is the slash command `/mindforge:init-project`, which the install has just placed in the harness.
77
+ //
78
+ // Rejecting is the right response rather than treating a positional as a target directory: this
79
+ // script's whole contract is expressed in flags, and guessing what a stray word meant is how
80
+ // `mindforge-cc /etc` becomes an interesting afternoon.
81
+ //
82
+ // `--runtime` takes its value as a SEPARATE token — installer-core.js's run() reads
83
+ // args[rtIdx + 1] after locating args.indexOf('--runtime') — so that token is skipped here; a
84
+ // naive "anything not starting with -" check would reject the documented `--runtime claude`.
85
+ //
86
+ // Cites the SYMBOL, not a line number. The original said :1112, which was exact when written and
87
+ // wrong by the time it merged: two PRs in the same batch grew installer-core.js from 1173 to 1347
88
+ // lines with 161 of them above that point, so the reference landed on unrelated code. A line
89
+ // number in a comment is a claim with an expiry date and nothing asserts it.
90
+ const VALUE_TAKING_FLAGS = new Set(['--runtime']);
91
+ const POSITIONALS = [];
92
+ for (let i = 0; i < ARGS.length; i++) {
93
+ if (ARGS[i].startsWith('-')) {
94
+ if (VALUE_TAKING_FLAGS.has(ARGS[i])) i += 1;
95
+ continue;
96
+ }
97
+ POSITIONALS.push(ARGS[i]);
98
+ }
99
+ if (POSITIONALS.length > 0) {
100
+ process.stderr.write(
101
+ `\n${c.red(Theme.chars.cross)} ${c.bold(`Unknown argument: ${POSITIONALS[0]}`)}\n` +
102
+ ' mindforge-cc takes flags only — it has no subcommands.\n' +
103
+ (POSITIONALS[0] === 'init'
104
+ ? ` To initialise a workspace, run the slash command ${c.cyan('/mindforge:init-project')}\n` +
105
+ ' inside your AI harness after installing.\n'
106
+ : '') +
107
+ ` Install: ${c.cyan('npx mindforge-cc --claude --local')}\n` +
108
+ ` All flags: ${c.cyan('npx mindforge-cc --help')}\n\n`
109
+ );
110
+ process.exit(1);
111
+ }
112
+
53
113
  // ── Determine execution mode ──────────────────────────────────────────────────
54
114
  const NON_INTERACTIVE_FLAGS = [
55
115
  '--claude', '--antigravity', '--cursor', '--opencode', '--gemini', '--copilot',