mindforge-cc 11.9.1 → 11.9.3

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 (121) 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/consult.md +1 -1
  10. package/.agent/mindforge/cost-report.md +1 -1
  11. package/.agent/mindforge/harness-audit.md +1 -1
  12. package/.agent/mindforge/orch-add-feature.md +1 -1
  13. package/.agent/mindforge/orch-build-mvp.md +1 -1
  14. package/.agent/mindforge/orch-change-feature.md +1 -1
  15. package/.agent/mindforge/orch-fix-defect.md +1 -1
  16. package/.agent/mindforge/orch-refine-code.md +1 -1
  17. package/.agent/mindforge/qa.md +2 -2
  18. package/.claude/CLAUDE.md +37 -13
  19. package/.claude/commands/mindforge/browse.md +2 -2
  20. package/.claude/commands/mindforge/checkpoint.md +1 -1
  21. package/.claude/commands/mindforge/consult.md +1 -1
  22. package/.claude/commands/mindforge/cost-report.md +1 -1
  23. package/.claude/commands/mindforge/harness-audit.md +1 -1
  24. package/.claude/commands/mindforge/orch-add-feature.md +1 -1
  25. package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
  26. package/.claude/commands/mindforge/orch-change-feature.md +1 -1
  27. package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
  28. package/.claude/commands/mindforge/orch-refine-code.md +1 -1
  29. package/.claude/commands/mindforge/qa.md +2 -2
  30. package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
  31. package/.mindforge/config.json +4 -4
  32. package/.mindforge/engine/autonomous/headless-adapter.md +2 -2
  33. package/.mindforge/engine/cost-tracking/router.md +1 -1
  34. package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
  35. package/.mindforge/engine/temporal-protocol.md +2 -2
  36. package/.mindforge/governance/change-classifier.md +20 -4
  37. package/.mindforge/memory/sync-manifest.json +1 -1
  38. package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
  39. package/.mindforge/personas/cost-optimizer.md +2 -2
  40. package/.mindforge/personas/multi-model-bridge.md +1 -1
  41. package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
  42. package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
  43. package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
  44. package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
  45. package/CHANGELOG.md +402 -0
  46. package/MINDFORGE.md +13 -6
  47. package/README.md +51 -2
  48. package/RELEASENOTES.md +55 -2
  49. package/SECURITY.md +22 -3
  50. package/bin/autonomous/audit-writer.js +48 -33
  51. package/bin/autonomous/auto-runner.js +65 -2
  52. package/bin/change-classifier.js +151 -16
  53. package/bin/dashboard/api-router.js +28 -47
  54. package/bin/dashboard/error-response.js +44 -0
  55. package/bin/dashboard/frontend/app.js +429 -0
  56. package/bin/dashboard/frontend/index.html +14 -390
  57. package/bin/dashboard/metrics-aggregator.js +75 -30
  58. package/bin/dashboard/revops-api.js +12 -2
  59. package/bin/dashboard/server.js +245 -6
  60. package/bin/dashboard/sse-bridge.js +11 -8
  61. package/bin/dashboard/temporal-api.js +11 -5
  62. package/bin/engine/remediation-engine.js +12 -1
  63. package/bin/engine/sre-manager.js +1 -1
  64. package/bin/engine/temporal-cli.js +56 -6
  65. package/bin/engine/temporal-hub.js +41 -9
  66. package/bin/engine/verification-runner.js +134 -17
  67. package/bin/engine/verify-cli.js +25 -7
  68. package/bin/eval/eval-harness.js +212 -1
  69. package/bin/eval/golden-set-retrieval.json +9 -0
  70. package/bin/governance/approval-record.js +147 -0
  71. package/bin/governance/approve.js +12 -7
  72. package/bin/governance/policy-engine.js +41 -3
  73. package/bin/governance/policy-gate-hardened.js +36 -1
  74. package/bin/governance/verify-approvals.js +163 -0
  75. package/bin/harness-audit.js +224 -10
  76. package/bin/hindsight-injector.js +8 -2
  77. package/bin/hooks/instinct-capture-hook.js +19 -5
  78. package/bin/install.js +63 -3
  79. package/bin/installer/harness-adapter-compliance.js +339 -28
  80. package/bin/installer/hook-registration.js +504 -0
  81. package/bin/installer-core.js +451 -63
  82. package/bin/learning/instinct-cli.js +14 -24
  83. package/bin/memory/knowledge-capture.js +23 -3
  84. package/bin/memory/knowledge-graph.js +70 -31
  85. package/bin/memory/vector-hub.js +500 -44
  86. package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
  87. package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
  88. package/bin/mindforge-cli.js +110 -17
  89. package/bin/models/cost-tracker.js +126 -29
  90. package/bin/models/model-client.js +6 -1
  91. package/bin/models/model-router.js +28 -7
  92. package/bin/models/usage-record.js +71 -0
  93. package/bin/revops/debt-monitor.js +57 -13
  94. package/bin/security/trust-gate-hook.js +50 -6
  95. package/bin/skill-validator.js +6 -1
  96. package/bin/skills-builder/skill-scorer.js +46 -6
  97. package/bin/updater/self-update.js +6 -1
  98. package/bin/updater/version-comparator.js +21 -1
  99. package/bin/utils/file-lock.js +106 -0
  100. package/bin/utils/mindforge-params.js +124 -0
  101. package/bin/utils/mindforge-version.js +99 -0
  102. package/bin/utils/redact-secrets.js +106 -0
  103. package/bin/validate-config.js +75 -17
  104. package/bin/wizard/setup-wizard.js +4 -1
  105. package/bin/wizard/theme.js +9 -1
  106. package/changelogs/index.json +11 -9
  107. package/changelogs/v11.9.2.md +209 -0
  108. package/changelogs/v11.9.3.md +195 -0
  109. package/docs/References/config-reference.md +76 -14
  110. package/docs/References/sdk-api.md +1 -1
  111. package/docs/Templates/Codebase/architecture.md +1 -1
  112. package/docs/commands-reference.md +4 -5
  113. package/docs/faq.md +25 -5
  114. package/docs/getting-started.md +3 -3
  115. package/docs/sdk-reference.md +15 -7
  116. package/docs/troubleshooting.md +10 -6
  117. package/docs/user-guide.md +14 -14
  118. package/examples/sdk-integration/README.md +1 -1
  119. package/package.json +10 -4
  120. package/subagents/.claude-plugin/marketplace.json +1 -1
  121. package/bin/dashboard/approval-handler.js +0 -136
@@ -3,18 +3,19 @@
3
3
  *
4
4
  * Changes:
5
5
  * 1. HANDOFF.json: add `plugin_api_version` field
6
- * 2. AUDIT.jsonl: backfill `session_id` for entries missing it
6
+ * 2. AUDIT.jsonl: append a migration record — existing entries are NEVER rewritten (see step 2)
7
7
  * 3. MINDFORGE.md: convert VERIFY_PASS_RATE_WARNING_THRESHOLD if in old 0-100 format
8
8
  * 4. STATE.md: add v1.0.0 compatibility note if it doesn't already have one
9
9
  */
10
10
  'use strict';
11
11
 
12
12
  const fs = require('fs');
13
+ const { appendAuditEntrySync } = require('../autonomous/audit-writer');
13
14
 
14
15
  module.exports = {
15
16
  fromVersion: '0.6.0',
16
17
  toVersion: '1.0.0',
17
- description: 'Add plugin_api_version; backfill session_id; normalise MINDFORGE.md thresholds',
18
+ description: 'Add plugin_api_version; record the migration in the audit log; normalise MINDFORGE.md thresholds',
18
19
 
19
20
  async run(paths) {
20
21
  // ── 1. HANDOFF.json ───────────────────────────────────────────────────────
@@ -36,31 +37,35 @@ module.exports = {
36
37
  }
37
38
 
38
39
  // ── 2. AUDIT.jsonl ────────────────────────────────────────────────────────
40
+ //
41
+ // APPEND-ONLY. This step used to rewrite every entry to backfill `session_id`, which BROKE the
42
+ // hash chain and then reported success. Measured on a 50-entry chain written by the real writer:
43
+ //
44
+ // before -> audit chain valid: 50 entries exit 0
45
+ // after -> audit chain BROKEN at entry 0: hash mismatch (entry mutated) exit 1
46
+ //
47
+ // 50 of 50 entries mutated, integrity destroyed at the very first entry, and the migration printed
48
+ // "backfilled session_id in 50 of 50 entries" and carried on to report "All migrations complete".
49
+ // bin/governance/audit-hash.js hashes {...entry, previous_hash} with JSON.stringify, so ANY added
50
+ // key changes the material — a back-linked log cannot be edited in place, only appended to. No file
51
+ // in this directory referenced the canonical hasher.
52
+ //
53
+ // AND THE BACKFILL BOUGHT NOTHING. The only consumer of `session_id` on an audit entry is
54
+ // bin/dashboard/metrics-aggregator.js:253,286, which reads
55
+ // `entry.authored_by || entry.session_id || 'unknown'`. So the rewrite swapped the placeholder
56
+ // 'unknown' for the placeholder 'migrated-from-pre-1.0' — no consumer distinguishes them — at the
57
+ // cost of every integrity guarantee in the file. Deleting it loses nothing.
58
+ //
59
+ // What IS worth recording is that a migration touched this project, so one entry is APPENDED
60
+ // through the canonical writer. An append extends the chain instead of invalidating it.
39
61
  if (fs.existsSync(paths.audit)) {
40
- const raw = fs.readFileSync(paths.audit, 'utf8');
41
- const lines = raw.split('\n').filter(Boolean);
42
- let modified = 0;
43
-
44
- const updated = lines.map(line => {
45
- try {
46
- const entry = JSON.parse(line);
47
- if (!entry.session_id) {
48
- entry.session_id = 'migrated-from-pre-1.0';
49
- modified++;
50
- return JSON.stringify(entry);
51
- }
52
- return line;
53
- } catch {
54
- return line; // Preserve unparseable lines exactly as-is (quarantine pattern)
55
- }
62
+ appendAuditEntrySync(paths.audit, {
63
+ event: 'schema_migrated',
64
+ target_id: 'AUDIT.jsonl',
65
+ description: 'schema 0.6.0 -> 1.0.0; existing entries left byte-identical (append-only log)',
66
+ agent: 'migrate',
56
67
  });
57
-
58
- if (modified > 0) {
59
- fs.writeFileSync(paths.audit, updated.join('\n') + '\n');
60
- console.log(` • AUDIT.jsonl: backfilled session_id in ${modified} of ${lines.length} entries`);
61
- } else {
62
- console.log(' • AUDIT.jsonl: all entries already have session_id');
63
- }
68
+ console.log(' • AUDIT.jsonl: recorded the migration as a new entry; existing entries untouched');
64
69
  }
65
70
 
66
71
  // ── 3. MINDFORGE.md ───────────────────────────────────────────────────────
@@ -11,11 +11,12 @@
11
11
 
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
+ const { appendAuditEntrySync } = require('../autonomous/audit-writer');
14
15
 
15
16
  module.exports = {
16
17
  fromVersion: '1.0.0',
17
18
  toVersion: '2.0.0',
18
- description: 'Additive schema upgrade: backfill runtime/agent_id in audit; model_group in tokens',
19
+ description: 'Additive schema upgrade: record the migration in the audit log; model_group in tokens',
19
20
 
20
21
  async run(paths) {
21
22
  const backupDir = path.join(path.dirname(paths.handoff), '.backups', `v1-to-v2-${Date.now()}`);
@@ -65,29 +66,27 @@ module.exports = {
65
66
  });
66
67
 
67
68
  // ── 2. AUDIT.jsonl ────────────────────────────────────────────────────────
68
- safeMigrate(paths.audit, (raw) => {
69
- const lines = raw.split('\n').filter(Boolean);
70
- let modified = 0;
71
-
72
- const updated = lines.map(line => {
73
- try {
74
- const entry = JSON.parse(line);
75
- let changed = false;
76
- if (!entry.runtime) { entry.runtime = 'unknown'; changed = true; }
77
- if (!entry.agent_id) { entry.agent_id = 'migrated-v1'; changed = true; }
78
-
79
- if (changed) {
80
- modified++;
81
- return JSON.stringify(entry);
82
- }
83
- return line;
84
- } catch {
85
- return line;
86
- }
69
+ //
70
+ // APPEND-ONLY, for the same reason as 0.6.0-to-1.0.0.js step 2: this rewrote every entry to add
71
+ // `runtime` and `agent_id`, and bin/governance/audit-hash.js hashes {...entry, previous_hash}, so
72
+ // any added key changes the hash material and the chain breaks at the first entry while the
73
+ // migration reports success.
74
+ //
75
+ // Worse than its sibling, because here the backfill had NO consumer at all: `git grep agent_id`
76
+ // and `git grep model_group` outside bin/migrations/ return zero readers in bin/. The chain was
77
+ // being destroyed to populate fields nothing reads.
78
+ //
79
+ // Note safeMigrate() cannot express this — it takes raw content and returns replacement content,
80
+ // which is a rewrite by construction. An append goes through the canonical writer instead.
81
+ if (fs.existsSync(paths.audit)) {
82
+ appendAuditEntrySync(paths.audit, {
83
+ event: 'schema_migrated',
84
+ target_id: 'AUDIT.jsonl',
85
+ description: 'schema 1.0.0 -> 2.0.0; existing entries left byte-identical (append-only log)',
86
+ agent: 'migrate',
87
87
  });
88
-
89
- return modified > 0 ? updated.join('\n') + '\n' : raw;
90
- });
88
+ console.log(' • AUDIT.jsonl: recorded the migration as a new entry; existing entries untouched');
89
+ }
91
90
 
92
91
  // ── 3. token-usage.jsonl ──────────────────────────────────────────────────
93
92
  const tokensFile = path.join(path.dirname(paths.handoff), 'token-usage.jsonl');
@@ -25,8 +25,10 @@ const ROOT = path.resolve(__dirname, '..');
25
25
  const COMMANDS = {
26
26
  'security-scan': {
27
27
  script: 'bin/validate-config.js',
28
- description: 'Validate configuration and run security checks',
29
- defaultArgs: ['MINDFORGE.md']
28
+ description: 'Validate configuration and run security checks'
29
+ // No defaultArgs: validate-config.js already defaults to MINDFORGE.md
30
+ // (bin/validate-config.js:13). Declaring it here would prepend a positional
31
+ // that shadows a user-supplied config path.
30
32
  },
31
33
  'health': {
32
34
  script: 'bin/installer-core.js',
@@ -57,20 +59,31 @@ const COMMANDS = {
57
59
  script: 'bin/skill-validator.js',
58
60
  description: 'Run Level 1 & 2 validation on a SKILL.md file'
59
61
  },
62
+ // NOTE: install-skill / register-skill / audit-skill deliberately carry NO
63
+ // defaultArgs. Supplying their subcommand token here reaches skill-registry's
64
+ // write paths, which perform no existence or validation checks:
65
+ // - audit-skill <name> <ver> <tier> appended a hash-chained
66
+ // {event:'skill_installed', validation_passed:true} entry for a skill that
67
+ // does not exist, and exited 0. For a product whose central claim is a
68
+ // tamper-evident audit chain, a CLI that mints authentic-looking false
69
+ // entries on request is worse than one that refuses.
70
+ // - register-skill <name> <ver> 1 wrote a malformed 5-column row above the
71
+ // table header of .mindforge/org/skills/MANIFEST.md, which ships in the
72
+ // tarball, and exited 0.
73
+ // Without defaultArgs these refuse with "Invalid or missing action" (exit 1),
74
+ // which is the pre-11.9.2 behaviour. Re-enable only once skill-registry gates
75
+ // on skill existence and fixes its table-separator match.
60
76
  'install-skill': {
61
77
  script: 'bin/skill-registry.js',
62
- description: 'Install a skill to the correct tier folder (Tier 1/2/3)',
63
- defaultArgs: ['install']
78
+ description: 'Install a skill to the correct tier folder (pass the "install" action explicitly)'
64
79
  },
65
80
  'register-skill': {
66
81
  script: 'bin/skill-registry.js',
67
- description: 'Register a skill in MANIFEST.md',
68
- defaultArgs: ['register']
82
+ description: 'Register a skill in MANIFEST.md (pass the "register" action explicitly)'
69
83
  },
70
84
  'audit-skill': {
71
85
  script: 'bin/skill-registry.js',
72
- description: 'Record skill life cycle events in audit log',
73
- defaultArgs: ['audit']
86
+ description: 'Record skill life cycle events in audit log (pass the "audit" action explicitly)'
74
87
  },
75
88
  'remember': {
76
89
  script: 'bin/memory/cli.js',
@@ -98,6 +111,15 @@ const COMMANDS = {
98
111
  description: 'Invoke a specialized identity from /agents/',
99
112
  defaultArgs: ['identity']
100
113
  },
114
+ // spawn-agent.js reads MODE from ARGS[0] and supports spawn|identity|subagent.
115
+ // 'subagent' had no router entry, so it was reached as `mindforge spawn subagent
116
+ // <name>` — which prepending 'spawn' now shadows (MODE='spawn', TARGET='subagent').
117
+ // Promoting it to a first-class command keeps the only implemented mode reachable.
118
+ 'subagent': {
119
+ script: 'bin/spawn-agent.js',
120
+ description: 'Invoke a subagent definition from /subagents/',
121
+ defaultArgs: ['subagent']
122
+ },
101
123
  'temporal': {
102
124
  script: 'bin/engine/temporal-cli.js',
103
125
  description: 'Manage time-travel debugging and state history'
@@ -129,10 +151,14 @@ const COMMANDS = {
129
151
  script: 'bin/engine/learning-manager.js',
130
152
  description: 'Consult or initialize the project agentic learning memory'
131
153
  },
154
+ // No defaultArgs: 'record' reaches a writer that resolves its target relative to
155
+ // the spawn cwd, which is still ROOT (deferred to v12). In a consumer install that
156
+ // writes inside node_modules/mindforge-cc/ and is destroyed by the next npm ci.
157
+ // Activating a writer while its path resolution is known-wrong is worse than
158
+ // leaving it inert; re-enable together with the cwd fix.
132
159
  'record-learning': {
133
160
  script: 'bin/engine/learning-manager.js',
134
- description: 'Append a new Learning Entry to the Evolution Log',
135
- defaultArgs: ['record']
161
+ description: 'Append a new Learning Entry to the Evolution Log (pass the "record" action explicitly)'
136
162
  },
137
163
  'verify': {
138
164
  script: 'bin/engine/verify-cli.js',
@@ -141,6 +167,17 @@ const COMMANDS = {
141
167
  };
142
168
 
143
169
  // ── Workflow subcommand (non-script, handled inline) ─────────────────────────
170
+ //
171
+ // Routable but NOT a COMMANDS key, because it dispatches to a module rather than spawning a script.
172
+ // That made the CLI lie about itself: `workflow` is the most-documented verb in the project (126
173
+ // references across the docs) and it works, yet it appeared in neither `--help` nor the
174
+ // "Available commands" list, so a user who mistyped it was told it does not exist. Declared here, next
175
+ // to the handler that makes it real, and consumed by both self-report sites below — one source, so the
176
+ // two cannot drift.
177
+ const INLINE_COMMANDS = ['workflow'];
178
+ /** Every verb the router will actually dispatch: table-driven plus inline. */
179
+ const ROUTABLE = [...Object.keys(COMMANDS), ...INLINE_COMMANDS].sort();
180
+
144
181
  if (COMMAND === 'workflow') {
145
182
  const workflowRunner = require('./workflows/workflow-runner');
146
183
  workflowRunner.run(COMMAND_ARGS[0], COMMAND_ARGS.slice(1));
@@ -148,8 +185,18 @@ if (COMMAND === 'workflow') {
148
185
  }
149
186
 
150
187
  if (ARGS.includes('--version') || ARGS.includes('-V')) {
151
- console.log(require('../package.json').version);
152
- process.exit(0);
188
+ // Resolve by package NAME. In an install this file lands at <project>/bin/mindforge-cli.js, so
189
+ // '../package.json' is the CONSUMER's manifest and `--version` confidently printed THEIR app's
190
+ // version as MindForge's — measured: 1.0.0 for a host app at 1.0.0, while MindForge was 11.9.2.
191
+ // Exit non-zero on an unresolvable version rather than guessing: the whole point of this command
192
+ // is to be trusted, and a plausible wrong answer is worse than an honest failure.
193
+ try {
194
+ console.log(require('./utils/mindforge-version').resolveMindforgeVersion().version);
195
+ process.exit(0);
196
+ } catch (err) {
197
+ console.error(err.message);
198
+ process.exit(1);
199
+ }
153
200
  }
154
201
 
155
202
  if (!COMMAND || ARGS.includes('--help') || ARGS.includes('-h')) {
@@ -161,8 +208,10 @@ const target = COMMANDS[COMMAND];
161
208
  if (!target) {
162
209
  console.error(`Unknown command: ${COMMAND}`);
163
210
 
164
- // Suggest similar commands using Levenshtein distance
165
- const suggestions = Object.keys(COMMANDS)
211
+ // Suggest similar commands using Levenshtein distance. ROUTABLE, not Object.keys(COMMANDS): a
212
+ // near-miss on `workflow` (`worklow`, `wokflow`) previously produced no suggestion at all, because
213
+ // the only verb it resembles was absent from the pool.
214
+ const suggestions = ROUTABLE
166
215
  .map(cmd => ({ cmd, dist: levenshtein(COMMAND, cmd) }))
167
216
  .filter(s => s.dist <= 3)
168
217
  .sort((a, b) => a.dist - b.dist)
@@ -171,18 +220,58 @@ if (!target) {
171
220
  if (suggestions.length > 0) {
172
221
  console.error(`\nDid you mean: ${suggestions.map(s => s.cmd).join(', ')}?`);
173
222
  } else {
174
- console.error('Available commands: ' + Object.keys(COMMANDS).join(', '));
223
+ console.error('Available commands: ' + ROUTABLE.join(', '));
175
224
  }
176
225
  process.exit(1);
177
226
  }
178
227
 
179
228
  const scriptPath = path.join(ROOT, target.script);
180
- const finalArgs = COMMAND_ARGS.length > 0 ? COMMAND_ARGS : (target.defaultArgs || []);
229
+
230
+ // defaultArgs are PREPENDED, never replaced. They carry the subcommand token or
231
+ // mode flag the child script requires (e.g. 'inject' for hindsight, '--check' for
232
+ // health), so dropping them when the user supplies an argument silently changes
233
+ // which code path runs — `health --force` used to lose '--check' and fall through
234
+ // to installer-core's install() path.
235
+ const finalArgs = [...(target.defaultArgs || []), ...COMMAND_ARGS];
181
236
 
182
237
  console.log(`🚀 Executing: ${COMMAND} (${target.description})`);
183
238
 
239
+ // The child runs in the USER'S project, not in MindForge's install directory.
240
+ //
241
+ // This was `cwd: ROOT`, which made every routed command operate on the framework's own tree
242
+ // instead of the caller's. The worst case was `security-scan`, the command the protocol mandates
243
+ // pre-commit for any Auth/Payment/PII change: measured against a fixture project whose
244
+ // MINDFORGE.md declared 4 settings including `[MIN_SOUL_SCORE] = 99` (schema maximum is 10) and
245
+ // `[COST_HARD_LIMIT_USD] = not-a-number`, it printed
246
+ //
247
+ // ✅ MINDFORGE.md valid — 43 settings configured
248
+ //
249
+ // and exited 0. 43 is MindForge's OWN setting count. Two different fixture configs produced
250
+ // byte-identical output, which is the proof it read neither: a security gate that cannot fail,
251
+ // because it never sees the input it claims to check.
252
+ //
253
+ // MEASURED BLAST RADIUS before changing it — all 27 routed commands run under both values against
254
+ // the same fixture. 7 differ, every one of them moving from the vendor's tree to the caller's, and
255
+ // NONE regressed from success to failure:
256
+ //
257
+ // security-scan validated MindForge's config -> reads the caller's
258
+ // classify TIER=2 from MindForge's git diff -> diffs the caller's repo
259
+ // pr-review, cross-review loaded MindForge's ConfigManager -> looks in the caller's project
260
+ // learning, record-learning reported MindForge's state -> reports the caller's
261
+ // test-memory same ConfigManager shift
262
+ //
263
+ // `classify` deserves its own note: for every consumer it was classifying MindForge's changes.
264
+ //
265
+ // CI is unaffected, checked rather than assumed. control-plane.yml:80 runs `security-scan` as the
266
+ // required ⚖️ Governance Enforcement check and mindforge-ci.yml:46 runs validate-config.js
267
+ // directly; both execute with the repo root as cwd, so ROOT and process.cwd() are the same path
268
+ // there and behaviour is byte-identical.
269
+ //
270
+ // A script that needs the FRAMEWORK's own assets must resolve them from __dirname, which is
271
+ // independent of cwd — see the SCHEMA_PATH note in bin/validate-config.js. Anchoring vendor assets
272
+ // to the process's working directory is what coupled these two unrelated things in the first place.
184
273
  const result = spawnSync('node', [scriptPath, ...finalArgs], {
185
- cwd: ROOT,
274
+ cwd: process.cwd(),
186
275
  stdio: 'inherit',
187
276
  env: { ...process.env, MINDFORGE_CLI: 'true' }
188
277
  });
@@ -220,8 +309,12 @@ function printUsage() {
220
309
  for (const [name, cfg] of Object.entries(COMMANDS)) {
221
310
  console.log(` ${name.padEnd(15)} ${cfg.description}`);
222
311
  }
312
+ // Listed separately because it dispatches to a module rather than spawning a script, so it has no
313
+ // COMMANDS entry to carry a description. Omitting it made --help contradict the router.
314
+ console.log(` ${'workflow'.padEnd(15)} Run a registered dynamic workflow (see \`workflow list\`)`);
223
315
  console.log('\nExamples:');
224
316
  console.log(' node bin/mindforge-cli.js security-scan');
225
317
  console.log(' node bin/mindforge-cli.js headless --phase 1');
318
+ console.log(' node bin/mindforge-cli.js workflow list');
226
319
  console.log('\n');
227
320
  }
@@ -4,32 +4,34 @@
4
4
  'use strict';
5
5
 
6
6
  const fs = require('fs');
7
- const path = require('path');
8
-
9
- const METRICS_DIR = path.join(process.cwd(), '.mindforge', 'metrics');
10
- const USAGE_LOG = path.join(METRICS_DIR, 'token-usage.jsonl');
7
+ const { ledgerPath, ledgerDir, buildRecord, entryCost, entryDay } = require('./usage-record');
11
8
 
9
+ // Paths are resolved lazily (see usage-record.js) so the suite can exercise the
10
+ // ledger inside a temp cwd instead of appending to the developer's real ledger.
12
11
  function ensureDir() {
13
- if (!fs.existsSync(METRICS_DIR)) {
14
- fs.mkdirSync(METRICS_DIR, { recursive: true });
12
+ const dir = ledgerDir();
13
+ if (!fs.existsSync(dir)) {
14
+ fs.mkdirSync(dir, { recursive: true });
15
15
  }
16
16
  }
17
17
 
18
18
  let _dailyCache = { value: 0, computed_at: 0 };
19
19
 
20
20
  function getTodaySpend() {
21
- if (!fs.existsSync(USAGE_LOG)) return 0;
22
-
21
+ const usageLog = ledgerPath();
22
+ if (!fs.existsSync(usageLog)) return 0;
23
+
23
24
  const today = new Date().toISOString().slice(0, 10);
24
- const content = fs.readFileSync(USAGE_LOG, 'utf8');
25
+ const content = fs.readFileSync(usageLog, 'utf8');
25
26
  const lines = content.trim().split('\n');
26
-
27
+
27
28
  let total = 0;
28
29
  for (const line of lines) {
30
+ if (!line) continue;
29
31
  try {
30
32
  const entry = JSON.parse(line);
31
- if (entry.date === today) {
32
- total += entry.cost_usd || 0;
33
+ if (entryDay(entry) === today) {
34
+ total += entryCost(entry);
33
35
  }
34
36
  } catch (e) {
35
37
  process.stderr.write('[cost-tracker] Skipped malformed entry\n');
@@ -47,42 +49,137 @@ function getTodaySpendCached() {
47
49
  return _dailyCache.value;
48
50
  }
49
51
 
52
+ // COST-02 — the $25/day cap declared at MINDFORGE.md:54 was inert from the day it
53
+ // was written. v11.9.2 read `settings.MODEL_COST_HARD_LIMIT_USD`; the registry
54
+ // declares `[COST_HARD_LIMIT_USD]`. The MODEL_-prefixed name appears in exactly one
55
+ // shipped file (.mindforge/MINDFORGE-V2-SCHEMA.json:58) and that file has no code
56
+ // reader, so the lookup was always undefined -> parseFloat('0.0') -> 0 -> the
57
+ // `hardLimit <= 0` guard returned before any spend was compared. Canonical registry
58
+ // key first; the MODEL_-prefixed name is still read second so anyone who copied it
59
+ // out of the V2 schema keeps the working cap they have instead of silently losing it.
60
+ const HARD_LIMIT_KEYS = ['COST_HARD_LIMIT_USD', 'MODEL_COST_HARD_LIMIT_USD'];
61
+ const WARN_LIMIT_KEYS = ['COST_WARN_USD', 'MODEL_COST_WARN_USD'];
62
+
63
+ /** First non-empty key from `keys`, canonical-first. Returns null when none is set. */
64
+ function findThreshold(settings, keys) {
65
+ for (const key of keys) {
66
+ const raw = settings[key];
67
+ if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
68
+ return { key, raw: String(raw) };
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Classify a registry cost threshold. Returns a new object, never mutates input:
76
+ * { state: 'unset' } key absent/empty -> caller fails OPEN
77
+ * { state: 'disabled', key, value:0 } explicit 0 -> caller fails OPEN
78
+ * { state: 'armed', key, value } finite positive USD -> caller enforces
79
+ * { state: 'invalid', key, raw } unreadable or negative -> caller fails CLOSED
80
+ *
81
+ * parseFloat after stripping a leading `$` and thousands separators — not Number() —
82
+ * because `= $25.00` and `= 25.00 USD` are shapes a human types into MINDFORGE.md and
83
+ * both plainly mean 25; Number() would call them invalid and refuse every model call.
84
+ * Only a value with no leading number at all is invalid. Non-finite is invalid on
85
+ * purpose: `Infinity` compares false against every projection, i.e. it is not a cap.
86
+ */
87
+ function classifyThreshold(found) {
88
+ if (!found) return { state: 'unset' };
89
+ const value = parseFloat(found.raw.trim());
90
+ if (!Number.isFinite(value) || value < 0) return { state: 'invalid', key: found.key, raw: found.raw };
91
+ if (value === 0) return { state: 'disabled', key: found.key, value: 0 };
92
+ return { state: 'armed', key: found.key, value };
93
+ }
94
+
95
+ // [COST_WARN_USD] had no reader anywhere in bin/ before COST-02. At most one line per
96
+ // UTC day per threshold, so an armed warning does not append to stderr on every call.
97
+ // Replaced as a whole object rather than mutated in place.
98
+ let _warnState = { day: '', threshold: 0 };
99
+
100
+ /** Soft threshold. Must never throw — a warning that blocks is a second hard cap. */
101
+ function warnIfCrossed(settings, projected, hardLimit) {
102
+ const warn = classifyThreshold(findThreshold(settings, WARN_LIMIT_KEYS));
103
+ if (warn.state !== 'armed') return;
104
+ // Suppress only when an ARMED hard cap sits at or below the warn value — there the
105
+ // throw preempts this line anyway. With no hard cap, nothing preempts it, and an
106
+ // upgraded install without [COST_HARD_LIMIT_USD] is the common case
107
+ // (installer-core.js:706 never rewrites an existing MINDFORGE.md) — precisely the
108
+ // install that most needs a spend warning.
109
+ if (hardLimit !== null && warn.value >= hardLimit) return;
110
+ if (projected < warn.value) return;
111
+
112
+ const today = new Date().toISOString().slice(0, 10);
113
+ if (_warnState.day === today && _warnState.threshold === warn.value) return;
114
+ _warnState = { day: today, threshold: warn.value };
115
+ const cap = hardLimit === null ? 'no hard cap set' : `hard cap ${hardLimit}`;
116
+ process.stderr.write(
117
+ `[cost-tracker] Projected daily spend ${projected.toFixed(4)} crossed [${warn.key}] = ${warn.value} (${cap})\n`
118
+ );
119
+ }
120
+
50
121
  async function preflight(estimatedCost = 0) {
51
122
  const settings = require('./model-router').getAllSettings();
52
- const hardLimit = parseFloat(settings.MODEL_COST_HARD_LIMIT_USD || '0.0');
53
-
54
- if (hardLimit <= 0) return;
123
+ const limit = classifyThreshold(findThreshold(settings, HARD_LIMIT_KEYS));
124
+
125
+ // Fail CLOSED on a limit that is present but unreadable: a cap nobody can parse is
126
+ // not a cap. Not a new surprise either — bin/validate-config.js already rejects this
127
+ // exact config with exit 1, because COST_HARD_LIMIT_USD is typed "number" at
128
+ // .mindforge/MINDFORGE-SCHEMA.json:87. The code is distinct from COST_LIMIT_REACHED
129
+ // so a caller can tell a spend stop from a config fault; bin/models/model-client.js
130
+ // re-throws both, which is the only reason this throw is not swallowed.
131
+ if (limit.state === 'invalid') {
132
+ throw Object.assign(
133
+ new Error(`[${limit.key}] = "${limit.raw}" is not a USD amount — the daily cost cap cannot be evaluated. Set a number in MINDFORGE.md (0 disables the cap).`),
134
+ { code: 'COST_LIMIT_MISCONFIGURED', key: limit.key, raw: limit.raw }
135
+ );
136
+ }
137
+
138
+ // Fail OPEN when the key is absent or explicitly 0. DELIBERATE — do not invert it.
139
+ // docs/research/2026-08-v12-upgrade-report.md:85 recommends making an unset limit a
140
+ // config error, but bin/installer-core.js:706 writes MINDFORGE.md only when it does
141
+ // not already exist, so every install upgraded from a registry predating this key
142
+ // would start refusing every model call. The shipped schema agrees the key is
143
+ // optional: it sits in `recommended`, not `required` (.mindforge/MINDFORGE-SCHEMA.json
144
+ // :13-17), and bin/validate-config.js:48-49 only warns and exits 0 when it is absent.
145
+ if (limit.state !== 'armed') {
146
+ // The soft threshold must NOT depend on the hard cap. Guarded on the warn key so
147
+ // the no-cost-config fast path stays a pure early return and never reads the ledger.
148
+ if (classifyThreshold(findThreshold(settings, WARN_LIMIT_KEYS)).state === 'armed') {
149
+ warnIfCrossed(settings, getTodaySpendCached() + estimatedCost, null);
150
+ }
151
+ return;
152
+ }
55
153
 
56
154
  const todaySpend = getTodaySpendCached();
57
155
  const projected = todaySpend + estimatedCost;
58
156
 
59
- if (projected >= hardLimit) {
157
+ if (projected >= limit.value) {
60
158
  throw Object.assign(
61
- new Error(`Daily cost limit $${hardLimit} reached (Today: $${todaySpend.toFixed(4)})`),
62
- { code: 'COST_LIMIT_REACHED', spend: todaySpend, limit: hardLimit }
159
+ new Error(`Daily cost limit $${limit.value} reached (Today: $${todaySpend.toFixed(4)})`),
160
+ { code: 'COST_LIMIT_REACHED', spend: todaySpend, limit: limit.value }
63
161
  );
64
162
  }
163
+
164
+ warnIfCrossed(settings, projected, limit.value);
65
165
  }
66
166
 
67
167
  async function record(entry) {
68
168
  ensureDir();
69
- const enriched = {
70
- ...entry,
71
- date: new Date().toISOString().slice(0, 10),
72
- timestamp: new Date().toISOString()
73
- };
74
- fs.appendFileSync(USAGE_LOG, JSON.stringify(enriched) + '\n');
169
+ const enriched = buildRecord(entry);
170
+ fs.appendFileSync(ledgerPath(), JSON.stringify(enriched) + '\n');
75
171
  _dailyCache.computed_at = 0; // Invalidate cache
76
172
  }
77
173
 
78
174
  function getSummary(params = { days: 7 }) {
79
- if (!fs.existsSync(USAGE_LOG)) return { total_usd: 0, by_model: {} };
80
-
175
+ const usageLog = ledgerPath();
176
+ if (!fs.existsSync(usageLog)) return { total_usd: 0, by_model: {} };
177
+
81
178
  const cutoffDate = new Date();
82
179
  cutoffDate.setDate(cutoffDate.getDate() - params.days);
83
180
  const cutoffStr = cutoffDate.toISOString().slice(0, 10);
84
181
 
85
- const content = fs.readFileSync(USAGE_LOG, 'utf8');
182
+ const content = fs.readFileSync(usageLog, 'utf8');
86
183
  const lines = content.trim().split('\n');
87
184
 
88
185
  const result = {
@@ -95,8 +192,8 @@ function getSummary(params = { days: 7 }) {
95
192
  for (const line of lines) {
96
193
  try {
97
194
  const entry = JSON.parse(line);
98
- if (entry.date >= cutoffStr) {
99
- const cost = entry.cost_usd || 0;
195
+ if (entryDay(entry) >= cutoffStr) {
196
+ const cost = entryCost(entry);
100
197
  result.total_usd += cost;
101
198
  result.calls++;
102
199
 
@@ -41,10 +41,15 @@ class ModelClient {
41
41
  let modelId = routing.model;
42
42
 
43
43
  // 2. Pre-flight cost check
44
+ // Both COST_ codes must propagate. This catch re-threw only COST_LIMIT_REACHED,
45
+ // so COST-02's fail-closed config fault would have been discarded here and the
46
+ // call would have proceeded uncapped — an interlock reporting success while doing
47
+ // nothing. Anything else still falls through on purpose: an unreadable ledger is
48
+ // a broken meter, and a broken meter must not block work.
44
49
  try {
45
50
  await CostTracker.preflight(0.05); // Conservative estimate
46
51
  } catch (e) {
47
- if (e.code === 'COST_LIMIT_REACHED') throw e;
52
+ if (e.code === 'COST_LIMIT_REACHED' || e.code === 'COST_LIMIT_MISCONFIGURED') throw e;
48
53
  }
49
54
 
50
55
  // 3. Execute with fallbacks
@@ -7,11 +7,15 @@
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
9
 
10
+ // The one bracket-aware MINDFORGE.md reader (see bin/utils/mindforge-params.js).
11
+ const { readParams } = require('../utils/mindforge-params');
12
+
10
13
  // v9: Model topology aligned to Claude 4.x family (2026-04)
11
14
  const DEFAULTS = {
12
15
  PLANNER_MODEL: 'claude-opus-4-7',
13
16
  EXECUTOR_MODEL: 'claude-sonnet-4-6',
14
17
  REVIEWER_MODEL: 'claude-sonnet-4-6',
18
+ VERIFIER_MODEL: 'claude-sonnet-4-6',
15
19
  SECURITY_MODEL: 'claude-opus-4-7',
16
20
  RESEARCH_MODEL: 'gemini-2.5-pro',
17
21
  QA_MODEL: 'claude-sonnet-4-6',
@@ -40,14 +44,31 @@ let _settingsMtime = 0;
40
44
  const CACHE_CHECK_INTERVAL_MS = 60000;
41
45
  let _lastCacheCheck = 0;
42
46
 
47
+ // MINDFORGE.md declares the model topology with SHORT persona keys ([PLANNER]);
48
+ // the router's canonical setting keys are the *_MODEL names in DEFAULTS above,
49
+ // which is what every getAllSettings() consumer reads. Map short -> canonical
50
+ // and keep BOTH in the returned object so nothing reading *_MODEL breaks.
51
+ const KEY_ALIASES = {
52
+ PLANNER: 'PLANNER_MODEL',
53
+ EXECUTOR: 'EXECUTOR_MODEL',
54
+ REVIEWER: 'REVIEWER_MODEL',
55
+ VERIFIER: 'VERIFIER_MODEL',
56
+ SECURITY: 'SECURITY_MODEL',
57
+ RESEARCH: 'RESEARCH_MODEL',
58
+ QA: 'QA_MODEL',
59
+ DEBUG: 'DEBUG_MODEL',
60
+ QUICK: 'QUICK_MODEL',
61
+ };
62
+
43
63
  function parseSettings(filePath) {
44
- const content = fs.readFileSync(filePath, 'utf8');
45
- const settings = { ...DEFAULTS };
46
- const lines = content.split('\n');
47
- for (const line of lines) {
48
- const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
49
- if (match) {
50
- settings[match[1]] = match[2].trim();
64
+ // v11.9.2 matched /^([A-Z0-9_]+)=(.*)$/ here, which matches ZERO lines of a
65
+ // bracketed MINDFORGE.md routing silently always used DEFAULTS.
66
+ const raw = readParams(filePath);
67
+ const settings = { ...DEFAULTS, ...raw };
68
+ // An explicit [PLANNER_MODEL] always wins over the short [PLANNER] form.
69
+ for (const [shortKey, canonicalKey] of Object.entries(KEY_ALIASES)) {
70
+ if (raw[shortKey] !== undefined && raw[canonicalKey] === undefined) {
71
+ settings[canonicalKey] = raw[shortKey];
51
72
  }
52
73
  }
53
74
  return settings;