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.
- package/.agent/mindforge/consult.md +1 -1
- package/.agent/mindforge/cost-report.md +1 -1
- package/.claude/commands/mindforge/consult.md +1 -1
- package/.claude/commands/mindforge/cost-report.md +1 -1
- package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
- package/.mindforge/config.json +3 -3
- package/.mindforge/engine/cost-tracking/router.md +1 -1
- package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
- package/.mindforge/memory/sync-manifest.json +1 -1
- package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
- package/.mindforge/personas/cost-optimizer.md +2 -2
- package/.mindforge/personas/multi-model-bridge.md +1 -1
- package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
- package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
- package/CHANGELOG.md +208 -0
- package/MINDFORGE.md +3 -3
- package/README.md +50 -2
- package/RELEASENOTES.md +53 -0
- package/bin/autonomous/audit-writer.js +48 -33
- package/bin/dashboard/api-router.js +11 -10
- package/bin/dashboard/error-response.js +44 -0
- package/bin/dashboard/frontend/index.html +20 -3
- package/bin/dashboard/metrics-aggregator.js +29 -8
- package/bin/dashboard/revops-api.js +12 -2
- package/bin/dashboard/server.js +85 -5
- package/bin/dashboard/temporal-api.js +11 -5
- package/bin/engine/remediation-engine.js +12 -1
- package/bin/engine/temporal-hub.js +41 -9
- package/bin/eval/eval-harness.js +212 -1
- package/bin/eval/golden-set-retrieval.json +9 -0
- package/bin/governance/policy-engine.js +8 -0
- package/bin/hindsight-injector.js +8 -2
- package/bin/hooks/instinct-capture-hook.js +7 -1
- package/bin/learning/instinct-cli.js +7 -24
- package/bin/memory/knowledge-capture.js +23 -3
- package/bin/memory/knowledge-graph.js +70 -31
- package/bin/memory/vector-hub.js +304 -31
- package/bin/mindforge-cli.js +43 -11
- package/bin/models/cost-tracker.js +22 -23
- package/bin/models/model-router.js +28 -7
- package/bin/models/usage-record.js +71 -0
- package/bin/utils/file-lock.js +106 -0
- package/bin/utils/mindforge-params.js +124 -0
- package/bin/validate-config.js +34 -16
- package/changelogs/v11.9.2.md +209 -0
- package/docs/References/config-reference.md +73 -14
- package/docs/sdk-reference.md +1 -1
- package/package.json +4 -2
|
@@ -4,32 +4,34 @@
|
|
|
4
4
|
'use strict';
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
|
-
const
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
32
|
-
total += entry
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
99
|
-
const cost = entry
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 };
|
package/bin/validate-config.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
|
-
const fs
|
|
11
|
-
|
|
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
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
|
72
|
-
|
|
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
|
|