buildcrew 1.8.7 → 1.9.1
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/README.md +129 -1
- package/agents/architect.md +26 -0
- package/agents/browser-qa.md +29 -0
- package/agents/buildcrew.md +31 -3
- package/agents/canary-monitor.md +22 -0
- package/agents/coherence-auditor.md +347 -0
- package/agents/design-reviewer.md +36 -0
- package/agents/designer.md +29 -0
- package/agents/developer.md +34 -0
- package/agents/health-checker.md +23 -0
- package/agents/investigator.md +39 -0
- package/agents/planner.md +26 -0
- package/agents/qa-auditor.md +32 -0
- package/agents/qa-tester.md +29 -0
- package/agents/reviewer.md +35 -0
- package/agents/security-auditor.md +23 -0
- package/agents/shipper.md +23 -0
- package/agents/thinker.md +32 -0
- package/bin/hook.js +17 -0
- package/bin/setup.js +166 -7
- package/bin/watch.js +594 -0
- package/lib/hook.js +230 -0
- package/lib/install-hooks.js +165 -0
- package/package.json +7 -3
package/bin/watch.js
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* buildcrew watch — terminal-native live monitor.
|
|
4
|
+
*
|
|
5
|
+
* Tails .claude/buildcrew/events.jsonl (written by the CC hook) and renders
|
|
6
|
+
* a compact live status pane in the user's terminal. Zero runtime deps —
|
|
7
|
+
* just ANSI + node:fs.
|
|
8
|
+
*
|
|
9
|
+
* Usage: npx buildcrew watch
|
|
10
|
+
*
|
|
11
|
+
* Exit with q or Ctrl-C.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createReadStream, watchFile, statSync, existsSync, mkdirSync, closeSync, openSync, readdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import { join, resolve, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import readline, { createInterface } from "node:readline";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
const EVENTS_PATH = process.env.BUILDCREW_EVENTS_PATH
|
|
23
|
+
?? join(process.cwd(), ".claude", "buildcrew", "events.jsonl");
|
|
24
|
+
|
|
25
|
+
// ------------------------------------------------------------------
|
|
26
|
+
// ANSI helpers
|
|
27
|
+
// ------------------------------------------------------------------
|
|
28
|
+
const NO_COLOR = !!process.env.NO_COLOR;
|
|
29
|
+
const c = NO_COLOR
|
|
30
|
+
? new Proxy({}, { get: () => "" })
|
|
31
|
+
: {
|
|
32
|
+
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
|
|
33
|
+
black: "\x1b[30m", red: "\x1b[31m", green: "\x1b[32m",
|
|
34
|
+
gold: "\x1b[33m", blue: "\x1b[34m", mag: "\x1b[35m",
|
|
35
|
+
cyan: "\x1b[36m", gray: "\x1b[90m",
|
|
36
|
+
bgWood: "\x1b[48;5;94m",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const CLEAR = "\x1b[2J\x1b[H";
|
|
40
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
41
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
42
|
+
|
|
43
|
+
// ------------------------------------------------------------------
|
|
44
|
+
// Agent roster (matches dashboard/client/scenes/TownScene.js)
|
|
45
|
+
// ------------------------------------------------------------------
|
|
46
|
+
const AGENTS = [
|
|
47
|
+
{ id: "buildcrew", room: "Meeting", emoji: "🎩" },
|
|
48
|
+
{ id: "planner", room: "Meeting", emoji: "📋" },
|
|
49
|
+
{ id: "designer", room: "Meeting", emoji: "🎨" },
|
|
50
|
+
{ id: "developer", room: "Meeting", emoji: "💻" },
|
|
51
|
+
{ id: "qa-tester", room: "QA Lab", emoji: "🧪" },
|
|
52
|
+
{ id: "browser-qa", room: "QA Lab", emoji: "🌐" },
|
|
53
|
+
{ id: "reviewer", room: "QA Lab", emoji: "🧐" },
|
|
54
|
+
{ id: "health-checker", room: "QA Lab", emoji: "🩺" },
|
|
55
|
+
{ id: "security-auditor", room: "SecOps", emoji: "🛡" },
|
|
56
|
+
{ id: "canary-monitor", room: "SecOps", emoji: "🐤" },
|
|
57
|
+
{ id: "shipper", room: "SecOps", emoji: "🚢" },
|
|
58
|
+
{ id: "thinker", room: "Think Tank", emoji: "🤔" },
|
|
59
|
+
{ id: "architect", room: "Think Tank", emoji: "📐" },
|
|
60
|
+
{ id: "design-reviewer", room: "Think Tank", emoji: "👀" },
|
|
61
|
+
{ id: "investigator", room: "Field", emoji: "🕵" },
|
|
62
|
+
{ id: "qa-auditor", room: "Field", emoji: "⚖" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const STAGES = ["PLAN", "DESIGN", "DEV", "QA", "REVIEW", "SHIP"];
|
|
66
|
+
const AGENT_STAGE = {
|
|
67
|
+
planner: "PLAN", thinker: "PLAN",
|
|
68
|
+
designer: "DESIGN", "design-reviewer": "DESIGN", architect: "DESIGN",
|
|
69
|
+
developer: "DEV",
|
|
70
|
+
"qa-tester": "QA", "browser-qa": "QA", "health-checker": "QA",
|
|
71
|
+
"qa-auditor": "QA", "security-auditor": "QA", investigator: "QA",
|
|
72
|
+
"canary-monitor": "QA",
|
|
73
|
+
reviewer: "REVIEW",
|
|
74
|
+
shipper: "SHIP",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ------------------------------------------------------------------
|
|
78
|
+
// State
|
|
79
|
+
// ------------------------------------------------------------------
|
|
80
|
+
const state = {
|
|
81
|
+
connected: false,
|
|
82
|
+
currentStage: null,
|
|
83
|
+
completedStages: new Set(),
|
|
84
|
+
activeAgents: new Map(), // id → { startAt, prompt }
|
|
85
|
+
completedAgents: new Map(), // id → { lastAt, duration, summary }
|
|
86
|
+
events: 0,
|
|
87
|
+
files: 0,
|
|
88
|
+
issues: { critical: 0, high: 0, med: 0, low: 0 },
|
|
89
|
+
recent: [], // last ~10 events
|
|
90
|
+
recentFiles: [], // last ~6 { path, tool, agent, at }
|
|
91
|
+
recentIssues: [], // last ~5 { severity, title, at }
|
|
92
|
+
sessionId: null,
|
|
93
|
+
sessionStartAt: null,
|
|
94
|
+
sessionEndAt: null,
|
|
95
|
+
// Coherence: loaded from .claude/pipeline/*/coherence-report.md after coherence-auditor runs
|
|
96
|
+
coherence: null, // { score, status, feature, gaps, fabrications, edgesActual, edgesPossible, path, ts }
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ------------------------------------------------------------------
|
|
100
|
+
// Coherence report loader — reads .claude/pipeline/{feature}/coherence-report.md
|
|
101
|
+
// Triggered on agent.completed(coherence-auditor) or file.written(*/coherence-report.md)
|
|
102
|
+
// ------------------------------------------------------------------
|
|
103
|
+
function loadLatestCoherence() {
|
|
104
|
+
try {
|
|
105
|
+
const pipelineDir = join(process.cwd(), ".claude", "pipeline");
|
|
106
|
+
if (!existsSync(pipelineDir)) return;
|
|
107
|
+
const features = readdirSync(pipelineDir, { withFileTypes: true }).filter(d => d.isDirectory());
|
|
108
|
+
let newest = null;
|
|
109
|
+
for (const f of features) {
|
|
110
|
+
const p = join(pipelineDir, f.name, "coherence-report.md");
|
|
111
|
+
if (!existsSync(p)) continue;
|
|
112
|
+
const s = statSync(p);
|
|
113
|
+
if (!newest || s.mtimeMs > newest.mtime) {
|
|
114
|
+
newest = { path: p, feature: f.name, mtime: s.mtimeMs };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!newest) return;
|
|
118
|
+
const content = readFileSync(newest.path, "utf8");
|
|
119
|
+
// Tolerant parsing — coherence-auditor writes Korean or English.
|
|
120
|
+
const score = parseInt(content.match(/Coordination Score\*?\*?:?\s*\*?\*?(\d+)\s*%/)?.[1] ?? "", 10);
|
|
121
|
+
const edges = content.match(/\((\d+)\s*\/\s*(\d+)\s+edges?\)/);
|
|
122
|
+
const status = content.match(/Status:\s*([A-Za-z]+)/)?.[1] ?? "";
|
|
123
|
+
const fabrications = parseInt(content.match(/Fabrications?:\s*\*?\*?(\d+)/)?.[1] ?? "0", 10);
|
|
124
|
+
// Gap count from "## Gaps (N)" heading
|
|
125
|
+
const gaps = parseInt(content.match(/##\s*Gaps?\s*\((\d+)\)/)?.[1] ?? "0", 10);
|
|
126
|
+
state.coherence = {
|
|
127
|
+
score: Number.isFinite(score) ? score : null,
|
|
128
|
+
status,
|
|
129
|
+
feature: newest.feature,
|
|
130
|
+
gaps,
|
|
131
|
+
fabrications,
|
|
132
|
+
edgesActual: edges ? parseInt(edges[1], 10) : null,
|
|
133
|
+
edgesPossible: edges ? parseInt(edges[2], 10) : null,
|
|
134
|
+
path: newest.path,
|
|
135
|
+
ts: newest.mtime,
|
|
136
|
+
};
|
|
137
|
+
scheduleRender();
|
|
138
|
+
} catch {
|
|
139
|
+
// Swallow — coherence is best-effort, never crashes the watch
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function handleEvent(ev) {
|
|
144
|
+
state.events += 1;
|
|
145
|
+
const at = Date.parse(ev.at) || Date.now();
|
|
146
|
+
if (!state.sessionStartAt) state.sessionStartAt = at;
|
|
147
|
+
if (ev.session_id && !state.sessionId) state.sessionId = ev.session_id;
|
|
148
|
+
|
|
149
|
+
switch (ev.type) {
|
|
150
|
+
case "session.start":
|
|
151
|
+
state.sessionStartAt = at;
|
|
152
|
+
state.sessionEndAt = null;
|
|
153
|
+
if (ev.session_id) state.sessionId = ev.session_id;
|
|
154
|
+
break;
|
|
155
|
+
case "session.end":
|
|
156
|
+
state.sessionEndAt = at;
|
|
157
|
+
break;
|
|
158
|
+
case "agent.dispatched": {
|
|
159
|
+
if (!ev.agent) break;
|
|
160
|
+
state.activeAgents.set(ev.agent, { startAt: at, prompt: ev.prompt ?? "" });
|
|
161
|
+
const stage = AGENT_STAGE[ev.agent];
|
|
162
|
+
if (stage) {
|
|
163
|
+
if (state.currentStage && state.currentStage !== stage) {
|
|
164
|
+
state.completedStages.add(state.currentStage);
|
|
165
|
+
}
|
|
166
|
+
state.currentStage = stage;
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case "agent.completed": {
|
|
171
|
+
const closeAgent = (id, closeAt) => {
|
|
172
|
+
const a = state.activeAgents.get(id);
|
|
173
|
+
const duration = a ? Math.max(0, closeAt - a.startAt) : 0;
|
|
174
|
+
state.activeAgents.delete(id);
|
|
175
|
+
state.completedAgents.set(id, {
|
|
176
|
+
lastAt: closeAt,
|
|
177
|
+
duration,
|
|
178
|
+
summary: ev.output_summary ?? "",
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
if (ev.agent) closeAgent(ev.agent, at);
|
|
182
|
+
else if (ev.sweep) for (const id of [...state.activeAgents.keys()]) closeAgent(id, at);
|
|
183
|
+
// Coherence: when coherence-auditor finishes, reload the latest report
|
|
184
|
+
if (ev.agent === "coherence-auditor") {
|
|
185
|
+
loadLatestCoherence();
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "file.written":
|
|
190
|
+
state.files += 1;
|
|
191
|
+
// Coherence: if a coherence-report.md was just written, reload
|
|
192
|
+
if (ev.path && ev.path.endsWith("/coherence-report.md")) {
|
|
193
|
+
loadLatestCoherence();
|
|
194
|
+
}
|
|
195
|
+
if (ev.path) {
|
|
196
|
+
state.recentFiles.push({ path: ev.path, tool: ev.tool_name, agent: ev.agent, at });
|
|
197
|
+
if (state.recentFiles.length > 6) state.recentFiles.shift();
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
case "issue.found":
|
|
201
|
+
if (ev.severity && state.issues[ev.severity] != null) {
|
|
202
|
+
state.issues[ev.severity] += 1;
|
|
203
|
+
}
|
|
204
|
+
state.recentIssues.push({ severity: ev.severity, title: ev.title ?? "", at });
|
|
205
|
+
if (state.recentIssues.length > 5) state.recentIssues.shift();
|
|
206
|
+
break;
|
|
207
|
+
case "pipeline.stage":
|
|
208
|
+
if (state.currentStage) state.completedStages.add(state.currentStage);
|
|
209
|
+
state.currentStage = (ev.stage ?? "").toUpperCase() || state.currentStage;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
state.recent.push({ ...ev, at });
|
|
214
|
+
if (state.recent.length > 14) state.recent.shift();
|
|
215
|
+
scheduleRender();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ------------------------------------------------------------------
|
|
219
|
+
// Render loop (throttled to ~20fps to avoid flicker on heavy event bursts)
|
|
220
|
+
// ------------------------------------------------------------------
|
|
221
|
+
let renderPending = false;
|
|
222
|
+
function scheduleRender() {
|
|
223
|
+
if (renderPending) return;
|
|
224
|
+
renderPending = true;
|
|
225
|
+
setImmediate(() => { renderPending = false; render(); });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderHeader() {
|
|
229
|
+
const width = Math.max(60, process.stdout.columns ?? 80);
|
|
230
|
+
const title = `${c.bold}🏠 buildcrew${c.reset}`;
|
|
231
|
+
const conn = state.connected
|
|
232
|
+
? `${c.green}● live${c.reset}`
|
|
233
|
+
: `${c.red}○ disconnected${c.reset}`;
|
|
234
|
+
const project = process.cwd().split("/").pop();
|
|
235
|
+
const sid = state.sessionId ? state.sessionId.slice(-8) : "—";
|
|
236
|
+
// Line 1: title + project + session + conn
|
|
237
|
+
const left = `${title} ${c.gray}📁${c.reset} ${project} ${c.gray}session${c.reset} ${c.cyan}${sid}${c.reset}`;
|
|
238
|
+
const pad = Math.max(2, width - stripAnsi(left).length - stripAnsi(conn).length);
|
|
239
|
+
console.log(left + " ".repeat(pad) + conn);
|
|
240
|
+
|
|
241
|
+
// Line 2: stats bar (stage · elapsed · events · files · issues)
|
|
242
|
+
const stage = state.currentStage
|
|
243
|
+
? `${c.gold}${c.bold}${state.currentStage}${c.reset}`
|
|
244
|
+
: `${c.gray}idle${c.reset}`;
|
|
245
|
+
const elapsedMs = state.sessionStartAt
|
|
246
|
+
? (state.sessionEndAt ?? Date.now()) - state.sessionStartAt : 0;
|
|
247
|
+
const elapsed = formatDuration(Math.floor(elapsedMs / 1000));
|
|
248
|
+
const crit = state.issues.critical, high = state.issues.high, med = state.issues.med;
|
|
249
|
+
const issuesStr = (crit + high + med) === 0
|
|
250
|
+
? `${c.gray}no issues${c.reset}`
|
|
251
|
+
: `${crit > 0 ? c.red + "🚨 " + crit + c.reset + " " : ""}${high > 0 ? c.gold + "⚠ " + high + c.reset + " " : ""}${med > 0 ? c.gold + "· " + med + c.reset : ""}`.trim();
|
|
252
|
+
console.log(
|
|
253
|
+
` ${c.gray}stage${c.reset} ${stage} ${c.gray}elapsed${c.reset} ${elapsed} ` +
|
|
254
|
+
`${c.gray}events${c.reset} ${state.events} ${c.gray}files${c.reset} ${state.files} ` +
|
|
255
|
+
`${c.gray}issues${c.reset} ${issuesStr}`
|
|
256
|
+
);
|
|
257
|
+
console.log("");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sectionTitle(label, width) {
|
|
261
|
+
const line = `${c.bold}${c.cyan}${label}${c.reset}`;
|
|
262
|
+
const rule = c.gray + "─".repeat(Math.max(0, width - stripAnsi(line).length - 3)) + c.reset;
|
|
263
|
+
return `${line} ${rule}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function renderNow(width) {
|
|
267
|
+
console.log(sectionTitle("NOW", width));
|
|
268
|
+
if (state.activeAgents.size === 0) {
|
|
269
|
+
const lastDone = [...state.completedAgents.entries()]
|
|
270
|
+
.sort((a, b) => b[1].lastAt - a[1].lastAt).slice(0, 3);
|
|
271
|
+
if (lastDone.length === 0) {
|
|
272
|
+
console.log(` ${c.gray}(idle — invoke any @agent to begin)${c.reset}`);
|
|
273
|
+
} else {
|
|
274
|
+
console.log(` ${c.gray}idle · last active:${c.reset}`);
|
|
275
|
+
for (const [id, info] of lastDone) {
|
|
276
|
+
const emoji = (AGENTS.find(a => a.id === id)?.emoji) ?? "●";
|
|
277
|
+
const dur = formatDuration(Math.floor(info.duration / 1000));
|
|
278
|
+
console.log(` ${c.green}✓${c.reset} ${emoji} ${id} ${c.gray}· ${dur}${c.reset}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
for (const [id, info] of state.activeAgents) {
|
|
284
|
+
const emoji = (AGENTS.find(a => a.id === id)?.emoji) ?? "●";
|
|
285
|
+
const elapsed = formatDuration(Math.floor((now - info.startAt) / 1000));
|
|
286
|
+
const prompt = truncate(info.prompt, Math.max(20, width - 28));
|
|
287
|
+
console.log(` ${c.gold}●${c.reset} ${emoji} ${c.bold}${id}${c.reset} ${c.gray}${elapsed} · ${prompt}${c.reset}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
console.log("");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderPipeline(width) {
|
|
294
|
+
console.log(sectionTitle("PIPELINE", width));
|
|
295
|
+
const parts = STAGES.map((name) => {
|
|
296
|
+
if (state.currentStage === name)
|
|
297
|
+
return `${c.gold}${c.bold}●${c.reset} ${c.gold}${name}${c.reset}`;
|
|
298
|
+
if (state.completedStages.has(name))
|
|
299
|
+
return `${c.green}✓${c.reset} ${c.green}${name}${c.reset}`;
|
|
300
|
+
return `${c.gray}○ ${name}${c.reset}`;
|
|
301
|
+
});
|
|
302
|
+
console.log(" " + parts.join(c.gray + " → " + c.reset));
|
|
303
|
+
console.log("");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderAgents(width) {
|
|
307
|
+
console.log(sectionTitle("ROSTER", width));
|
|
308
|
+
const nCols = width >= 120 ? 4 : width >= 80 ? 3 : 2;
|
|
309
|
+
const cellW = Math.floor((width - 4) / nCols) - 1;
|
|
310
|
+
const byRoom = new Map();
|
|
311
|
+
for (const a of AGENTS) {
|
|
312
|
+
if (!byRoom.has(a.room)) byRoom.set(a.room, []);
|
|
313
|
+
byRoom.get(a.room).push(a);
|
|
314
|
+
}
|
|
315
|
+
for (const [room, list] of byRoom) {
|
|
316
|
+
const activeCount = list.filter(a => state.activeAgents.has(a.id)).length;
|
|
317
|
+
const badge = activeCount > 0 ? `${c.gold}${activeCount} active${c.reset}` : `${c.gray}—${c.reset}`;
|
|
318
|
+
console.log(` ${c.bold}${room}${c.reset} ${c.gray}${list.length} agents${c.reset} ${badge}`);
|
|
319
|
+
const rows = Math.ceil(list.length / nCols);
|
|
320
|
+
for (let r = 0; r < rows; r++) {
|
|
321
|
+
const cells = [];
|
|
322
|
+
for (let col = 0; col < nCols; col++) {
|
|
323
|
+
const a = list[r + col * rows];
|
|
324
|
+
if (!a) { cells.push(""); continue; }
|
|
325
|
+
const isActive = state.activeAgents.has(a.id);
|
|
326
|
+
const didRun = state.completedAgents.has(a.id);
|
|
327
|
+
const mark = isActive ? `${c.gold}●${c.reset}` : didRun ? `${c.green}✓${c.reset}` : `${c.gray}○${c.reset}`;
|
|
328
|
+
const name = isActive ? `${c.bold}${a.id}${c.reset}` : didRun ? a.id : `${c.gray}${a.id}${c.reset}`;
|
|
329
|
+
const cell = ` ${mark} ${a.emoji} ${name}`;
|
|
330
|
+
cells.push(padEnd(cell, cellW));
|
|
331
|
+
}
|
|
332
|
+
console.log(cells.join(" "));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
console.log("");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderFiles(width) {
|
|
339
|
+
if (state.recentFiles.length === 0) return;
|
|
340
|
+
console.log(sectionTitle("FILES", width));
|
|
341
|
+
for (const f of state.recentFiles.slice(-5)) {
|
|
342
|
+
const rel = f.path.split("/").slice(-3).join("/");
|
|
343
|
+
const by = f.agent ? ` ${c.gray}by ${f.agent}${c.reset}` : "";
|
|
344
|
+
console.log(` ${c.blue}📝${c.reset} ${rel} ${c.gray}(${f.tool ?? "edit"})${c.reset}${by}`);
|
|
345
|
+
}
|
|
346
|
+
console.log("");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function renderIssues(width) {
|
|
350
|
+
if (state.recentIssues.length === 0) return;
|
|
351
|
+
console.log(sectionTitle("ISSUES", width));
|
|
352
|
+
for (const iss of state.recentIssues.slice(-3)) {
|
|
353
|
+
const sev = iss.severity ?? "low";
|
|
354
|
+
const color = sev === "critical" ? c.red : sev === "high" ? c.gold : c.gold;
|
|
355
|
+
console.log(` ${color}⚠${c.reset} ${color}${sev}${c.reset} ${c.gray}·${c.reset} ${truncate(iss.title, width - 16)}`);
|
|
356
|
+
}
|
|
357
|
+
console.log("");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function renderCoherence(width) {
|
|
361
|
+
if (!state.coherence) return;
|
|
362
|
+
const co = state.coherence;
|
|
363
|
+
console.log(sectionTitle("COHERENCE", width));
|
|
364
|
+
// Score color: 90+ green, 70-89 cyan, 50-69 gold, <50 red
|
|
365
|
+
let scoreColor = c.gray, statusEmoji = "○";
|
|
366
|
+
if (co.score == null) {
|
|
367
|
+
scoreColor = c.gray;
|
|
368
|
+
statusEmoji = "?";
|
|
369
|
+
} else if (co.score >= 90) {
|
|
370
|
+
scoreColor = c.green; statusEmoji = "✓";
|
|
371
|
+
} else if (co.score >= 70) {
|
|
372
|
+
scoreColor = c.cyan; statusEmoji = "●";
|
|
373
|
+
} else if (co.score >= 50) {
|
|
374
|
+
scoreColor = c.gold; statusEmoji = "⚠";
|
|
375
|
+
} else {
|
|
376
|
+
scoreColor = c.red; statusEmoji = "✗";
|
|
377
|
+
}
|
|
378
|
+
const scoreStr = co.score == null ? `${c.gray}—${c.reset}` : `${scoreColor}${c.bold}${co.score}%${c.reset}`;
|
|
379
|
+
const statusStr = co.status ? `${scoreColor}${co.status}${c.reset}` : `${c.gray}—${c.reset}`;
|
|
380
|
+
const edgesStr = (co.edgesActual != null && co.edgesPossible != null)
|
|
381
|
+
? `${c.gray}(${co.edgesActual}/${co.edgesPossible} edges)${c.reset}`
|
|
382
|
+
: "";
|
|
383
|
+
const fabBadge = co.fabrications > 0
|
|
384
|
+
? ` ${c.red}🚨 ${co.fabrications} fabrication${co.fabrications > 1 ? "s" : ""}${c.reset}`
|
|
385
|
+
: "";
|
|
386
|
+
const gapBadge = co.gaps > 0
|
|
387
|
+
? ` ${c.gold}⚠ ${co.gaps} gap${co.gaps > 1 ? "s" : ""}${c.reset}`
|
|
388
|
+
: ` ${c.gray}no gaps${c.reset}`;
|
|
389
|
+
console.log(` ${statusEmoji} ${scoreStr} ${statusStr} ${edgesStr}${gapBadge}${fabBadge}`);
|
|
390
|
+
console.log(` ${c.gray}feature ${c.cyan}${co.feature}${c.reset} ${c.gray}· press ${c.bold}r${c.reset}${c.gray} for full report${c.reset}`);
|
|
391
|
+
console.log("");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function renderRecent(width) {
|
|
395
|
+
console.log(sectionTitle("LOG", width));
|
|
396
|
+
if (state.recent.length === 0) {
|
|
397
|
+
console.log(` ${c.gray}(no events yet)${c.reset}`);
|
|
398
|
+
} else {
|
|
399
|
+
for (const ev of state.recent.slice(-6)) {
|
|
400
|
+
console.log(" " + formatEvent(ev, width - 4));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function formatEvent(ev, maxLen) {
|
|
406
|
+
const t = new Date(ev.at || Date.now());
|
|
407
|
+
const hh = String(t.getHours()).padStart(2, "0");
|
|
408
|
+
const mm = String(t.getMinutes()).padStart(2, "0");
|
|
409
|
+
const ss = String(t.getSeconds()).padStart(2, "0");
|
|
410
|
+
const ts = `${c.gray}${hh}:${mm}:${ss}${c.reset}`;
|
|
411
|
+
let body;
|
|
412
|
+
switch (ev.type) {
|
|
413
|
+
case "agent.dispatched":
|
|
414
|
+
body = `${c.gold}▶${c.reset} ${c.bold}${ev.agent ?? "?"}${c.reset} ${c.gray}· ${truncate(ev.prompt, 60)}${c.reset}`;
|
|
415
|
+
break;
|
|
416
|
+
case "agent.completed":
|
|
417
|
+
body = `${c.green}✓${c.reset} ${ev.agent ?? "*"} ${c.gray}done${c.reset}`;
|
|
418
|
+
break;
|
|
419
|
+
case "file.written":
|
|
420
|
+
body = `${c.blue}📝${c.reset} ${truncate(ev.path?.split("/").slice(-2).join("/") ?? "?", maxLen - 20)}`;
|
|
421
|
+
break;
|
|
422
|
+
case "issue.found":
|
|
423
|
+
body = `${c.red}⚠${c.reset} ${ev.severity ?? ""} ${ev.title ?? ""}`;
|
|
424
|
+
break;
|
|
425
|
+
case "session.start":
|
|
426
|
+
body = `${c.mag}◆${c.reset} session started`;
|
|
427
|
+
break;
|
|
428
|
+
case "session.end":
|
|
429
|
+
body = `${c.mag}◼${c.reset} session ended`;
|
|
430
|
+
break;
|
|
431
|
+
case "pipeline.stage":
|
|
432
|
+
body = `${c.cyan}→${c.reset} stage: ${ev.stage}`;
|
|
433
|
+
break;
|
|
434
|
+
default:
|
|
435
|
+
body = `${c.gray}${ev.type}${c.reset}`;
|
|
436
|
+
}
|
|
437
|
+
return `${ts} ${body}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function render() {
|
|
441
|
+
const width = Math.max(60, process.stdout.columns ?? 80);
|
|
442
|
+
process.stdout.write(CLEAR);
|
|
443
|
+
renderHeader();
|
|
444
|
+
renderNow(width);
|
|
445
|
+
renderPipeline(width);
|
|
446
|
+
renderAgents(width);
|
|
447
|
+
renderFiles(width);
|
|
448
|
+
renderIssues(width);
|
|
449
|
+
renderCoherence(width);
|
|
450
|
+
renderRecent(width);
|
|
451
|
+
process.stdout.write(`\n${c.gray}q quit · r show full coherence report${c.reset}\n`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ------------------------------------------------------------------
|
|
455
|
+
// JSONL tail — read existing history, then watch for new lines.
|
|
456
|
+
// ------------------------------------------------------------------
|
|
457
|
+
let tailOffset = 0;
|
|
458
|
+
|
|
459
|
+
async function ensureEventsFile() {
|
|
460
|
+
try {
|
|
461
|
+
mkdirSync(join(process.cwd(), ".claude", "buildcrew"), { recursive: true });
|
|
462
|
+
if (!existsSync(EVENTS_PATH)) {
|
|
463
|
+
// Create empty file so watchFile has something to watch
|
|
464
|
+
closeSync(openSync(EVENTS_PATH, "a"));
|
|
465
|
+
}
|
|
466
|
+
} catch {}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function replayExisting() {
|
|
470
|
+
try {
|
|
471
|
+
const st = statSync(EVENTS_PATH);
|
|
472
|
+
tailOffset = st.size;
|
|
473
|
+
} catch {
|
|
474
|
+
tailOffset = 0;
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Empty events.jsonl (first run / fresh install) — nothing to replay.
|
|
478
|
+
// createReadStream with end: -1 would throw RangeError.
|
|
479
|
+
if (tailOffset === 0) {
|
|
480
|
+
state.connected = true;
|
|
481
|
+
scheduleRender();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const stream = createReadStream(EVENTS_PATH, { encoding: "utf8", end: tailOffset - 1 });
|
|
485
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
486
|
+
for await (const line of rl) {
|
|
487
|
+
if (!line.trim()) continue;
|
|
488
|
+
try { handleEvent(JSON.parse(line)); } catch {}
|
|
489
|
+
}
|
|
490
|
+
state.connected = true;
|
|
491
|
+
scheduleRender();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function readNewBytes() {
|
|
495
|
+
let st;
|
|
496
|
+
try { st = statSync(EVENTS_PATH); } catch { return; }
|
|
497
|
+
if (st.size < tailOffset) { tailOffset = 0; } // truncated / rotated
|
|
498
|
+
if (st.size === tailOffset) return;
|
|
499
|
+
const stream = createReadStream(EVENTS_PATH, {
|
|
500
|
+
encoding: "utf8", start: tailOffset, end: st.size - 1,
|
|
501
|
+
});
|
|
502
|
+
let buf = "";
|
|
503
|
+
stream.on("data", (chunk) => { buf += chunk; });
|
|
504
|
+
await new Promise((resolve) => stream.on("end", resolve));
|
|
505
|
+
tailOffset = st.size;
|
|
506
|
+
const lines = buf.split("\n");
|
|
507
|
+
for (const line of lines) {
|
|
508
|
+
if (!line.trim()) continue;
|
|
509
|
+
try { handleEvent(JSON.parse(line)); } catch {}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function subscribeTail() {
|
|
514
|
+
watchFile(EVENTS_PATH, { interval: 400 }, () => { readNewBytes(); });
|
|
515
|
+
// Also poll as a fallback in case watchFile misses writes
|
|
516
|
+
setInterval(readNewBytes, 1000);
|
|
517
|
+
state.connected = true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ------------------------------------------------------------------
|
|
521
|
+
// Bootstrap
|
|
522
|
+
// ------------------------------------------------------------------
|
|
523
|
+
process.stdout.write(HIDE_CURSOR);
|
|
524
|
+
process.on("exit", () => process.stdout.write(SHOW_CURSOR));
|
|
525
|
+
process.on("SIGINT", () => { process.stdout.write(SHOW_CURSOR); process.exit(0); });
|
|
526
|
+
process.on("SIGTERM", () => { process.stdout.write(SHOW_CURSOR); process.exit(0); });
|
|
527
|
+
|
|
528
|
+
// Keypress handlers: q/Ctrl-C quit, r open full coherence report
|
|
529
|
+
if (process.stdin.isTTY) {
|
|
530
|
+
readline.emitKeypressEvents(process.stdin);
|
|
531
|
+
process.stdin.setRawMode(true);
|
|
532
|
+
process.stdin.on("keypress", (_str, key) => {
|
|
533
|
+
if (key?.name === "q" || (key?.ctrl && key?.name === "c")) {
|
|
534
|
+
process.stdout.write(SHOW_CURSOR);
|
|
535
|
+
process.exit(0);
|
|
536
|
+
}
|
|
537
|
+
if (key?.name === "r") {
|
|
538
|
+
// Open the full coherence report. Hand off the terminal to setup.js's
|
|
539
|
+
// report subcommand which uses `less -R` for paging. Restore TTY state
|
|
540
|
+
// after the child exits.
|
|
541
|
+
process.stdout.write(SHOW_CURSOR);
|
|
542
|
+
process.stdin.setRawMode(false);
|
|
543
|
+
process.stdout.write(CLEAR);
|
|
544
|
+
const setupEntry = resolve(__dirname, "setup.js");
|
|
545
|
+
spawnSync(process.execPath, [setupEntry, "report"], {
|
|
546
|
+
stdio: "inherit",
|
|
547
|
+
cwd: process.cwd(),
|
|
548
|
+
env: process.env,
|
|
549
|
+
});
|
|
550
|
+
// Restore raw mode + hide cursor + redraw
|
|
551
|
+
process.stdin.setRawMode(true);
|
|
552
|
+
process.stdout.write(HIDE_CURSOR);
|
|
553
|
+
scheduleRender();
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Heartbeat redraw (updates elapsed even without events)
|
|
559
|
+
setInterval(scheduleRender, 1000);
|
|
560
|
+
|
|
561
|
+
// Tail the events.jsonl — replay history then watch for new lines
|
|
562
|
+
(async () => {
|
|
563
|
+
await ensureEventsFile();
|
|
564
|
+
await replayExisting();
|
|
565
|
+
// Surface the latest coherence report (if any) on startup, even before any
|
|
566
|
+
// new events fire — so users see their last score when they open watch.
|
|
567
|
+
loadLatestCoherence();
|
|
568
|
+
subscribeTail();
|
|
569
|
+
render();
|
|
570
|
+
})();
|
|
571
|
+
|
|
572
|
+
// ------------------------------------------------------------------
|
|
573
|
+
// tiny utils
|
|
574
|
+
// ------------------------------------------------------------------
|
|
575
|
+
function truncate(s, n) {
|
|
576
|
+
if (!s) return "";
|
|
577
|
+
const t = String(s);
|
|
578
|
+
return t.length <= n ? t : t.slice(0, n - 1) + "…";
|
|
579
|
+
}
|
|
580
|
+
function formatDuration(sec) {
|
|
581
|
+
if (!Number.isFinite(sec) || sec < 0) return "0s";
|
|
582
|
+
const h = Math.floor(sec / 3600);
|
|
583
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
584
|
+
const s = sec % 60;
|
|
585
|
+
if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`;
|
|
586
|
+
if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`;
|
|
587
|
+
return `${s}s`;
|
|
588
|
+
}
|
|
589
|
+
function stripAnsi(s) { return String(s).replace(/\x1b\[[0-9;]*m/g, ""); }
|
|
590
|
+
function padEnd(s, n) {
|
|
591
|
+
const visible = stripAnsi(s);
|
|
592
|
+
const padLen = Math.max(0, n - visible.length);
|
|
593
|
+
return s + " ".repeat(padLen);
|
|
594
|
+
}
|