mindforge-cc 2.0.0-alpha.4 → 2.0.0-alpha.7

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 (39) hide show
  1. package/.agent/CLAUDE.md +37 -7
  2. package/.agent/mindforge/dashboard.md +98 -0
  3. package/.agent/mindforge/init-project.md +12 -0
  4. package/.claude/CLAUDE.md +37 -7
  5. package/.claude/commands/mindforge/dashboard.md +98 -0
  6. package/.mindforge/dashboard/api-reference.md +122 -0
  7. package/.mindforge/dashboard/dashboard-spec.md +96 -0
  8. package/.planning/approvals/v2-architecture-approval.json +15 -0
  9. package/CHANGELOG.md +12 -2
  10. package/README.md +18 -2
  11. package/RELEASENOTES.md +1 -1
  12. package/bin/change-classifier.js +86 -0
  13. package/bin/dashboard/api-router.js +198 -0
  14. package/bin/dashboard/approval-handler.js +134 -0
  15. package/bin/dashboard/frontend/index.html +511 -0
  16. package/bin/dashboard/metrics-aggregator.js +296 -0
  17. package/bin/dashboard/server.js +135 -0
  18. package/bin/dashboard/sse-bridge.js +178 -0
  19. package/bin/dashboard/team-tracker.js +0 -0
  20. package/bin/governance/approve.js +60 -0
  21. package/bin/installer-core.js +68 -12
  22. package/bin/mindforge-cli.js +87 -0
  23. package/bin/wizard/setup-wizard.js +5 -1
  24. package/docs/Context/Master-Context.md +11 -11
  25. package/docs/architecture/README.md +2 -0
  26. package/docs/architecture/decision-records-index.md +20 -20
  27. package/docs/ci-cd.md +92 -0
  28. package/docs/commands-reference.md +1 -0
  29. package/docs/enterprise-setup.md +1 -1
  30. package/docs/feature-dashboard.md +52 -0
  31. package/docs/publishing-guide.md +16 -51
  32. package/docs/reference/commands.md +42 -42
  33. package/docs/reference/config-reference.md +2 -2
  34. package/docs/reference/sdk-api.md +1 -1
  35. package/docs/testing-current-version.md +130 -0
  36. package/docs/user-guide.md +24 -2
  37. package/docs/usp-features.md +15 -1
  38. package/docs/workflow-atlas.md +57 -0
  39. package/package.json +5 -2
@@ -0,0 +1,296 @@
1
+ /**
2
+ * MindForge v2 — Metrics Aggregator
3
+ * Reads .mindforge/metrics/ and .planning/ files and produces
4
+ * structured metrics for the dashboard API.
5
+ */
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ // Paths are resolved lazily to support testing in temp directories
12
+ const getPaths = () => ({
13
+ quality: path.join(process.cwd(), '.mindforge', 'metrics', 'session-quality.jsonl'),
14
+ usage: path.join(process.cwd(), '.mindforge', 'metrics', 'token-usage.jsonl'),
15
+ audit: path.join(process.cwd(), '.planning', 'AUDIT.jsonl'),
16
+ handoff: path.join(process.cwd(), '.planning', 'HANDOFF.json'),
17
+ auto: path.join(process.cwd(), '.planning', 'auto-state.json'),
18
+ approvals: path.join(process.cwd(), '.planning', 'approvals'),
19
+ team: path.join(process.cwd(), '.planning', 'TEAM-STATE.jsonl'),
20
+ kb: path.join(process.cwd(), '.mindforge', 'memory', 'knowledge-base.jsonl'),
21
+ project: path.join(process.cwd(), '.planning', 'PROJECT.md'),
22
+ });
23
+
24
+ function readJSONL(filePath, limit = 500) {
25
+ if (!fs.existsSync(filePath)) return [];
26
+ return fs.readFileSync(filePath, 'utf8')
27
+ .split('\n').filter(Boolean).slice(-limit)
28
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
29
+ .filter(Boolean);
30
+ }
31
+
32
+ function readJSON(filePath) {
33
+ if (!fs.existsSync(filePath)) return null;
34
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
35
+ }
36
+
37
+ // ── Status ────────────────────────────────────────────────────────────────────
38
+ function getStatus() {
39
+ const paths = getPaths();
40
+ const handoff = readJSON(paths.handoff);
41
+ const autoState = readJSON(paths.auto);
42
+
43
+ // Read project name from PROJECT.md
44
+ let projectName = 'MindForge Project';
45
+ const projectMd = paths.project;
46
+ if (fs.existsSync(projectMd)) {
47
+ const m = fs.readFileSync(projectMd, 'utf8').match(/^# (.+)/m);
48
+ if (m) projectName = m[1].trim();
49
+ }
50
+
51
+ return {
52
+ project_name: projectName,
53
+ phase: handoff?.current_phase ?? null,
54
+ phase_description: handoff?.phase_description ?? null,
55
+ auto_mode: autoState?.auto_mode_active ?? false,
56
+ auto_status: autoState?.status ?? 'idle',
57
+ current_task: autoState?.current_task ?? handoff?.next_task ?? null,
58
+ wave_current: autoState?.wave_current ?? null,
59
+ wave_total: autoState?.wave_total ?? null,
60
+ tasks_completed: autoState?.tasks_completed ?? null,
61
+ tasks_total: autoState?.tasks_total ?? null,
62
+ elapsed_ms: autoState?.elapsed_ms ?? null,
63
+ node_repairs: autoState?.node_repairs ?? 0,
64
+ escalations: autoState?.escalations ?? 0,
65
+ last_commit: autoState?.last_commit ?? null,
66
+ last_event_at: handoff?.last_updated ?? null,
67
+ schema_version: handoff?.schema_version ?? null,
68
+ };
69
+ }
70
+
71
+ // ── Audit ─────────────────────────────────────────────────────────────────────
72
+ function getAuditEntries(limit = 50, offset = 0, eventFilter = null) {
73
+ const paths = getPaths();
74
+ const all = readJSONL(paths.audit, 1000);
75
+ const reversed = all.reverse(); // Newest first
76
+
77
+ const filtered = eventFilter
78
+ ? reversed.filter(e => e.event === eventFilter)
79
+ : reversed;
80
+
81
+ return {
82
+ entries: filtered.slice(offset, offset + limit),
83
+ total: filtered.length,
84
+ limit,
85
+ offset,
86
+ };
87
+ }
88
+
89
+ // ── Metrics ───────────────────────────────────────────────────────────────────
90
+ function getMetrics() {
91
+ const paths = getPaths();
92
+ const qualityEntries = readJSONL(paths.quality, 20);
93
+ const usageEntries = readJSONL(paths.usage, 200);
94
+ const auditEntries = readJSONL(paths.audit, 500);
95
+
96
+ // Quality scores (last 20 sessions)
97
+ const sessions = qualityEntries.map(e => ({
98
+ id: e.session_id,
99
+ timestamp: e.timestamp,
100
+ quality_score: e.quality_score ?? 0,
101
+ verify_pass_rate: e.verify_pass_rate ?? 0,
102
+ cost_usd: e.total_cost_usd ?? 0,
103
+ node_repairs: e.node_repairs ?? 0,
104
+ }));
105
+
106
+ const avg_quality = sessions.length
107
+ ? sessions.reduce((s, e) => s + e.quality_score, 0) / sessions.length : 0;
108
+ const avg_cost_usd = sessions.length
109
+ ? sessions.reduce((s, e) => s + e.cost_usd, 0) / sessions.length : 0;
110
+
111
+ // Security findings from AUDIT
112
+ const securityFindings = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
113
+ auditEntries
114
+ .filter(e => e.event === 'security_finding')
115
+ .forEach(e => {
116
+ const sev = e.severity || 'LOW';
117
+ securityFindings[sev] = (securityFindings[sev] || 0) + 1;
118
+ });
119
+
120
+ // Node repair rate
121
+ const taskEvents = auditEntries.filter(e => e.event === 'task_completed' || e.event === 'task_failed');
122
+ const repairEvents = auditEntries.filter(e => e.event === 'node_repair');
123
+ const node_repair_rate = taskEvents.length
124
+ ? repairEvents.length / taskEvents.length : 0;
125
+
126
+ return {
127
+ sessions,
128
+ avg_quality: Math.round(avg_quality * 100) / 100,
129
+ avg_cost_usd: Math.round(avg_cost_usd * 10000) / 10000,
130
+ security_findings: securityFindings,
131
+ node_repair_rate: Math.round(node_repair_rate * 100) / 100,
132
+ total_tasks: taskEvents.filter(e => e.event === 'task_completed').length,
133
+ };
134
+ }
135
+
136
+ // ── Approvals ─────────────────────────────────────────────────────────────────
137
+ function getApprovals() {
138
+ const paths = getPaths();
139
+ if (!fs.existsSync(paths.approvals)) return { pending: [], approved: [], rejected: [], expired: [] };
140
+
141
+ const now = Date.now();
142
+ const files = fs.readdirSync(paths.approvals)
143
+ .filter(f => f.startsWith('APPROVAL-') && f.endsWith('.json'))
144
+ .sort();
145
+
146
+ const pending = [];
147
+ const approved = [];
148
+ const rejected = [];
149
+ const expired = [];
150
+
151
+ for (const f of files) {
152
+ try {
153
+ const data = JSON.parse(fs.readFileSync(path.join(paths.approvals, f), 'utf8'));
154
+ const expiry = data.expires_at ? new Date(data.expires_at).getTime() : Infinity;
155
+ const hoursRemaining = expiry === Infinity ? null : (expiry - now) / 3_600_000;
156
+
157
+ const enriched = { ...data, hours_remaining: hoursRemaining };
158
+
159
+ if (data.status === 'approved') approved.push(enriched);
160
+ else if (data.status === 'rejected') rejected.push(enriched);
161
+ else if (expiry < now) expired.push({ ...enriched, status: 'expired' });
162
+ else pending.push(enriched);
163
+ } catch { /* skip corrupt files */ }
164
+ }
165
+
166
+ return { pending, approved, rejected, expired };
167
+ }
168
+
169
+ // ── Team activity ─────────────────────────────────────────────────────────────
170
+ function getTeamActivity() {
171
+ const paths = getPaths();
172
+ const auditEntries = readJSONL(paths.audit, 200);
173
+
174
+ // Group by author (git email from session_id or authored_by field)
175
+ const byAuthor = {};
176
+ for (const entry of auditEntries) {
177
+ const author = entry.authored_by || entry.session_id || 'unknown';
178
+ if (!byAuthor[author] || entry.timestamp > byAuthor[author].last_seen) {
179
+ byAuthor[author] = {
180
+ email: author,
181
+ last_seen: entry.timestamp,
182
+ current_task: entry.plan ? `Plan ${entry.phase}-${entry.plan}` : null,
183
+ event: entry.event,
184
+ };
185
+ }
186
+ }
187
+
188
+ const now = Date.now();
189
+ const active = Object.values(byAuthor)
190
+ .map(a => ({
191
+ ...a,
192
+ last_seen_mins: Math.round((now - new Date(a.last_seen).getTime()) / 60_000),
193
+ }))
194
+ .filter(a => a.last_seen_mins < 120) // Active in last 2 hours
195
+ .sort((a, b) => a.last_seen_mins - b.last_seen_mins);
196
+
197
+ // Conflict detection — two authors recently touching same file
198
+ const conflicts = detectFileConflicts(auditEntries);
199
+
200
+ return { active, conflicts };
201
+ }
202
+
203
+ function detectFileConflicts(auditEntries) {
204
+ const fileToAuthors = {};
205
+
206
+ for (const entry of auditEntries.slice(-100)) {
207
+ if (!entry.files_modified) continue;
208
+ const author = entry.authored_by || entry.session_id;
209
+ if (!author) continue;
210
+
211
+ const files = Array.isArray(entry.files_modified) ? entry.files_modified : [entry.files_modified];
212
+ for (const f of files) {
213
+ if (!fileToAuthors[f]) fileToAuthors[f] = new Set();
214
+ fileToAuthors[f].add(author);
215
+ }
216
+ }
217
+
218
+ return Object.entries(fileToAuthors)
219
+ .filter(([, authors]) => authors.size > 1)
220
+ .map(([file, authors]) => ({ file, developers: [...authors] }));
221
+ }
222
+
223
+ // ── Memory ────────────────────────────────────────────────────────────────────
224
+ function getMemory(query = '', limit = 20) {
225
+ const paths = getPaths();
226
+ if (!fs.existsSync(paths.kb)) return { entries: [], total: 0 };
227
+
228
+ const lines = fs.readFileSync(paths.kb, 'utf8').split('\n').filter(Boolean);
229
+ const byId = new Map();
230
+ for (const l of lines) {
231
+ try { const e = JSON.parse(l); byId.set(e.id, e); } catch { /* skip */ }
232
+ }
233
+
234
+ let entries = [...byId.values()].filter(e => !e.deprecated);
235
+
236
+ if (query) {
237
+ const q = query.toLowerCase();
238
+ entries = entries.filter(e =>
239
+ e.topic.toLowerCase().includes(q) ||
240
+ e.content.toLowerCase().includes(q) ||
241
+ (e.tags || []).some(t => t.toLowerCase().includes(q))
242
+ );
243
+ }
244
+
245
+ entries.sort((a, b) => b.confidence - a.confidence);
246
+
247
+ return { entries: entries.slice(0, limit), total: entries.length };
248
+ }
249
+
250
+ // ── Costs ─────────────────────────────────────────────────────────────────────
251
+ function getCosts(windowDays = 7) {
252
+ const paths = getPaths();
253
+ const entries = readJSONL(paths.usage, 1000);
254
+ const cutoff = new Date(Date.now() - windowDays * 86_400_000).toISOString().slice(0, 10);
255
+ const today = new Date().toISOString().slice(0, 10);
256
+
257
+ const stats = {
258
+ total_usd: 0,
259
+ today_usd: 0,
260
+ by_model: {},
261
+ daily_limit: 10.00, // Default for viz
262
+ };
263
+
264
+ for (const e of entries) {
265
+ if (e.timestamp < cutoff) continue;
266
+
267
+ const cost = e.total_cost_usd || 0;
268
+ stats.total_usd += cost;
269
+
270
+ if (e.timestamp && e.timestamp.startsWith(today)) {
271
+ stats.today_usd += cost;
272
+ }
273
+
274
+ const model = e.model || 'unknown';
275
+ stats.by_model[model] = (stats.by_model[model] || 0) + cost;
276
+ }
277
+
278
+ // Cleanup numbers
279
+ stats.total_usd = Math.round(stats.total_usd * 100) / 100;
280
+ stats.today_usd = Math.round(stats.today_usd * 100) / 100;
281
+ for (const m in stats.by_model) {
282
+ stats.by_model[m] = Math.round(stats.by_model[m] * 100) / 100;
283
+ }
284
+
285
+ return stats;
286
+ }
287
+
288
+ module.exports = {
289
+ getStatus,
290
+ getAuditEntries,
291
+ getMetrics,
292
+ getApprovals,
293
+ getTeamActivity,
294
+ getMemory,
295
+ getCosts
296
+ };
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MindForge v2 — Dashboard Server
4
+ * Real-time web observability at localhost:7339.
5
+ *
6
+ * Usage:
7
+ * node bin/dashboard/server.js [--port 7339] [--open]
8
+ * /mindforge:dashboard [--port 7339] [--open] [--stop]
9
+ *
10
+ * Security: binds to 127.0.0.1 only (ADR-017 policy).
11
+ * No authentication — localhost-only access is the security model.
12
+ */
13
+ 'use strict';
14
+
15
+ const http = require('http');
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+ const ARGS = process.argv.slice(2);
19
+
20
+ const PORT = parseInt(ARGS.find((_, i, a) => a[i-1] === '--port') || '7339', 10);
21
+ const OPEN_BROWSER = ARGS.includes('--open');
22
+ const PID_FILE = path.join(process.cwd(), '.planning', 'dashboard-server.pid');
23
+ const FRONTEND = path.join(__dirname, 'frontend', 'index.html');
24
+
25
+ // ── Load dependencies gracefully ──────────────────────────────────────────────
26
+ let express;
27
+ try {
28
+ express = require('express');
29
+ } catch {
30
+ console.error('[dashboard] express not installed. Run: npm install express');
31
+ process.exit(1);
32
+ }
33
+
34
+ const SSE = require('./sse-bridge');
35
+ const API = require('./api-router');
36
+
37
+ // ── Express app ───────────────────────────────────────────────────────────────
38
+ const app = express();
39
+
40
+ // Security middleware
41
+ app.use((req, res, next) => {
42
+ const addr = req.socket.remoteAddress;
43
+ const isLocal = addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
44
+ if (!isLocal) {
45
+ return res.status(403).json({ error: 'Dashboard is localhost-only' });
46
+ }
47
+ next();
48
+ });
49
+
50
+ // CORS — only allow requests from localhost origins
51
+ app.use((req, res, next) => {
52
+ const origin = req.headers.origin;
53
+
54
+ if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
55
+ // Explicit localhost origin — set CORS headers
56
+ res.setHeader('Access-Control-Allow-Origin', origin);
57
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
58
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
59
+ res.setHeader('Vary', 'Origin'); // Important: vary by origin for caching
60
+ }
61
+ // No origin header (same-origin/curl/postman): don't set CORS headers
62
+ // This is correct — same-origin requests don't need CORS headers
63
+ if (req.method === 'OPTIONS') return res.status(204).end();
64
+ next();
65
+ });
66
+
67
+ app.use(express.json({ limit: '64kb' })); // Limit request body size
68
+
69
+ // Security headers
70
+ app.use((req, res, next) => {
71
+ res.setHeader('X-Content-Type-Options', 'nosniff');
72
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
73
+ res.setHeader('Cache-Control', 'no-store'); // Never cache dashboard responses
74
+ next();
75
+ });
76
+
77
+ // ── Static frontend ───────────────────────────────────────────────────────────
78
+ app.get('/', (req, res) => {
79
+ if (!fs.existsSync(FRONTEND)) {
80
+ return res.status(503).send('<h1>Dashboard frontend not found</h1><p>Run: npm run build:dashboard</p>');
81
+ }
82
+ res.sendFile(FRONTEND);
83
+ });
84
+
85
+ // ── Register API routes ───────────────────────────────────────────────────────
86
+ API.register(app);
87
+
88
+ // ── Start SSE bridge ──────────────────────────────────────────────────────────
89
+ SSE.start();
90
+
91
+ // ── HTTP server ───────────────────────────────────────────────────────────────
92
+ const server = http.createServer(app);
93
+
94
+ server.listen(PORT, '127.0.0.1', () => {
95
+ fs.mkdirSync(path.dirname(PID_FILE), { recursive: true });
96
+ fs.writeFileSync(PID_FILE, String(process.pid));
97
+
98
+ console.log(`\n⚡ MindForge Dashboard`);
99
+ console.log(` URL: http://localhost:${PORT}`);
100
+ console.log(` Status: http://localhost:${PORT}/api/status`);
101
+ console.log(` Events: http://localhost:${PORT}/events`);
102
+ console.log(` PID: ${process.pid}`);
103
+ console.log(`\n Press CTRL+C to stop\n`);
104
+
105
+ if (OPEN_BROWSER) {
106
+ const open = process.platform === 'darwin' ? 'open'
107
+ : process.platform === 'win32' ? 'start'
108
+ : 'xdg-open';
109
+ const { spawn } = require('child_process');
110
+ spawn(open, [`http://localhost:${PORT}`], { detached: true, stdio: 'ignore' });
111
+ }
112
+ });
113
+
114
+ server.on('error', err => {
115
+ if (err.code === 'EADDRINUSE') {
116
+ console.error(`[dashboard] Port ${PORT} already in use.`);
117
+ console.error(`[dashboard] Stop it: /mindforge:dashboard --stop`);
118
+ console.error(`[dashboard] Or use a different port: /mindforge:dashboard --port 7340`);
119
+ }
120
+ process.exit(1);
121
+ });
122
+
123
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
124
+ function shutdown(signal) {
125
+ console.log(`\n[dashboard] ${signal} received — shutting down`);
126
+ SSE.stop();
127
+ server.close(() => {
128
+ if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
129
+ process.exit(0);
130
+ });
131
+ setTimeout(() => process.exit(0), 3000);
132
+ }
133
+
134
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
135
+ process.on('SIGINT', () => shutdown('SIGINT'));
@@ -0,0 +1,178 @@
1
+ /**
2
+ * MindForge v2 — SSE Event Bridge
3
+ * Tails AUDIT.jsonl and auto-state.json and pushes changes
4
+ * to all connected SSE clients.
5
+ *
6
+ * Design:
7
+ * - Uses fs.watchFile() for cross-platform file watching (not fs.watch)
8
+ * - Each connected client gets a Response object stored in a Set
9
+ * - Events are broadcast to ALL connected clients on every file change
10
+ * - Keepalive ping every 15 seconds to detect disconnected clients
11
+ */
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const AUDIT_PATH = path.join(process.cwd(), '.planning', 'AUDIT.jsonl');
18
+ const AUTO_STATE_PATH = path.join(process.cwd(), '.planning', 'auto-state.json');
19
+ const APPROVAL_DIR = path.join(process.cwd(), '.planning', 'approvals');
20
+
21
+ const clients = new Set(); // Connected SSE response objects
22
+
23
+ let _lastAuditSize = 0;
24
+ let _auditInode = 0; // Track file inode for rotation detection
25
+ let _lastAutoState = '';
26
+ let _lastApprovals = '';
27
+
28
+ // ── Client management ─────────────────────────────────────────────────────────
29
+
30
+ function addClient(res) {
31
+ clients.add(res);
32
+ res.on('close', () => {
33
+ clients.delete(res);
34
+ });
35
+ }
36
+
37
+ function broadcast(eventName, data) {
38
+ const message = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
39
+ const toRemove = [];
40
+
41
+ for (const res of clients) {
42
+ try {
43
+ res.write(message);
44
+ } catch (err) {
45
+ // Connection died ungracefully (EPIPE, ECONNRESET, etc.)
46
+ toRemove.push(res);
47
+ }
48
+ }
49
+
50
+ // Remove dead clients outside iteration
51
+ for (const res of toRemove) {
52
+ clients.delete(res);
53
+ }
54
+ }
55
+
56
+ function broadcastRaw(message) {
57
+ for (const res of clients) {
58
+ try {
59
+ res.write(message);
60
+ } catch {
61
+ clients.delete(res);
62
+ }
63
+ }
64
+ }
65
+
66
+ // ── File tail: AUDIT.jsonl ────────────────────────────────────────────────────
67
+
68
+ function pollAuditLog() {
69
+ if (!fs.existsSync(AUDIT_PATH)) return;
70
+
71
+ try {
72
+ const stat = fs.statSync(AUDIT_PATH);
73
+ const newSize = stat.size;
74
+ const newIno = stat.ino;
75
+
76
+ // File rotation detected: inode changed or new file is smaller
77
+ if (newIno !== _auditInode && _auditInode !== 0) {
78
+ process.stderr.write(`[sse-bridge] AUDIT.jsonl rotation detected (old ino: ${_auditInode}, new ino: ${newIno})\n`);
79
+ _lastAuditSize = 0;
80
+ }
81
+ _auditInode = newIno;
82
+
83
+ if (newSize <= _lastAuditSize) return; // No new data
84
+
85
+ // Read only the new bytes appended since last poll
86
+ const fd = fs.openSync(AUDIT_PATH, 'r');
87
+ const chunk = Buffer.alloc(newSize - _lastAuditSize);
88
+ fs.readSync(fd, chunk, 0, chunk.length, _lastAuditSize);
89
+ fs.closeSync(fd);
90
+ _lastAuditSize = newSize;
91
+
92
+ // Parse new lines
93
+ const newLines = chunk.toString().split('\n').filter(Boolean);
94
+ for (const line of newLines) {
95
+ try {
96
+ const entry = JSON.parse(line);
97
+ broadcast('audit:new', entry);
98
+ } catch { /* skip malformed */ }
99
+ }
100
+ } catch { /* ignore read errors — file may be locked */ }
101
+ }
102
+
103
+ // ── File poll: auto-state.json ────────────────────────────────────────────────
104
+
105
+ function pollAutoState() {
106
+ if (!fs.existsSync(AUTO_STATE_PATH)) return;
107
+
108
+ try {
109
+ const raw = fs.readFileSync(AUTO_STATE_PATH, 'utf8');
110
+ if (raw === _lastAutoState) return;
111
+ _lastAutoState = raw;
112
+ const state = JSON.parse(raw);
113
+ broadcast('status:update', state);
114
+ } catch { /* ignore */ }
115
+ }
116
+
117
+ // ── File poll: approval directory ─────────────────────────────────────────────
118
+
119
+ function pollApprovals() {
120
+ if (!fs.existsSync(APPROVAL_DIR)) return;
121
+
122
+ try {
123
+ const files = fs.readdirSync(APPROVAL_DIR)
124
+ .filter(f => f.startsWith('APPROVAL-') && f.endsWith('.json'))
125
+ .sort();
126
+ const key = files.join(',');
127
+ if (key === _lastApprovals) return;
128
+ _lastApprovals = key;
129
+
130
+ // Find new pending approvals
131
+ for (const f of files) {
132
+ try {
133
+ const data = JSON.parse(fs.readFileSync(path.join(APPROVAL_DIR, f), 'utf8'));
134
+ if (data.status === 'pending') {
135
+ broadcast('approval:new', data);
136
+ }
137
+ } catch { /* skip */ }
138
+ }
139
+ } catch { /* ignore */ }
140
+ }
141
+
142
+ // ── Keepalive ─────────────────────────────────────────────────────────────────
143
+
144
+ let _pollInterval = null;
145
+ let _pingInterval = null;
146
+
147
+ function start() {
148
+ // Initialize AUDIT position
149
+ if (fs.existsSync(AUDIT_PATH)) {
150
+ const stat = fs.statSync(AUDIT_PATH);
151
+ _lastAuditSize = stat.size;
152
+ _auditInode = stat.ino;
153
+ }
154
+
155
+ // Poll every 2 seconds
156
+ _pollInterval = setInterval(() => {
157
+ pollAuditLog();
158
+ pollAutoState();
159
+ pollApprovals();
160
+ }, 2000);
161
+
162
+ // Keepalive ping every 15 seconds
163
+ _pingInterval = setInterval(() => {
164
+ broadcastRaw(`: ping\n\n`);
165
+ }, 15_000);
166
+
167
+ _pollInterval.unref();
168
+ _pingInterval.unref();
169
+ }
170
+
171
+ function stop() {
172
+ if (_pollInterval) { clearInterval(_pollInterval); _pollInterval = null; }
173
+ if (_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; }
174
+ }
175
+
176
+ function getClientCount() { return clients.size; }
177
+
178
+ module.exports = { addClient, broadcast, start, stop, getClientCount };
File without changes
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MindForge Governance — Approval Signature Generator
4
+ * Usage: node bin/governance/approve.js "Reason for approval"
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const crypto = require('crypto');
13
+
14
+ const REASON = process.argv[2] || 'Manual approval for sensitive changes.';
15
+ const ROOT = path.resolve(__dirname, '../../');
16
+ const APPROVALS_DIR = path.join(ROOT, '.planning/approvals');
17
+
18
+ if (!fs.existsSync(APPROVALS_DIR)) {
19
+ fs.mkdirSync(APPROVALS_DIR, { recursive: true });
20
+ }
21
+
22
+ async function approve() {
23
+ const pkgPath = path.join(ROOT, 'package.json');
24
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
25
+
26
+ const id = `MF-AUTH-${Date.now().toString(36).toUpperCase()}`;
27
+ const timestamp = new Date().toISOString();
28
+
29
+ // Calculate a mock signature based on current state (can be hardened with real crypto sign later)
30
+ const signature = crypto.createHash('sha256')
31
+ .update(`${id}:${REASON}:${timestamp}:${os.hostname()}`)
32
+ .digest('hex');
33
+
34
+ const record = {
35
+ id,
36
+ project: pkg.name,
37
+ version: pkg.version,
38
+ tier: 3,
39
+ approved_by: process.env.USER || 'MindForge User',
40
+ timestamp,
41
+ reason: REASON,
42
+ signature: `sha256:${signature}`
43
+ };
44
+
45
+ const filename = `approval-${id.toLowerCase()}.json`;
46
+ const filePath = path.join(APPROVALS_DIR, filename);
47
+
48
+ fs.writeFileSync(filePath, JSON.stringify(record, null, 2));
49
+
50
+ console.log(`\n✅ Governance approval generated!\n`);
51
+ console.log(`ID: ${id}`);
52
+ console.log(`Reason: ${REASON}`);
53
+ console.log(`File: .planning/approvals/${filename}`);
54
+ console.log(`\nCommit this file to unblock Tier 3 gates in CI.\n`);
55
+ }
56
+
57
+ approve().catch(err => {
58
+ console.error(`❌ Approval failed: ${err.message}`);
59
+ process.exit(1);
60
+ });