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
@@ -11,7 +11,13 @@ const SessionMemoryLoader = require('./memory/session-memory-loader');
11
11
  const Theme = require('./wizard/theme');
12
12
  const c = Theme.colors;
13
13
 
14
- const VERSION = require('../package.json').version;
14
+ // A `const VERSION = require('../package.json').version` used to sit here. It was DEAD — declared,
15
+ // never read, never exported — and it was the only reason this module could fail to load at all.
16
+ // `require` of a missing path throws MODULE_NOT_FOUND, and in an install this file lands at
17
+ // <project>/bin/installer-core.js, so '../package.json' is the CONSUMER's manifest: present and
18
+ // wrong (their app's version), or absent and fatal. A dead read that can only ever crash is pure
19
+ // liability, so it is gone rather than rewired. Anything here that needs MindForge's version must
20
+ // call resolveMindforgeVersion() from ./utils/mindforge-version, which resolves by package NAME.
15
21
 
16
22
  // ── Runtime configurations ────────────────────────────────────────────────────
17
23
  const RUNTIMES = {
@@ -164,13 +170,108 @@ function getCommandDescription(content) {
164
170
  return 'No description available';
165
171
  }
166
172
 
173
+ /**
174
+ * Where a command file from a given namespace must be written, and under what name.
175
+ *
176
+ * THE DEFECT THIS FIXES. Both command sources — `.agent/mindforge` and `.agent/forge` — were written
177
+ * into the SAME directory under their bare filenames. `.agent/forge` holds exactly three files and
178
+ * ALL THREE collide with mindforge commands, and forge was pushed second, so forge won. Measured on
179
+ * a real `--claude --local` install, the installed file against its two candidate sources:
180
+ *
181
+ * help.md 33 lines -> 11 (-22)
182
+ * init-project.md 170 lines -> 36 (-134)
183
+ * plan-phase.md 131 lines -> 34 (-97)
184
+ *
185
+ * 253 lines of the three flagship commands destroyed on every install, with no warning. The same bug
186
+ * inflated the reported count: `totalCount` summed both sources, so the installer announced 224
187
+ * commands while 221 landed — the overcount was exactly the three overwritten files.
188
+ *
189
+ * They are not duplicates, which is what makes the loss silent rather than harmless. They are a
190
+ * SEPARATE command family: forge's own body says "Show all available FORGE commands" and
191
+ * "List every .md file in `.claude/commands/forge/`" — a directory the installer never created. So
192
+ * `/mindforge:help` was answering with forge's text, which then pointed the model at a path that
193
+ * does not exist.
194
+ *
195
+ * THE FIX follows a pattern this file already contains. The cross-IDE mirror below has always
196
+ * written `path.join(cwd, '.claude', 'commands', source.namespace)` — per-namespace directories. The
197
+ * primary install simply never did the same.
198
+ *
199
+ * @param {string} cmdsDir the runtime's configured commands directory
200
+ * @param {string} runtime RUNTIMES key
201
+ * @param {string} namespace 'mindforge' | 'forge'
202
+ * @param {string} file basename, e.g. 'help.md'
203
+ * @returns {{dir: string, name: string}}
204
+ */
205
+ function resolveCommandTarget(cmdsDir, runtime, namespace, file) {
206
+ // Antigravity flattens everything into one `workflows/` directory and has always disambiguated by
207
+ // prefixing the namespace. Left exactly as it was — it never had the collision.
208
+ if (runtime === 'antigravity') return { dir: cmdsDir, name: `${namespace}:${file}` };
209
+
210
+ // Runtimes whose commands directory is already per-family (leaf 'mindforge': claude, opencode,
211
+ // gemini, copilot) get a SIBLING directory per namespace. For claude that is
212
+ // .claude/commands/forge/ — exactly where forge's own help text says forge commands live, so the
213
+ // fix makes that text true rather than merely stopping the overwrite.
214
+ if (path.basename(cmdsDir) === 'mindforge') {
215
+ return namespace === 'mindforge'
216
+ ? { dir: cmdsDir, name: file }
217
+ : { dir: path.join(path.dirname(cmdsDir), namespace), name: file };
218
+ }
219
+
220
+ // Flat runtimes (cursor writes into `rules/`). Keep mindforge's filenames unchanged — renaming
221
+ // them would alter cursor's rule set for reasons unrelated to this defect — and prefix the rest.
222
+ return namespace === 'mindforge'
223
+ ? { dir: cmdsDir, name: file }
224
+ : { dir: cmdsDir, name: `${namespace}:${file}` };
225
+ }
226
+
167
227
  // ── File system utilities ─────────────────────────────────────────────────────
228
+ /**
229
+ * Refuse to write through a symlink.
230
+ *
231
+ * THE DEFECT, reproduced with a canary before this guard existed. `fs.writeFileSync` and
232
+ * `fs.copyFileSync` open the destination O_WRONLY|O_CREAT|O_TRUNC and FOLLOW symlinks, and every
233
+ * installer write funnels through the two primitives below. So a repository that commits its entry
234
+ * file as a symlink turned the documented install command into an arbitrary-file overwrite:
235
+ *
236
+ * $ ln -s <victim> <project>/CLAUDE.md # the repo carries this; git preserves symlinks
237
+ * $ npx mindforge-cc@latest --claude --local
238
+ * victim before: 24 bytes sha 2cfdbb20c25ced11
239
+ * victim after: 5646 bytes sha 05b78d05307b2350 <- overwritten
240
+ * <project>/CLAUDE.md.backup-<epoch> CONTAINS THE VICTIM CONTENT <- and disclosed
241
+ *
242
+ * Two separate harms in one step: the target is destroyed, and because safeCopyClaude reads the
243
+ * destination THROUGH the link before replacing it, the victim's previous contents are copied into
244
+ * the project's working tree as a backup file. Point the link at anything the installing user can
245
+ * write and both happen with their privileges.
246
+ *
247
+ * Refusing rather than unlinking is deliberate. Unlinking would silently change what the user's
248
+ * project looks like; refusing leaves their file untouched and tells them why. Writing through a
249
+ * symlink is not something an installer ever legitimately needs to do.
250
+ *
251
+ * SCOPE, stated rather than implied: this guards the destination FILE. A symlinked DIRECTORY in the
252
+ * destination path is a separate escape — mkdirSync/copyFileSync resolve it too — and is not covered
253
+ * here. Closing that needs a containment check against the install root, which behaves differently
254
+ * for --local (cwd-relative) and global installs, so it belongs in its own change.
255
+ */
256
+ function assertNotSymlink(p) {
257
+ let st;
258
+ try { st = fs.lstatSync(p); } catch { return; } // absent is a normal, safe state
259
+ if (!st.isSymbolicLink()) return;
260
+ let target = '';
261
+ try { target = ` -> ${fs.readlinkSync(p)}`; } catch { target = ' -> <dangling>'; }
262
+ throw new Error(
263
+ `[installer] REFUSING to write through a symlink: ${p}${target}\n`
264
+ + ' fs.writeFileSync/copyFileSync follow symlinks, so this would overwrite the target outside\n'
265
+ + ' the project and could copy its contents into the working tree as a backup.\n'
266
+ + ' Remove or replace the link, then re-run the installer.');
267
+ }
268
+
168
269
  const fsu = {
169
270
  exists: p => fs.existsSync(p),
170
271
  read: p => fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '',
171
- write: (p, t) => { fsu.ensureDir(path.dirname(p)); fs.writeFileSync(p, t, 'utf8'); },
272
+ write: (p, t) => { assertNotSymlink(p); fsu.ensureDir(path.dirname(p)); fs.writeFileSync(p, t, 'utf8'); },
172
273
  ensureDir: p => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); },
173
- copy: (src, dst) => { fsu.ensureDir(path.dirname(dst)); fs.copyFileSync(src, dst); },
274
+ copy: (src, dst) => { assertNotSymlink(dst); fsu.ensureDir(path.dirname(dst)); fs.copyFileSync(src, dst); },
174
275
  listFiles: p => fs.existsSync(p) ? fs.readdirSync(p) : [],
175
276
  listFilesRecursive: (p, ext = '.md') => {
176
277
  if (!fs.existsSync(p)) return [];
@@ -301,6 +402,26 @@ const SENSITIVE_EXCLUDE = [
301
402
  '.git',
302
403
  '.DS_Store',
303
404
  'browser-daemon.log',
405
+ // The framework's OWN runtime state, which is not the consumer's and is not small.
406
+ //
407
+ // Measured on a `--claude --local` install whose source was this working tree: the target received
408
+ // celestial.db (11.2 MB, sha256 IDENTICAL to the source, so copied rather than created) plus its
409
+ // -wal and -shm sidecars and SEVEN orphaned `celestial.db.tmp.<pid>.async` exports at 11.1 MB each
410
+ // — about 89 MB of the developer's traces, skills and attestations landing in someone else's
411
+ // project. `browser-daemon.log` was already excluded here; the database beside it was not.
412
+ //
413
+ // SCOPE, stated honestly: `npm pack --dry-run` confirms neither celestial.db nor the daemon token
414
+ // ships in the tarball, so an ordinary `npx mindforge-cc` install has nothing to copy and was never
415
+ // exposed. This bites installs whose SOURCE is a working tree — a git-clone install, or local
416
+ // development. It is defence in depth rather than a live consumer leak, and it belongs here because
417
+ // `files[]` excluding it today is not a guarantee about tomorrow.
418
+ //
419
+ // One pattern for the database and every sidecar and orphan: .db, .db-wal, .db-shm,
420
+ // .db.tmp.<pid>.async, .db.conflict.<pid>.<len>.
421
+ /^celestial\.db($|[.-])/,
422
+ // A live capability token. Not a credential for a remote service, but still this machine's handle
423
+ // to a running daemon, and nothing the target project should receive.
424
+ /^\.browser-daemon-token$/,
304
425
  /audit\.jsonl/i,
305
426
  /handoff\.json/i,
306
427
  /jira-sync\.json/i,
@@ -359,6 +480,12 @@ function resolveBaseDir(runtime, scope) {
359
480
  function safeCopyClaude(src, dst, options = {}) {
360
481
  const { force = false, verbose = false } = options;
361
482
 
483
+ // BEFORE reading. fsu.read() resolves the link, so checking here rather than relying on the guard
484
+ // inside fsu.copy() is what stops the DISCLOSURE half: with a symlinked destination the old order
485
+ // read the victim's contents and wrote them to `${dst}.backup-<epoch>` inside the project, and that
486
+ // backup path is a fresh regular file, so the primitive's guard would never have fired on it.
487
+ assertNotSymlink(dst);
488
+
362
489
  if (fsu.exists(dst)) {
363
490
  const existing = fsu.read(dst);
364
491
 
@@ -382,6 +509,29 @@ function safeCopyClaude(src, dst, options = {}) {
382
509
  }
383
510
 
384
511
  // ── Install verification ──────────────────────────────────────────────────────
512
+ /**
513
+ * Check that an install produced the files it promised.
514
+ *
515
+ * THIS WAS DEAD CODE. Declared here and called from nowhere, while install() printed
516
+ * "Install verified" unconditionally under a section header reading "4. Verify installation".
517
+ * Two tests kept it alive without ever running it — tests/production.test.js:86 and
518
+ * tests/install.test.js:179 both assert only that the SOURCE CONTAINS the string
519
+ * "verifyInstall". They would pass against an empty function body, and they are why the dead
520
+ * function survived: the suite guaranteed the appearance of verification.
521
+ *
522
+ * Its contract was also UNSATISFIABLE. It required docs/registry/COMMANDS.md and
523
+ * docs/registry/PERSONAS.md under the project root, but docs/registry/ ships ZERO files in the
524
+ * npm tarball, so those two could never exist in a consumer install. Measured against a clean
525
+ * `--claude --local`: 12 of the 14 required files present, those two absent. Wiring it as
526
+ * written would therefore have exited 1 on every successful install — which is presumably why
527
+ * nobody wired it. They are removed rather than "fixed" by copying the files, because requiring
528
+ * an artefact the package does not publish is a category error, not a copy gap.
529
+ *
530
+ * Returns a result instead of calling process.exit, so it is testable without spawning an
531
+ * installer. The caller owns the exit decision.
532
+ *
533
+ * @returns {{ok: boolean, missing: string[], checked: number}}
534
+ */
385
535
  function verifyInstall(baseDir, cmdsDir, runtime, scope) {
386
536
  const cfg = RUNTIMES[runtime];
387
537
  const pfx = runtime === 'antigravity' ? 'mindforge:' : '';
@@ -392,25 +542,42 @@ function verifyInstall(baseDir, cmdsDir, runtime, scope) {
392
542
  path.join(cmdsDir, `${pfx}health.md`),
393
543
  path.join(cmdsDir, `${pfx}execute-phase.md`),
394
544
  path.join(cmdsDir, `${pfx}security-scan.md`),
395
- // Sovereign Engine logic
396
- path.join(process.cwd(), 'bin/governance/policy-engine.js'),
397
- path.join(process.cwd(), 'bin/governance/quantum-crypto.js'),
398
- path.join(process.cwd(), 'bin/autonomous/intent-harvester.js'),
399
- path.join(process.cwd(), 'bin/memory/cli.js'),
400
- path.join(process.cwd(), 'bin/models/cost-tracker.js'),
401
- path.join(process.cwd(), 'bin/research/research-engine.js'),
402
- path.join(process.cwd(), 'docs/registry/COMMANDS.md'),
403
- path.join(process.cwd(), 'docs/registry/PERSONAS.md'),
545
+ // Sovereign Engine logic — LOCAL SCOPE ONLY, because a global install never writes it.
546
+ //
547
+ // These six were required unconditionally against process.cwd(), so every `--claude --global`
548
+ // install ended:
549
+ //
550
+ // ❌ Install verification failed — 6 of 12 required file(s) missing
551
+ // Retry: npx mindforge-cc@latest --claude --global --force
552
+ // exit 1
553
+ //
554
+ // Measured: a global install writes 389 files to $HOME/.claude and ZERO to bin/ anywhere —
555
+ // neither the project nor $HOME/.claude/bin. That is deliberate, not a gap: the block that copies
556
+ // sovereignEngines is gated `if (scope === 'local' && !selfInstall)` at :845. So verification was
557
+ // demanding artifacts of an operation the installer had correctly chosen not to perform, then
558
+ // advising a --force retry that cannot help — --force does not change which scope branch runs.
559
+ //
560
+ // The scope-conditional shape was already here, applied to the entry file on the first line of
561
+ // this array and to nothing else. Extending it is the smaller change; the alternative — making a
562
+ // global install carry bin/ — is a decision about what global scope MEANS and is deliberately not
563
+ // taken here. Flagged instead: a global install currently gives a user commands under
564
+ // ~/.claude/commands/mindforge/ with no Node runtime beside them, and whether that is coherent is
565
+ // a product question rather than a verification bug.
566
+ ...(scope === 'local' ? [
567
+ path.join(process.cwd(), 'bin/governance/policy-engine.js'),
568
+ path.join(process.cwd(), 'bin/governance/quantum-crypto.js'),
569
+ path.join(process.cwd(), 'bin/autonomous/intent-harvester.js'),
570
+ path.join(process.cwd(), 'bin/memory/cli.js'),
571
+ path.join(process.cwd(), 'bin/models/cost-tracker.js'),
572
+ path.join(process.cwd(), 'bin/research/research-engine.js'),
573
+ ] : []),
404
574
  ];
405
575
 
406
576
  const missing = required.filter(f => !fsu.exists(f));
407
-
408
- if (missing.length > 0) {
409
- console.error(`\n ❌ Install verification failed ${missing.length} required file(s) missing:`);
410
- missing.forEach(f => console.error(` ${f}`));
411
- console.error(`\n Retry: npx mindforge-cc@latest --${runtime} --${scope} --force`);
412
- process.exit(1);
413
- }
577
+ // `checked` is reported by the caller ("Install verified (N required files present)"), so it has to
578
+ // reflect what was actually examined for this scope — 12 local, 6 global. Printing 12 after checking
579
+ // 6 would be the same class of false claim this function exists to catch.
580
+ return { ok: missing.length === 0, missing, checked: required.length };
414
581
  }
415
582
 
416
583
  // ── Install single runtime ────────────────────────────────────────────────────
@@ -425,13 +592,39 @@ async function install(runtime, scope, options = {}) {
425
592
  const cfg = RUNTIMES[runtime];
426
593
  const baseDir = resolveBaseDir(runtime, scope);
427
594
  const cmdsDir = norm(path.join(baseDir, cfg.commandsSubdir));
428
- const selfInstall = isSelfInstall();
595
+ // SCOPE MATTERS, and leaving it out was a regression this commit introduced and then had to fix.
596
+ //
597
+ // isSelfInstall() answers only "is the CURRENT DIRECTORY the MindForge repo". The guard's actual
598
+ // premise is narrower: "the files I am about to write ARE this repository's own tracked files".
599
+ // That holds for a LOCAL install, whose baseDir is `.claude/` inside the repo. It does NOT hold for
600
+ // a GLOBAL install, whose baseDir is `~/.claude/` — a directory that has nothing to do with the
601
+ // checkout you happen to be standing in.
602
+ //
603
+ // Measured on `--claude --global` run from a MindForge checkout, with HOME confined:
604
+ // develop exit 0, 225 files written to $HOME/.claude
605
+ // with cwd-only gate exit 1, 0 files, then "Retry: ... --force" — which also fails
606
+ // So gating on cwd alone broke global installs for every MindForge developer, and offered a retry
607
+ // that could not work. Anchoring on scope as well restores it while keeping the local-install
608
+ // protection that is the whole point.
609
+ //
610
+ // Every downstream gate reads this one binding, so the correction lands at all twelve sites at
611
+ // once rather than being re-derived (and re-forgotten) at each.
612
+ const selfInstall = isSelfInstall() && scope === 'local';
429
613
  const targetDir = baseDir;
614
+ // REG-01 result, printed in the final summary. Declared here so the summary cannot reference an
615
+ // undefined binding when the registration block is skipped (global scope, self-install, etc.).
616
+ let hookRegistration = { status: 'not-attempted', reason: 'registration block not reached', registered: false };
430
617
 
431
618
  Theme.printPrompt(`Runtime : ${c.cyan(runtime)}`);
432
619
  Theme.printPrompt(`Scope : ${c.dim(scope)} → ${c.bold(targetDir)}`);
433
620
  if (options.dryRun) Theme.printStatus('Mode : DRY RUN (no changes)', 'warn');
434
- if (selfInstall) Theme.printStatus(c.yellow('Self-install detected skipping framework file copy'), 'warn');
621
+ // Names what is skipped, because the previous wording was FALSE. It read "skipping framework file
622
+ // copy" while the code still overwrote the entry file and 149 tracked command files. A message
623
+ // describing work the code does not do is worse than no message — it is why nobody noticed.
624
+ if (selfInstall) {
625
+ Theme.printStatus(c.yellow('Self-install detected — leaving this repository\'s own tracked files alone'), 'warn');
626
+ Theme.printStatus(c.dim(` not written: ${cfg.entryFile}, commands, skills, hooks, personas, docs, subagents, memory`), 'info');
627
+ }
435
628
 
436
629
  if (dryRun) {
437
630
  console.log('\n Would install:');
@@ -493,12 +686,31 @@ async function install(runtime, scope, options = {}) {
493
686
  // ✨ RUNTIME ADAPTATION: Generate specific content for this runtime
494
687
  const adaptedContent = generateEntryContent(runtime, content);
495
688
 
496
- // Keep legacy location based on runtime config
689
+ // Keep legacy location based on runtime config.
690
+ //
691
+ // THE LEAK. This staging file was written and never removed — no unlink, no finally, no exit
692
+ // handler. Every install left one behind, forever. Measured on this machine: 2,711 orphaned
693
+ // `/tmp/CLAUDE.md-<ms>.md` files (~21 MB), and 905 more accumulated within a day of clearing
694
+ // them. Unconditional, unlike the .mindforge excludes below: it fires on an ordinary
695
+ // `npx mindforge-cc` install too, because os.tmpdir() has nothing to do with the source tree.
696
+ //
697
+ // Removed in a `finally` rather than after the last write, so a throw from any of the three
698
+ // safeCopyClaude calls cannot skip it — a leak on the failure path is how the original one
699
+ // survived review.
497
700
  const tempEntry = path.join(os.tmpdir(), `${cfg.entryFile}-${Date.now()}.md`);
498
701
  fsu.write(tempEntry, adaptedContent);
499
-
702
+ try {
703
+
500
704
  const targetPath = path.join(baseDir, cfg.entryFile);
501
- safeCopyClaude(tempEntry, targetPath, { force, verbose });
705
+ // GATED ON !selfInstall, which it was not. The guard existed and stopped one line short: the root
706
+ // mirror below has always been gated, this write never was. In MindForge's own repository
707
+ // `.claude/CLAUDE.md` is TRACKED and not gitignored (226 files under .claude/ are tracked), so a
708
+ // self-install overwrote a committed file. No backup was taken either, and for a reason worth
709
+ // naming: safeCopyClaude only backs up when the existing content does NOT contain "MindForge" —
710
+ // and the repo's own entry file does, so it took the silent-replace path every time.
711
+ if (!selfInstall) {
712
+ safeCopyClaude(tempEntry, targetPath, { force, verbose });
713
+ }
502
714
 
503
715
  // ✨ STANDARD: Inject into project root and IDE-specific rules files
504
716
  if (scope === 'local' && !selfInstall) {
@@ -515,9 +727,19 @@ async function install(runtime, scope, options = {}) {
515
727
  } else {
516
728
  Theme.printResolved(`${c.bold('CLAUDE.md')} (Mirrored to project root)`);
517
729
  }
518
- } else {
730
+ } else if (!selfInstall) {
731
+ // `!selfInstall` added alongside the write gate above. This branch printed the entry file name
732
+ // as a resolved artifact whenever the root mirror was skipped — including on a self-install,
733
+ // where the write no longer happens. Gating the write and leaving the print would have replaced
734
+ // one false claim with another, which is the failure this whole change is about.
519
735
  Theme.printResolved(c.bold(cfg.entryFile));
520
736
  }
737
+
738
+ } finally {
739
+ // Best-effort: a staging file we cannot remove is not worth failing an otherwise good install
740
+ // over, and the next run writes a differently-named one regardless.
741
+ try { fs.rmSync(tempEntry, { force: true }); } catch { /* nothing further to do */ }
742
+ }
521
743
  }
522
744
 
523
745
  // ── 2. Install commands ─────────────────────────────────────────────────────
@@ -526,29 +748,39 @@ async function install(runtime, scope, options = {}) {
526
748
  { src: src('.agent', 'forge'), namespace: 'forge' }
527
749
  ];
528
750
 
529
- if (runtime === 'claude') {
530
- // Claude Code looks in .claude/commands/mindforge. Use .agent/mindforge as the
531
- // canonical source it is always committed, while .claude/ is gitignored. The
532
- // previous behaviour of reading from .claude/commands/mindforge/ (itself gitignored)
533
- // left CI with an empty command set on a fresh checkout.
534
- cmdSources.length = 0;
535
- cmdSources.push({ src: src('.agent', 'mindforge'), namespace: 'mindforge' });
536
- cmdSources.push({ src: src('.agent', 'forge'), namespace: 'forge' });
537
- }
538
-
751
+ // A `if (runtime === 'claude') { cmdSources.length = 0; ...push the identical two entries... }`
752
+ // block used to sit here. It was a NO-OP — it cleared the array and pushed back exactly what the
753
+ // initializer above already contains. Its comment described a historical change (reading from
754
+ // .agent/mindforge rather than the gitignored .claude/commands/mindforge) that the initializer
755
+ // already reflects. Removed, because a special case that does nothing implies claude is handled
756
+ // differently here when it is not.
757
+
758
+ // GATED ON !selfInstall for the same reason as the entry file above. Measured on a self-install into
759
+ // a clone of this repository: 149 TRACKED files under .claude/commands/ overwritten, zero backups,
760
+ // while the installer printed "Self-install detected — skipping framework file copy". It was not
761
+ // skipping; it said so and then copied.
762
+ //
763
+ // Skipping is correct rather than merely safe. In this repository `.agent/mindforge` and
764
+ // `.claude/commands/mindforge` are BOTH committed and legitimately differ (an audit measured 146
765
+ // entries differing between the two tracked copies, mostly frontmatter quoting). Copying does not
766
+ // "sync" them, it silently picks one side and destroys the other's committed state — inside a git
767
+ // working tree, with no prompt, under a message saying it did nothing.
768
+ //
769
+ // verifyInstall still passes afterwards: every file it requires under cmdsDir (help.md,
770
+ // init-project.md, health.md, execute-phase.md, security-scan.md) is tracked, so the files it checks
771
+ // are present from git rather than from this copy. Verified, not assumed.
539
772
  let totalCount = 0;
540
- cmdSources.forEach(source => {
773
+ (selfInstall ? [] : cmdSources).forEach(source => {
541
774
  if (!fsu.exists(source.src)) return;
542
775
 
543
776
  const files = fsu.listFiles(source.src).filter(f => f.endsWith('.md'));
544
777
  totalCount += files.length;
545
- fsu.ensureDir(cmdsDir);
546
778
 
547
779
  files.forEach(f => {
548
- // Logic for naming: antigravity uses namespace:prefix, others use just the file name
549
- const targetName = runtime === 'antigravity' ? `${source.namespace}:${f}` : f;
780
+ const { dir: destDir, name: targetName } = resolveCommandTarget(cmdsDir, runtime, source.namespace, f);
781
+ fsu.ensureDir(destDir);
550
782
  const srcPath = path.join(source.src, f);
551
- const dstPath = path.join(cmdsDir, targetName);
783
+ const dstPath = path.join(destDir, targetName);
552
784
 
553
785
  if (runtime === 'antigravity') {
554
786
  const content = fsu.read(srcPath);
@@ -607,6 +839,16 @@ async function install(runtime, scope, options = {}) {
607
839
  Theme.printResolved(`${c.bold(asset.label.padEnd(12))} (Enterprise sync)`);
608
840
  }
609
841
  });
842
+
843
+ // ── 2.1b REG-01: register the hooks we just copied ────────────────────────
844
+ // Measured before this landed: 0 of 6 harnesses wrote any settings.json, and this file plus
845
+ // bin/install.js contained ZERO references to settings.json, PreToolUse or a hook dispatcher.
846
+ // 11 hook scripts landed and none of them could ever fire. register() is deliberately narrow —
847
+ // claude + local + non-Windows only — and returns a machine-readable status for every other
848
+ // case rather than writing a config it cannot verify. It EXECUTES all 8 emitted commands before
849
+ // keeping the file, and rolls back if any deny-class hook fails to deny.
850
+ hookRegistration = require('./installer/hook-registration')
851
+ .register({ projectRoot: process.cwd(), repoRoot: SOURCE_ROOT, runtime, scope, selfInstall, dryRun });
610
852
  }
611
853
 
612
854
  // ── 2.2 Install Subagents (native Claude-Code agents, both scopes) ──────────
@@ -630,14 +872,13 @@ async function install(runtime, scope, options = {}) {
630
872
  const forgeSrc = src('.mindforge');
631
873
  const forgeDst = path.join(process.cwd(), '.mindforge');
632
874
  if (fsu.exists(forgeSrc)) {
633
- // Define all required enterprise framework folders
634
- const standardFrameworkFolders = [
635
- 'engine', 'org', 'governance', 'integrations', 'personas', 'skills',
636
- 'team', 'intelligence', 'memory', 'metrics', 'models', 'plugins',
637
- 'dashboard', 'browser', 'monorepo', 'production', 'distribution',
638
- 'docs/registry'
639
- ];
640
-
875
+ // An 18-entry `standardFrameworkFolders` list was declared here and never referenced. The
876
+ // non-minimal branch below copies the whole of .mindforge/ with copyDir, so the list
877
+ // described a selection that no code performed — deleting it changes nothing. It was the
878
+ // second dead copy-list in this file; the other was `coreEngines`, which named four
879
+ // bin/sre/ paths nothing installed. tests/install-module-load.test.js now fails on any
880
+ // array declared and never used here, because a copy list nothing reads is
881
+ // indistinguishable from files that are not installed.
641
882
  if (minimal) {
642
883
  const minimalEntries = new Set([
643
884
  'MINDFORGE-SCHEMA.json',
@@ -724,18 +965,38 @@ async function install(runtime, scope, options = {}) {
724
965
  Theme.printResolved(`${c.bold('WALKTHROUGH.md')} (updated)`);
725
966
  }
726
967
 
727
- // Sovereign Intelligence v8.2.0: Copy core engines by default
728
- const coreEngines = [
729
- 'bin/engine/nexus-tracer.js',
730
- 'bin/engine/learning-manager.js',
731
- 'bin/sre/sentinel.js',
732
- 'bin/sre/shadow-mirror.js',
733
- 'bin/sre/adversarial-sre.js',
734
- 'bin/sre/sli-verifier.js'
735
- ];
968
+ // Engine subtrees copied into the consumer project.
969
+ //
970
+ // The first eleven entries are the feature engines. The last four are their DEPENDENCIES,
971
+ // and their absence was breaking the install: measured on a clean `--claude --local`, 16 of
972
+ // 119 installed modules failed to load with MODULE_NOT_FOUND, across seven subtrees —
973
+ // bin/autonomous/audit-writer.js -> ../utils/file-lock (the audit-chain writer)
974
+ // bin/autonomous/auto-runner.js -> ../utils/file-lock
975
+ // bin/autonomous/state-manager.js -> ../utils/file-io
976
+ // bin/governance/policy-engine.js -> ../utils/file-io
977
+ // bin/memory/knowledge-graph.js -> ../utils/file-lock
978
+ // bin/models/model-router.js -> ../utils/mindforge-params
979
+ // bin/engine/nexus-tracer.js -> ../utils/index
980
+ // ... and nine more
981
+ // bin/utils/ alone accounts for all sixteen. revops/, review/ and migrations/ are each
982
+ // required by an installed engine module too (engine/remediation-engine.js ->
983
+ // ../revops/remediation-queue, and so on).
984
+ //
985
+ // tests/install-module-load.test.js performs a real install and requires every installed
986
+ // module, so this list cannot silently drift again: adding an engine whose dependency is
987
+ // absent fails there rather than at a consumer's first run.
736
988
  const sovereignEngines = [
737
- 'governance', 'autonomous', 'memory', 'models', 'research',
738
- 'wizard', 'updater', 'dashboard', 'browser', 'skills-builder', 'engine'
989
+ 'governance', 'autonomous', 'memory', 'models', 'research',
990
+ 'wizard', 'updater', 'dashboard', 'browser', 'skills-builder', 'engine',
991
+ // dependencies of the above — not features
992
+ 'utils', 'revops', 'review', 'migrations',
993
+ // Required by bin/mindforge-cli.js:171 for the `workflow` verb. Added in the same change that
994
+ // started shipping the CLI: the router's require is lazy, so the CLI still LOADED without it,
995
+ // but `mindforge workflow list` exited 1 with MODULE_NOT_FOUND. Caught by
996
+ // tests/install-module-load.test.js's require-resolution scan the moment the CLI began landing
997
+ // — the entry point and its dispatch targets are one unit. One file, 4 KB, and its own requires
998
+ // are `fs` and `path` only.
999
+ 'workflows'
739
1000
  ];
740
1001
  sovereignEngines.forEach(engine => {
741
1002
  const srcDir = src('bin', engine);
@@ -746,6 +1007,88 @@ async function install(runtime, scope, options = {}) {
746
1007
  }
747
1008
  });
748
1009
 
1010
+ // Individual top-level bin/ files an installed module requires. hindsight-injector is
1011
+ // required by both bin/dashboard/temporal-api.js and bin/engine/temporal-cli.js, which do
1012
+ // install, so without it those two fail to load.
1013
+ //
1014
+ // This replaces a `coreEngines` array that was declared here and NEVER REFERENCED — six
1015
+ // paths, four of them under bin/sre/, copied by nothing. bin/sre/ stays uninstalled
1016
+ // deliberately: no installed module requires it, so wiring the dead array would have shipped
1017
+ // files nothing loads rather than fixing anything.
1018
+ // bin/mindforge-cli.js is here, not behind --with-utils, because it is the ENTRY POINT the
1019
+ // documentation tells users to run. Measured on a default `--claude --local --skip-wizard`
1020
+ // install before this change: 152 bin/**/*.js landed across 15 subdirectories, but only ONE
1021
+ // top-level file, and `find . -name mindforge-cli.js` returned nothing. So
1022
+ // docs/getting-started.md's promise that "the `mindforge` CLI command is available for runtime
1023
+ // operations" was false on the documented path, and every fix to that CLI was invisible to
1024
+ // anyone who followed the docs.
1025
+ //
1026
+ // Shipping the single file is sufficient, verified rather than assumed: its only load-time
1027
+ // requires are `child_process` and `path`, both Node builtins, and the subdirectories it
1028
+ // dispatches into (bin/utils 11 files, bin/engine 23, bin/wizard 4) already land via
1029
+ // sovereignEngines above.
1030
+ //
1031
+ // The rest of bin/ stays behind --with-utils. This is the entry point, not a bulk copy.
1032
+ // EVERY top-level bin/*.js the router can dispatch to, not just the entry point.
1033
+ //
1034
+ // `sovereignEngines` copies bin/ SUBDIRECTORIES, so the 13 nested scripts in the COMMANDS table
1035
+ // arrive fine. The 6 scripts that live directly in bin/ had no carrier beyond this list, which
1036
+ // held two entries. Measured on the real npx shape — `npm pack`, extract into
1037
+ // node_modules/mindforge-cc, run its installer, then invoke each verb in the installed project —
1038
+ // 11 of 27 routed verbs died in Node's module loader:
1039
+ //
1040
+ // security-scan Cannot find module '<proj>/bin/validate-config.js'
1041
+ // health Cannot find module '<proj>/bin/installer-core.js'
1042
+ // classify Cannot find module '<proj>/bin/change-classifier.js'
1043
+ // validate-skill Cannot find module '<proj>/bin/skill-validator.js'
1044
+ // install-skill / register-skill / audit-skill bin/skill-registry.js
1045
+ // spawn / identity / subagent bin/spawn-agent.js
1046
+ // test-memory tests/memory.test.js
1047
+ //
1048
+ // Two of those matter more than their count. `security-scan` is the verb the protocol mandates
1049
+ // PRE-COMMIT for any Auth/Payment/PII change, and `health` is step 1 of "Verify install" in
1050
+ // docs/getting-started.md:110 — so the documented first thing a new user runs exited non-zero
1051
+ // with a stack trace.
1052
+ //
1053
+ // WHY THIS DID NOT SHOW UP EARLIER. tests/install-module-load.test.js checks that every internal
1054
+ // require inside an INSTALLED module resolves, and it passes: the copied files' own dependencies
1055
+ // are complete. The gap was one level up — files the ROUTER references that were never copied at
1056
+ // all, so there was no installed module whose requires could be checked. And installing from a
1057
+ // working tree hides it entirely, because `src()` then points at the full checkout; only the
1058
+ // packed-tarball shape reproduces it. That is why the new assertion in
1059
+ // tests/install-module-load.test.js drives the expectation off the COMMANDS table instead of a
1060
+ // hardcoded list — a route added without a carrier here will fail immediately.
1061
+ //
1062
+ // tests/memory.test.js is NOT fixable from here: `files[]` excludes tests/, so it is not in the
1063
+ // tarball and the installer has nothing to copy. That verb has to be removed from the router,
1064
+ // which bin/mindforge-cli.js owns.
1065
+ const coreFiles = [
1066
+ 'bin/hindsight-injector.js',
1067
+ 'bin/mindforge-cli.js',
1068
+ 'bin/validate-config.js',
1069
+ 'bin/installer-core.js',
1070
+ 'bin/change-classifier.js',
1071
+ 'bin/skill-validator.js',
1072
+ 'bin/skill-registry.js',
1073
+ 'bin/spawn-agent.js',
1074
+ // installer-core.js requires ./installer/hook-registration at :825, so installing the former
1075
+ // without this one trades a missing verb for a module-loader crash inside it. Caught by
1076
+ // tests/install-module-load.test.js the moment installer-core.js was added here, which is
1077
+ // exactly what that test is for. Named individually rather than adding 'installer' to
1078
+ // sovereignEngines: that directory also holds harness-adapter-compliance.js and the
1079
+ // install-manifests/install-state pair, which are build- and CI-side and have no business in a
1080
+ // consumer project.
1081
+ 'bin/installer/hook-registration.js',
1082
+ ];
1083
+ coreFiles.forEach(rel => {
1084
+ const srcFile = src(...rel.split('/'));
1085
+ const dstFile = path.join(process.cwd(), rel);
1086
+ if (fsu.exists(srcFile)) {
1087
+ fsu.ensureDir(path.dirname(dstFile));
1088
+ fsu.copy(srcFile, dstFile);
1089
+ }
1090
+ });
1091
+
749
1092
  // ✨ SOVEREIGN INITIALIZATION: report actual security posture honestly.
750
1093
  // The PQAS minter is gated SOLELY behind experimental.pqc_demo (see
751
1094
  // bin/governance/quantum-crypto.js: getProvider/_assertPqcDemoEnabled). When
@@ -778,7 +1121,47 @@ async function install(runtime, scope, options = {}) {
778
1121
  }
779
1122
 
780
1123
  // ── 4. Verify installation ──────────────────────────────────────────────────
781
- Theme.printResolved(c.bold('Install verified'));
1124
+ // This line used to print "Install verified" unconditionally, directly under this header,
1125
+ // while verifyInstall() sat unreferenced 400 lines above. The claim is now earned: the check
1126
+ // runs, names what is missing, and exits non-zero rather than reporting success.
1127
+ //
1128
+ // Not reachable on a dry run — that path returns earlier, so nothing is verified against a
1129
+ // tree nothing was written to.
1130
+ const verification = verifyInstall(baseDir, cmdsDir, runtime, scope);
1131
+ if (!verification.ok) {
1132
+ console.error(`\n ❌ Install verification failed — ${verification.missing.length} of ` +
1133
+ `${verification.checked} required file(s) missing:`);
1134
+ verification.missing.forEach(f => console.error(` ${f}`));
1135
+ console.error(`\n Retry: npx mindforge-cc@latest --${runtime} --${scope} --force`);
1136
+ process.exit(1);
1137
+ }
1138
+ Theme.printResolved(c.bold(`Install verified (${verification.checked} required files present)`));
1139
+
1140
+ // ── 4b. REG-01 hook-registration status ─────────────────────────────────────
1141
+ // Printed ALWAYS, in one machine-readable line, including when nothing was registered. A silent
1142
+ // skip is how "0 of 6 harnesses register a hook" went unnoticed for the product's whole life: the
1143
+ // installer exited 0 with a success banner and never mentioned that the gates it had just copied
1144
+ // were inert. Whatever the outcome, the operator is told which it was and why.
1145
+ if (hookRegistration.registered) {
1146
+ Theme.printResolved(c.bold(`Hooks registered: ${hookRegistration.reason}`));
1147
+ Theme.printStatus(c.yellow('Restart your harness — Claude Code snapshots hooks at session start, '
1148
+ + 'so the gates are not live in an already-open session.'), 'warn');
1149
+ if (hookRegistration.backup) {
1150
+ Theme.printStatus(c.dim(`Previous settings backed up to ${hookRegistration.backup}`), 'info');
1151
+ }
1152
+ } else {
1153
+ Theme.printStatus(c.yellow(`Hooks NOT registered (${hookRegistration.status}): ${hookRegistration.reason}`), 'warn');
1154
+ Theme.printStatus(c.dim('The hook scripts are installed but nothing invokes them, so no tool call '
1155
+ + 'is gated. This is stated rather than implied — see "Hooks are installed but nothing is '
1156
+ + 'blocked" in docs/troubleshooting.md.'), 'info');
1157
+ }
1158
+
1159
+ // Non-fatal advisories. Printed for BOTH outcomes, because the case that produces one today —
1160
+ // an ancestor project with its own settings.json — used to CANCEL registration outright, and the
1161
+ // whole point of demoting it to a warning is that the operator hears it without losing the gates.
1162
+ for (const w of hookRegistration.warnings || []) {
1163
+ Theme.printStatus(c.yellow(w), 'warn');
1164
+ }
782
1165
  }
783
1166
 
784
1167
  // ── Uninstall ─────────────────────────────────────────────────────────────────
@@ -922,11 +1305,31 @@ async function run(args) {
922
1305
  const options = { dryRun, force, verbose, withUtils, minimal };
923
1306
 
924
1307
  // Get package.json for version
925
- const pJSON = JSON.parse(fsu.read(path.join(SOURCE_ROOT, 'package.json')));
1308
+ // MINDFORGE'S VERSION, NOT THE HOST PROJECT'S — and it must not crash when there is no manifest.
1309
+ //
1310
+ // This read was `JSON.parse(fsu.read(path.join(SOURCE_ROOT, 'package.json')))`, and SOURCE_ROOT is
1311
+ // path.resolve(__dirname, '..'). In an install that lands at <project>/bin/, so it read the CONSUMER's
1312
+ // package.json. Measured on the published 11.9.3 tarball: in a project declaring version 0.4.2,
1313
+ // `mindforge health` printed the banner `RELEASE v0.4.2` and then, 26 lines later, `Current : v11.9.3`
1314
+ // — one command, two contradictory versions, exit 0. In a project with NO package.json the whole
1315
+ // command was `Unexpected end of JSON input`, exit 1, no report at all: fsu.read returns empty for a
1316
+ // missing file and JSON.parse throws.
1317
+ //
1318
+ // This is the same defect 11.9.3 fixed for `--version`, in the one verb whose advertised job is
1319
+ // "verify project health and installation integrity". bin/utils/mindforge-version.js already resolves
1320
+ // correctly — by package NAME, which is what distinguishes our manifest from a consumer's — and it
1321
+ // shipped in the same release. It simply was not used here.
1322
+ //
1323
+ // It THROWS rather than guessing, which is right for a version check and wrong for a banner, so the
1324
+ // banner degrades to 'unknown' instead of taking the whole health report down with it.
1325
+ let bannerVersion = 'unknown';
1326
+ try {
1327
+ bannerVersion = require('./utils/mindforge-version').resolveMindforgeVersion(process.cwd()).version;
1328
+ } catch { /* a banner must never be the reason health cannot run */ }
926
1329
 
927
1330
  // Print header and brand manifest
928
1331
  // Print header and brand manifest
929
- Theme.printHeader(pJSON.version);
1332
+ Theme.printHeader(bannerVersion);
930
1333
  Theme.printBrandManifest();
931
1334
  // Check for updates only
932
1335
  if (isCheck) {
@@ -944,14 +1347,27 @@ async function run(args) {
944
1347
  }
945
1348
 
946
1349
  if (!isUninstall) {
947
- const stats = collectManifestStats();
948
- Theme.printSuccessV2(runtime, scope, stats);
1350
+ // collectManifestStats() counts the SOURCE tree, not what was written. For a normal install those
1351
+ // coincide, so the panel is accidentally accurate. For a self-install nothing is copied, and the
1352
+ // panel announced "ACTIONS 221 — Total autonomous commands deployed" and
1353
+ // "Skill Packs (123 verified)" for a run that deployed and verified nothing: a summary of the
1354
+ // repository presenting itself as an installation report. Gating it is the honest minimum. Making
1355
+ // the panel report MEASURED counts on every path is a larger change and is deliberately not
1356
+ // attempted here — it would need the install to return what it wrote.
1357
+ if (isSelfInstall()) {
1358
+ Theme.printResolved(c.bold('Self-install complete — no framework files were written'));
1359
+ Theme.printStatus(c.dim('This repository IS the framework: its committed .claude/ and .agent/ '
1360
+ + 'trees are the source, so there was nothing to deploy.'), 'info');
1361
+ } else {
1362
+ const stats = collectManifestStats();
1363
+ Theme.printSuccessV2(runtime, scope, stats);
1364
+ }
949
1365
  } else {
950
1366
  Theme.printResolved(c.bold('MindForge uninstalled'));
951
1367
  }
952
1368
  }
953
1369
 
954
- module.exports = { run, install, uninstall, RUNTIMES, generateEntryContent, SENSITIVE_EXCLUDE, MINDFORGE_DEV_EXCLUDE };
1370
+ module.exports = { run, install, uninstall, verifyInstall, RUNTIMES, generateEntryContent, SENSITIVE_EXCLUDE, MINDFORGE_DEV_EXCLUDE };
955
1371
 
956
1372
  if (require.main === module) {
957
1373
  const args = process.argv.slice(2);