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
@@ -7,8 +7,59 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
+ const { atomicWriteJSON } = require('../utils/file-io');
10
11
 
11
12
  const VALID_PHASES = ['idle', 'planning', 'executing', 'verifying', 'complete', 'running', 'paused', 'completed'];
13
+ const VALID_STATUSES = ['idle', 'running', 'paused', 'completed', 'escalated', 'timeout'];
14
+
15
+ /**
16
+ * Validates HANDOFF.json structure without blocking on failure (fail-open).
17
+ * Warns on malformed fields but always returns the data for processing.
18
+ * @param {*} data — Parsed HANDOFF.json content
19
+ * @returns {{ valid: boolean, warnings: string[] }}
20
+ */
21
+ function validateHandoff(data) {
22
+ const warnings = [];
23
+
24
+ if (!data || typeof data !== 'object') {
25
+ warnings.push('HANDOFF.json is not a valid object');
26
+ return { valid: false, warnings };
27
+ }
28
+
29
+ if (!data.schema_version) {
30
+ warnings.push('Missing schema_version field');
31
+ }
32
+
33
+ if (data.handoffs && !Array.isArray(data.handoffs)) {
34
+ warnings.push('handoffs field must be an array');
35
+ }
36
+
37
+ if (data.handoffs && Array.isArray(data.handoffs)) {
38
+ for (let i = 0; i < data.handoffs.length; i++) {
39
+ const task = data.handoffs[i];
40
+ if (!task.id) warnings.push(`handoffs[${i}] missing required field: id`);
41
+ if (!task.name) warnings.push(`handoffs[${i}] missing required field: name`);
42
+ }
43
+ }
44
+
45
+ if (data.status && !VALID_STATUSES.includes(data.status)) {
46
+ warnings.push(`Invalid status: "${data.status}". Expected one of: ${VALID_STATUSES.join(', ')}`);
47
+ }
48
+
49
+ if (data.wave_current !== undefined && typeof data.wave_current !== 'number') {
50
+ warnings.push('wave_current must be a number');
51
+ }
52
+
53
+ if (data.tasks_completed !== undefined && typeof data.tasks_completed !== 'number') {
54
+ warnings.push('tasks_completed must be a number');
55
+ }
56
+
57
+ if (data.timestamps && typeof data.timestamps !== 'object') {
58
+ warnings.push('timestamps must be an object');
59
+ }
60
+
61
+ return { valid: warnings.length === 0, warnings };
62
+ }
12
63
 
13
64
  /**
14
65
  * Creates a state manager for the given planning directory.
@@ -45,7 +96,7 @@ function createStateManager(planningDir) {
45
96
  function updateState(patch) {
46
97
  const current = getState();
47
98
  const merged = Object.assign(Object.create(null), current, patch);
48
- fs.writeFileSync(statePath, JSON.stringify(merged, null, 2));
99
+ atomicWriteJSON(statePath, merged);
49
100
  return merged;
50
101
  }
51
102
 
@@ -64,7 +115,8 @@ function createStateManager(planningDir) {
64
115
  }
65
116
 
66
117
  /**
67
- * Reads and parses HANDOFF.json. Throws if missing or malformed.
118
+ * Reads and parses HANDOFF.json with schema validation (fail-open).
119
+ * Logs warnings for structural issues but always returns data if parseable.
68
120
  * @returns {object} Parsed handoff data (fresh object)
69
121
  */
70
122
  function readHandoff() {
@@ -79,8 +131,12 @@ function createStateManager(planningDir) {
79
131
  throw new Error(`HANDOFF.json is malformed: ${e.message}`);
80
132
  }
81
133
 
82
- if (!handoff.handoffs || !Array.isArray(handoff.handoffs)) {
83
- throw new Error('HANDOFF.json has no handoffs array');
134
+ // Fail-open schema validation — warn but never block
135
+ const { valid, warnings } = validateHandoff(handoff);
136
+ if (!valid) {
137
+ for (const warning of warnings) {
138
+ console.warn('[STATE] HANDOFF validation:', warning);
139
+ }
84
140
  }
85
141
 
86
142
  return handoff;
@@ -94,7 +150,7 @@ function createStateManager(planningDir) {
94
150
  const timestamped = Object.assign(Object.create(null), data, {
95
151
  last_updated: new Date().toISOString(),
96
152
  });
97
- fs.writeFileSync(handoffPath, JSON.stringify(timestamped, null, 2) + '\n');
153
+ atomicWriteJSON(handoffPath, timestamped);
98
154
  return timestamped;
99
155
  }
100
156
 
@@ -113,4 +169,4 @@ function sanitizeState(parsed) {
113
169
  return clean;
114
170
  }
115
171
 
116
- module.exports = { createStateManager };
172
+ module.exports = { createStateManager, validateHandoff };
@@ -96,12 +96,49 @@ class StuckMonitor {
96
96
  return identical.length >= 3;
97
97
  }
98
98
 
99
- isContentSimilar(a, b) {
99
+ isContentSimilar(a, b, threshold = 10) {
100
100
  if (!a || !b) return false;
101
101
  if (a === b) return true;
102
- // Simple similarity check (hardened from Roadmap requirement)
103
- const dist = this.levenshtein(a.slice(0, 100), b.slice(0, 100));
104
- return dist < 10;
102
+
103
+ const hashA = this._quickHash(a);
104
+ const hashB = this._quickHash(b);
105
+ if (hashA === hashB) return true;
106
+
107
+ const lenDiff = Math.abs(a.length - b.length);
108
+ if (lenDiff > Math.max(a.length, b.length) * 0.2) return false;
109
+
110
+ const cached = this._getCachedSimilarity(hashA, hashB);
111
+ if (cached !== undefined) return cached;
112
+
113
+ const truncA = a.substring(0, 100);
114
+ const truncB = b.substring(0, 100);
115
+ const result = this.levenshtein(truncA, truncB) <= threshold;
116
+
117
+ this._setCachedSimilarity(hashA, hashB, result);
118
+ return result;
119
+ }
120
+
121
+ _quickHash(str) {
122
+ let hash = 0;
123
+ for (let i = 0; i < str.length; i++) {
124
+ hash = ((hash << 5) - hash) + str.charCodeAt(i);
125
+ hash = hash & hash;
126
+ }
127
+ return hash;
128
+ }
129
+
130
+ _getCachedSimilarity(keyA, keyB) {
131
+ const key = keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}`;
132
+ return StuckMonitor._similarityCache.get(key);
133
+ }
134
+
135
+ _setCachedSimilarity(keyA, keyB, result) {
136
+ const key = keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}`;
137
+ if (StuckMonitor._similarityCache.size >= 200) {
138
+ const firstKey = StuckMonitor._similarityCache.keys().next().value;
139
+ StuckMonitor._similarityCache.delete(firstKey);
140
+ }
141
+ StuckMonitor._similarityCache.set(key, result);
105
142
  }
106
143
 
107
144
  levenshtein(a, b) {
@@ -109,12 +146,14 @@ class StuckMonitor {
109
146
  for (let i = 0; i <= a.length; i++) { tmp[i] = [i]; }
110
147
  for (let j = 0; j <= b.length; j++) { tmp[0][j] = j; }
111
148
  for (let i = 1; i <= a.length; i++) {
112
- for (let j = 1; j <= b.length; j++) {
113
- tmp[i][j] = Math.min(tmp[i - 1][j] + 1, tmp[i][j - 1] + 1, tmp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
114
- }
149
+ for (let j = 1; j <= b.length; j++) {
150
+ tmp[i][j] = Math.min(tmp[i - 1][j] + 1, tmp[i][j - 1] + 1, tmp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
151
+ }
115
152
  }
116
153
  return tmp[a.length][b.length];
117
154
  }
118
155
  }
119
156
 
157
+ StuckMonitor._similarityCache = new Map();
158
+
120
159
  module.exports = StuckMonitor;
@@ -6,6 +6,36 @@
6
6
  'use strict';
7
7
 
8
8
  const crypto = require('crypto');
9
+ const { buildGraph, groupIntoWaves } = require('./dependency-dag');
10
+
11
+ /**
12
+ * Semaphore for bounding concurrency within a wave.
13
+ * Tasks within a wave are independent — this limits how many run simultaneously.
14
+ */
15
+ class Semaphore {
16
+ constructor(max) {
17
+ this.max = max;
18
+ this.current = 0;
19
+ this.queue = [];
20
+ }
21
+
22
+ async acquire() {
23
+ if (this.current < this.max) {
24
+ this.current++;
25
+ return;
26
+ }
27
+ await new Promise(resolve => this.queue.push(resolve));
28
+ this.current++;
29
+ }
30
+
31
+ release() {
32
+ this.current--;
33
+ if (this.queue.length > 0) {
34
+ const next = this.queue.shift();
35
+ next();
36
+ }
37
+ }
38
+ }
9
39
 
10
40
  /**
11
41
  * Creates a wave executor with the given configuration.
@@ -35,10 +65,19 @@ function createWaveExecutor(config = {}) {
35
65
  /**
36
66
  * Groups handoff tasks into sequential waves based on wave field or dependency topology.
37
67
  * Returns a new array of wave objects — does not mutate input.
68
+ *
69
+ * Resolution order (UC-03):
70
+ * 1. Explicit numeric `.wave` field ALWAYS wins (legacy behavior, unchanged).
71
+ * 2. Else if `options.useDag === true` (OPT-IN), order by `depends_on` via Kahn
72
+ * topological sort. Halts loud (throws) on cycles or unknown dependencies.
73
+ * 3. Else (legacy default), all tasks in a single parallel wave (unchanged).
74
+ *
75
+ * DAG ordering is OPT-IN to avoid silently reordering existing PLAN files.
38
76
  * @param {Array} handoffs — Raw handoffs array from HANDOFF.json
77
+ * @param {object} [options] — { useDag?: boolean }
39
78
  * @returns {Array<{ wave: number, tasks: Array }>}
40
79
  */
41
- function planWaves(handoffs) {
80
+ function planWaves(handoffs, options = {}) {
42
81
  if (!Array.isArray(handoffs) || handoffs.length === 0) {
43
82
  waves = [];
44
83
  return [];
@@ -57,6 +96,15 @@ function createWaveExecutor(config = {}) {
57
96
  waves = Array.from(byWave.entries())
58
97
  .sort((a, b) => a[0] - b[0])
59
98
  .map(([waveNum, tasks]) => Object.freeze({ wave: waveNum, tasks: Object.freeze(tasks) }));
99
+ } else if (options.useDag === true) {
100
+ const normalized = handoffs.map(normalizeTask);
101
+ const graph = buildGraph(normalized);
102
+ const waveIds = groupIntoWaves(graph);
103
+ const byId = new Map(normalized.map(t => [t.id, t]));
104
+ waves = waveIds.map((ids, i) => Object.freeze({
105
+ wave: i,
106
+ tasks: Object.freeze(ids.map(id => byId.get(id))),
107
+ }));
60
108
  } else {
61
109
  // Single wave with all tasks
62
110
  waves = [Object.freeze({
@@ -73,42 +121,54 @@ function createWaveExecutor(config = {}) {
73
121
  }
74
122
 
75
123
  /**
76
- * Executes a single wave — runs tasks sequentially, skipping already-completed ones.
124
+ * Executes a single wave — runs tasks in parallel (bounded by maxConcurrency),
125
+ * skipping already-completed ones.
77
126
  * @param {object} wave — A wave object from planWaves
78
127
  * @param {object} context — Execution context passed to callbacks
79
- * @param {object} context.executor — async function(task) => result (performs actual work)
128
+ * @param {function} context.executor — async function(task) => result (performs actual work)
129
+ * @param {number} [context.maxConcurrency=3] — Max parallel tasks within this wave
80
130
  * @returns {Promise<{ completed: string[], failed: string[], skipped: string[] }>}
81
131
  */
82
132
  async function executeWave(wave, context = {}) {
83
- const { executor = async () => {} } = context;
133
+ const { executor = async () => {}, maxConcurrency = 3 } = context;
84
134
  status = 'running';
85
135
 
86
136
  const pending = wave.tasks.filter(t => !completedTasks.has(t.id));
87
137
  const result = { completed: [], failed: [], skipped: [] };
138
+ const semaphore = new Semaphore(maxConcurrency);
88
139
 
89
140
  onWaveStart({ wave: wave.wave, taskCount: pending.length });
90
141
 
91
- for (const task of pending) {
92
- const taskStart = Date.now();
93
- onTaskStart({ task, wave: wave.wave });
94
-
95
- try {
96
- await executor(task);
97
-
98
- const duration = Date.now() - taskStart;
99
- completedTasks = new Set([...completedTasks, task.id]);
100
- result.completed.push(task.id);
101
-
102
- onTaskComplete({ task, wave: wave.wave, duration_ms: duration });
103
- } catch (err) {
104
- const duration = Date.now() - taskStart;
105
- result.failed.push(task.id);
106
-
107
- onTaskFail({ task, wave: wave.wave, error: err, duration_ms: duration });
108
-
109
- // Re-throw to let caller decide on retry/escalation strategy
110
- throw err;
111
- }
142
+ const settled = await Promise.allSettled(
143
+ pending.map(async (task) => {
144
+ await semaphore.acquire();
145
+ const taskStart = Date.now();
146
+ try {
147
+ onTaskStart({ task, wave: wave.wave });
148
+ await executor(task);
149
+
150
+ const duration = Date.now() - taskStart;
151
+ completedTasks = new Set([...completedTasks, task.id]);
152
+ result.completed.push(task.id);
153
+
154
+ onTaskComplete({ task, wave: wave.wave, duration_ms: duration });
155
+ return { task, status: 'fulfilled' };
156
+ } catch (err) {
157
+ const duration = Date.now() - taskStart;
158
+ result.failed.push(task.id);
159
+
160
+ onTaskFail({ task, wave: wave.wave, error: err, duration_ms: duration });
161
+ throw err;
162
+ } finally {
163
+ semaphore.release();
164
+ }
165
+ })
166
+ );
167
+
168
+ const failures = settled.filter(r => r.status === 'rejected');
169
+ if (failures.length > 0) {
170
+ const failMsg = failures.map(f => f.reason?.message || 'unknown').join(', ');
171
+ throw new Error(`${failures.length} task(s) failed in wave: ${failMsg}`);
112
172
  }
113
173
 
114
174
  currentWaveIndex++;
@@ -166,4 +226,4 @@ function normalizeTask(h) {
166
226
  });
167
227
  }
168
228
 
169
- module.exports = { createWaveExecutor };
229
+ module.exports = { createWaveExecutor, Semaphore };
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * MindForge — Council CLI (UC-22)
5
+ *
6
+ * Thin CLI wrapper around council-runtime.runCouncil. Provides the injectable
7
+ * model function via ModelClient and formats structured output for the
8
+ * /mindforge:council command.
9
+ *
10
+ * Usage:
11
+ * node bin/council-cli.js "Should we adopt event sourcing for the payments domain?"
12
+ * node bin/council-cli.js --id payment-es "Should we adopt event sourcing?"
13
+ *
14
+ * Exit codes:
15
+ * 0 — PROCEED
16
+ * 1 — REVISE
17
+ * 2 — NO_CONSENSUS
18
+ * 3 — Runtime error
19
+ */
20
+ const { runCouncil } = require('./engine/council-runtime');
21
+ const ModelClient = require('./models/model-client');
22
+
23
+ const VOICE_SYSTEM_PROMPTS = {
24
+ architect: 'You are the Architect voice in a decision council. You focus on system design, scalability, maintainability, and long-term architectural integrity. Evaluate the decision from a structural perspective.',
25
+ skeptic: 'You are the Skeptic voice in a decision council. You challenge assumptions, identify risks, hidden costs, and failure modes. Your job is to stress-test the proposal.',
26
+ pragmatist: 'You are the Pragmatist voice in a decision council. You focus on delivery timelines, team capacity, incremental value, and practical trade-offs. Favor what ships reliably.',
27
+ critic: 'You are the Critic voice in a decision council. You evaluate quality, correctness, edge cases, and whether the solution meets its stated goals without over-engineering.',
28
+ };
29
+
30
+ const POSITION_INSTRUCTION = `
31
+ Respond with ONLY a JSON object (no markdown fences, no prose) in this exact shape:
32
+ {
33
+ "recommendation": "PROCEED" or "REVISE",
34
+ "confidence": <number between 0 and 1>,
35
+ "rationale": "<1-3 sentence explanation>"
36
+ }
37
+ Do NOT include any text outside the JSON object.`;
38
+
39
+ /**
40
+ * Injectable model function for runCouncil — calls ModelClient.complete per voice.
41
+ */
42
+ async function councilModel({ voice, question }) {
43
+ const systemPrompt = (VOICE_SYSTEM_PROMPTS[voice] || VOICE_SYSTEM_PROMPTS.architect) +
44
+ '\n' + POSITION_INSTRUCTION;
45
+
46
+ const result = await ModelClient.complete({
47
+ persona: 'council',
48
+ tier: 2,
49
+ systemPrompt,
50
+ userMessage: `Decision under review:\n${question}`,
51
+ maxTokens: 300,
52
+ temperature: 0.4,
53
+ taskName: `council-${voice}`,
54
+ });
55
+
56
+ // Parse the JSON response from the model
57
+ const content = (result.content || '').trim();
58
+ let parsed;
59
+ try {
60
+ // Strip markdown fences if model adds them despite instructions
61
+ const cleaned = content.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
62
+ parsed = JSON.parse(cleaned);
63
+ } catch {
64
+ throw new Error(`Council voice "${voice}" returned unparseable response: ${content.slice(0, 200)}`);
65
+ }
66
+
67
+ return {
68
+ recommendation: parsed.recommendation,
69
+ confidence: parsed.confidence,
70
+ rationale: parsed.rationale,
71
+ };
72
+ }
73
+
74
+ // --- CLI argument parsing ---
75
+ function parseArgs(argv) {
76
+ const args = argv.slice(2);
77
+ const opts = { question: null, decisionId: null };
78
+
79
+ for (let i = 0; i < args.length; i++) {
80
+ if (args[i] === '--id' && args[i + 1]) {
81
+ opts.decisionId = args[++i];
82
+ } else if (!args[i].startsWith('-')) {
83
+ opts.question = opts.question ? `${opts.question} ${args[i]}` : args[i];
84
+ }
85
+ }
86
+ return opts;
87
+ }
88
+
89
+ // --- Formatted output ---
90
+ function formatOutput(result) {
91
+ const lines = [];
92
+ lines.push('');
93
+ lines.push('=== COUNCIL VERDICT ===');
94
+ lines.push('');
95
+ lines.push(`Question: ${result.question}`);
96
+ lines.push('');
97
+ lines.push('--- Positions ---');
98
+ for (const pos of result.positions) {
99
+ const icon = pos.recommendation === 'PROCEED' ? '[+]' : '[-]';
100
+ lines.push(` ${icon} ${pos.voice.toUpperCase()} (${pos.recommendation}, confidence: ${pos.confidence.toFixed(2)})`);
101
+ lines.push(` ${pos.rationale}`);
102
+ }
103
+ lines.push('');
104
+ lines.push(`--- Consensus: ${(result.consensus * 100).toFixed(1)}% ---`);
105
+ lines.push('');
106
+ lines.push(`VERDICT: ${result.verdict}`);
107
+
108
+ if (result.verdict === 'NO_CONSENSUS' && result.dissent.length > 0) {
109
+ lines.push('');
110
+ lines.push('--- Dissent (full split) ---');
111
+ for (const d of result.dissent) {
112
+ lines.push(` * ${d.voice.toUpperCase()} (${d.recommendation}): ${d.rationale}`);
113
+ }
114
+ } else if (result.dissent.length > 0) {
115
+ lines.push('');
116
+ lines.push('--- Dissent ---');
117
+ for (const d of result.dissent) {
118
+ lines.push(` * ${d.voice.toUpperCase()}: ${d.rationale}`);
119
+ }
120
+ }
121
+
122
+ lines.push('');
123
+ lines.push('Council is advisory -- you have final say.');
124
+ lines.push('');
125
+ return lines.join('\n');
126
+ }
127
+
128
+ // --- Main ---
129
+ async function main() {
130
+ const { question, decisionId } = parseArgs(process.argv);
131
+
132
+ if (!question) {
133
+ process.stderr.write('Usage: node bin/council-cli.js [--id <decision-id>] "<question>"\n');
134
+ process.exit(3);
135
+ }
136
+
137
+ try {
138
+ const result = await runCouncil(question, {
139
+ model: councilModel,
140
+ writeDecision: true,
141
+ decisionId: decisionId || undefined,
142
+ });
143
+
144
+ // Output structured JSON to stdout for programmatic consumption
145
+ console.log(JSON.stringify(result, null, 2));
146
+
147
+ // Output formatted human-readable summary to stderr
148
+ process.stderr.write(formatOutput(result));
149
+
150
+ // Exit code reflects verdict
151
+ const exitCode = result.verdict === 'PROCEED' ? 0
152
+ : result.verdict === 'REVISE' ? 1
153
+ : 2;
154
+ process.exit(exitCode);
155
+ } catch (err) {
156
+ process.stderr.write(`[council-cli] ERROR: ${err.message}\n`);
157
+ process.exit(3);
158
+ }
159
+ }
160
+
161
+ main();
@@ -192,6 +192,49 @@ function register(app) {
192
192
  app.get('/api/connections', (req, res) => {
193
193
  res.json({ clients: SSE.getClientCount() });
194
194
  });
195
+
196
+ // ── System observability ────────────────────────────────────────────────────
197
+ app.get('/api/v1/system', (req, res) => {
198
+ try {
199
+ const heapUsed = process.memoryUsage().heapUsed;
200
+ const heapTotal = process.memoryUsage().heapTotal;
201
+ const uptime = process.uptime();
202
+
203
+ let auditLines = 0;
204
+ try {
205
+ const auditPath = path.join(process.cwd(), '.planning', 'AUDIT.jsonl');
206
+ if (fs.existsSync(auditPath)) {
207
+ const content = fs.readFileSync(auditPath, 'utf8');
208
+ auditLines = content.split('\n').filter(l => l.trim()).length;
209
+ }
210
+ } catch { /* non-critical */ }
211
+
212
+ let snapshotCount = 0;
213
+ try {
214
+ const historyDir = path.join(process.cwd(), '.planning', 'history');
215
+ if (fs.existsSync(historyDir)) {
216
+ snapshotCount = fs.readdirSync(historyDir).length;
217
+ }
218
+ } catch { /* non-critical */ }
219
+
220
+ const heapHealth = Metrics.checkHeapHealth();
221
+
222
+ res.json({
223
+ heap_used_mb: Math.round(heapUsed / 1024 / 1024 * 100) / 100,
224
+ heap_total_mb: Math.round(heapTotal / 1024 / 1024 * 100) / 100,
225
+ heap_usage_pct: Math.round(heapUsed / heapTotal * 100),
226
+ heap_alert: heapHealth,
227
+ uptime_seconds: Math.round(uptime),
228
+ audit_lines: auditLines,
229
+ snapshot_count: snapshotCount,
230
+ sse_clients: SSE.getClientCount(),
231
+ node_version: process.version,
232
+ timestamp: new Date().toISOString()
233
+ });
234
+ } catch (err) {
235
+ res.status(500).json({ error: err.message });
236
+ }
237
+ });
195
238
  }
196
239
 
197
240
  module.exports = { register };
@@ -115,7 +115,9 @@ function writeAuditEntry(entry) {
115
115
  try {
116
116
  const paths = getPaths();
117
117
  if (!fs.existsSync(path.dirname(paths.audit))) return;
118
- fs.appendFileSync(paths.audit, JSON.stringify(entry) + '\n');
118
+ // UC-04b: unified, hash-chained, durable append into the single verifiable chain.
119
+ const { appendAuditEntrySync } = require('../autonomous/audit-writer');
120
+ appendAuditEntrySync(paths.audit, entry);
119
121
  } catch { /* ignore AUDIT write failures */ }
120
122
  }
121
123
 
@@ -326,6 +326,32 @@ function getCosts(windowDays = 7) {
326
326
  return stats;
327
327
  }
328
328
 
329
+ // ── Heap Health ──────────────────────────────────────────────────────────────
330
+ function checkHeapHealth() {
331
+ const heapUsed = process.memoryUsage().heapUsed;
332
+ const maxHeap = getMaxOldSpaceSize();
333
+ const usagePct = Math.round(heapUsed / maxHeap * 100);
334
+
335
+ let status = 'healthy';
336
+ if (usagePct > 85) {
337
+ status = 'critical';
338
+ } else if (usagePct > 70) {
339
+ status = 'warning';
340
+ }
341
+
342
+ return { status, usage_pct: usagePct };
343
+ }
344
+
345
+ function getMaxOldSpaceSize() {
346
+ // Parse --max-old-space-size from process args, default 1.4GB
347
+ const flag = process.execArgv.find(a => a.startsWith('--max-old-space-size'));
348
+ if (flag) {
349
+ const mb = parseInt(flag.split('=')[1], 10);
350
+ if (mb > 0) return mb * 1024 * 1024;
351
+ }
352
+ return 1.4 * 1024 * 1024 * 1024;
353
+ }
354
+
329
355
  module.exports = {
330
356
  getStatus,
331
357
  getAuditEntries,
@@ -333,5 +359,6 @@ module.exports = {
333
359
  getApprovals,
334
360
  getTeamActivity,
335
361
  getMemory,
336
- getCosts
362
+ getCosts,
363
+ checkHeapHealth
337
364
  };