mindforge-cc 11.9.1 → 11.9.2

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 (48) hide show
  1. package/.agent/mindforge/consult.md +1 -1
  2. package/.agent/mindforge/cost-report.md +1 -1
  3. package/.claude/commands/mindforge/consult.md +1 -1
  4. package/.claude/commands/mindforge/cost-report.md +1 -1
  5. package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
  6. package/.mindforge/config.json +3 -3
  7. package/.mindforge/engine/cost-tracking/router.md +1 -1
  8. package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
  9. package/.mindforge/memory/sync-manifest.json +1 -1
  10. package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
  11. package/.mindforge/personas/cost-optimizer.md +2 -2
  12. package/.mindforge/personas/multi-model-bridge.md +1 -1
  13. package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
  14. package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
  15. package/CHANGELOG.md +208 -0
  16. package/MINDFORGE.md +3 -3
  17. package/README.md +50 -2
  18. package/RELEASENOTES.md +53 -0
  19. package/bin/autonomous/audit-writer.js +48 -33
  20. package/bin/dashboard/api-router.js +11 -10
  21. package/bin/dashboard/error-response.js +44 -0
  22. package/bin/dashboard/frontend/index.html +20 -3
  23. package/bin/dashboard/metrics-aggregator.js +29 -8
  24. package/bin/dashboard/revops-api.js +12 -2
  25. package/bin/dashboard/server.js +85 -5
  26. package/bin/dashboard/temporal-api.js +11 -5
  27. package/bin/engine/remediation-engine.js +12 -1
  28. package/bin/engine/temporal-hub.js +41 -9
  29. package/bin/eval/eval-harness.js +212 -1
  30. package/bin/eval/golden-set-retrieval.json +9 -0
  31. package/bin/governance/policy-engine.js +8 -0
  32. package/bin/hindsight-injector.js +8 -2
  33. package/bin/hooks/instinct-capture-hook.js +7 -1
  34. package/bin/learning/instinct-cli.js +7 -24
  35. package/bin/memory/knowledge-capture.js +23 -3
  36. package/bin/memory/knowledge-graph.js +70 -31
  37. package/bin/memory/vector-hub.js +304 -31
  38. package/bin/mindforge-cli.js +43 -11
  39. package/bin/models/cost-tracker.js +22 -23
  40. package/bin/models/model-router.js +28 -7
  41. package/bin/models/usage-record.js +71 -0
  42. package/bin/utils/file-lock.js +106 -0
  43. package/bin/utils/mindforge-params.js +124 -0
  44. package/bin/validate-config.js +34 -16
  45. package/changelogs/v11.9.2.md +209 -0
  46. package/docs/References/config-reference.md +73 -14
  47. package/docs/sdk-reference.md +1 -1
  48. package/package.json +4 -2
@@ -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');
@@ -66,23 +68,20 @@ async function preflight(estimatedCost = 0) {
66
68
 
67
69
  async function record(entry) {
68
70
  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');
71
+ const enriched = buildRecord(entry);
72
+ fs.appendFileSync(ledgerPath(), JSON.stringify(enriched) + '\n');
75
73
  _dailyCache.computed_at = 0; // Invalidate cache
76
74
  }
77
75
 
78
76
  function getSummary(params = { days: 7 }) {
79
- if (!fs.existsSync(USAGE_LOG)) return { total_usd: 0, by_model: {} };
80
-
77
+ const usageLog = ledgerPath();
78
+ if (!fs.existsSync(usageLog)) return { total_usd: 0, by_model: {} };
79
+
81
80
  const cutoffDate = new Date();
82
81
  cutoffDate.setDate(cutoffDate.getDate() - params.days);
83
82
  const cutoffStr = cutoffDate.toISOString().slice(0, 10);
84
83
 
85
- const content = fs.readFileSync(USAGE_LOG, 'utf8');
84
+ const content = fs.readFileSync(usageLog, 'utf8');
86
85
  const lines = content.trim().split('\n');
87
86
 
88
87
  const result = {
@@ -95,8 +94,8 @@ function getSummary(params = { days: 7 }) {
95
94
  for (const line of lines) {
96
95
  try {
97
96
  const entry = JSON.parse(line);
98
- if (entry.date >= cutoffStr) {
99
- const cost = entry.cost_usd || 0;
97
+ if (entryDay(entry) >= cutoffStr) {
98
+ const cost = entryCost(entry);
100
99
  result.total_usd += cost;
101
100
  result.calls++;
102
101
 
@@ -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;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * MindForge — Token-usage ledger record shape.
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for `.mindforge/metrics/token-usage.jsonl`.
5
+ * The writer (bin/models/cost-tracker.js, fed by every provider in
6
+ * bin/models/*-provider.js) and every reader (bin/models/cost-tracker.js,
7
+ * bin/dashboard/metrics-aggregator.js) MUST go through this module so the
8
+ * field names cannot drift apart again.
9
+ *
10
+ * Canonical row (all five providers emit exactly this cost field):
11
+ * {
12
+ * model, input_tokens, output_tokens,
13
+ * cache_read_input_tokens?, cache_creation_input_tokens?,
14
+ * cost_usd, // <- the ONLY cost field. Never total_cost_usd.
15
+ * task_name?, session_id?, phase?,
16
+ * date, // 'YYYY-MM-DD', added by buildRecord()
17
+ * timestamp // full ISO 8601, added by buildRecord()
18
+ * }
19
+ *
20
+ * `total_cost_usd` is a DIFFERENT concept that belongs to cross-review reports
21
+ * (bin/review/cross-review-engine.js:72) and must never appear in this ledger.
22
+ *
23
+ * NOTE ON PATHS: resolution stays on process.cwd() (not bin/utils/paths.js
24
+ * findProjectRoot) to preserve today's behaviour exactly; relocating user state
25
+ * is deliberately deferred to v12.
26
+ *
27
+ * KNOWN STALE SITE, deliberately NOT migrated here: bin/migrations/1.0.0-to-2.0.0.js:93
28
+ * resolves the ledger to .planning/token-usage.jsonl — a path that has never existed —
29
+ * so that migration silently no-ops. Repointing it is out of scope for COST-01 because
30
+ * it would start a migration that has never run against real data. Until then, this
31
+ * module is the single source of truth for every LIVE reader and writer, not literally
32
+ * every path-resolution site in the tree.
33
+ */
34
+ 'use strict';
35
+
36
+ const path = require('path');
37
+
38
+ // Project-root-relative location of the append-only usage ledger.
39
+ const LEDGER_SEGMENTS = ['.mindforge', 'metrics', 'token-usage.jsonl'];
40
+
41
+ /** Absolute path to the ledger. Resolved lazily so tests can chdir. */
42
+ function ledgerPath(root = process.cwd()) {
43
+ return path.join(root, ...LEDGER_SEGMENTS);
44
+ }
45
+
46
+ /** Directory containing the ledger. */
47
+ function ledgerDir(root = process.cwd()) {
48
+ return path.join(root, ...LEDGER_SEGMENTS.slice(0, -1));
49
+ }
50
+
51
+ /** Stamp a provider result into a canonical ledger row. */
52
+ function buildRecord(entry, now = new Date()) {
53
+ const iso = now.toISOString();
54
+ return { ...entry, date: iso.slice(0, 10), timestamp: iso };
55
+ }
56
+
57
+ /** Cost of one row, in USD. Returns 0 for absent/non-finite values. */
58
+ function entryCost(entry) {
59
+ const v = entry && entry.cost_usd;
60
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
61
+ }
62
+
63
+ /** 'YYYY-MM-DD' day of one row: prefers `date`, falls back to `timestamp`. */
64
+ function entryDay(entry) {
65
+ if (!entry) return '';
66
+ if (typeof entry.date === 'string' && entry.date.length >= 10) return entry.date.slice(0, 10);
67
+ if (typeof entry.timestamp === 'string' && entry.timestamp.length >= 10) return entry.timestamp.slice(0, 10);
68
+ return '';
69
+ }
70
+
71
+ module.exports = { LEDGER_SEGMENTS, ledgerPath, ledgerDir, buildRecord, entryCost, entryDay };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MindForge — Fail-closed advisory file lock (LOCK-01).
5
+ *
6
+ * Promoted from bin/learning/instinct-cli.js:78-100 (`withStoreLock`), preserving its
7
+ * semantics: an O_CREAT|O_EXCL lockfile beside the target, a bounded retry spin, a
8
+ * stale-reclaim by mtime for locks orphaned by a killed process, and unlink in finally.
9
+ *
10
+ * FAIL-CLOSED: when the lock cannot be taken this THROWS. It NEVER writes anyway.
11
+ * Deliberately NOT modelled on .agent/bin/lib/state.cjs:784-789, which unlinks the other
12
+ * holder's lock and writes regardless on its last retry — that converts a detectable
13
+ * contention error into silent data loss.
14
+ *
15
+ * `fn` MUST be SYNCHRONOUS. The re-entrancy depth counter below is only sound because
16
+ * nothing else in this process can interleave between acquire and release.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const MAX_TRIES = 50; // 50 x 20-40ms => ~1-2s ceiling before failing closed
23
+ const WAIT_MS = 20;
24
+ const STALE_MS = 10000; // reclaim a lockfile whose mtime is older than this
25
+
26
+ // Re-entrancy depth per lockfile. Required: knowledge-graph applyDecay() holds this
27
+ // lock and calls deprecateEdge(), which takes the SAME lock. Without re-entrancy that
28
+ // self-deadlocks and then fails closed, so a decay pass could never prune an edge.
29
+ const _held = new Map(); // lockPath -> depth
30
+
31
+ const _sleepView = new Int32Array(new SharedArrayBuffer(4));
32
+
33
+ /** Blocking sleep that does not burn a core; busy-waits if Atomics.wait is refused. */
34
+ function sleepSync(ms) {
35
+ try {
36
+ Atomics.wait(_sleepView, 0, 0, ms);
37
+ } catch {
38
+ const until = Date.now() + ms;
39
+ while (Date.now() < until) { /* busy-wait fallback */ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Rejects an async fn. The `finally` in withFileLock unlinks the lockfile the moment
45
+ * fn() returns, so a promise-returning fn would run its critical section UNLOCKED while
46
+ * looking guarded. Failing loudly here is far cheaper than debugging that corruption.
47
+ */
48
+ function assertSync(out) {
49
+ if (out && typeof out.then === 'function') {
50
+ throw new TypeError('withFileLock requires a SYNCHRONOUS fn; got a thenable');
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Runs fn() while holding an exclusive advisory lock on `${targetPath}.lock`.
57
+ * @param {string} targetPath the file being protected (the lock is a sibling `.lock`)
58
+ * @param {Function} fn SYNCHRONOUS critical section; its return value is returned
59
+ * @param {object} [opts] { maxTries, waitMs, staleMs, label }
60
+ * @returns {*} whatever fn() returns
61
+ * @throws if the lock cannot be acquired within maxTries — the caller MUST NOT write
62
+ */
63
+ function withFileLock(targetPath, fn, opts = {}) {
64
+ const lock = `${targetPath}.lock`;
65
+
66
+ const depth = _held.get(lock) || 0;
67
+ if (depth > 0) { // already ours — re-enter, do not re-acquire
68
+ _held.set(lock, depth + 1);
69
+ try { return assertSync(fn()); }
70
+ finally { _held.set(lock, _held.get(lock) - 1); }
71
+ }
72
+
73
+ const maxTries = opts.maxTries ?? MAX_TRIES;
74
+ const waitMs = opts.waitMs ?? WAIT_MS;
75
+ const staleMs = opts.staleMs ?? STALE_MS;
76
+ const label = opts.label || targetPath;
77
+
78
+ // The lock must be creatable before the target's own mkdir runs, so ensure the dir
79
+ // here. A bad path still throws ENOTDIR/ENOENT to the caller, unchanged.
80
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
81
+
82
+ let acquired = false;
83
+ for (let i = 0; i < maxTries && !acquired; i++) {
84
+ try {
85
+ fs.closeSync(fs.openSync(lock, 'wx')); // O_CREAT|O_EXCL|O_WRONLY
86
+ acquired = true;
87
+ } catch (err) {
88
+ if (err.code !== 'EEXIST') throw err;
89
+ try {
90
+ const age = Date.now() - fs.statSync(lock).mtimeMs;
91
+ if (age > staleMs) { fs.unlinkSync(lock); continue; } // orphaned by a kill
92
+ } catch { /* lock vanished between EEXIST and stat — retry */ }
93
+ sleepSync(waitMs + Math.floor(Math.random() * waitMs)); // jitter breaks the herd
94
+ }
95
+ }
96
+ if (!acquired) throw new Error(`could not acquire ${label} lock: ${lock}`);
97
+
98
+ _held.set(lock, 1);
99
+ try { return assertSync(fn()); }
100
+ finally {
101
+ _held.set(lock, 0);
102
+ try { fs.unlinkSync(lock); } catch { /* already gone */ }
103
+ }
104
+ }
105
+
106
+ module.exports = { withFileLock, MAX_TRIES, WAIT_MS, STALE_MS };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ /**
3
+ * MindForge — MINDFORGE.md parameter parser (single source of truth).
4
+ *
5
+ * TWO on-disk forms are supported, because both ship:
6
+ *
7
+ * 1. BRACKETED (current, MINDFORGE.md) — 43 keys in the shipped registry:
8
+ * [PLANNER] = claude-opus-4-7
9
+ * [MODE] = "Platform Sovereign"
10
+ * [API_URL] = <http://localhost:3000>
11
+ * [PQAS_ENFORCED] = false # trailing note
12
+ * [FORBIDDEN] = """
13
+ * ...multi-line block...
14
+ * """
15
+ *
16
+ * 2. LEGACY PLAIN (shell-style) — 28 keys in examples/starter-project/MINDFORGE.md,
17
+ * which ships (package.json files[] contains "examples/"), and the
18
+ * tests/cli-router.test.js:110 fixture. A bracket-only parser silently
19
+ * zeroes those out:
20
+ * MAX_TASKS_PER_PHASE=999
21
+ * DISABLED_SKILLS= <- empty value is LEGAL, hence (.*) not (.+)
22
+ *
23
+ * Bracketed semantics are ported from sdk/src/client.ts:141-158 (the only
24
+ * previously correct bracket-aware reader) and extended with value
25
+ * normalisation so the runtime and the validator agree on one interpretation.
26
+ *
27
+ * NOT captured: prose bullets such as `- [MIN_SOUL_SCORE] — description`
28
+ * (section 7 of MINDFORGE.md). A bracketed key only counts when the bracket
29
+ * opens the line (leading whitespace allowed) and is followed by `=`.
30
+ *
31
+ * The legacy form deliberately allows NO leading whitespace, matching the two
32
+ * regexes it replaces (bin/validate-config.js:36, bin/models/model-router.js:48).
33
+ * That is a safety property, not an oversight: it stops indented `KEY=value`
34
+ * lines inside markdown code blocks from being read as configuration.
35
+ */
36
+
37
+ const fs = require('fs');
38
+
39
+ /** [KEY] = value — key must open the line; only spaces/tabs may precede it. */
40
+ const ASSIGN_RE = /^[ \t]*\[([A-Z0-9_]+)\][ \t]*=[ \t]*(.*)$/;
41
+
42
+ /** KEY=value — legacy plain form. Column 0 only. `(.*)`: empty values are legal. */
43
+ const LEGACY_RE = /^([A-Z0-9_]+)=(.*)$/;
44
+
45
+ const FENCE = '"""';
46
+
47
+ /** Drop a trailing ` # comment`. Requires whitespace before `#` so that
48
+ * values legitimately containing `#` (URL fragments, colours) survive. */
49
+ function stripComment(raw) {
50
+ const i = raw.search(/\s#/);
51
+ return i === -1 ? raw : raw.slice(0, i);
52
+ }
53
+
54
+ /** Unwrap "quoted" values and <markdown-autolink> URLs. */
55
+ function unwrap(v) {
56
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1);
57
+ if (v.length >= 2 && v.startsWith('<') && v.endsWith('>')) return v.slice(1, -1);
58
+ return v;
59
+ }
60
+
61
+ function normalise(raw) {
62
+ return unwrap(stripComment(raw).trim());
63
+ }
64
+
65
+ /**
66
+ * @param {string} content raw MINDFORGE.md text
67
+ * @returns {Record<string,string>} key -> normalised string value
68
+ */
69
+ function parseParams(content) {
70
+ const bracketed = {};
71
+ const legacy = {};
72
+ const lines = String(content).split(/\r?\n/);
73
+
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const m = lines[i].match(ASSIGN_RE);
76
+
77
+ if (!m) {
78
+ // Legacy plain form. Only reached when the line is not a bracketed
79
+ // assignment; the two patterns can never both match one line.
80
+ const g = lines[i].match(LEGACY_RE);
81
+ if (g) legacy[g[1]] = normalise(g[2]);
82
+ continue;
83
+ }
84
+
85
+ const key = m[1];
86
+ const rest = m[2].trim();
87
+
88
+ if (rest.startsWith(FENCE)) {
89
+ const inline = rest.slice(FENCE.length);
90
+ // Single-line fenced value: [K] = """text"""
91
+ if (inline.trimEnd().endsWith(FENCE) && inline.trim().length >= FENCE.length) {
92
+ bracketed[key] = inline.trimEnd().slice(0, -FENCE.length).trim();
93
+ continue;
94
+ }
95
+ const block = [];
96
+ let j = i + 1;
97
+ for (; j < lines.length; j++) {
98
+ if (lines[j].trim() === FENCE) break;
99
+ block.push(lines[j]);
100
+ }
101
+ bracketed[key] = block.join('\n').trim();
102
+ i = j; // resume after the closing fence (or at EOF)
103
+ continue;
104
+ }
105
+
106
+ bracketed[key] = normalise(rest);
107
+ }
108
+
109
+ // Bracketed is the canonical modern form and wins on collision, regardless
110
+ // of which appeared first in the file.
111
+ return { ...legacy, ...bracketed };
112
+ }
113
+
114
+ /**
115
+ * Read + parse a MINDFORGE.md. Returns {} when the file is absent so callers
116
+ * can fall back to their own defaults (fail-open on absence, not on garbage).
117
+ * @param {string} filePath
118
+ */
119
+ function readParams(filePath) {
120
+ if (!fs.existsSync(filePath)) return {};
121
+ return parseParams(fs.readFileSync(filePath, 'utf8'));
122
+ }
123
+
124
+ module.exports = { parseParams, readParams, ASSIGN_RE, LEGACY_RE };
@@ -7,8 +7,10 @@
7
7
 
8
8
  'use strict';
9
9
 
10
- const fs = require('fs');
11
- const path = require('path');
10
+ const fs = require('fs');
11
+
12
+ // The one bracket-aware MINDFORGE.md reader (see bin/utils/mindforge-params.js).
13
+ const { readParams } = require('./utils/mindforge-params');
12
14
 
13
15
  const CONFIG_PATH = process.argv[2] || 'MINDFORGE.md';
14
16
  const SCHEMA_PATH = '.mindforge/MINDFORGE-SCHEMA.json';
@@ -23,28 +25,36 @@ if (!fs.existsSync(SCHEMA_PATH)) {
23
25
  process.exit(0);
24
26
  }
25
27
 
26
- const content = fs.readFileSync(CONFIG_PATH, 'utf8');
27
28
  const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
28
29
 
29
30
  const errors = [];
30
31
  const warnings = [];
31
32
 
32
- // Parse key=value pairs from MINDFORGE.md
33
- const settings = {};
34
- const lines = content.split('\n');
35
- lines.forEach(line => {
36
- const match = line.match(/^([A-Z_]+)=(.+)$/);
37
- if (match) {
38
- const [, key, value] = match;
39
- settings[key] = value.trim();
40
- }
41
- });
33
+ // MINDFORGE.md declares parameters as bracketed assignments ([KEY] = value).
34
+ // v11.9.2 and earlier matched /^([A-Z_]+)=(.+)$/, which matches ZERO lines of a
35
+ // real MINDFORGE.md — the validator parsed 0 settings and could never fail.
36
+ // readParams() also still handles the legacy plain KEY=value form.
37
+ const settings = readParams(CONFIG_PATH);
38
+
39
+ // Required / recommended key sets (top-level arrays in the schema). Kept
40
+ // deliberately identical to sdk/src/client.ts validateConfig() so the SDK and
41
+ // the CLI validator enforce ONE contract.
42
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
43
+ const recommended = new Set(Array.isArray(schema.recommended) ? schema.recommended : []);
44
+
45
+ for (const key of required) {
46
+ if (!settings[key]) errors.push(`${key} is required but not set`);
47
+ }
48
+ for (const key of recommended) {
49
+ if (!settings[key]) warnings.push(`${key} is recommended but not set`);
50
+ }
42
51
 
43
52
  // Validate against schema
44
53
  for (const [key, def] of Object.entries(schema.properties || {})) {
45
54
  const value = settings[key];
46
55
 
47
- if (def.required && !value) {
56
+ // Legacy per-property `required` flag still honoured (schema.required wins).
57
+ if (def.required && !value && !required.has(key)) {
48
58
  errors.push(`${key} is required but not set`);
49
59
  continue;
50
60
  }
@@ -68,8 +78,16 @@ for (const [key, def] of Object.entries(schema.properties || {})) {
68
78
  errors.push(`${key}: expected true or false, got "${value}"`);
69
79
  }
70
80
 
71
- if (def.nonOverridable) {
72
- warnings.push(`${key}: this is a non-overridable governance primitive (value will be ignored)`);
81
+ if (def.type === 'string' && def.pattern && !new RegExp(def.pattern).test(value)) {
82
+ errors.push(`${key}: "${value}" does not match required pattern ${def.pattern}`);
83
+ }
84
+
85
+ // A non-overridable governance primitive may be SET (MINDFORGE.md is its
86
+ // source of truth) but never DISABLED. Mirrors sdk/src/client.ts:176-182,
87
+ // which errors on `[KEY] = false`. The old code warned on every occurrence
88
+ // and claimed the value "will be ignored" — which was untrue and pure noise.
89
+ if (def.nonOverridable && def.type === 'boolean' && value === 'false') {
90
+ errors.push(`${key}: non-overridable governance primitive cannot be disabled`);
73
91
  }
74
92
  }
75
93