mindforge-cc 11.9.0 → 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 (201) hide show
  1. package/.agent/mindforge/consult.md +1 -1
  2. package/.agent/mindforge/cost-report.md +1 -1
  3. package/.agent/mindforge/ship.md +2 -2
  4. package/.agent/mindforge/update.md +1 -1
  5. package/.agent/mindforge/wf-catalog.md +5 -2
  6. package/.claude/commands/mindforge/consult.md +1 -1
  7. package/.claude/commands/mindforge/cost-report.md +1 -1
  8. package/.claude/commands/mindforge/ship.md +2 -2
  9. package/.claude/commands/mindforge/skill-tdd.md +53 -0
  10. package/.claude/commands/mindforge/skills-index.md +118 -0
  11. package/.claude/commands/mindforge/systematic-debug.md +60 -0
  12. package/.claude/commands/mindforge/update.md +1 -1
  13. package/.claude/commands/mindforge/wf-accessibility-audit.md +31 -0
  14. package/.claude/commands/mindforge/wf-ai-model-eval.md +31 -0
  15. package/.claude/commands/mindforge/wf-api-migration.md +31 -0
  16. package/.claude/commands/mindforge/wf-architecture-modernization.md +32 -0
  17. package/.claude/commands/mindforge/wf-catalog.md +62 -0
  18. package/.claude/commands/mindforge/wf-code-audit.md +31 -0
  19. package/.claude/commands/mindforge/wf-code-explainer.md +31 -0
  20. package/.claude/commands/mindforge/wf-competitive-analysis.md +31 -0
  21. package/.claude/commands/mindforge/wf-competitive-teardown.md +31 -0
  22. package/.claude/commands/mindforge/wf-cost-analysis.md +31 -0
  23. package/.claude/commands/mindforge/wf-data-pipeline-validate.md +31 -0
  24. package/.claude/commands/mindforge/wf-database-migration.md +31 -0
  25. package/.claude/commands/mindforge/wf-debug-detective.md +32 -0
  26. package/.claude/commands/mindforge/wf-design-system-audit.md +31 -0
  27. package/.claude/commands/mindforge/wf-documentation-gen.md +31 -0
  28. package/.claude/commands/mindforge/wf-feature-planner.md +31 -0
  29. package/.claude/commands/mindforge/wf-incident-response.md +31 -0
  30. package/.claude/commands/mindforge/wf-multi-repo-sync.md +31 -0
  31. package/.claude/commands/mindforge/wf-mutation-testing.md +31 -0
  32. package/.claude/commands/mindforge/wf-onboard-codebase.md +31 -0
  33. package/.claude/commands/mindforge/wf-orchestrate-review.md +35 -0
  34. package/.claude/commands/mindforge/wf-perf-optimize.md +31 -0
  35. package/.claude/commands/mindforge/wf-pr-review.md +31 -0
  36. package/.claude/commands/mindforge/wf-refactor-plan.md +31 -0
  37. package/.claude/commands/mindforge/wf-release-prep.md +31 -0
  38. package/.claude/commands/mindforge/wf-security-threat-model.md +31 -0
  39. package/.claude/commands/mindforge/wf-tdd-sprint.md +31 -0
  40. package/.claude/commands/mindforge/wf-tech-evaluation.md +31 -0
  41. package/.claude/commands/mindforge/wf-ux-heuristic-audit.md +31 -0
  42. package/.claude/commands/mindforge/wf-verification-loop.md +45 -0
  43. package/.claude/commands/mindforge/wf-workflow-optimizer.md +51 -0
  44. package/.claude/commands/mindforge/wf-writer-reviewer.md +30 -0
  45. package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
  46. package/.mindforge/config.json +3 -3
  47. package/.mindforge/engine/cost-tracking/router.md +1 -1
  48. package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
  49. package/.mindforge/memory/sync-manifest.json +1 -1
  50. package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
  51. package/.mindforge/personas/cost-optimizer.md +2 -2
  52. package/.mindforge/personas/doc-auditor.md +1 -1
  53. package/.mindforge/personas/monorepo-architect.md +2 -1
  54. package/.mindforge/personas/multi-model-bridge.md +1 -1
  55. package/.mindforge/personas/release-manager.md +5 -3
  56. package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
  57. package/.mindforge/skills/documentation/SKILL.md +4 -1
  58. package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
  59. package/CHANGELOG.md +216 -1821
  60. package/MINDFORGE.md +4 -4
  61. package/README.md +103 -509
  62. package/RELEASENOTES.md +75 -0
  63. package/SECURITY.md +1 -1
  64. package/bin/autonomous/audit-writer.js +48 -33
  65. package/bin/dashboard/api-router.js +11 -10
  66. package/bin/dashboard/error-response.js +44 -0
  67. package/bin/dashboard/frontend/index.html +20 -3
  68. package/bin/dashboard/metrics-aggregator.js +29 -8
  69. package/bin/dashboard/revops-api.js +12 -2
  70. package/bin/dashboard/server.js +85 -5
  71. package/bin/dashboard/temporal-api.js +11 -5
  72. package/bin/engine/remediation-engine.js +12 -1
  73. package/bin/engine/temporal-hub.js +41 -9
  74. package/bin/eval/eval-harness.js +212 -1
  75. package/bin/eval/golden-set-retrieval.json +9 -0
  76. package/bin/governance/policy-engine.js +8 -0
  77. package/bin/hindsight-injector.js +8 -2
  78. package/bin/hooks/instinct-capture-hook.js +7 -1
  79. package/bin/install.js +1 -1
  80. package/bin/learning/instinct-cli.js +7 -24
  81. package/bin/memory/knowledge-capture.js +23 -3
  82. package/bin/memory/knowledge-graph.js +70 -31
  83. package/bin/memory/vector-hub.js +304 -31
  84. package/bin/mindforge-cli.js +43 -11
  85. package/bin/models/cost-tracker.js +22 -23
  86. package/bin/models/model-router.js +28 -7
  87. package/bin/models/usage-record.js +71 -0
  88. package/bin/updater/changelog-fetcher.js +95 -5
  89. package/bin/utils/file-lock.js +106 -0
  90. package/bin/utils/mindforge-params.js +124 -0
  91. package/bin/utils/readiness-gate.js +3 -3
  92. package/bin/validate-config.js +34 -16
  93. package/changelogs/README.md +109 -0
  94. package/changelogs/index.json +103 -0
  95. package/changelogs/v0.1.0.md +21 -0
  96. package/changelogs/v0.2.0.md +15 -0
  97. package/changelogs/v0.3.0.md +28 -0
  98. package/changelogs/v0.4.0.md +29 -0
  99. package/changelogs/v0.5.0.md +34 -0
  100. package/changelogs/v0.6.0.md +34 -0
  101. package/changelogs/v1.0.0.md +60 -0
  102. package/changelogs/v1.0.1.md +15 -0
  103. package/changelogs/v1.0.2.md +10 -0
  104. package/changelogs/v1.0.3.md +10 -0
  105. package/changelogs/v1.0.4.md +10 -0
  106. package/changelogs/v1.0.5.md +10 -0
  107. package/changelogs/v10.0.0.md +71 -0
  108. package/changelogs/v10.0.2.md +15 -0
  109. package/changelogs/v10.0.3.md +21 -0
  110. package/changelogs/v10.0.4.md +16 -0
  111. package/changelogs/v10.0.5.md +16 -0
  112. package/changelogs/v10.0.6.md +15 -0
  113. package/changelogs/v10.0.7.md +15 -0
  114. package/changelogs/v10.0.8.md +15 -0
  115. package/changelogs/v10.0.9.md +15 -0
  116. package/changelogs/v10.1.0.md +16 -0
  117. package/changelogs/v10.1.1.md +15 -0
  118. package/changelogs/v10.2.0.md +14 -0
  119. package/changelogs/v10.3.0.md +13 -0
  120. package/changelogs/v10.4.0.md +13 -0
  121. package/changelogs/v10.5.0.md +13 -0
  122. package/changelogs/v10.6.0.md +13 -0
  123. package/changelogs/v10.7.0.md +15 -0
  124. package/changelogs/v11.0.0.md +66 -0
  125. package/changelogs/v11.0.1.md +10 -0
  126. package/changelogs/v11.1.0.md +56 -0
  127. package/changelogs/v11.2.0.md +41 -0
  128. package/changelogs/v11.2.1.md +38 -0
  129. package/changelogs/v11.3.0.md +45 -0
  130. package/changelogs/v11.3.1.md +39 -0
  131. package/changelogs/v11.4.0.md +38 -0
  132. package/changelogs/v11.5.0.md +77 -0
  133. package/changelogs/v11.5.1.md +47 -0
  134. package/changelogs/v11.6.0.md +45 -0
  135. package/changelogs/v11.7.0.md +30 -0
  136. package/changelogs/v11.7.1.md +8 -0
  137. package/changelogs/v11.8.0.md +19 -0
  138. package/changelogs/v11.8.1.md +28 -0
  139. package/changelogs/v11.8.2.md +22 -0
  140. package/changelogs/v11.8.3.md +22 -0
  141. package/changelogs/v11.9.0.md +17 -0
  142. package/changelogs/v11.9.1.md +8 -0
  143. package/changelogs/v11.9.2.md +209 -0
  144. package/changelogs/v2.0.0-alpha.1.md +42 -0
  145. package/changelogs/v2.0.0-alpha.10.md +13 -0
  146. package/changelogs/v2.0.0-alpha.11.md +14 -0
  147. package/changelogs/v2.0.0-alpha.12.md +16 -0
  148. package/changelogs/v2.0.0-alpha.2.md +14 -0
  149. package/changelogs/v2.0.0-alpha.3.md +17 -0
  150. package/changelogs/v2.0.0-alpha.4.md +17 -0
  151. package/changelogs/v2.0.0-alpha.7.md +18 -0
  152. package/changelogs/v2.0.0-alpha.8.md +11 -0
  153. package/changelogs/v2.0.0-alpha.9.md +12 -0
  154. package/changelogs/v2.0.0.md +35 -0
  155. package/changelogs/v2.1.0.md +22 -0
  156. package/changelogs/v2.1.1.md +25 -0
  157. package/changelogs/v2.1.2.md +21 -0
  158. package/changelogs/v2.3.2.md +19 -0
  159. package/changelogs/v2.3.3.md +16 -0
  160. package/changelogs/v2.3.4.md +16 -0
  161. package/changelogs/v2.3.5.md +21 -0
  162. package/changelogs/v2.4.0.md +29 -0
  163. package/changelogs/v2.5.0.md +26 -0
  164. package/changelogs/v2.6.0.md +26 -0
  165. package/changelogs/v3.0.0.md +17 -0
  166. package/changelogs/v4.0.0.md +19 -0
  167. package/changelogs/v4.1.0.md +19 -0
  168. package/changelogs/v4.3.0.md +31 -0
  169. package/changelogs/v5.0.0-alpha.1.md +35 -0
  170. package/changelogs/v5.0.0-alpha.2.md +22 -0
  171. package/changelogs/v5.1.0.md +42 -0
  172. package/changelogs/v5.2.0.md +18 -0
  173. package/changelogs/v5.3.0.md +14 -0
  174. package/changelogs/v5.4.0.md +28 -0
  175. package/changelogs/v5.5.0.md +11 -0
  176. package/changelogs/v5.6.0.md +11 -0
  177. package/changelogs/v5.7.0.md +12 -0
  178. package/changelogs/v5.8.0.md +12 -0
  179. package/changelogs/v5.9.0.md +11 -0
  180. package/changelogs/v6.0.0-alpha.md +45 -0
  181. package/changelogs/v6.1.0-alpha.md +29 -0
  182. package/changelogs/v6.2.0-alpha.md +30 -0
  183. package/changelogs/v6.2.0.md +21 -0
  184. package/changelogs/v6.3.0.md +14 -0
  185. package/changelogs/v6.4.0.md +13 -0
  186. package/changelogs/v6.5.0.md +13 -0
  187. package/changelogs/v6.6.0.md +12 -0
  188. package/changelogs/v6.7.0.md +12 -0
  189. package/changelogs/v7.0.0.md +13 -0
  190. package/changelogs/v8.0.0.md +14 -0
  191. package/changelogs/v8.1.0.md +13 -0
  192. package/changelogs/v8.2.0.md +12 -0
  193. package/changelogs/v8.2.1.md +11 -0
  194. package/changelogs/v9.0.0.md +20 -0
  195. package/docs/References/config-reference.md +73 -14
  196. package/docs/commands-reference.md +12 -6
  197. package/docs/faq.md +3 -3
  198. package/docs/getting-started.md +28 -3
  199. package/docs/sdk-reference.md +1 -1
  200. package/docs/user-guide.md +2 -2
  201. package/package.json +5 -2
package/RELEASENOTES.md CHANGED
@@ -1,5 +1,80 @@
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
+
56
+ ## v11.9.0 — 2026-07-27 — Bedrock Provider + Full Dry-Run Audit
57
+
58
+ ### What's New
59
+ Added an AWS Bedrock provider and completed a full exhaustive dry-run audit of all 224 commands, 216 personas, and 355 skills.
60
+
61
+ ### Fixes
62
+ - `bin/models/bedrock-provider.js`: AWS Bedrock provider with Anthropic/Bedrock config switch and base64 credential decoding
63
+ - Resolved phantom-tool references ported unmodified from a different agent harness across 40 personas and ~40 skills (`Context7`/`search_web`/`read_url_content` → `WebFetch`/`WebSearch`; `CommandStatus`/`ReadTerminal`/`terminal`/`read_file`/`write_file`/`search_files`/`patch` → real Claude Code tool names; `delegate_task` → `Agent`; `kanban_create` → `TaskCreate`)
64
+ - Repointed 21 broken `@.agent/references/` and `@.agent/templates/` path references to their real `docs/References/` and `docs/Templates/*/` locations
65
+ - Copied in missing companion `scripts/`/`references/`/`templates/` assets for 17 skills that referenced files never carried over during porting
66
+ - Fixed hardcoded broken paths (`~/.hermes/skills`, `/home/teknium/...`, `HERMES_HOME`)
67
+ - Fixed a markdown fence-nesting bug in `code-tour` and 4 truncated heredoc strings in `github-code-review`
68
+ - Removed banned single-word triggers (`test`, `tests`) from `testing-standards`
69
+ - CI: build SDK before test suite; run Verification before Health Check in execution-plane
70
+ - Security: patched hono and picomatch HIGH-severity vulnerabilities
71
+
72
+ ### Stats
73
+ - Dry-run audit: 224 commands, 216 personas, 355 skills reviewed
74
+ - Phantom-tool references resolved: 40 personas, ~40 skills
75
+
76
+ ---
77
+
3
78
  ## v11.8.3 — 2026-07-01 — Autopsy Fixes Stable Release
4
79
 
5
80
  ### What's New
package/SECURITY.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Security Policy
2
2
 
3
- > **Current version:** 11.8.3 | **npm audit:** 0 vulnerabilities across root, sdk, mcp-server
3
+ > **Current version:** 11.9.0 | **npm audit:** 0 vulnerabilities across root, sdk, mcp-server
4
4
 
5
5
  ## Supported Versions
6
6
 
@@ -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
 
@@ -37,6 +37,7 @@ const SSE = require('./sse-bridge');
37
37
  const API = require('./api-router');
38
38
  const TemporalAPI = require('./temporal-api');
39
39
  const RevOpsAPI = require('./revops-api');
40
+ const { newCorrelationId } = require('./error-response');
40
41
 
41
42
  // ── Express app ───────────────────────────────────────────────────────────────
42
43
  const app = express();
@@ -190,6 +191,73 @@ app.post('/api/v1/token/refresh', requireAuth, (req, res) => {
190
191
  // ── Register API routes ───────────────────────────────────────────────────────
191
192
  API.register(app);
192
193
  app.use('/api/temporal', TemporalAPI);
194
+ // RevOpsAPI was required at the top of this file but never mounted, so /api/revops
195
+ // returned 404 while the AgRevOps dashboard panels and docs described it as live.
196
+ app.use('/api/revops', RevOpsAPI);
197
+
198
+ // ── Terminal error handler (LEAK-01) ─────────────────────────────────────────
199
+ // MUST stay last, after every route, and MUST keep its 4-arg signature or express
200
+ // will treat it as ordinary middleware. Without it, express's default handler
201
+ // renders err.stack into the response body whenever NODE_ENV !== 'production':
202
+ // a single unauthenticated malformed-JSON POST (express.json() is mounted BEFORE
203
+ // requireAuth, and requireAuth exempts GET anyway) returned the absolute paths of
204
+ // node_modules and the repo — the operator's username and home directory — to any
205
+ // local caller. Log server-side; return a generic body plus a correlation id.
206
+ app.use((err, req, res, next) => {
207
+ if (res.headersSent) return next(err); // e.g. an SSE stream already flushed headers
208
+ const status = Number.isInteger(err && err.status) && err.status >= 400 && err.status < 600
209
+ ? err.status
210
+ : 500;
211
+ const correlationId = newCorrelationId();
212
+ console.error(
213
+ `[dashboard] unhandled ${req.method} ${req.originalUrl} -> ${status} [cid=${correlationId}]:`,
214
+ err && err.stack ? err.stack : err
215
+ );
216
+ res.status(status).json({
217
+ error: status >= 500 ? 'Internal server error' : 'Invalid request',
218
+ correlation_id: correlationId
219
+ });
220
+ });
221
+
222
+ // ── Crash guards ──────────────────────────────────────────────────────────────
223
+ // Both guards log and exit. That is deliberate and symmetric.
224
+ //
225
+ // unhandledRejection: an escaped rejection is the ONLY reliable signal that an
226
+ // async call was left un-awaited — the ASYNC-01 class this release fixes. Measured
227
+ // on v11.9.1: an un-awaited rollbackTo() rejected, and by the time the rejection
228
+ // surfaced a hash-chained `hindsight_injected` entry had already been fsync'd and
229
+ // auto-state.json flipped to awaiting_regeneration for a rollback that never
230
+ // happened; the client got ECONNRESET at ~16ms and the process exited 1. The
231
+ // durable damage is committed before any handler can run, so a 500-and-continue
232
+ // response would answer the request while leaving the audit chain asserting an
233
+ // event that did not occur.
234
+ // Keeping it survivable is also not free: express 4.22.1 does not route async
235
+ // handler rejections to its error middleware (measured: a 4-arity app.use never
236
+ // fires), so log-and-continue leaves the client socket open until the CLIENT gives
237
+ // up — 2.5s, 4s and 8s clients all timed out with no response — versus a ~15ms
238
+ // connection reset when the process exits. A silent hang is worse than a restart.
239
+ // Cost accepted: one faulting request takes the observability surface down. The
240
+ // dashboard is a 127.0.0.1-only single-operator tool, and an audit chain that
241
+ // verifies as valid while recording events that did not happen is not a survivable
242
+ // state to keep serving from.
243
+ process.on('unhandledRejection', (reason) => {
244
+ console.error('[Dashboard] Unhandled rejection — exiting:',
245
+ reason instanceof Error ? reason.stack : reason);
246
+ process.exit(1);
247
+ });
248
+
249
+ // uncaughtException MUST exit. After an uncaught throw the process state is
250
+ // undefined, and log-and-continue actively broke shutdown: a throw anywhere in the
251
+ // first half of shutdown() (SSE.stop(), or unlinking the token file) was swallowed,
252
+ // so server.close() was never reached and the forced-exit timer was never armed.
253
+ // The result was a dashboard that IGNORED SIGTERM while still serving the
254
+ // token-authenticated mutation endpoints, with the bearer token left on disk and
255
+ // still valid in memory — i.e. the operator's documented stop command silently
256
+ // failed while reporting success. Only SIGKILL stopped it.
257
+ process.on('uncaughtException', (err) => {
258
+ console.error('[Dashboard] Uncaught exception — exiting:', err && err.stack ? err.stack : err);
259
+ process.exit(1);
260
+ });
193
261
 
194
262
  // ── Start SSE bridge ──────────────────────────────────────────────────────────
195
263
  SSE.start();
@@ -231,14 +299,26 @@ server.on('error', err => {
231
299
  // ── Graceful shutdown ─────────────────────────────────────────────────────────
232
300
  function shutdown(signal) {
233
301
  console.log(`\n[dashboard] ${signal} received — shutting down`);
234
- SSE.stop();
235
- // Remove sensitive token file on shutdown
236
- if (fs.existsSync(TOKEN_FILE)) fs.unlinkSync(TOKEN_FILE);
302
+
303
+ // Arm the forced exit FIRST. Every step below can throw (permission drift on the
304
+ // token path, a read-only .mindforge, an SSE listener error), and if any of them
305
+ // does before this timer is set, the process would keep serving the authenticated
306
+ // mutation API after the operator asked it to stop.
307
+ const forced = setTimeout(() => process.exit(0), 3000);
308
+ forced.unref();
309
+
310
+ try { SSE.stop(); } catch (err) { console.error('[dashboard] SSE.stop failed:', err.message); }
311
+
312
+ // Destroying the bearer token is a security step, not housekeeping — never let it
313
+ // throw. rmSync with force tolerates a missing path and most permission cases.
314
+ try { fs.rmSync(TOKEN_FILE, { force: true }); } catch (err) {
315
+ console.error('[dashboard] could not remove token file:', err.message);
316
+ }
317
+
237
318
  server.close(() => {
238
- if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
319
+ try { fs.rmSync(PID_FILE, { force: true }); } catch { /* best effort */ }
239
320
  process.exit(0);
240
321
  });
241
- setTimeout(() => process.exit(0), 3000);
242
322
  }
243
323
 
244
324
  process.on('SIGTERM', () => shutdown('SIGTERM'));