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
package/MINDFORGE.md CHANGED
@@ -1,9 +1,9 @@
1
- # MINDFORGE.md — Parameter Registry (v11.9.1)
1
+ # MINDFORGE.md — Parameter Registry (v11.9.2)
2
2
 
3
3
  ## 1. IDENTITY & VERSIONING
4
4
 
5
5
  [NAME] = MindForge
6
- [VERSION] = 11.9.1
6
+ [VERSION] = 11.9.2
7
7
  [STABLE] = true
8
8
  [MODE] = "Platform Sovereign"
9
9
  [REQUIRED_CORE_VERSION] = 11.9.1
@@ -99,7 +99,7 @@ The following parameters cannot be overridden by plugins, agents, or session-lev
99
99
 
100
100
  - [MIN_SOUL_SCORE] — Minimum SOUL score required for architectural changes
101
101
  - [BLOCK_ON_SECURITY] — Security gate enforcement cannot be disabled
102
- - [COST_HARD_LIMIT_USD] — Hard cost limit cannot be raised without human approval
102
+ - [COST_HARD_LIMIT_USD] — declared here, but **NOT enforced as of 11.9.2**; do not rely on it as a spend control. `bin/models/cost-tracker.js` reads `MODEL_COST_HARD_LIMIT_USD`, a key this registry does not declare, so `preflight()` returns early and no cap is ever applied. Wiring it is tracked as COST-02 for 11.9.3
103
103
  - [BLOCK_ON_SECURITY] is non-overridable; PQAS itself is simulated/experimental (inactive by default) and is NOT a non-overridable guarantee — do not rely on it as an enforced control
104
104
  - [SOVEREIGN_IDENTITY] — Identity verification is always required
105
105
  - [ENABLE_ZTAI] — Zero-trust identity cannot be bypassed
package/README.md CHANGED
@@ -6,13 +6,55 @@
6
6
 
7
7
  ## Latest release
8
8
 
9
- **v11.9.0** (2026-07-27) — Bedrock provider + full dry-run audit. See [CHANGELOG.md](./CHANGELOG.md) for full history, or [RELEASENOTES.md](./RELEASENOTES.md) for human-readable notes.
9
+ **v11.9.2** (2026-08-16) — Correctness: the config gate can now fail, the audit chain no longer
10
+ forks under concurrent writers, trace retrieval works, and the cost ledger has one record shape.
11
+ **Contains a breaking change to the dashboard HTTP surface** (loopback-only) — see the BREAKING
12
+ section in [CHANGELOG.md](./CHANGELOG.md), or [RELEASENOTES.md](./RELEASENOTES.md) for
13
+ human-readable notes.
14
+
15
+ ---
16
+
17
+ ## What is actually enforced
18
+
19
+ Read this before the install instructions. MindForge ships a large corpus of agent
20
+ instructions — commands, skills, personas, protocols — and those are advisory: they work by
21
+ being in the model's context, and a model can decline them. The parts that would *block* an
22
+ action are hooks, and **no install channel currently registers them.**
23
+
24
+ | Capability | Plugin channel | `npx` channel |
25
+ |---|---|---|
26
+ | Slash commands | Yes | Yes |
27
+ | Skills / personas / protocol docs | Yes | Yes |
28
+ | Subagents | Yes | Yes |
29
+ | Audit hash-chain (`bin/verify-audit.js`) | Yes | Yes |
30
+ | **Hooks enforced (can block a tool call)** | **No** | **No** |
31
+
32
+ Why, specifically:
33
+
34
+ - **No hook configuration ships, and nothing generates one.** `package.json` `files[]` has 47
35
+ entries and none contains `settings`, so neither `.claude/settings.json` nor
36
+ `.agent/settings.json` is published. All references to those paths in `bin/` are reads or
37
+ metadata strings — there is no code that writes or merges one. `bin/harness-audit.js:335`
38
+ even offers "wire trust-gate + block-no-verify into both …" as a *fix suggestion*, auditing a
39
+ wiring nothing creates.
40
+ - **The plugin channel's hooks additionally crash when fired.**
41
+ `plugins/mindforge/scripts/run-with-flags.js:24` requires `./lib/hook-flags`, and
42
+ `plugins/mindforge/scripts/lib/` does not exist in the published plugin. Running the
43
+ dispatcher gives `Error: Cannot find module './lib/hook-flags'` and exit 1. The module it
44
+ needs does exist at `.agent/hooks/lib/hook-flags.js`; it was never copied in.
45
+
46
+ So treat MindForge as **governance-by-convention plus a tamper-evident audit log**, not as a
47
+ policy enforcement point. Installing it also expands your repository's trust boundary by a large
48
+ volume of agent instructions — review what you install. Making hook registration real per
49
+ harness is the headline goal of v12; the audit chain is genuinely verifiable today
50
+ (`node bin/verify-audit.js`).
10
51
 
11
52
  ---
12
53
 
13
54
  ## Install
14
55
 
15
- Fastest path — Claude Code plugin marketplace (no project files written):
56
+ Claude Code plugin marketplace (no project files written). **Note:** the plugin's hooks do not
57
+ fire — see *What is actually enforced* above. Slash commands, skills and subagents do work.
16
58
 
17
59
  ```bash
18
60
  /plugin marketplace add sairam0424/MindForge
@@ -27,6 +69,12 @@ npx mindforge-cc@latest --claude --local
27
69
 
28
70
  All install channels (global, local, Antigravity, Cursor, Copilot, Gemini CLI, MCP server, combined runtimes, `--minimal`): see [docs/getting-started.md](docs/getting-started.md).
29
71
 
72
+ **Upgrading from 11.9.x?** The installer does not overwrite an existing
73
+ `.mindforge/MINDFORGE-SCHEMA.json`, so 11.9.2's armed config validator keeps the older
74
+ permissive schema on a plain upgrade. Run with `--force` if you want the stricter gate. The
75
+ daily cost cap declared as `[COST_HARD_LIMIT_USD]` in `MINDFORGE.md` is **not enforced** in
76
+ 11.9.2 — see the CHANGELOG.
77
+
30
78
  ---
31
79
 
32
80
  ## Verify
package/RELEASENOTES.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Release Notes
2
2
 
3
+ ## v11.9.2 — 2026-08-16 — Correctness: config gate, audit chain, retrieval, cost ledger
4
+
5
+ ### What's New
6
+ Nothing. This is a correctness release: four things that were reported as working were measured
7
+ and found not to be, and a fifth — the plugin channel's hooks — is now documented as broken
8
+ instead of advertised as the fastest path.
9
+
10
+ ### ⚠️ Breaking (under a patch bump)
11
+ The dashboard's HTTP surface changed shape. `detail` is gone from 5 endpoints and raw errno
12
+ strings from 10 more; `correlation_id` is added to 15; malformed bodies now return
13
+ `application/json` instead of an HTML error page. Those fields were leaking absolute filesystem
14
+ paths — and so the operator's username and home directory — into an unauthenticated response.
15
+ The dashboard also now **exits** on an unhandled rejection or uncaught exception where 11.9.1
16
+ logged and continued, so supervised deployments will see restarts instead of silent errors.
17
+ The surface is loopback-only, so this affects you only if you script against it.
18
+
19
+ ### Fixes
20
+ - **`security-scan` could not fail.** It reported `MINDFORGE.md valid — 0 settings configured`
21
+ and exited 0 on every input, because both config parsers used a plain `KEY=value` regex while
22
+ the registry declares bracketed `[KEY] = value`. All 43 parameters were invisible. One shared
23
+ parser now reads both forms, and the schema has real `required` keys. Three CI gates go from
24
+ unfailable to failable — on fresh installs and `--force` reinstalls; a plain upgrade keeps
25
+ your existing schema.
26
+ - **The audit hash chain forked under concurrent writers.** No `.jsonl` append took a lock, and
27
+ the chain head was cached in-process and never invalidated — so a second process's append made
28
+ the first keep chaining from a superseded hash. Both halves are fixed: a fail-closed lock, and
29
+ a cached head that carries the file size witnessing it is still the tail. A lock alone was
30
+ measured insufficient. 8 concurrent appenders went from 199 broken links to 0.
31
+ - **44.4% of trace content was unsearchable and every multi-word query returned nothing.** The
32
+ index was keyed on `trace_id` rather than the row's primary key, so each span evicted the
33
+ previous one; and whole queries were wrapped as a single FTS phrase, so one absent term zeroed
34
+ the result. 2,270 of 5,117 rows are recovered losslessly and queries are now tokenised and
35
+ ranked. The shipped retrieval eval had zero callers and now runs as `npm run eval:retrieval`.
36
+ - **The cost ledger reported two different totals for one concept** ($13.73 vs $0.00) because
37
+ writer and reader used different field names — and the test wrote the reader's name, so the
38
+ mismatch passed. One record shape now, and the configured ledger path no longer points at a
39
+ file that never existed.
40
+ - **The daily cost cap is declared but not enforced**, and is now labelled that way in
41
+ `MINDFORGE.md` and the schema rather than described as non-overridable. Wiring it is COST-02.
42
+
43
+ ### Honesty
44
+ `README.md` now carries a per-channel capability table with an explicit **Hooks enforced: No**
45
+ row for both install channels, because no hook configuration ships and nothing generates one.
46
+ The plugin channel's dispatcher additionally fails with `Cannot find module './lib/hook-flags'`
47
+ on every fire. Treat MindForge as governance-by-convention plus a verifiable audit log, not as a
48
+ policy enforcement point. Making enforcement real per harness is the goal of v12.
49
+
50
+ ### Stats
51
+ 105 test files, 103 pass, 2 environment-dependent skips. Four suites that could not report
52
+ failure now can. Audit chain verifies clean. Tarball 1,973 entries.
53
+
54
+ ---
55
+
3
56
  ## v11.9.0 — 2026-07-27 — Bedrock Provider + Full Dry-Run Audit
4
57
 
5
58
  ### What's New
@@ -20,6 +20,7 @@ const fs = require('fs');
20
20
  const path = require('path');
21
21
  const crypto = require('crypto');
22
22
  const { hashAuditEntry } = require('../governance/audit-hash');
23
+ const { withFileLock } = require('../utils/file-lock');
23
24
 
24
25
  /**
25
26
  * Computes the SHA-256 hash of an entry chained to its predecessor (UC-04).
@@ -57,19 +58,20 @@ function readLastHash(auditPath) {
57
58
  // sync write gives in-process consumers (e.g. StuckMonitor, which is fed the event
58
59
  // object directly but may also re-read the file) immediate, durable data.
59
60
  //
60
- // Chain head caching: re-reading the file's tail on every append is O(file) — bad
61
- // on hot paths. Instead we keep a per-path in-memory lastHash (Map keyed by the
62
- // RESOLVED absolute path), seeded ONCE from the file's last entry on the first
63
- // append, then advanced in-process for O(1) appends. If the cache is cold (new
64
- // process, or a path never written in this process) we seed from disk so a
65
- // second process correctly continues the on-disk chain from its tail.
61
+ // Chain head caching: re-reading the file's tail on every append is O(file) — bad on
62
+ // hot paths. We keep a per-path in-memory head (Map keyed by the RESOLVED absolute
63
+ // path) as { hash, size }, where `size` is the file size immediately after our own
64
+ // write. The size is the WITNESS that the cache is still the true tail: if the file is
65
+ // still exactly that long, nobody else appended and the cached hash is the tail (O(1));
66
+ // if it differs, another process appended and we MUST re-seed from disk.
66
67
  //
67
- // Concurrency: within a process this is fully synchronous, so calls cannot
68
- // interleave and the cached lastHash is always current. ACROSS processes, each
69
- // process seeds from the file tail on its first append; this is correct only under
70
- // the single-operator model (no two processes appending CONCURRENTLY to the same
71
- // audit file). MindForge runs one autonomous operator at a time, so this holds.
72
- const _lastHashCache = new Map(); // resolvedPath -> last `_hash` written/seen
68
+ // Concurrency (LOCK-01): the seed-through-write sequence below runs inside
69
+ // withFileLock, so no other process can read the head, or append, while we hold it.
70
+ // The size check is what makes the cache safe across processes a lock alone is NOT
71
+ // enough, because a warm cache would still hand back a hash that a lock-respecting
72
+ // second writer has since superseded, forking the chain. Verified: lock-only leaves
73
+ // 4-6 link breaks per 8-process run; lock + size-validated reseed leaves 0.
74
+ const _lastHashCache = new Map(); // resolvedPath -> { hash, size }
73
75
 
74
76
  /**
75
77
  * Synchronously appends ONE hash-chained, durable entry to an audit JSONL file.
@@ -96,30 +98,43 @@ function appendAuditEntrySync(auditPath, event) {
96
98
  timestamp: event.timestamp || new Date().toISOString(),
97
99
  };
98
100
 
99
- // 2. Seed previous_hash: prefer the warm in-process cache; fall back to the
100
- // file's last entry when cold (first append in this process for this path).
101
- let previous_hash = _lastHashCache.has(resolved)
102
- ? _lastHashCache.get(resolved)
103
- : readLastHash(resolved);
101
+ // 2-5. LOCK-01: seed-through-write is ONE critical section. The lock opens BEFORE
102
+ // the head is read and closes AFTER the fsync, so no other process can read the
103
+ // same head, or interleave an append, while we hold it. maxTries is raised above
104
+ // the module default because failing closed here DROPS an audit entry: at 16
105
+ // concurrent writers the default ~1s ceiling loses ~35% of entries, 150 tries
106
+ // loses none.
107
+ return withFileLock(resolved, () => {
108
+ // 2. Seed previous_hash from the warm cache ONLY if the file is still exactly as
109
+ // long as we left it (proof nobody else appended); otherwise re-seed from disk.
110
+ const cached = _lastHashCache.get(resolved);
111
+ let onDiskSize = -1;
112
+ try { onDiskSize = fs.statSync(resolved).size; } catch { onDiskSize = -1; }
113
+ const previous_hash = (cached && cached.size === onDiskSize)
114
+ ? cached.hash
115
+ : readLastHash(resolved);
104
116
 
105
- // 3. Compute _hash over {...stamped, previous_hash} WITHOUT _hash in the material.
106
- const _hash = hashEntry(stamped, previous_hash);
117
+ // 3. Compute _hash over {...stamped, previous_hash} WITHOUT _hash in the material.
118
+ const _hash = hashEntry(stamped, previous_hash);
107
119
 
108
- // 4. Write {...stamped, previous_hash, _hash} as one JSON line, durably+synchronously
109
- // (openSync('a') + writeSync + fsyncSync + closeSync — mirrors appendDurableSync).
110
- const chained = { ...stamped, previous_hash, _hash };
111
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
112
- const fd = fs.openSync(resolved, 'a');
113
- try {
114
- fs.writeSync(fd, JSON.stringify(chained) + '\n');
115
- fs.fsyncSync(fd);
116
- } finally {
117
- fs.closeSync(fd);
118
- }
120
+ // 4. Write {...stamped, previous_hash, _hash} as one JSON line, durably+synchronously
121
+ // (openSync('a') + writeSync + fsyncSync + closeSync — mirrors appendDurableSync).
122
+ const chained = { ...stamped, previous_hash, _hash };
123
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
124
+ const fd = fs.openSync(resolved, 'a');
125
+ let sizeAfter = -1;
126
+ try {
127
+ fs.writeSync(fd, JSON.stringify(chained) + '\n');
128
+ fs.fsyncSync(fd);
129
+ sizeAfter = fs.fstatSync(fd).size; // exact new length, one syscall, fd already open
130
+ } finally {
131
+ fs.closeSync(fd);
132
+ }
119
133
 
120
- // 5. Advance the in-process chain head and return the written entry.
121
- _lastHashCache.set(resolved, _hash);
122
- return chained;
134
+ // 5. Advance the in-process chain head (hash + its witness) and return the entry.
135
+ _lastHashCache.set(resolved, { hash: _hash, size: sizeAfter });
136
+ return chained;
137
+ }, { maxTries: 150, label: 'audit' });
123
138
  }
124
139
 
125
140
  module.exports = { appendAuditEntrySync };
@@ -9,6 +9,7 @@ const fs = require('fs');
9
9
  const Metrics = require('./metrics-aggregator');
10
10
  const Approval = require('./approval-handler');
11
11
  const SSE = require('./sse-bridge');
12
+ const { sendServerError } = require('./error-response');
12
13
 
13
14
  // Steering queue path (from Day 8 auto-executor)
14
15
  const STEERING_QUEUE = path.join(process.cwd(), '.planning', 'steering-queue.jsonl');
@@ -38,7 +39,7 @@ function register(app) {
38
39
  try {
39
40
  res.json(Metrics.getStatus());
40
41
  } catch (err) {
41
- res.status(500).json({ error: err.message });
42
+ sendServerError(res, 'GET /api/status', err, 'Failed to read project status');
42
43
  }
43
44
  });
44
45
 
@@ -50,7 +51,7 @@ function register(app) {
50
51
  const event = typeof req.query.event === 'string' ? req.query.event : null;
51
52
  res.json(Metrics.getAuditEntries(limit, offset, event));
52
53
  } catch (err) {
53
- res.status(500).json({ error: err.message });
54
+ sendServerError(res, 'GET /api/audit', err, 'Failed to read audit entries');
54
55
  }
55
56
  });
56
57
 
@@ -59,7 +60,7 @@ function register(app) {
59
60
  try {
60
61
  res.json(Metrics.getMetrics());
61
62
  } catch (err) {
62
- res.status(500).json({ error: err.message });
63
+ sendServerError(res, 'GET /api/metrics', err, 'Failed to read quality metrics');
63
64
  }
64
65
  });
65
66
 
@@ -68,7 +69,7 @@ function register(app) {
68
69
  try {
69
70
  res.json(Metrics.getApprovals());
70
71
  } catch (err) {
71
- res.status(500).json({ error: err.message });
72
+ sendServerError(res, 'GET /api/approvals', err, 'Failed to read approvals');
72
73
  }
73
74
  });
74
75
 
@@ -104,7 +105,7 @@ function register(app) {
104
105
 
105
106
  res.json(result);
106
107
  } catch (err) {
107
- res.status(500).json({ error: err.message });
108
+ sendServerError(res, 'POST /api/approve/:id', err, 'Failed to record approval decision');
108
109
  }
109
110
  });
110
111
 
@@ -113,7 +114,7 @@ function register(app) {
113
114
  try {
114
115
  res.json(Metrics.getTeamActivity());
115
116
  } catch (err) {
116
- res.status(500).json({ error: err.message });
117
+ sendServerError(res, 'GET /api/team', err, 'Failed to read team activity');
117
118
  }
118
119
  });
119
120
 
@@ -124,7 +125,7 @@ function register(app) {
124
125
  const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
125
126
  res.json(Metrics.getMemory(q, limit));
126
127
  } catch (err) {
127
- res.status(500).json({ error: err.message });
128
+ sendServerError(res, 'GET /api/memory', err, 'Failed to query knowledge base');
128
129
  }
129
130
  });
130
131
 
@@ -134,7 +135,7 @@ function register(app) {
134
135
  const window = Math.min(parseInt(req.query.window || '7', 10), 90);
135
136
  res.json(Metrics.getCosts(window));
136
137
  } catch (err) {
137
- res.status(500).json({ error: err.message });
138
+ sendServerError(res, 'GET /api/costs', err, 'Failed to read cost metrics');
138
139
  }
139
140
  });
140
141
 
@@ -193,7 +194,7 @@ function register(app) {
193
194
 
194
195
  res.json({ success: true, queued: true, id: entry.id, action });
195
196
  } catch (err) {
196
- res.status(500).json({ error: err.message });
197
+ sendServerError(res, 'POST /api/steer', err, 'Failed to queue steering directive');
197
198
  }
198
199
  });
199
200
 
@@ -241,7 +242,7 @@ function register(app) {
241
242
  timestamp: new Date().toISOString()
242
243
  });
243
244
  } catch (err) {
244
- res.status(500).json({ error: err.message });
245
+ sendServerError(res, 'GET /api/v1/system', err, 'Failed to read system observability data');
245
246
  }
246
247
  });
247
248
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * MindForge — Dashboard error responses (LEAK-01)
3
+ *
4
+ * Every dashboard route used to return `err.message` — and express's default
5
+ * handler returned `err.stack` — straight to the client. fs-sourced errors embed
6
+ * ABSOLUTE paths (EACCES/ENOENT/EISDIR), so a 500 disclosed the operator's home
7
+ * directory and username. This matters more than a normal 500 body leak because
8
+ * `requireAuth` (server.js) lets GET and OPTIONS through unguarded: every read
9
+ * route was an *unauthenticated* filesystem-layout oracle.
10
+ *
11
+ * Contract: the full error (stack included) is logged server-side against a
12
+ * correlation id; the client receives only a generic message plus that id.
13
+ */
14
+ 'use strict';
15
+
16
+ const crypto = require('crypto');
17
+
18
+ /** Short, non-guessable id used to tie a client response to a server log line. */
19
+ function newCorrelationId() {
20
+ return crypto.randomBytes(8).toString('hex');
21
+ }
22
+
23
+ /**
24
+ * Log `err` server-side and answer with a generic 500 carrying a correlation id.
25
+ *
26
+ * @param {object} res Express response.
27
+ * @param {string} context Route identifier for the log, e.g. 'GET /api/metrics'.
28
+ * @param {unknown} err The caught error (or any value).
29
+ * @param {string} clientMessage Safe, static message for the client.
30
+ * @param {object} [extra] Extra fields merged into the body (e.g. { success: false }).
31
+ * @returns {object} the Express response.
32
+ */
33
+ function sendServerError(res, context, err, clientMessage, extra = {}) {
34
+ const correlationId = newCorrelationId();
35
+ const detail = err && err.stack ? err.stack : String(err);
36
+ console.error(`[dashboard] ${context} failed [cid=${correlationId}]: ${detail}`);
37
+ return res.status(500).json({
38
+ ...extra,
39
+ error: clientMessage,
40
+ correlation_id: correlationId
41
+ });
42
+ }
43
+
44
+ module.exports = { sendServerError, newCorrelationId };
@@ -198,7 +198,7 @@
198
198
  </div>
199
199
  </div>
200
200
  <div class="grid-4">
201
- <div class="card"><div class="card-body"><div class="stat-label">Cumulative Cost</div><div id="stat-total-cost" class="stat-value">$0.00</div></div></div>
201
+ <div class="card"><div class="card-body"><div class="stat-label">Cost (7d)</div><div id="stat-total-cost" class="stat-value">$0.00</div></div></div>
202
202
  <div class="card"><div class="card-body"><div class="stat-label">Avg Quality</div><div id="stat-avg-quality" class="stat-value">0.0</div></div></div>
203
203
  <div class="card"><div class="card-body"><div class="stat-label">LSPs Registered</div><div id="stat-lsp-count" class="stat-value">0</div></div></div>
204
204
  <div class="card"><div class="card-body"><div class="stat-label">Token Velocity</div><div id="stat-velocity" class="stat-value">0 <span class="stat-unit">tx/m</span></div></div></div>
@@ -430,6 +430,7 @@
430
430
  const data = await res.json();
431
431
  state = data;
432
432
  updateMetricsUI(data);
433
+ refreshCosts();
433
434
  refreshApprovals();
434
435
  refreshMemory();
435
436
  refreshTeam();
@@ -538,8 +539,24 @@
538
539
  }
539
540
 
540
541
  function updateMetricsUI(data) {
541
- document.getElementById('stat-total-cost').textContent = `$${(data.costs?.reduce((a,b) => a + b.cost, 0) || 0).toFixed(4)}`;
542
- document.getElementById('stat-avg-quality').textContent = (data.quality?.reduce((a,b) => a + b.score, 0) / (data.quality?.length || 1)).toFixed(1);
542
+ // /api/metrics returns { sessions, avg_quality, avg_cost_usd, ... } see
543
+ // bin/dashboard/metrics-aggregator.js getMetrics(). It has never returned
544
+ // `costs` or `quality`, so these tiles used to render "$0.0000" and "NaN".
545
+ document.getElementById('stat-avg-quality').textContent = (data.avg_quality ?? 0).toFixed(1);
546
+ }
547
+
548
+ // Cumulative spend comes from the usage ledger via /api/costs (already wired
549
+ // in bin/dashboard/api-router.js but never fetched until now).
550
+ async function refreshCosts() {
551
+ try {
552
+ const res = await fetch('/api/costs?window=7');
553
+ // sendServerError returns a well-formed JSON body, so res.json() would
554
+ // SUCCEED on a 500 and `total_usd ?? 0` would render "$0.00" — an outage
555
+ // and genuine zero spend look identical under the "Cost (7d)" label.
556
+ if (!res.ok) throw new Error(`/api/costs ${res.status}`);
557
+ const data = await res.json();
558
+ document.getElementById('stat-total-cost').textContent = `${(data.total_usd ?? 0).toFixed(2)}`;
559
+ } catch (e) { console.error('Cost refresh failed', e); }
543
560
  }
544
561
 
545
562
  function appendAuditEvent(data) {
@@ -7,6 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
+ const { ledgerPath, entryCost, entryDay } = require('../models/usage-record');
10
11
 
11
12
  // ── TTL Cache (5-second window) ──────────────────────────────────────────────
12
13
  const _cache = new Map();
@@ -29,7 +30,7 @@ function cacheSet(key, data) {
29
30
  // Paths are resolved lazily to support testing in temp directories
30
31
  const getPaths = () => ({
31
32
  quality: path.join(process.cwd(), '.mindforge', 'metrics', 'session-quality.jsonl'),
32
- usage: path.join(process.cwd(), '.mindforge', 'metrics', 'token-usage.jsonl'),
33
+ usage: ledgerPath(),
33
34
  audit: path.join(process.cwd(), '.planning', 'AUDIT.jsonl'),
34
35
  handoff: path.join(process.cwd(), '.planning', 'HANDOFF.json'),
35
36
  auto: path.join(process.cwd(), '.planning', 'auto-state.json'),
@@ -122,12 +123,29 @@ function getMetrics() {
122
123
  const auditEntries = readJSONL(paths.audit, 500);
123
124
 
124
125
  // Quality scores (last 20 sessions)
126
+ // session-quality.jsonl has NO writer anywhere in this repo — every one of its
127
+ // four references (here, sdk/src/client.ts, mcp-server/src/vendor/client.ts,
128
+ // plugins/mindforge/mcp/dist/index.js) is a READ, and no command instructs an
129
+ // agent to append it either. The read is kept because those three other
130
+ // consumers depend on the file and a schema-compliant one may exist in a user
131
+ // project; supplying a writer is OUT OF SCOPE for this patch. Read the
132
+ // SCHEMA-DECLARED names first (.mindforge/metrics/METRICS-SCHEMA.md), keeping
133
+ // the historical names as fallbacks.
134
+ // Per-session cost is NOT a session-quality field: it is joined from the
135
+ // usage ledger by session_id (every provider result carries one — see
136
+ // bin/models/model-client.js:69).
137
+ const costBySession = usageEntries.reduce((acc, u) => {
138
+ if (!u || typeof u.session_id !== 'string') return acc;
139
+ acc[u.session_id] = (acc[u.session_id] || 0) + entryCost(u);
140
+ return acc;
141
+ }, {});
142
+
125
143
  const sessions = qualityEntries.map(e => ({
126
144
  id: e.session_id,
127
145
  timestamp: e.timestamp,
128
- quality_score: e.quality_score ?? 0,
146
+ quality_score: e.session_quality_score ?? e.quality_score ?? 0,
129
147
  verify_pass_rate: e.verify_pass_rate ?? 0,
130
- cost_usd: e.total_cost_usd ?? 0,
148
+ cost_usd: costBySession[e.session_id] ?? 0,
131
149
  node_repairs: e.node_repairs ?? 0,
132
150
  }));
133
151
 
@@ -302,12 +320,15 @@ function getCosts(windowDays = 7) {
302
320
  };
303
321
 
304
322
  for (const e of entries) {
305
- if (e.timestamp < cutoff) continue;
306
-
307
- const cost = e.total_cost_usd || 0;
323
+ // Ledger rows carry both `date` and `timestamp` (see bin/models/usage-record.js).
324
+ // Bucket on the canonical day so date-only and full-ISO rows behave alike.
325
+ const day = entryDay(e);
326
+ if (!day || day < cutoff) continue;
327
+
328
+ const cost = entryCost(e);
308
329
  stats.total_usd += cost;
309
-
310
- if (e.timestamp && e.timestamp.startsWith(today)) {
330
+
331
+ if (day === today) {
311
332
  stats.today_usd += cost;
312
333
  }
313
334
 
@@ -10,6 +10,7 @@ const roiEngine = require('../revops/roi-engine');
10
10
  const velocityForecaster = require('../revops/velocity-forecaster');
11
11
  const debtMonitor = require('../revops/debt-monitor');
12
12
  const metricsAggregator = require('./metrics-aggregator');
13
+ const { sendServerError } = require('./error-response');
13
14
 
14
15
  /**
15
16
  * GET /api/revops/overview
@@ -25,7 +26,11 @@ router.get('/overview', (req, res) => {
25
26
  ...metrics,
26
27
  tasks_total: status.tasks_total || 0,
27
28
  tasks_completed: status.tasks_completed || 0,
28
- auditEntries: metricsAggregator.getAuditEntries(500) // need enough history for velocity
29
+ // .entries — getAuditEntries() returns { entries, total, limit, offset }, not an
30
+ // array. roi-engine, velocity-forecaster and debt-monitor all call .filter() on
31
+ // this value, so handing them the wrapper object throws a TypeError that the
32
+ // route's catch turns into an opaque 500.
33
+ auditEntries: metricsAggregator.getAuditEntries(500).entries // need enough history for velocity
29
34
  };
30
35
 
31
36
  const roi = roiEngine.calculate(fullMetrics);
@@ -40,7 +45,12 @@ router.get('/overview', (req, res) => {
40
45
  timestamp: new Date().toISOString()
41
46
  });
42
47
  } catch (err) {
43
- res.status(500).json({ success: false, error: 'AgRevOps metrics retrieval failed', detail: err.message });
48
+ // LEAK-01: echoing err.message as a `detail` field put absolute filesystem paths
49
+ // — and therefore the operator's username and home directory — into an
50
+ // unauthenticated HTTP response body (requireAuth exempts GET). Log the full
51
+ // error server-side; return a generic message plus a correlation id.
52
+ sendServerError(res, 'GET /api/revops/overview', err,
53
+ 'AgRevOps metrics retrieval failed', { success: false });
44
54
  }
45
55
  });
46
56