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.
- package/.agent/CLAUDE.md +37 -7
- package/.agent/mindforge/dashboard.md +98 -0
- package/.agent/mindforge/init-project.md +12 -0
- package/.claude/CLAUDE.md +37 -7
- package/.claude/commands/mindforge/dashboard.md +98 -0
- package/.mindforge/dashboard/api-reference.md +122 -0
- package/.mindforge/dashboard/dashboard-spec.md +96 -0
- package/.planning/approvals/v2-architecture-approval.json +15 -0
- package/CHANGELOG.md +12 -2
- package/README.md +18 -2
- package/RELEASENOTES.md +1 -1
- package/bin/change-classifier.js +86 -0
- package/bin/dashboard/api-router.js +198 -0
- package/bin/dashboard/approval-handler.js +134 -0
- package/bin/dashboard/frontend/index.html +511 -0
- package/bin/dashboard/metrics-aggregator.js +296 -0
- package/bin/dashboard/server.js +135 -0
- package/bin/dashboard/sse-bridge.js +178 -0
- package/bin/dashboard/team-tracker.js +0 -0
- package/bin/governance/approve.js +60 -0
- package/bin/installer-core.js +68 -12
- package/bin/mindforge-cli.js +87 -0
- package/bin/wizard/setup-wizard.js +5 -1
- package/docs/Context/Master-Context.md +11 -11
- package/docs/architecture/README.md +2 -0
- package/docs/architecture/decision-records-index.md +20 -20
- package/docs/ci-cd.md +92 -0
- package/docs/commands-reference.md +1 -0
- package/docs/enterprise-setup.md +1 -1
- package/docs/feature-dashboard.md +52 -0
- package/docs/publishing-guide.md +16 -51
- package/docs/reference/commands.md +42 -42
- package/docs/reference/config-reference.md +2 -2
- package/docs/reference/sdk-api.md +1 -1
- package/docs/testing-current-version.md +130 -0
- package/docs/user-guide.md +24 -2
- package/docs/usp-features.md +15 -1
- package/docs/workflow-atlas.md +57 -0
- 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
|
+
});
|