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
package/.agent/CLAUDE.md CHANGED
@@ -25,25 +25,37 @@ You are a **Dynamic Multi-Agent Swarm (Agentic Mesh)**. Your mission is to execu
25
25
 
26
26
  ## 🛠️ CORE PROTOCOLS (The "How")
27
27
 
28
+ > **These are protocols you follow, not modules you call.** `SwarmController`, `PersonaFactory`
29
+ > and `WaveExecutor` are role names in the specs under `.mindforge/engine/`, not importable code —
30
+ > there is no file by any of those names. Every step below is something you do by reasoning and by
31
+ > using your own tools. Where a real executable exists, it is named with its path.
32
+
28
33
  ### 1. Swarm Dynamic Orchestration (V4)
29
34
  **IF** task complexity/impact is high **OR** cross-disciplinary logic is required:
30
- 1. Invoke `SwarmController` and activate `mindforge-swarm-execution`.
35
+ 1. Adopt the swarm-orchestration protocol described in `.mindforge/engine/`.
31
36
  2. Spawn task-specific ephemeral specialist cluster (AIEngineering, Security, etc.).
32
- 3. Inject knowledge patches via `PersonaFactory` (Context7).
33
- 4. Execute parallel mesh waves via `WaveExecutor` guided by `mindforge-parallel-mesh_extended`.
37
+ 3. Load the relevant persona brief from `.mindforge/personas/` before each specialist acts.
38
+ 4. Execute parallel mesh waves, consolidating dependent work before independent work.
34
39
  5. Consolidate mesh findings into a single `SWARM-SUMMARY`.
35
40
 
36
41
  ### 2. The Sharded Memory Loop (SRD)
37
42
  **IF** context ≥ 70% **OR** starting a new task:
38
- 1. Initialize `shard-controller.js`.
39
- 2. Rotate context per the Tri-Tier strategy (Hot/Warm/Cold).
40
- 3. Inject only sharded relevant data into the active buffer.
43
+ 1. Rotate context per the Tri-Tier strategy (Hot/Warm/Cold) yourself — this is an advisory
44
+ discipline, not an automated step.
45
+ 2. Inject only sharded relevant data into the active buffer.
46
+ 3. Re-read the current phase's plan rather than carrying stale detail forward.
41
47
 
42
48
  ### 3. The Adversarial Decision Loop (ADS)
43
49
  **BEFORE** committing any architectural change:
44
- 1. Spawn Red-Team/Blue-Team debate contexts.
45
- 2. Run `soul-engine.js` on the proposed diff.
46
- 3. **STOP** if SOUL Score < `[MIN_SOUL_SCORE]` from MINDFORGE.md.
50
+ 1. Spawn Red-Team/Blue-Team debate contexts and argue the change against itself.
51
+ 2. Score the proposal on impact, leverage, reversibility, effort, risk and cost.
52
+ 3. **STOP** if the resulting SOUL Score < `[MIN_SOUL_SCORE]` from MINDFORGE.md.
53
+
54
+ ADS is a reasoning protocol you run, not a command. Do not look for a script: measured, the two
55
+ scripts this section used to name (soul-engine.js and shard-controller.js) do not exist
56
+ anywhere in the package. The nearest real implementation, `runADSSynthesis()` in
57
+ `bin/review/ads-engine.js`, is a library with a single internal caller, exposes no CLI, and takes
58
+ `{phaseNum, goal, context, sessionId}` rather than a diff, so it cannot be invoked here either.
47
59
 
48
60
  ### 4. Standard Extended Protocols (Quality Gates)
49
61
  **MANDATORY**: For specific workflows, activate the corresponding `_extended` protocol:
@@ -78,18 +90,26 @@ Prioritize based on `[REACTIVE_MODE]` in MINDFORGE.md. These are the **Quality g
78
90
  - [ ] **AgRevOps Check**: Verify ROI trends and Security Health Score via `/api/revops`.
79
91
  - [ ] **PLAN-FIRST RULE**: Never code without a verified XML plan.
80
92
  - [ ] **Verify First**: Never task-complete without successful `<verify>` output.
81
- - [ ] **Audit Always**: Write a JSONL entry for every significant session event. All entries must be Merkle-linked.
93
+ - [ ] **Audit Always**: Write a JSONL entry for every significant session event. The result is a hash-chained append-only audit log (SHA-256 back-links): each entry sets `previous_hash` to the prior entry's `_hash`.
82
94
 
83
95
  ---
84
96
 
85
97
  ## ⚡ COMMAND SUITE
86
98
 
99
+ Every entry below has a backing command file, checked by `tests/protocol-claims.test.js`. Two that
100
+ did not (the brainstorming and history entries) are gone: neither existed in
101
+ `.claude/commands/mindforge/` or `.agent/mindforge/` (both hold exactly 221 files, so this was not
102
+ a mirror gap), and neither had a near-match to correct to. Temporal history is reachable, just not
103
+ as a slash command, so it is named as what it actually is.
104
+
87
105
  - `/mindforge:next` — Primary auto-discovery.
88
106
  - `/mindforge:auto` — Reactive engine start.
89
- - `/mindforge:brainstorming` — Creative & architectural exploration.
90
- - `/mindforge:history` — Temporal Hub access.
107
+ - `/mindforge:plan-phase` — Plan the next phase before writing code.
108
+ - `/mindforge:verify-phase` — Verify the phase against its plan.
91
109
  - `/mindforge:status` — Project health & sharding state.
92
110
  - `/mindforge:audit` — Day 4 governance access.
111
+ - `mindforge temporal <status|cleanup|inject>` — reasoning-history access (a CLI command, not a
112
+ slash command).
93
113
 
94
114
  ---
95
115
 
@@ -109,7 +129,11 @@ Adopt the Principal AI persona. Be instruction-dense, unambiguous, and architect
109
129
 
110
130
  **Source of Truth Hierarchy**:
111
131
 
112
- 1. SOUL.md (Sovereign Identity — Behavioral OS)
132
+ 1. SOUL.md (Sovereign Identity — Behavioral OS). **Not shipped in the package**, and generated
133
+ locally rather than installed: `bin/memory/identity-synthesizer.js` creates and evolves it. If
134
+ your project has no SOUL.md, this entry is vacant and authority passes to MINDFORGE.md — do not
135
+ infer its contents. The prompt-defense baseline it carries is reproduced verbatim at the top of
136
+ this file, so that part reaches you either way.
113
137
  2. MINDFORGE.md (Parameter Registry)
114
138
  3. .agent/CLAUDE.md (Protocols)
115
139
  4. `.mindforge/` (Framework Binary Logic)
@@ -486,34 +486,73 @@ function checkCommand(input) {
486
486
  /**
487
487
  * Extract the command string from hook input (JSON or plain text).
488
488
  */
489
+ /**
490
+ * Extract the command, and report whether input that LOOKED like JSON failed to parse.
491
+ *
492
+ * The parse-failure fallback used to `return trimmed` — scan the raw text for the bypass flag
493
+ * instead. That is a reasonable defence for genuinely non-JSON input, but on a TRUNCATED JSON
494
+ * payload it becomes a bypass: the flag is simply absent from the surviving prefix, the scan finds
495
+ * nothing, and the hook approves. Callers now block instead, matching trust-gate-hook.js, which
496
+ * prints "parse error (BLOCKING)" and exits 2 — and which is why trust-gate was NOT bypassable
497
+ * this way while this hook was.
498
+ *
499
+ * @returns {{command: string, jsonParseFailed: boolean}}
500
+ */
489
501
  function extractCommand(rawInput) {
490
502
  const trimmed = rawInput.trim();
491
- if (!trimmed.startsWith('{')) return trimmed;
503
+ if (!trimmed.startsWith('{')) return { command: trimmed, jsonParseFailed: false };
492
504
 
493
505
  try {
494
506
  const parsed = JSON.parse(trimmed);
495
- if (typeof parsed !== 'object' || parsed === null) return trimmed;
507
+ if (typeof parsed !== 'object' || parsed === null) {
508
+ return { command: trimmed, jsonParseFailed: false };
509
+ }
496
510
 
497
511
  // Claude Code format: { tool_input: { command: "..." } }
498
512
  const cmd = parsed.tool_input?.command;
499
- if (typeof cmd === 'string') return cmd;
513
+ if (typeof cmd === 'string') return { command: cmd, jsonParseFailed: false };
500
514
 
501
515
  // Generic JSON formats
502
516
  for (const key of ['command', 'cmd', 'input', 'shell', 'script']) {
503
- if (typeof parsed[key] === 'string') return parsed[key];
517
+ if (typeof parsed[key] === 'string') return { command: parsed[key], jsonParseFailed: false };
504
518
  }
505
519
 
506
- return trimmed;
520
+ return { command: trimmed, jsonParseFailed: false };
507
521
  } catch {
508
- return trimmed;
522
+ return { command: trimmed, jsonParseFailed: true };
509
523
  }
510
524
  }
511
525
 
512
526
  /**
513
527
  * Exportable run() for in-process execution via run-with-flags.js.
514
528
  */
515
- function run(rawInput) {
516
- const command = extractCommand(rawInput);
529
+ function run(rawInput, options = {}) {
530
+ // A truncated payload cannot be judged: the dispatcher caps stdin at MAX_STDIN, so a caller can
531
+ // push the git command past the cap and the flag never reaches this hook. MEASURED before this
532
+ // guard: `git commit --no-verify` preceded by ~1 MiB of padding scored exit 0 (allowed), while
533
+ // the same command alone scored exit 2. Its sibling mindforge-config-protection.js:66 already
534
+ // refused on truncation; this hook computed the flag and never read it, and run() took only one
535
+ // parameter so `options` never arrived at all.
536
+ if (options.truncated) {
537
+ return {
538
+ exitCode: 2,
539
+ stderr:
540
+ `BLOCKED: Hook input exceeded ${options.maxStdin || MAX_STDIN} bytes. Refusing to approve ` +
541
+ 'a git command on a truncated payload — the bypass flag this hook looks for may have been ' +
542
+ 'cut off. Retry with a smaller payload.',
543
+ };
544
+ }
545
+
546
+ const { command, jsonParseFailed } = extractCommand(rawInput);
547
+ if (jsonParseFailed) {
548
+ return {
549
+ exitCode: 2,
550
+ stderr:
551
+ 'BLOCKED: hook input began with "{" but is not valid JSON, so the command could not be ' +
552
+ 'read. Failing closed rather than scanning a partial payload for the bypass flag.',
553
+ };
554
+ }
555
+
517
556
  const result = checkCommand(command);
518
557
 
519
558
  if (result.blocked) {
@@ -530,21 +569,30 @@ module.exports = { run };
530
569
 
531
570
  // Stdin fallback for spawnSync execution — only when invoked directly, not via require()
532
571
  if (require.main === module) {
572
+ // Truncation is tracked here too, and seeded from the dispatcher's env flag. This path had the
573
+ // same bypass as run(): it capped stdin at MAX_STDIN and then judged the surviving prefix,
574
+ // silently dropping whatever came after. Going through run() keeps ONE decision path for both
575
+ // invocation modes rather than two that can drift — the same discipline audit-hash.js uses for
576
+ // its writer and verifier.
577
+ let truncated = /^(1|true|yes)$/i.test(String(process.env.MINDFORGE_HOOK_INPUT_TRUNCATED || ''));
578
+
533
579
  process.stdin.setEncoding('utf8');
534
580
  process.stdin.on('data', chunk => {
535
581
  if (raw.length < MAX_STDIN) {
536
582
  const remaining = MAX_STDIN - raw.length;
537
583
  raw += chunk.substring(0, remaining);
584
+ if (chunk.length > remaining) truncated = true;
585
+ } else {
586
+ truncated = true;
538
587
  }
539
588
  });
540
589
 
541
590
  process.stdin.on('end', () => {
542
- const command = extractCommand(raw);
543
- const result = checkCommand(command);
591
+ const output = run(raw, { truncated, maxStdin: MAX_STDIN });
544
592
 
545
- if (result.blocked) {
546
- process.stderr.write(result.reason + '\n');
547
- process.exit(2);
593
+ if (Number.isInteger(output.exitCode) && output.exitCode !== 0) {
594
+ if (output.stderr) process.stderr.write(output.stderr.endsWith('\n') ? output.stderr : output.stderr + '\n');
595
+ process.exit(output.exitCode);
548
596
  }
549
597
 
550
598
  process.stdout.write(raw);
@@ -62,6 +62,58 @@ function parseInput(inputOrRaw) {
62
62
  /**
63
63
  * Exportable run() for in-process execution via run-with-flags.js.
64
64
  */
65
+ /**
66
+ * Return the protected path a Bash command would WRITE, or '' if it writes none.
67
+ *
68
+ * Deliberately conservative and pattern-specific rather than clever. This hook is deny-class, so a
69
+ * false positive blocks a legitimate command; and every pattern here was chosen because the protected
70
+ * file appears in an unambiguous write position:
71
+ *
72
+ * > path >> path >| path shell redirect
73
+ * tee ... path writes each named file
74
+ * sed -i ... path edits in place
75
+ * dd of=path writes the output file
76
+ * truncate ... path truncates the named file
77
+ * cp/mv src path path is the DESTINATION only when it is last
78
+ *
79
+ * NOT matched, on purpose: `cat path`, `grep x path`, `git diff path`, `< path`, and
80
+ * `cp path /tmp/backup` — all reads. A pipeline is split on |, ; and && first, so
81
+ * `cat tsconfig.json | grep strict` cannot be mistaken for a write.
82
+ */
83
+ function bashWriteTarget(command) {
84
+ if (!command) return '';
85
+ const names = [...PROTECTED_FILES].map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
86
+ if (!names.length) return '';
87
+ const anyName = `(?:${names.join('|')})`;
88
+ // A path token ending in a protected basename, at a word boundary so `my-tsconfig.json` is not
89
+ // mistaken for `tsconfig.json`.
90
+ const PATH = `((?:[^\\s;|&<>]*[/])?${anyName})(?![\\w.-])`;
91
+
92
+ // Normalise `>|` (bash's noclobber override) to `>` BEFORE splitting on pipelines. Splitting on a
93
+ // bare `|` tears `>|` in half — `echo {} >| tsconfig.json` became the segments `echo {} >` and
94
+ // ` tsconfig.json`, so the redirect pattern found nothing and the write was permitted. The
95
+ // segmentation was defeating the operator match it exists to support. `>|` and `>` are the same
96
+ // write as far as this gate is concerned, so collapsing them is exact, not a heuristic.
97
+ const normalised = command.replace(/>\s*\|/g, '>');
98
+ for (const segment of normalised.split(/\|\||&&|[;|\n]/)) {
99
+ const s = segment.trim();
100
+ if (!s) continue;
101
+ const checks = [
102
+ new RegExp(`>>?\\|?\\s*${PATH}`), // redirect
103
+ new RegExp(`\\btee\\b(?:\\s+-\\S+)*\\s+${PATH}`), // tee [flags] path
104
+ new RegExp(`\\bsed\\b[^\\n]*?\\s-i\\S*[^\\n]*?\\s${PATH}`), // sed -i ... path
105
+ new RegExp(`\\bdd\\b[^\\n]*?\\bof=${PATH}`), // dd of=path
106
+ new RegExp(`\\btruncate\\b[^\\n]*?\\s${PATH}`), // truncate ... path
107
+ new RegExp(`\\b(?:cp|mv|install)\\b[^\\n]*?\\s${PATH}\\s*$`), // destination is the LAST token
108
+ ];
109
+ for (const re of checks) {
110
+ const m = s.match(re);
111
+ if (m) return m[1];
112
+ }
113
+ }
114
+ return '';
115
+ }
116
+
65
117
  function run(inputOrRaw, options = {}) {
66
118
  if (options.truncated) {
67
119
  return {
@@ -74,7 +126,24 @@ function run(inputOrRaw, options = {}) {
74
126
  }
75
127
 
76
128
  const input = parseInput(inputOrRaw);
77
- const filePath = input?.tool_input?.file_path || input?.tool_input?.file || '';
129
+ let filePath = input?.tool_input?.file_path || input?.tool_input?.file || '';
130
+
131
+ // A Bash command reaches the same files by a different door.
132
+ //
133
+ // Measured before this: an Edit targeting an existing tsconfig.json returned exit 2, while
134
+ // `echo {} > tsconfig.json` as a Bash command returned exit 0 — and trust-gate permitted it too.
135
+ // The identical outcome was blocked through one tool surface and silently permitted through
136
+ // another, with no warning on the permitted path. That is what made "Non-Bypassable Compliance
137
+ // Gates" unsupportable: not a disabled gate, a gate watching one entrance.
138
+ //
139
+ // Only WRITE intent counts. `cat tsconfig.json`, `grep x tsconfig.json` and
140
+ // `cp tsconfig.json /tmp/backup` are reads of a protected file and must pass — a gate that blocks
141
+ // reading its own protected files would be worse than the hole it closes, and this hook is
142
+ // deny-class, so a false positive blocks the operator's real work.
143
+ if (!filePath) {
144
+ const target = bashWriteTarget(String(input?.tool_input?.command || ''));
145
+ if (target) filePath = target;
146
+ }
78
147
  if (!filePath) return { exitCode: 0 };
79
148
 
80
149
  const basename = path.basename(filePath);
@@ -96,13 +165,23 @@ function run(inputOrRaw, options = {}) {
96
165
  return { exitCode: 0 };
97
166
  }
98
167
 
168
+ // The deny reason no longer names the env var that turns this gate off.
169
+ //
170
+ // A PreToolUse denial is delivered to the MODEL whose tool call was just refused, so the old
171
+ // message ended by telling the agent it had blocked exactly how to stop being blocked
172
+ // ("disable the config-protection hook temporarily (MINDFORGE_DISABLED_HOOKS)"). A control
173
+ // that hands out its own bypass in the refusal is a suggestion, not a control.
174
+ //
175
+ // The escape hatch still exists and is documented in this file's header for the HUMAN
176
+ // operator, who is the party entitled to use it — and run-with-flags.js now writes
177
+ // "SECURITY GATE DISABLED" to stderr whenever it is used, so exercising it leaves a record.
99
178
  return {
100
179
  exitCode: 2,
101
180
  stderr:
102
181
  `BLOCKED: Modifying ${basename} is not allowed. ` +
103
182
  'Fix the source code to satisfy linter/formatter/tsconfig rules instead ' +
104
- 'of weakening the config. If this is a legitimate config change, disable ' +
105
- 'the config-protection hook temporarily (MINDFORGE_DISABLED_HOOKS).'
183
+ 'of weakening the config. A legitimate config change needs a human decision, ' +
184
+ 'not a hook bypass — ask the operator.'
106
185
  };
107
186
  }
108
187
 
@@ -127,7 +127,7 @@ process.stdin.on('end', () => {
127
127
  ? `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
128
128
  'Context is nearly exhausted. Do NOT start new complex work or write handoff files — ' +
129
129
  'MindForge state is already tracked in STATE.md. Inform the user so they can run ' +
130
- '/mindforge:pause-work at the next natural stopping point.'
130
+ '/mindforge:checkpoint at the next natural stopping point.'
131
131
  : `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
132
132
  'Context is nearly exhausted. Inform the user that context is low and ask how they ' +
133
133
  'want to proceed. Do NOT autonomously save state or write handoff files unless the user asks.';
@@ -5,7 +5,7 @@
5
5
  // (no active /mindforge: command or Task subagent) and injects an advisory warning.
6
6
  //
7
7
  // This is a SOFT guard — it advises, not blocks. The edit still proceeds.
8
- // The warning nudges Claude to use /mindforge:quick or /mindforge:fast instead of
8
+ // The warning nudges Claude to use /mindforge:quick or /mindforge:do instead of
9
9
  // making direct edits that bypass state tracking.
10
10
  //
11
11
  // Enable via config: hooks.workflow_guard: true (default: false)
@@ -80,7 +80,7 @@ process.stdin.on('end', () => {
80
80
  hookEventName: 'PreToolUse',
81
81
  additionalContext: `⚠️ WORKFLOW ADVISORY: You're editing ${path.basename(filePath)} directly without a MindForge command. ` +
82
82
  'This edit will not be tracked in STATE.md or produce a SUMMARY.md. ' +
83
- 'Consider using /mindforge:fast for trivial fixes or /mindforge:quick for larger changes ' +
83
+ 'Consider using /mindforge:do to route the intent, or /mindforge:quick for a small tracked change, ' +
84
84
  'to maintain project state tracking. ' +
85
85
  'If this is intentional (e.g., user explicitly asked for a direct edit), proceed normally.'
86
86
  }
@@ -21,11 +21,78 @@
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
  const { spawnSync } = require('child_process');
24
- const { isHookEnabled } = require('./lib/hook-flags');
24
+ const { isHookEnabled, getDisabledHookIds, getHookProfile, normalizeId } = require('./lib/hook-flags');
25
25
  const { buildPreToolUseAdditionalContext } = require('./lib/pretooluse-visible-output');
26
26
 
27
27
  const MAX_STDIN = 1024 * 1024;
28
28
 
29
+ /**
30
+ * Hooks whose entire job is to DENY. If one of these cannot produce a verdict, the safe answer
31
+ * is "block", because nothing has established that the operation is safe.
32
+ *
33
+ * Measured contract, identical across all three: exit 0 = allow, exit 2 = block. (trust-gate
34
+ * also writes {"decision":"block"} to stdout; the other two write a reason to stderr.) Exit 1 is
35
+ * not in the contract at all — Claude Code does not read it as a denial.
36
+ *
37
+ * Every OTHER hook stays fail-open on purpose. context-monitor, session-init, check-update and
38
+ * instinct-capture are advisory: if telemetry cannot run, nothing about the operation's safety
39
+ * became unknown, and blocking a tool call because a logger failed would be indefensible.
40
+ */
41
+ const DENY_CLASS = new Set([
42
+ 'trust-gate',
43
+ 'mindforge-block-no-verify',
44
+ 'mindforge-config-protection',
45
+ ]);
46
+
47
+ /**
48
+ * Escape hatch, deliberately loud.
49
+ *
50
+ * Rationale for having one at all: these gates fire on every Bash call for anyone working in a
51
+ * MindForge checkout, so a latent crash in trust-gate would otherwise brick every tool call with
52
+ * no way out but editing hook source. The reviewed risk of fail-closed behaviour was precisely
53
+ * "visible unexplained tool-call denials". This makes the override explicit and auditable rather
54
+ * than leaving the failure mode silent.
55
+ */
56
+ const FAIL_OPEN_OVERRIDE = process.env.MINDFORGE_HOOK_FAILOPEN === '1';
57
+
58
+ /**
59
+ * A hook could not produce a verdict. Decide what that means and exit.
60
+ *
61
+ * Before this, EVERY such path echoed stdin and exited 0 — which Claude Code reads as an explicit
62
+ * ALLOW, indistinguishable from a hook that ran and approved. Measured paths that behaved this
63
+ * way: a script that does not exist, a rejected path traversal (it printed "Path traversal
64
+ * rejected" and then permitted), a run() that threw, a non-integer exitCode, and the top-level
65
+ * catch. The spawn path exited 1 instead, which is out of contract and therefore also permits —
66
+ * that one covers a hook TIMEOUT, where no exit 2 was previously reachable at all.
67
+ *
68
+ * @param {string} hookId
69
+ * @param {string} raw the original payload, echoed only when allowing
70
+ * @param {string} reason
71
+ */
72
+ function failed(hookId, raw, reason) {
73
+ const isDenyClass = DENY_CLASS.has(hookId);
74
+
75
+ if (isDenyClass && !FAIL_OPEN_OVERRIDE) {
76
+ process.stderr.write(
77
+ `[Hook] BLOCKED: ${hookId} could not run, so this operation was not checked — ${reason}\n` +
78
+ `[Hook] ${hookId} is a deny-class gate; failing closed. Fix the hook, or set ` +
79
+ 'MINDFORGE_HOOK_FAILOPEN=1 to permit unchecked operations.\n');
80
+ // No stdout: a block carries its reason on stderr (see mindforge-block-no-verify).
81
+ process.exit(2);
82
+ }
83
+
84
+ if (isDenyClass) {
85
+ process.stderr.write(
86
+ `[Hook] FAIL-OPEN OVERRIDE: ${hookId} could not run (${reason}) and ` +
87
+ 'MINDFORGE_HOOK_FAILOPEN=1 is set, so the operation proceeds UNCHECKED.\n');
88
+ } else {
89
+ process.stderr.write(`[Hook] ${hookId} skipped (advisory) — ${reason}\n`);
90
+ }
91
+
92
+ process.stdout.write(raw);
93
+ process.exit(0);
94
+ }
95
+
29
96
  function readStdinRaw() {
30
97
  return new Promise(resolve => {
31
98
  let raw = '';
@@ -92,16 +159,73 @@ function writeLegacySpawnOutput(raw, result) {
92
159
  }
93
160
 
94
161
  /**
95
- * Resolve the MindForge install root. In a plugin-install layout CLAUDE_PLUGIN_ROOT
96
- * points at the plugin root; otherwise we resolve the repo/.agent root from this
97
- * file's location (.agent/hooks/ -> repo root two levels up).
162
+ * The roots this dispatcher can legitimately run under, derived from its OWN location.
163
+ *
164
+ * plugin install <pluginRoot>/scripts/run-with-flags.js -> __dirname/..
165
+ * repo / npx <root>/.agent/hooks/run-with-flags.js -> __dirname/../..
166
+ * installed project <project>/.claude/hooks/run-with-flags.js -> __dirname/../..
167
+ *
168
+ * In every real layout the root is an ancestor of __dirname. That is the property the
169
+ * CLAUDE_PLUGIN_ROOT check below relies on. A new layout must be added here deliberately —
170
+ * failing closed on an unrecognised one is the point.
171
+ */
172
+ function candidateRoots() {
173
+ return [
174
+ path.resolve(__dirname, '..'),
175
+ path.resolve(__dirname, '..', '..'),
176
+ ];
177
+ }
178
+
179
+ /**
180
+ * realpath if the path exists, plain resolve otherwise.
181
+ *
182
+ * This exists to prevent FALSE REJECTIONS, not to block an attack — a correction to my first
183
+ * reading of it. An unrelated directory is rejected by the candidate comparison whether or not it
184
+ * is a symlink, because its resolved path is not a candidate either way. What realpath buys is the
185
+ * converse: a LEGITIMATE root reached by an equivalent path still matches. That happens routinely —
186
+ * a symlinked checkout, or macOS reporting /tmp/x while process paths resolve to /private/tmp/x.
187
+ * Comparing unresolved strings would reject those and silently fall back to the derived root.
188
+ */
189
+ function canonical(p) {
190
+ try { return fs.realpathSync(path.resolve(p)); } catch { return path.resolve(p); }
191
+ }
192
+
193
+ /**
194
+ * Resolve the MindForge install root.
195
+ *
196
+ * CLAUDE_PLUGIN_ROOT is honoured ONLY when it names a root this dispatcher could actually be
197
+ * running from. It used to be returned verbatim, which made it a gate-substitution primitive
198
+ * rather than a relocation hint: the dispatcher resolves the hook script relative to this root,
199
+ * so pointing the variable at an attacker-controlled directory makes an arbitrary file execute
200
+ * WITH THE AUTHORITY OF THE SECURITY GATE. Measured against the real trust-gate:
201
+ *
202
+ * unset `rm -rf /` -> exit 2 (denied)
203
+ * CLAUDE_PLUGIN_ROOT=<dir with a stub> `rm -rf /` -> exit 0 (APPROVED by the stub)
204
+ * CLAUDE_PLUGIN_ROOT=/tmp `rm -rf /` -> exit 2 (fails closed: no script there)
205
+ *
206
+ * The third case was already safe once deny-class hooks began failing closed. The second was
207
+ * not, and no amount of fail-closed logic fixes it — the substituted gate returns a clean ALLOW.
208
+ * The existing traversal guard does not help either, because the root it measures against is
209
+ * exactly what moved.
210
+ *
211
+ * Rejection is loud and falls back to the derived root, so a mis-set variable degrades to the
212
+ * correct gate rather than to no gate.
98
213
  */
99
214
  function getHookRoot() {
100
- if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
101
- return process.env.CLAUDE_PLUGIN_ROOT;
215
+ const derived = path.resolve(__dirname, '..', '..');
216
+ const raw = process.env.CLAUDE_PLUGIN_ROOT;
217
+ if (!raw || !raw.trim()) return derived;
218
+
219
+ const requested = canonical(raw.trim());
220
+ if (candidateRoots().some((c) => canonical(c) === requested)) {
221
+ return requested;
102
222
  }
103
- // .agent/hooks/run-with-flags.js -> repo root is two dirs up.
104
- return path.resolve(__dirname, '..', '..');
223
+
224
+ process.stderr.write(
225
+ `[Hook] IGNORING CLAUDE_PLUGIN_ROOT=${raw.trim()} — it is not a root this dispatcher runs ` +
226
+ 'from, and honouring it would resolve hook scripts out of an unrelated directory (executing ' +
227
+ `them with the gate's authority). Using ${derived} instead.\n`);
228
+ return derived;
105
229
  }
106
230
 
107
231
  async function main() {
@@ -114,6 +238,23 @@ async function main() {
114
238
  }
115
239
 
116
240
  if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
241
+ // Leave a record. Switching a hook off used to be entirely silent — measured,
242
+ // MINDFORGE_DISABLED_HOOKS=trust-gate gave exit 0 with EMPTY stderr, so a disabled security
243
+ // gate was indistinguishable from a gate that ran and approved, in the log as well as in the
244
+ // exit code. The two reasons are reported separately because they are different events: an
245
+ // explicit opt-out is somebody's decision, while a profile mismatch is ordinary configuration.
246
+ const explicitlyDisabled = getDisabledHookIds().has(normalizeId(hookId));
247
+ if (explicitlyDisabled && DENY_CLASS.has(hookId)) {
248
+ process.stderr.write(
249
+ `[Hook] SECURITY GATE DISABLED: ${hookId} was switched off via MINDFORGE_DISABLED_HOOKS, ` +
250
+ 'so this operation ran unchecked.\n');
251
+ } else if (explicitlyDisabled) {
252
+ process.stderr.write(`[Hook] ${hookId} disabled via MINDFORGE_DISABLED_HOOKS.\n`);
253
+ } else {
254
+ process.stderr.write(
255
+ `[Hook] ${hookId} not active for profile ${getHookProfile()} ` +
256
+ `(registered for: ${profilesCsv || 'standard,strict'}).\n`);
257
+ }
117
258
  process.stdout.write(raw);
118
259
  process.exit(0);
119
260
  }
@@ -122,17 +263,14 @@ async function main() {
122
263
  const resolvedRoot = path.resolve(hookRoot);
123
264
  const scriptPath = path.resolve(hookRoot, relScriptPath);
124
265
 
125
- // Prevent path traversal outside the install root
266
+ // Prevent path traversal outside the install root. This branch previously printed
267
+ // "Path traversal rejected" and then exited 0 — announcing an attack signal and permitting it.
126
268
  if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
127
- process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
128
- process.stdout.write(raw);
129
- process.exit(0);
269
+ failed(hookId, raw, `script path escapes the install root: ${scriptPath}`);
130
270
  }
131
271
 
132
272
  if (!fs.existsSync(scriptPath)) {
133
- process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
134
- process.stdout.write(raw);
135
- process.exit(0);
273
+ failed(hookId, raw, `script not found at ${scriptPath}`);
136
274
  }
137
275
 
138
276
  // Prefer direct require() when the hook exports run(rawInput). Eliminates one
@@ -161,10 +299,18 @@ async function main() {
161
299
  truncated,
162
300
  maxStdin: MAX_STDIN
163
301
  });
302
+ // A hook that returns an exitCode which is not an integer has produced a result we cannot
303
+ // read. emitHookResult() coerced that to 0, so `{exitCode: '2'}` — a string, e.g. from
304
+ // JSON round-tripping — silently became ALLOW. Absent exitCode is different and legitimate:
305
+ // it means "allow, with a message".
306
+ if (output && typeof output === 'object'
307
+ && Object.prototype.hasOwnProperty.call(output, 'exitCode')
308
+ && !Number.isInteger(output.exitCode)) {
309
+ failed(hookId, raw, `run() returned a non-integer exitCode: ${JSON.stringify(output.exitCode)}`);
310
+ }
164
311
  process.exit(emitHookResult(raw, output));
165
312
  } catch (runErr) {
166
- process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
167
- process.stdout.write(raw);
313
+ failed(hookId, raw, `run() threw: ${runErr.message}`);
168
314
  }
169
315
  process.exit(0);
170
316
  }
@@ -189,19 +335,43 @@ async function main() {
189
335
  if (result.stderr) process.stderr.write(result.stderr);
190
336
 
191
337
  if (result.error || result.signal || result.status === null) {
338
+ // Covers the 30s spawn TIMEOUT, which arrives as signal SIGTERM with a null status. Exiting 1
339
+ // here meant a timed-out security gate permitted the operation: 1 is not in the 0-allow/
340
+ // 2-block contract, so Claude Code does not read it as a denial.
192
341
  const failureDetail = result.error
193
342
  ? result.error.message
194
343
  : result.signal
195
344
  ? `terminated by signal ${result.signal}`
196
345
  : 'missing exit status';
197
- writeStderr(`[Hook] legacy hook execution failed for ${hookId}: ${failureDetail}`);
198
- process.exit(1);
346
+ failed(hookId, raw, `child process failed: ${failureDetail}`);
347
+ }
348
+
349
+ if (!Number.isInteger(result.status)) {
350
+ failed(hookId, raw, `child returned a non-integer status: ${String(result.status)}`);
199
351
  }
200
352
 
201
- process.exit(Number.isInteger(result.status) ? result.status : 0);
353
+ // Enforce the contract: 0 = allow, 2 = block. Anything else is the hook ERRORING, not deciding.
354
+ // Measured: a hook that throws at module scope exits 1, and 1 is not a denial Claude Code
355
+ // honours — so a crashed security gate was permitting the very operation it exists to check.
356
+ // Advisory hooks keep their status verbatim; only a deny-class gate turns an unreadable result
357
+ // into a block, because only there does "we could not decide" mean "do not proceed".
358
+ if (result.status !== 0 && result.status !== 2) {
359
+ failed(hookId, raw, `child exited ${result.status}, which is outside the 0-allow/2-block contract`);
360
+ }
361
+
362
+ process.exit(result.status);
202
363
  }
203
364
 
204
365
  main().catch(err => {
366
+ // The dispatcher itself failed. hookId is argv[2] — read it directly, because main() may have
367
+ // thrown before assigning anything, and the class decision needs it.
368
+ const hookId = process.argv[2] || '';
369
+ if (DENY_CLASS.has(hookId) && !FAIL_OPEN_OVERRIDE) {
370
+ process.stderr.write(
371
+ `[Hook] BLOCKED: dispatcher failed for ${hookId} — ${err.message}\n` +
372
+ '[Hook] deny-class gate; failing closed. Set MINDFORGE_HOOK_FAILOPEN=1 to override.\n');
373
+ process.exit(2);
374
+ }
205
375
  process.stderr.write(`[Hook] run-with-flags error: ${err.message}\n`);
206
376
  process.exit(0);
207
377
  });
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: @mindforge browse <url | action>
2
+ description: /mindforge:browse <url | action>
3
3
  ---
4
4
 
5
5
  # /mindforge:browse
6
6
 
7
7
  ## Usage
8
- `@mindforge browse <url | action>`
8
+ `/mindforge:browse <url | action>`
9
9
 
10
10
  ## Description
11
11
  Controls the persistent MindForge browser daemon.
@@ -61,7 +61,7 @@ Remove old checkpoints, keeping the last 5.
61
61
 
62
62
  ## AUDIT linkage
63
63
 
64
- Each create/verify optionally writes a Merkle-linked AUDIT.jsonl entry:
64
+ Each create/verify optionally writes a hash-chained AUDIT.jsonl entry:
65
65
 
66
66
  ```json
67
67
  { "event": "checkpoint_created", "name": "core-done", "sha": "abc1234", "tests_pass_rate": 1.0, "coverage": 0.0 }
@@ -31,7 +31,7 @@ LLM layer judges *whether it matters here* and proposes the highest-leverage fix
31
31
 
32
32
  ## Step 3 — Report + AUDIT entry
33
33
 
34
- Summarize the scorecard, then write a Merkle-linked AUDIT.jsonl entry:
34
+ Summarize the scorecard, then write a hash-chained AUDIT.jsonl entry:
35
35
 
36
36
  ```json
37
37
  {