mindforge-cc 10.7.0 → 11.2.0

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 (85) hide show
  1. package/.agent/hooks/mindforge-statusline.js +2 -2
  2. package/.mindforge/MINDFORGE-V2-SCHEMA.json +43 -10
  3. package/.mindforge/config.json +18 -4
  4. package/CHANGELOG.md +165 -0
  5. package/MINDFORGE.md +3 -3
  6. package/README.md +49 -4
  7. package/RELEASENOTES.md +81 -1
  8. package/SECURITY.md +20 -8
  9. package/bin/autonomous/audit-writer.js +105 -70
  10. package/bin/autonomous/auto-runner.js +377 -34
  11. package/bin/autonomous/context-refactorer.js +26 -11
  12. package/bin/autonomous/dependency-dag.js +59 -0
  13. package/bin/autonomous/state-manager.js +62 -6
  14. package/bin/autonomous/stuck-monitor.js +46 -7
  15. package/bin/autonomous/wave-executor.js +86 -26
  16. package/bin/council-cli.js +161 -0
  17. package/bin/dashboard/api-router.js +43 -0
  18. package/bin/dashboard/approval-handler.js +3 -1
  19. package/bin/dashboard/metrics-aggregator.js +28 -1
  20. package/bin/dashboard/server.js +68 -5
  21. package/bin/dashboard/sse-bridge.js +10 -13
  22. package/bin/engine/council-runtime.js +124 -0
  23. package/bin/engine/feedback-loop.js +8 -0
  24. package/bin/engine/intelligence-interlock.js +32 -15
  25. package/bin/engine/logic-drift-detector.js +2 -1
  26. package/bin/engine/nexus-tracer.js +3 -2
  27. package/bin/engine/otel-exporter.js +123 -0
  28. package/bin/engine/remediation-engine.js +155 -32
  29. package/bin/engine/self-corrective-synthesizer.js +84 -10
  30. package/bin/engine/sre-manager.js +12 -4
  31. package/bin/engine/temporal-cli.js +4 -2
  32. package/bin/engine/temporal-hub.js +131 -34
  33. package/bin/engine/verification-runner.js +131 -0
  34. package/bin/engine/verify-cli.js +34 -0
  35. package/bin/eval/eval-harness.js +82 -0
  36. package/bin/eval/golden-set-retrieval.json +46 -0
  37. package/bin/governance/approve.js +41 -5
  38. package/bin/governance/audit-hash.js +12 -0
  39. package/bin/governance/audit-verifier.js +60 -0
  40. package/bin/governance/impact-analyzer.js +28 -0
  41. package/bin/governance/policy-engine.js +10 -3
  42. package/bin/governance/quantum-crypto.js +95 -28
  43. package/bin/governance/rbac-manager.js +74 -2
  44. package/bin/governance/ztai-manager.js +79 -9
  45. package/bin/hindsight-injector.js +8 -9
  46. package/bin/hooks/instinct-capture-hook.js +186 -0
  47. package/bin/memory/auto-shadow.js +32 -3
  48. package/bin/memory/eis-client.js +71 -34
  49. package/bin/memory/embedding-engine.js +61 -0
  50. package/bin/memory/identity-synthesizer.js +2 -2
  51. package/bin/memory/knowledge-graph.js +58 -5
  52. package/bin/memory/knowledge-indexer.js +53 -6
  53. package/bin/memory/knowledge-store.js +52 -6
  54. package/bin/memory/retrieval-fusion.js +58 -0
  55. package/bin/memory/semantic-hub.js +2 -2
  56. package/bin/memory/vector-hub.js +111 -6
  57. package/bin/migrations/10.7.0-to-11.0.0.js +110 -0
  58. package/bin/migrations/schema-versions.js +13 -0
  59. package/bin/mindforge-cli.js +4 -5
  60. package/bin/models/anthropic-provider.js +58 -4
  61. package/bin/models/cloud-broker.js +68 -20
  62. package/bin/models/cost-tracker.js +3 -1
  63. package/bin/models/difficulty-scorer.js +54 -0
  64. package/bin/models/gemini-provider.js +57 -2
  65. package/bin/models/model-client.js +20 -0
  66. package/bin/models/model-router.js +59 -26
  67. package/bin/models/openai-provider.js +50 -3
  68. package/bin/models/pricing-registry.js +128 -0
  69. package/bin/review/ads-engine.js +1 -1
  70. package/bin/security/trust-boundaries.js +102 -0
  71. package/bin/security/trust-gate-hook.js +39 -0
  72. package/bin/skill-registry.js +3 -2
  73. package/bin/skills-builder/marketplace-cli.js +5 -3
  74. package/bin/skills-builder/skill-registrar.js +4 -6
  75. package/bin/sre/sentinel.js +7 -5
  76. package/bin/utils/append-queue.js +55 -0
  77. package/bin/utils/file-io.js +90 -38
  78. package/bin/utils/index.js +58 -0
  79. package/bin/utils/version-check.js +59 -0
  80. package/bin/verify-audit.js +12 -0
  81. package/bin/wizard/theme.js +1 -2
  82. package/docs/getting-started.md +1 -1
  83. package/docs/user-guide.md +2 -2
  84. package/package.json +2 -2
  85. package/bin/dashboard/team-tracker.js +0 -0
@@ -42,12 +42,18 @@ const RevOpsAPI = require('./revops-api');
42
42
  const app = express();
43
43
 
44
44
  // ── Bearer token authentication ──────────────────────────────────────────────
45
- const DASHBOARD_TOKEN = crypto.randomBytes(32).toString('hex');
45
+ let currentToken = crypto.randomBytes(32).toString('hex');
46
46
  const TOKEN_FILE = path.join(process.cwd(), '.mindforge', '.dashboard-token');
47
+ const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
48
+ let tokenCreatedAt = Date.now();
49
+
50
+ function isTokenExpired() {
51
+ return (Date.now() - tokenCreatedAt) > TOKEN_EXPIRY_MS;
52
+ }
47
53
 
48
54
  // Write token to file with restrictive permissions (owner-only read/write)
49
55
  fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true });
50
- fs.writeFileSync(TOKEN_FILE, DASHBOARD_TOKEN, { mode: 0o600 });
56
+ fs.writeFileSync(TOKEN_FILE, currentToken, { mode: 0o600 });
51
57
 
52
58
  /**
53
59
  * requireAuth — Validates Bearer token on mutating requests (POST/PUT/DELETE).
@@ -56,6 +62,11 @@ fs.writeFileSync(TOKEN_FILE, DASHBOARD_TOKEN, { mode: 0o600 });
56
62
  function requireAuth(req, res, next) {
57
63
  if (req.method === 'GET' || req.method === 'OPTIONS') return next();
58
64
 
65
+ // Check token expiration first
66
+ if (isTokenExpired()) {
67
+ return res.status(401).json({ error: 'token_expired' });
68
+ }
69
+
59
70
  const authHeader = req.headers.authorization;
60
71
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
61
72
  return res.status(401).json({
@@ -65,7 +76,9 @@ function requireAuth(req, res, next) {
65
76
 
66
77
  const provided = authHeader.slice(7);
67
78
  // Constant-time comparison to prevent timing attacks
68
- if (!crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(DASHBOARD_TOKEN))) {
79
+ const tokenBuf = Buffer.from(currentToken);
80
+ const providedBuf = Buffer.from(provided);
81
+ if (tokenBuf.length !== providedBuf.length || !crypto.timingSafeEqual(providedBuf, tokenBuf)) {
69
82
  return res.status(401).json({
70
83
  error: 'Authentication required. Use the token printed at dashboard startup.'
71
84
  });
@@ -74,6 +87,44 @@ function requireAuth(req, res, next) {
74
87
  next();
75
88
  }
76
89
 
90
+ // ── Rate limiting (100 req/min/IP) ───────────────────────────────────────────
91
+ const rateLimitMap = new Map(); // ip -> { count, resetAt }
92
+ const RATE_LIMIT = 100;
93
+ const RATE_WINDOW_MS = 60000;
94
+
95
+ function rateLimitMiddleware(req, res, next) {
96
+ const ip = req.ip || req.connection.remoteAddress;
97
+ const now = Date.now();
98
+ let entry = rateLimitMap.get(ip);
99
+
100
+ if (!entry || now > entry.resetAt) {
101
+ entry = { count: 0, resetAt: now + RATE_WINDOW_MS };
102
+ rateLimitMap.set(ip, entry);
103
+ }
104
+
105
+ entry.count++;
106
+
107
+ if (entry.count > RATE_LIMIT) {
108
+ return res.status(429).json({
109
+ error: 'rate_limit_exceeded',
110
+ retry_after_ms: entry.resetAt - now
111
+ });
112
+ }
113
+
114
+ next();
115
+ }
116
+
117
+ // Periodically clean stale rate-limit entries to prevent memory growth
118
+ const rateLimitCleanupInterval = setInterval(() => {
119
+ const now = Date.now();
120
+ for (const [ip, entry] of rateLimitMap.entries()) {
121
+ if (now > entry.resetAt) {
122
+ rateLimitMap.delete(ip);
123
+ }
124
+ }
125
+ }, 60000);
126
+ if (rateLimitCleanupInterval.unref) rateLimitCleanupInterval.unref();
127
+
77
128
  // Security middleware
78
129
  app.use((req, res, next) => {
79
130
  const addr = req.socket.remoteAddress;
@@ -84,6 +135,9 @@ app.use((req, res, next) => {
84
135
  next();
85
136
  });
86
137
 
138
+ // ── Rate limiting — applied after localhost check, before auth ────────────────
139
+ app.use(rateLimitMiddleware);
140
+
87
141
  // CORS — restrict to dashboard's own origin only (prevent cross-origin attacks)
88
142
  const DASHBOARD_ORIGIN = `http://127.0.0.1:${PORT}`;
89
143
  app.use((req, res, next) => {
@@ -107,7 +161,7 @@ app.use((req, res, next) => {
107
161
  res.setHeader('X-Content-Type-Options', 'nosniff');
108
162
  res.setHeader('X-Frame-Options', 'DENY');
109
163
  res.setHeader('Cache-Control', 'no-store'); // Never cache dashboard responses
110
- res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'");
164
+ res.setHeader('Content-Security-Policy', 'default-src \'self\'; script-src \'self\'; style-src \'self\' \'unsafe-inline\'; connect-src \'self\'');
111
165
  res.setHeader('X-XSS-Protection', '1; mode=block');
112
166
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
113
167
  next();
@@ -124,6 +178,15 @@ app.get('/', (req, res) => {
124
178
  res.sendFile(FRONTEND);
125
179
  });
126
180
 
181
+ // ── Token refresh endpoint (requires valid existing token) ───────────────────
182
+ app.post('/api/v1/token/refresh', requireAuth, (req, res) => {
183
+ const newToken = crypto.randomBytes(32).toString('hex');
184
+ fs.writeFileSync(TOKEN_FILE, newToken, { mode: 0o600 });
185
+ tokenCreatedAt = Date.now();
186
+ currentToken = newToken;
187
+ res.json({ success: true, token: newToken, expires_in_ms: TOKEN_EXPIRY_MS });
188
+ });
189
+
127
190
  // ── Register API routes ───────────────────────────────────────────────────────
128
191
  API.register(app);
129
192
  app.use('/api/temporal', TemporalAPI);
@@ -143,7 +206,7 @@ server.listen(PORT, '127.0.0.1', () => {
143
206
  console.log(` Status: http://localhost:${PORT}/api/status`);
144
207
  console.log(` Events: http://localhost:${PORT}/events`);
145
208
  console.log(` PID: ${process.pid}`);
146
- console.log(`[Dashboard] Auth token: ${DASHBOARD_TOKEN}`);
209
+ console.log('[Dashboard] Auth token written to token file (not logged for security).');
147
210
  console.log(` Token file: ${TOKEN_FILE}`);
148
211
  console.log('\n Press CTRL+C to stop\n');
149
212
 
@@ -21,7 +21,6 @@ const APPROVAL_DIR = path.join(process.cwd(), '.planning', 'approvals');
21
21
  const clients = new Set(); // Connected SSE response objects
22
22
 
23
23
  let _lastAuditSize = 0;
24
- let _auditInode = 0; // Track file inode for rotation detection
25
24
  let _lastAutoState = '';
26
25
  let _lastApprovals = '';
27
26
 
@@ -78,16 +77,18 @@ function pollAuditLog() {
78
77
  try {
79
78
  const stat = fs.statSync(AUDIT_PATH);
80
79
  const newSize = stat.size;
81
- const newIno = stat.ino;
82
80
 
83
- // File rotation detected: inode changed or new file is smaller
84
- if (newIno !== _auditInode && _auditInode !== 0) {
85
- process.stderr.write(`[sse-bridge] AUDIT.jsonl rotation detected (old ino: ${_auditInode}, new ino: ${newIno})\n`);
81
+ // Truncation / recreation recovery (NOT rotation audit rotation was retired in
82
+ // UC-04b because it broke the hash chain; AUDIT.jsonl grows unbounded). If the
83
+ // file ever SHRINKS (manually truncated, .planning wiped, or replaced), reset the
84
+ // read offset to 0 so the live tail keeps working instead of stalling forever on
85
+ // the `newSize <= _lastAuditSize` early-return below or reading at a stale offset.
86
+ if (newSize < _lastAuditSize) {
87
+ process.stderr.write(`[sse-bridge] AUDIT.jsonl shrank (size: ${_lastAuditSize} -> ${newSize}) — re-tailing from start\n`);
86
88
  _lastAuditSize = 0;
87
89
  }
88
- _auditInode = newIno;
89
90
 
90
- if (newSize <= _lastAuditSize) return; // No new data
91
+ if (newSize <= _lastAuditSize) return;
91
92
 
92
93
  // Read only the new bytes appended since last poll
93
94
  const fd = fs.openSync(AUDIT_PATH, 'r');
@@ -169,9 +170,7 @@ function startPolling() {
169
170
 
170
171
  // Initialize AUDIT position on first start
171
172
  if (!_initialized && fs.existsSync(AUDIT_PATH)) {
172
- const stat = fs.statSync(AUDIT_PATH);
173
- _lastAuditSize = stat.size;
174
- _auditInode = stat.ino;
173
+ _lastAuditSize = fs.statSync(AUDIT_PATH).size;
175
174
  _initialized = true;
176
175
  }
177
176
 
@@ -207,9 +206,7 @@ function stopPolling() {
207
206
  function start() {
208
207
  // Pre-initialize AUDIT position so first client gets instant data
209
208
  if (!_initialized && fs.existsSync(AUDIT_PATH)) {
210
- const stat = fs.statSync(AUDIT_PATH);
211
- _lastAuditSize = stat.size;
212
- _auditInode = stat.ino;
209
+ _lastAuditSize = fs.statSync(AUDIT_PATH).size;
213
210
  _initialized = true;
214
211
  }
215
212
  // Polling starts lazily when addClient() is called
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ /**
3
+ * MindForge — Council Runtime (UC-10). Thin multi-voice decision harness (ADS).
4
+ *
5
+ * Activates the Adversarial Decision Loop (ADS) mandated by CLAUDE.md: instead of
6
+ * a multi-round debate simulator, this is a THIN runtime — parallel position
7
+ * collection (one per voice) + consensus scoring + dissent capture.
8
+ *
9
+ * The model is INJECTABLE (no hard LLM dependency) so callers/tests can supply a
10
+ * mock. The Semaphore from wave-executor is reused to bound concurrent voice calls.
11
+ *
12
+ * Design decision — NO challenge round (kept thin per adversarial review + YAGNI):
13
+ * A second model call per dissenter that folds a revised confidence back into the
14
+ * consensus adds ordering/weighting complexity and extra failure modes without
15
+ * changing the activation goal. Position-collection + consensus fully satisfies the
16
+ * ADS loop and the verdict contract. Add a challenge round only if a concrete need
17
+ * arises (an explicit `opts.challengeRound` flag would be the clean extension point).
18
+ *
19
+ * Filenames use opts.decisionId (NOT Date.now(), which is unavailable in some
20
+ * MindForge execution contexts); falls back to "council-latest.json".
21
+ */
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { Semaphore } = require('../autonomous/wave-executor');
25
+
26
+ const DEFAULT_VOICES = ['architect', 'skeptic', 'pragmatist', 'critic'];
27
+ const VALID_RECOMMENDATIONS = ['PROCEED', 'REVISE'];
28
+
29
+ /**
30
+ * Validates a single voice's position payload. Throws a clear, voice-named error
31
+ * on a malformed payload rather than letting it degrade into NaN consensus.
32
+ * @param {string} voice — The voice that produced the position (for the error message).
33
+ * @param {object} position — The raw position returned by the model.
34
+ */
35
+ function validatePosition(voice, position) {
36
+ if (!position || typeof position !== 'object') {
37
+ throw new Error(`Council voice "${voice}" returned an invalid position: expected an object, got ${position === null ? 'null' : typeof position}`);
38
+ }
39
+ if (!VALID_RECOMMENDATIONS.includes(position.recommendation)) {
40
+ throw new Error(`Council voice "${voice}" returned an invalid position: recommendation must be one of ${VALID_RECOMMENDATIONS.join('/')}, got ${JSON.stringify(position.recommendation)}`);
41
+ }
42
+ if (typeof position.confidence !== 'number' || !Number.isFinite(position.confidence) || position.confidence < 0 || position.confidence > 1) {
43
+ throw new Error(`Council voice "${voice}" returned an invalid position: confidence must be a number in [0,1], got ${position.confidence}`);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Runs a thin adversarial council over a question.
49
+ * @param {string} question — The decision/question put to the council.
50
+ * @param {object} [opts]
51
+ * @param {string[]} [opts.voices] — Voice personas to consult (default: 4 ADS voices).
52
+ * @param {number} [opts.consensusThreshold=0.75] — Threshold for PROCEED/REVISE.
53
+ * @param {function} opts.model — REQUIRED. async ({voice, question}) =>
54
+ * { recommendation: 'PROCEED'|'REVISE', confidence: number(0..1), rationale: string }
55
+ * @param {number} [opts.maxConcurrency] — Bound on parallel voice calls (default: #voices).
56
+ * @param {boolean} [opts.writeDecision=true] — Persist a decision record to disk.
57
+ * @param {string} [opts.outputPath] — Directory for the record (default: .planning/decisions).
58
+ * @param {string} [opts.decisionId] — Stable id used in the filename (no Date.now()).
59
+ * @returns {Promise<{question,positions,consensus,verdict,dissent}>}
60
+ */
61
+ async function runCouncil(question, opts = {}) {
62
+ const voices = Array.isArray(opts.voices) && opts.voices.length > 0
63
+ ? opts.voices
64
+ : DEFAULT_VOICES;
65
+ const consensusThreshold = opts.consensusThreshold ?? 0.75;
66
+ const model = opts.model;
67
+ if (typeof model !== 'function') {
68
+ throw new Error('runCouncil requires an injectable model function (opts.model)');
69
+ }
70
+ const maxConcurrency = opts.maxConcurrency || voices.length;
71
+
72
+ // Parallel position collection — bounded by the reused Semaphore.
73
+ const sem = new Semaphore(maxConcurrency);
74
+ const positions = await Promise.all(voices.map(async (voice) => {
75
+ await sem.acquire();
76
+ try {
77
+ const position = await model({ voice, question });
78
+ // Validate the payload immediately — never silently swallow a malformed
79
+ // position into NaN consensus (which would collapse to NO_CONSENSUS and
80
+ // write NaN to the decision record).
81
+ validatePosition(voice, position);
82
+ return { voice, ...position };
83
+ } finally {
84
+ sem.release();
85
+ }
86
+ }));
87
+
88
+ // Consensus = mean approval signal across voices.
89
+ // A PROCEED contributes its confidence; a REVISE contributes its inverse
90
+ // (so a high-confidence REVISE pulls consensus down hard).
91
+ const consensus = positions.reduce((sum, p) => {
92
+ const approval = p.recommendation === 'PROCEED' ? p.confidence : (1 - p.confidence);
93
+ return sum + approval;
94
+ }, 0) / positions.length;
95
+
96
+ const verdict = consensus >= consensusThreshold ? 'PROCEED'
97
+ : consensus <= (1 - consensusThreshold) ? 'REVISE'
98
+ : 'NO_CONSENSUS';
99
+
100
+ // Dissent capture:
101
+ // - For a decisive verdict (PROCEED/REVISE): the voices opposing that direction.
102
+ // - For NO_CONSENSUS (the deadlock ADS most needs documented): the FULL split —
103
+ // every voice's {voice, recommendation, rationale} — so the decision record
104
+ // preserves both camps rather than recording an empty dissent list.
105
+ const dissent = verdict === 'NO_CONSENSUS'
106
+ ? positions.map((p) => ({ voice: p.voice, recommendation: p.recommendation, rationale: p.rationale }))
107
+ : positions.filter((p) =>
108
+ (verdict === 'PROCEED' && p.recommendation !== 'PROCEED') ||
109
+ (verdict === 'REVISE' && p.recommendation === 'PROCEED'))
110
+ .map((d) => ({ voice: d.voice, rationale: d.rationale }));
111
+
112
+ const result = { question, positions, consensus, verdict, dissent };
113
+
114
+ if (opts.writeDecision !== false) {
115
+ const dir = opts.outputPath || path.join(process.cwd(), '.planning', 'decisions');
116
+ fs.mkdirSync(dir, { recursive: true });
117
+ const name = opts.decisionId ? `council-${opts.decisionId}.json` : 'council-latest.json';
118
+ fs.writeFileSync(path.join(dir, name), JSON.stringify(result, null, 2));
119
+ }
120
+
121
+ return result;
122
+ }
123
+
124
+ module.exports = { runCouncil };
@@ -98,6 +98,14 @@ class WaveFeedbackLoop {
98
98
  return { shouldPause: false };
99
99
  }
100
100
 
101
+ recordRemediationOutcome(remediationId, outcome) {
102
+ this.recordPerformance(
103
+ outcome.strategy,
104
+ 'remediation',
105
+ outcome.effective
106
+ );
107
+ }
108
+
101
109
  reset() {
102
110
  this.waveState = { completed: 0, failed: 0, skipped: 0, total: 0 };
103
111
  }
@@ -15,24 +15,41 @@ class IntelligenceInterlock {
15
15
  this.UPGRADE_THRESHOLD = 0.50; // v6.3.0 Threshold (Recalibrated for IDC readiness)
16
16
  }
17
17
 
18
- /**
19
- * Evaluates if a model upgrade is required based on reasoning drift.
20
- * @param {string} spanId
21
- * @param {string} thought
22
- */
23
18
  evaluate(spanId, thought) {
24
- const analysis = driftDetector.analyze(spanId, thought);
25
-
26
- if (analysis.drift_score > this.UPGRADE_THRESHOLD) {
27
- console.log(`[IDC] Critical Drift Detected (${analysis.drift_score}). Recommending intelligence upgrade for Span ${spanId}.`);
28
- return {
29
- action: 'UPGRADE_MIR',
30
- new_mir: 99, // Force maximum intelligence (Tier 1+)
31
- reason: analysis.markers
32
- };
19
+ const driftReport = driftDetector.analyze(spanId, thought);
20
+ const driftScore = driftReport.drift_score;
21
+
22
+ if (driftScore <= this.UPGRADE_THRESHOLD) {
23
+ return { action: 'CONTINUE', drift_score: driftScore };
24
+ }
25
+
26
+ let tierIncrease;
27
+ if (driftScore > 0.80) {
28
+ tierIncrease = 'MAX';
29
+ } else if (driftScore > 0.65) {
30
+ tierIncrease = 2;
31
+ } else {
32
+ tierIncrease = 1;
33
33
  }
34
34
 
35
- return { action: 'CONTINUE', drift: analysis.drift_score };
35
+ let costWarning = false;
36
+ try {
37
+ const CostTracker = require('../models/cost-tracker');
38
+ const dailySpend = CostTracker.getDailySpend ? CostTracker.getDailySpend() : 0;
39
+ const hardLimit = CostTracker.getHardLimit ? CostTracker.getHardLimit() : Infinity;
40
+ if (dailySpend / hardLimit > 0.8) {
41
+ costWarning = true;
42
+ tierIncrease = Math.min(tierIncrease === 'MAX' ? 3 : tierIncrease, 1);
43
+ }
44
+ } catch { /* cost tracker unavailable */ }
45
+
46
+ return {
47
+ action: 'UPGRADE_MIR',
48
+ tier_increase: tierIncrease,
49
+ drift_score: driftScore,
50
+ cost_constrained: costWarning,
51
+ reason: driftReport.markers
52
+ };
36
53
  }
37
54
  }
38
55
 
@@ -8,10 +8,11 @@
8
8
  'use strict';
9
9
 
10
10
  const configManager = require('../governance/config-manager');
11
+ const { LRUMap } = require('../utils/index');
11
12
 
12
13
  class LogicDriftDetector {
13
14
  constructor() {
14
- this.sessionDriftHistory = new Map(); // spanId -> [scores]
15
+ this.sessionDriftHistory = new LRUMap(500);
15
16
  this.DRIFT_THRESHOLD = configManager.get('governance.drift_threshold', 0.75);
16
17
  this.CRITICAL_DRIFT_THRESHOLD = configManager.get('governance.critical_drift_threshold', 0.50);
17
18
  }
@@ -16,6 +16,7 @@ const remediationEngine = require('./remediation-engine'); // v6.1 Pillar X
16
16
  const logicValidator = require('./logic-validator'); // v7 Pillar X
17
17
  const vectorHub = require('../memory/vector-hub'); // v8 Pillar XV
18
18
  const { AuditWriter } = require('../utils/file-io');
19
+ const { LRUMap } = require('../utils/index');
19
20
 
20
21
  class NexusTracer {
21
22
  constructor(config = {}) {
@@ -29,8 +30,8 @@ class NexusTracer {
29
30
  this.vhInitialized = false;
30
31
 
31
32
  // v7: Centralized Thresholds
32
- this.RES_THRESHOLD = configManager.get('governance.res_threshold', 0.8);
33
- this.entropyCache = new Map();
33
+ this.RES_THRESHOLD = configManager.get('governance.res_threshold', 0.8);
34
+ this.entropyCache = new LRUMap(1000);
34
35
 
35
36
  // v9: Async Audit Writer (replaces sync appendFileSync)
36
37
  this._auditWriter = new AuditWriter(this.auditPath);
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ /**
3
+ * MindForge — OTel GenAI Exporter (UC-18).
4
+ * Translates NexusTracer spans to OpenTelemetry GenAI semantic conventions.
5
+ * Active only when OTEL_EXPORTER_OTLP_ENDPOINT is set.
6
+ *
7
+ * NexusTracer span shape (from nexus-tracer.js startSpan/endSpan):
8
+ * {
9
+ * id: 'sp_<hex>',
10
+ * trace_id: 'tr_<hex>',
11
+ * parent_id: string|null,
12
+ * name: string,
13
+ * status: 'active'|'success'|'error',
14
+ * start_time: ISO-8601,
15
+ * end_time: ISO-8601,
16
+ * attributes: {
17
+ * service: string,
18
+ * host: string,
19
+ * pid: number,
20
+ * model_id?: string,
21
+ * skill?: string,
22
+ * input_tokens?: number,
23
+ * output_tokens?: number,
24
+ * ...
25
+ * }
26
+ * }
27
+ *
28
+ * Mapping to OTel GenAI semantic conventions:
29
+ * span.name → name
30
+ * span.attributes.model_id → gen_ai.request.model, gen_ai.response.model
31
+ * span.attributes.input_tokens → gen_ai.usage.input_tokens
32
+ * span.attributes.output_tokens → gen_ai.usage.output_tokens
33
+ * span.name → gen_ai.operation.name
34
+ * 'mindforge' → gen_ai.system (or span.attributes.provider if present)
35
+ */
36
+
37
+ const crypto = require('crypto');
38
+
39
+ /**
40
+ * Check if the OTel exporter is enabled (env var gate).
41
+ * @returns {boolean}
42
+ */
43
+ function isEnabled() {
44
+ return !!process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
45
+ }
46
+
47
+ /**
48
+ * Translate a NexusTracer span to OTel GenAI-compatible format.
49
+ * Produces a valid 16-byte hex traceId and 8-byte hex spanId.
50
+ *
51
+ * @param {object} nexusSpan - A span object from NexusTracer
52
+ * @returns {object} OTel-compatible span object
53
+ */
54
+ function toOtelSpan(nexusSpan) {
55
+ const attrs = nexusSpan.attributes || {};
56
+
57
+ return {
58
+ traceId: crypto.randomBytes(16).toString('hex'),
59
+ spanId: crypto.randomBytes(8).toString('hex'),
60
+ parentSpanId: nexusSpan.parent_id || '',
61
+ name: nexusSpan.name || 'unknown',
62
+ kind: 1, // SPAN_KIND_INTERNAL
63
+ startTimeUnixNano: nexusSpan.start_time
64
+ ? BigInt(new Date(nexusSpan.start_time).getTime()) * 1_000_000n
65
+ : 0n,
66
+ endTimeUnixNano: nexusSpan.end_time
67
+ ? BigInt(new Date(nexusSpan.end_time).getTime()) * 1_000_000n
68
+ : 0n,
69
+ status: nexusSpan.status === 'success' ? { code: 1 } : { code: 2 },
70
+ attributes: {
71
+ 'gen_ai.system': attrs.provider || 'mindforge',
72
+ 'gen_ai.request.model': attrs.model_id || '',
73
+ 'gen_ai.response.model': attrs.model_id || '',
74
+ 'gen_ai.usage.input_tokens': attrs.input_tokens || 0,
75
+ 'gen_ai.usage.output_tokens': attrs.output_tokens || 0,
76
+ 'gen_ai.operation.name': nexusSpan.name || '',
77
+ 'service.name': attrs.service || 'mindforge-nexus',
78
+ },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Serialize BigInt values to strings for JSON compatibility.
84
+ * @param {object} otelSpan
85
+ * @returns {object}
86
+ */
87
+ function toJsonSafe(otelSpan) {
88
+ return {
89
+ ...otelSpan,
90
+ startTimeUnixNano: String(otelSpan.startTimeUnixNano),
91
+ endTimeUnixNano: String(otelSpan.endTimeUnixNano),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Export a NexusTracer span to the OTel-compatible local file.
97
+ * In production, this would POST to OTEL_EXPORTER_OTLP_ENDPOINT/v1/traces.
98
+ * For now, appends to .mindforge/metrics/otel-spans.jsonl for verification.
99
+ *
100
+ * @param {object} nexusSpan - A span from NexusTracer
101
+ */
102
+ async function exportSpan(nexusSpan) {
103
+ if (!isEnabled()) return;
104
+
105
+ const otelSpan = toOtelSpan(nexusSpan);
106
+ const jsonSafe = toJsonSafe(otelSpan);
107
+
108
+ const fs = require('fs');
109
+ const path = require('path');
110
+ const outPath = path.join(process.cwd(), '.mindforge', 'metrics', 'otel-spans.jsonl');
111
+
112
+ try {
113
+ const dir = path.dirname(outPath);
114
+ if (!fs.existsSync(dir)) {
115
+ fs.mkdirSync(dir, { recursive: true });
116
+ }
117
+ fs.appendFileSync(outPath, JSON.stringify(jsonSafe) + '\n');
118
+ } catch {
119
+ // Non-fatal: observability export should never break the main flow
120
+ }
121
+ }
122
+
123
+ module.exports = { isEnabled, toOtelSpan, toJsonSafe, exportSpan };