buildcrew 1.8.6 → 1.9.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.
- package/README.md +129 -1
- package/agents/architect.md +26 -0
- package/agents/browser-qa.md +29 -0
- package/agents/buildcrew.md +33 -4
- 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 +56 -1
- 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 +69 -5
- package/bin/watch.js +473 -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,473 @@
|
|
|
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 } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import readline, { createInterface } from "node:readline";
|
|
17
|
+
|
|
18
|
+
const EVENTS_PATH = process.env.BUILDCREW_EVENTS_PATH
|
|
19
|
+
?? join(process.cwd(), ".claude", "buildcrew", "events.jsonl");
|
|
20
|
+
|
|
21
|
+
// ------------------------------------------------------------------
|
|
22
|
+
// ANSI helpers
|
|
23
|
+
// ------------------------------------------------------------------
|
|
24
|
+
const NO_COLOR = !!process.env.NO_COLOR;
|
|
25
|
+
const c = NO_COLOR
|
|
26
|
+
? new Proxy({}, { get: () => "" })
|
|
27
|
+
: {
|
|
28
|
+
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
|
|
29
|
+
black: "\x1b[30m", red: "\x1b[31m", green: "\x1b[32m",
|
|
30
|
+
gold: "\x1b[33m", blue: "\x1b[34m", mag: "\x1b[35m",
|
|
31
|
+
cyan: "\x1b[36m", gray: "\x1b[90m",
|
|
32
|
+
bgWood: "\x1b[48;5;94m",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const CLEAR = "\x1b[2J\x1b[H";
|
|
36
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
37
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
38
|
+
|
|
39
|
+
// ------------------------------------------------------------------
|
|
40
|
+
// Agent roster (matches dashboard/client/scenes/TownScene.js)
|
|
41
|
+
// ------------------------------------------------------------------
|
|
42
|
+
const AGENTS = [
|
|
43
|
+
{ id: "buildcrew", room: "Meeting", emoji: "🎩" },
|
|
44
|
+
{ id: "planner", room: "Meeting", emoji: "📋" },
|
|
45
|
+
{ id: "designer", room: "Meeting", emoji: "🎨" },
|
|
46
|
+
{ id: "developer", room: "Meeting", emoji: "💻" },
|
|
47
|
+
{ id: "qa-tester", room: "QA Lab", emoji: "🧪" },
|
|
48
|
+
{ id: "browser-qa", room: "QA Lab", emoji: "🌐" },
|
|
49
|
+
{ id: "reviewer", room: "QA Lab", emoji: "🧐" },
|
|
50
|
+
{ id: "health-checker", room: "QA Lab", emoji: "🩺" },
|
|
51
|
+
{ id: "security-auditor", room: "SecOps", emoji: "🛡" },
|
|
52
|
+
{ id: "canary-monitor", room: "SecOps", emoji: "🐤" },
|
|
53
|
+
{ id: "shipper", room: "SecOps", emoji: "🚢" },
|
|
54
|
+
{ id: "thinker", room: "Think Tank", emoji: "🤔" },
|
|
55
|
+
{ id: "architect", room: "Think Tank", emoji: "📐" },
|
|
56
|
+
{ id: "design-reviewer", room: "Think Tank", emoji: "👀" },
|
|
57
|
+
{ id: "investigator", room: "Field", emoji: "🕵" },
|
|
58
|
+
{ id: "qa-auditor", room: "Field", emoji: "⚖" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const STAGES = ["PLAN", "DESIGN", "DEV", "QA", "REVIEW", "SHIP"];
|
|
62
|
+
const AGENT_STAGE = {
|
|
63
|
+
planner: "PLAN", thinker: "PLAN",
|
|
64
|
+
designer: "DESIGN", "design-reviewer": "DESIGN", architect: "DESIGN",
|
|
65
|
+
developer: "DEV",
|
|
66
|
+
"qa-tester": "QA", "browser-qa": "QA", "health-checker": "QA",
|
|
67
|
+
"qa-auditor": "QA", "security-auditor": "QA", investigator: "QA",
|
|
68
|
+
"canary-monitor": "QA",
|
|
69
|
+
reviewer: "REVIEW",
|
|
70
|
+
shipper: "SHIP",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ------------------------------------------------------------------
|
|
74
|
+
// State
|
|
75
|
+
// ------------------------------------------------------------------
|
|
76
|
+
const state = {
|
|
77
|
+
connected: false,
|
|
78
|
+
currentStage: null,
|
|
79
|
+
completedStages: new Set(),
|
|
80
|
+
activeAgents: new Map(), // id → { startAt, prompt }
|
|
81
|
+
completedAgents: new Map(), // id → { lastAt, duration, summary }
|
|
82
|
+
events: 0,
|
|
83
|
+
files: 0,
|
|
84
|
+
issues: { critical: 0, high: 0, med: 0, low: 0 },
|
|
85
|
+
recent: [], // last ~10 events
|
|
86
|
+
recentFiles: [], // last ~6 { path, tool, agent, at }
|
|
87
|
+
recentIssues: [], // last ~5 { severity, title, at }
|
|
88
|
+
sessionId: null,
|
|
89
|
+
sessionStartAt: null,
|
|
90
|
+
sessionEndAt: null,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function handleEvent(ev) {
|
|
94
|
+
state.events += 1;
|
|
95
|
+
const at = Date.parse(ev.at) || Date.now();
|
|
96
|
+
if (!state.sessionStartAt) state.sessionStartAt = at;
|
|
97
|
+
if (ev.session_id && !state.sessionId) state.sessionId = ev.session_id;
|
|
98
|
+
|
|
99
|
+
switch (ev.type) {
|
|
100
|
+
case "session.start":
|
|
101
|
+
state.sessionStartAt = at;
|
|
102
|
+
state.sessionEndAt = null;
|
|
103
|
+
if (ev.session_id) state.sessionId = ev.session_id;
|
|
104
|
+
break;
|
|
105
|
+
case "session.end":
|
|
106
|
+
state.sessionEndAt = at;
|
|
107
|
+
break;
|
|
108
|
+
case "agent.dispatched": {
|
|
109
|
+
if (!ev.agent) break;
|
|
110
|
+
state.activeAgents.set(ev.agent, { startAt: at, prompt: ev.prompt ?? "" });
|
|
111
|
+
const stage = AGENT_STAGE[ev.agent];
|
|
112
|
+
if (stage) {
|
|
113
|
+
if (state.currentStage && state.currentStage !== stage) {
|
|
114
|
+
state.completedStages.add(state.currentStage);
|
|
115
|
+
}
|
|
116
|
+
state.currentStage = stage;
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "agent.completed": {
|
|
121
|
+
const closeAgent = (id, closeAt) => {
|
|
122
|
+
const a = state.activeAgents.get(id);
|
|
123
|
+
const duration = a ? Math.max(0, closeAt - a.startAt) : 0;
|
|
124
|
+
state.activeAgents.delete(id);
|
|
125
|
+
state.completedAgents.set(id, {
|
|
126
|
+
lastAt: closeAt,
|
|
127
|
+
duration,
|
|
128
|
+
summary: ev.output_summary ?? "",
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
if (ev.agent) closeAgent(ev.agent, at);
|
|
132
|
+
else if (ev.sweep) for (const id of [...state.activeAgents.keys()]) closeAgent(id, at);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "file.written":
|
|
136
|
+
state.files += 1;
|
|
137
|
+
if (ev.path) {
|
|
138
|
+
state.recentFiles.push({ path: ev.path, tool: ev.tool_name, agent: ev.agent, at });
|
|
139
|
+
if (state.recentFiles.length > 6) state.recentFiles.shift();
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
case "issue.found":
|
|
143
|
+
if (ev.severity && state.issues[ev.severity] != null) {
|
|
144
|
+
state.issues[ev.severity] += 1;
|
|
145
|
+
}
|
|
146
|
+
state.recentIssues.push({ severity: ev.severity, title: ev.title ?? "", at });
|
|
147
|
+
if (state.recentIssues.length > 5) state.recentIssues.shift();
|
|
148
|
+
break;
|
|
149
|
+
case "pipeline.stage":
|
|
150
|
+
if (state.currentStage) state.completedStages.add(state.currentStage);
|
|
151
|
+
state.currentStage = (ev.stage ?? "").toUpperCase() || state.currentStage;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
state.recent.push({ ...ev, at });
|
|
156
|
+
if (state.recent.length > 14) state.recent.shift();
|
|
157
|
+
scheduleRender();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ------------------------------------------------------------------
|
|
161
|
+
// Render loop (throttled to ~20fps to avoid flicker on heavy event bursts)
|
|
162
|
+
// ------------------------------------------------------------------
|
|
163
|
+
let renderPending = false;
|
|
164
|
+
function scheduleRender() {
|
|
165
|
+
if (renderPending) return;
|
|
166
|
+
renderPending = true;
|
|
167
|
+
setImmediate(() => { renderPending = false; render(); });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderHeader() {
|
|
171
|
+
const width = Math.max(60, process.stdout.columns ?? 80);
|
|
172
|
+
const title = `${c.bold}🏠 buildcrew${c.reset}`;
|
|
173
|
+
const conn = state.connected
|
|
174
|
+
? `${c.green}● live${c.reset}`
|
|
175
|
+
: `${c.red}○ disconnected${c.reset}`;
|
|
176
|
+
const project = process.cwd().split("/").pop();
|
|
177
|
+
const sid = state.sessionId ? state.sessionId.slice(-8) : "—";
|
|
178
|
+
// Line 1: title + project + session + conn
|
|
179
|
+
const left = `${title} ${c.gray}📁${c.reset} ${project} ${c.gray}session${c.reset} ${c.cyan}${sid}${c.reset}`;
|
|
180
|
+
const pad = Math.max(2, width - stripAnsi(left).length - stripAnsi(conn).length);
|
|
181
|
+
console.log(left + " ".repeat(pad) + conn);
|
|
182
|
+
|
|
183
|
+
// Line 2: stats bar (stage · elapsed · events · files · issues)
|
|
184
|
+
const stage = state.currentStage
|
|
185
|
+
? `${c.gold}${c.bold}${state.currentStage}${c.reset}`
|
|
186
|
+
: `${c.gray}idle${c.reset}`;
|
|
187
|
+
const elapsedMs = state.sessionStartAt
|
|
188
|
+
? (state.sessionEndAt ?? Date.now()) - state.sessionStartAt : 0;
|
|
189
|
+
const elapsed = formatDuration(Math.floor(elapsedMs / 1000));
|
|
190
|
+
const crit = state.issues.critical, high = state.issues.high, med = state.issues.med;
|
|
191
|
+
const issuesStr = (crit + high + med) === 0
|
|
192
|
+
? `${c.gray}no issues${c.reset}`
|
|
193
|
+
: `${crit > 0 ? c.red + "🚨 " + crit + c.reset + " " : ""}${high > 0 ? c.gold + "⚠ " + high + c.reset + " " : ""}${med > 0 ? c.gold + "· " + med + c.reset : ""}`.trim();
|
|
194
|
+
console.log(
|
|
195
|
+
` ${c.gray}stage${c.reset} ${stage} ${c.gray}elapsed${c.reset} ${elapsed} ` +
|
|
196
|
+
`${c.gray}events${c.reset} ${state.events} ${c.gray}files${c.reset} ${state.files} ` +
|
|
197
|
+
`${c.gray}issues${c.reset} ${issuesStr}`
|
|
198
|
+
);
|
|
199
|
+
console.log("");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sectionTitle(label, width) {
|
|
203
|
+
const line = `${c.bold}${c.cyan}${label}${c.reset}`;
|
|
204
|
+
const rule = c.gray + "─".repeat(Math.max(0, width - stripAnsi(line).length - 3)) + c.reset;
|
|
205
|
+
return `${line} ${rule}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderNow(width) {
|
|
209
|
+
console.log(sectionTitle("NOW", width));
|
|
210
|
+
if (state.activeAgents.size === 0) {
|
|
211
|
+
const lastDone = [...state.completedAgents.entries()]
|
|
212
|
+
.sort((a, b) => b[1].lastAt - a[1].lastAt).slice(0, 3);
|
|
213
|
+
if (lastDone.length === 0) {
|
|
214
|
+
console.log(` ${c.gray}(idle — invoke any @agent to begin)${c.reset}`);
|
|
215
|
+
} else {
|
|
216
|
+
console.log(` ${c.gray}idle · last active:${c.reset}`);
|
|
217
|
+
for (const [id, info] of lastDone) {
|
|
218
|
+
const emoji = (AGENTS.find(a => a.id === id)?.emoji) ?? "●";
|
|
219
|
+
const dur = formatDuration(Math.floor(info.duration / 1000));
|
|
220
|
+
console.log(` ${c.green}✓${c.reset} ${emoji} ${id} ${c.gray}· ${dur}${c.reset}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
for (const [id, info] of state.activeAgents) {
|
|
226
|
+
const emoji = (AGENTS.find(a => a.id === id)?.emoji) ?? "●";
|
|
227
|
+
const elapsed = formatDuration(Math.floor((now - info.startAt) / 1000));
|
|
228
|
+
const prompt = truncate(info.prompt, Math.max(20, width - 28));
|
|
229
|
+
console.log(` ${c.gold}●${c.reset} ${emoji} ${c.bold}${id}${c.reset} ${c.gray}${elapsed} · ${prompt}${c.reset}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
console.log("");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderPipeline(width) {
|
|
236
|
+
console.log(sectionTitle("PIPELINE", width));
|
|
237
|
+
const parts = STAGES.map((name) => {
|
|
238
|
+
if (state.currentStage === name)
|
|
239
|
+
return `${c.gold}${c.bold}●${c.reset} ${c.gold}${name}${c.reset}`;
|
|
240
|
+
if (state.completedStages.has(name))
|
|
241
|
+
return `${c.green}✓${c.reset} ${c.green}${name}${c.reset}`;
|
|
242
|
+
return `${c.gray}○ ${name}${c.reset}`;
|
|
243
|
+
});
|
|
244
|
+
console.log(" " + parts.join(c.gray + " → " + c.reset));
|
|
245
|
+
console.log("");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderAgents(width) {
|
|
249
|
+
console.log(sectionTitle("ROSTER", width));
|
|
250
|
+
const nCols = width >= 120 ? 4 : width >= 80 ? 3 : 2;
|
|
251
|
+
const cellW = Math.floor((width - 4) / nCols) - 1;
|
|
252
|
+
const byRoom = new Map();
|
|
253
|
+
for (const a of AGENTS) {
|
|
254
|
+
if (!byRoom.has(a.room)) byRoom.set(a.room, []);
|
|
255
|
+
byRoom.get(a.room).push(a);
|
|
256
|
+
}
|
|
257
|
+
for (const [room, list] of byRoom) {
|
|
258
|
+
const activeCount = list.filter(a => state.activeAgents.has(a.id)).length;
|
|
259
|
+
const badge = activeCount > 0 ? `${c.gold}${activeCount} active${c.reset}` : `${c.gray}—${c.reset}`;
|
|
260
|
+
console.log(` ${c.bold}${room}${c.reset} ${c.gray}${list.length} agents${c.reset} ${badge}`);
|
|
261
|
+
const rows = Math.ceil(list.length / nCols);
|
|
262
|
+
for (let r = 0; r < rows; r++) {
|
|
263
|
+
const cells = [];
|
|
264
|
+
for (let col = 0; col < nCols; col++) {
|
|
265
|
+
const a = list[r + col * rows];
|
|
266
|
+
if (!a) { cells.push(""); continue; }
|
|
267
|
+
const isActive = state.activeAgents.has(a.id);
|
|
268
|
+
const didRun = state.completedAgents.has(a.id);
|
|
269
|
+
const mark = isActive ? `${c.gold}●${c.reset}` : didRun ? `${c.green}✓${c.reset}` : `${c.gray}○${c.reset}`;
|
|
270
|
+
const name = isActive ? `${c.bold}${a.id}${c.reset}` : didRun ? a.id : `${c.gray}${a.id}${c.reset}`;
|
|
271
|
+
const cell = ` ${mark} ${a.emoji} ${name}`;
|
|
272
|
+
cells.push(padEnd(cell, cellW));
|
|
273
|
+
}
|
|
274
|
+
console.log(cells.join(" "));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
console.log("");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderFiles(width) {
|
|
281
|
+
if (state.recentFiles.length === 0) return;
|
|
282
|
+
console.log(sectionTitle("FILES", width));
|
|
283
|
+
for (const f of state.recentFiles.slice(-5)) {
|
|
284
|
+
const rel = f.path.split("/").slice(-3).join("/");
|
|
285
|
+
const by = f.agent ? ` ${c.gray}by ${f.agent}${c.reset}` : "";
|
|
286
|
+
console.log(` ${c.blue}📝${c.reset} ${rel} ${c.gray}(${f.tool ?? "edit"})${c.reset}${by}`);
|
|
287
|
+
}
|
|
288
|
+
console.log("");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderIssues(width) {
|
|
292
|
+
if (state.recentIssues.length === 0) return;
|
|
293
|
+
console.log(sectionTitle("ISSUES", width));
|
|
294
|
+
for (const iss of state.recentIssues.slice(-3)) {
|
|
295
|
+
const sev = iss.severity ?? "low";
|
|
296
|
+
const color = sev === "critical" ? c.red : sev === "high" ? c.gold : c.gold;
|
|
297
|
+
console.log(` ${color}⚠${c.reset} ${color}${sev}${c.reset} ${c.gray}·${c.reset} ${truncate(iss.title, width - 16)}`);
|
|
298
|
+
}
|
|
299
|
+
console.log("");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderRecent(width) {
|
|
303
|
+
console.log(sectionTitle("LOG", width));
|
|
304
|
+
if (state.recent.length === 0) {
|
|
305
|
+
console.log(` ${c.gray}(no events yet)${c.reset}`);
|
|
306
|
+
} else {
|
|
307
|
+
for (const ev of state.recent.slice(-6)) {
|
|
308
|
+
console.log(" " + formatEvent(ev, width - 4));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function formatEvent(ev, maxLen) {
|
|
314
|
+
const t = new Date(ev.at || Date.now());
|
|
315
|
+
const hh = String(t.getHours()).padStart(2, "0");
|
|
316
|
+
const mm = String(t.getMinutes()).padStart(2, "0");
|
|
317
|
+
const ss = String(t.getSeconds()).padStart(2, "0");
|
|
318
|
+
const ts = `${c.gray}${hh}:${mm}:${ss}${c.reset}`;
|
|
319
|
+
let body;
|
|
320
|
+
switch (ev.type) {
|
|
321
|
+
case "agent.dispatched":
|
|
322
|
+
body = `${c.gold}▶${c.reset} ${c.bold}${ev.agent ?? "?"}${c.reset} ${c.gray}· ${truncate(ev.prompt, 60)}${c.reset}`;
|
|
323
|
+
break;
|
|
324
|
+
case "agent.completed":
|
|
325
|
+
body = `${c.green}✓${c.reset} ${ev.agent ?? "*"} ${c.gray}done${c.reset}`;
|
|
326
|
+
break;
|
|
327
|
+
case "file.written":
|
|
328
|
+
body = `${c.blue}📝${c.reset} ${truncate(ev.path?.split("/").slice(-2).join("/") ?? "?", maxLen - 20)}`;
|
|
329
|
+
break;
|
|
330
|
+
case "issue.found":
|
|
331
|
+
body = `${c.red}⚠${c.reset} ${ev.severity ?? ""} ${ev.title ?? ""}`;
|
|
332
|
+
break;
|
|
333
|
+
case "session.start":
|
|
334
|
+
body = `${c.mag}◆${c.reset} session started`;
|
|
335
|
+
break;
|
|
336
|
+
case "session.end":
|
|
337
|
+
body = `${c.mag}◼${c.reset} session ended`;
|
|
338
|
+
break;
|
|
339
|
+
case "pipeline.stage":
|
|
340
|
+
body = `${c.cyan}→${c.reset} stage: ${ev.stage}`;
|
|
341
|
+
break;
|
|
342
|
+
default:
|
|
343
|
+
body = `${c.gray}${ev.type}${c.reset}`;
|
|
344
|
+
}
|
|
345
|
+
return `${ts} ${body}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function render() {
|
|
349
|
+
const width = Math.max(60, process.stdout.columns ?? 80);
|
|
350
|
+
process.stdout.write(CLEAR);
|
|
351
|
+
renderHeader();
|
|
352
|
+
renderNow(width);
|
|
353
|
+
renderPipeline(width);
|
|
354
|
+
renderAgents(width);
|
|
355
|
+
renderFiles(width);
|
|
356
|
+
renderIssues(width);
|
|
357
|
+
renderRecent(width);
|
|
358
|
+
process.stdout.write(`\n${c.gray}q / Ctrl-C to exit${c.reset}\n`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ------------------------------------------------------------------
|
|
362
|
+
// JSONL tail — read existing history, then watch for new lines.
|
|
363
|
+
// ------------------------------------------------------------------
|
|
364
|
+
let tailOffset = 0;
|
|
365
|
+
|
|
366
|
+
async function ensureEventsFile() {
|
|
367
|
+
try {
|
|
368
|
+
mkdirSync(join(process.cwd(), ".claude", "buildcrew"), { recursive: true });
|
|
369
|
+
if (!existsSync(EVENTS_PATH)) {
|
|
370
|
+
// Create empty file so watchFile has something to watch
|
|
371
|
+
closeSync(openSync(EVENTS_PATH, "a"));
|
|
372
|
+
}
|
|
373
|
+
} catch {}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function replayExisting() {
|
|
377
|
+
try {
|
|
378
|
+
const st = statSync(EVENTS_PATH);
|
|
379
|
+
tailOffset = st.size;
|
|
380
|
+
} catch {
|
|
381
|
+
tailOffset = 0;
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const stream = createReadStream(EVENTS_PATH, { encoding: "utf8", end: tailOffset - 1 });
|
|
385
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
386
|
+
for await (const line of rl) {
|
|
387
|
+
if (!line.trim()) continue;
|
|
388
|
+
try { handleEvent(JSON.parse(line)); } catch {}
|
|
389
|
+
}
|
|
390
|
+
state.connected = true;
|
|
391
|
+
scheduleRender();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function readNewBytes() {
|
|
395
|
+
let st;
|
|
396
|
+
try { st = statSync(EVENTS_PATH); } catch { return; }
|
|
397
|
+
if (st.size < tailOffset) { tailOffset = 0; } // truncated / rotated
|
|
398
|
+
if (st.size === tailOffset) return;
|
|
399
|
+
const stream = createReadStream(EVENTS_PATH, {
|
|
400
|
+
encoding: "utf8", start: tailOffset, end: st.size - 1,
|
|
401
|
+
});
|
|
402
|
+
let buf = "";
|
|
403
|
+
stream.on("data", (chunk) => { buf += chunk; });
|
|
404
|
+
await new Promise((resolve) => stream.on("end", resolve));
|
|
405
|
+
tailOffset = st.size;
|
|
406
|
+
const lines = buf.split("\n");
|
|
407
|
+
for (const line of lines) {
|
|
408
|
+
if (!line.trim()) continue;
|
|
409
|
+
try { handleEvent(JSON.parse(line)); } catch {}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function subscribeTail() {
|
|
414
|
+
watchFile(EVENTS_PATH, { interval: 400 }, () => { readNewBytes(); });
|
|
415
|
+
// Also poll as a fallback in case watchFile misses writes
|
|
416
|
+
setInterval(readNewBytes, 1000);
|
|
417
|
+
state.connected = true;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ------------------------------------------------------------------
|
|
421
|
+
// Bootstrap
|
|
422
|
+
// ------------------------------------------------------------------
|
|
423
|
+
process.stdout.write(HIDE_CURSOR);
|
|
424
|
+
process.on("exit", () => process.stdout.write(SHOW_CURSOR));
|
|
425
|
+
process.on("SIGINT", () => { process.stdout.write(SHOW_CURSOR); process.exit(0); });
|
|
426
|
+
process.on("SIGTERM", () => { process.stdout.write(SHOW_CURSOR); process.exit(0); });
|
|
427
|
+
|
|
428
|
+
// Allow 'q' to quit
|
|
429
|
+
if (process.stdin.isTTY) {
|
|
430
|
+
readline.emitKeypressEvents(process.stdin);
|
|
431
|
+
process.stdin.setRawMode(true);
|
|
432
|
+
process.stdin.on("keypress", (_str, key) => {
|
|
433
|
+
if (key?.name === "q" || (key?.ctrl && key?.name === "c")) {
|
|
434
|
+
process.stdout.write(SHOW_CURSOR);
|
|
435
|
+
process.exit(0);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Heartbeat redraw (updates elapsed even without events)
|
|
441
|
+
setInterval(scheduleRender, 1000);
|
|
442
|
+
|
|
443
|
+
// Tail the events.jsonl — replay history then watch for new lines
|
|
444
|
+
(async () => {
|
|
445
|
+
await ensureEventsFile();
|
|
446
|
+
await replayExisting();
|
|
447
|
+
subscribeTail();
|
|
448
|
+
render();
|
|
449
|
+
})();
|
|
450
|
+
|
|
451
|
+
// ------------------------------------------------------------------
|
|
452
|
+
// tiny utils
|
|
453
|
+
// ------------------------------------------------------------------
|
|
454
|
+
function truncate(s, n) {
|
|
455
|
+
if (!s) return "";
|
|
456
|
+
const t = String(s);
|
|
457
|
+
return t.length <= n ? t : t.slice(0, n - 1) + "…";
|
|
458
|
+
}
|
|
459
|
+
function formatDuration(sec) {
|
|
460
|
+
if (!Number.isFinite(sec) || sec < 0) return "0s";
|
|
461
|
+
const h = Math.floor(sec / 3600);
|
|
462
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
463
|
+
const s = sec % 60;
|
|
464
|
+
if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`;
|
|
465
|
+
if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`;
|
|
466
|
+
return `${s}s`;
|
|
467
|
+
}
|
|
468
|
+
function stripAnsi(s) { return String(s).replace(/\x1b\[[0-9;]*m/g, ""); }
|
|
469
|
+
function padEnd(s, n) {
|
|
470
|
+
const visible = stripAnsi(s);
|
|
471
|
+
const padLen = Math.max(0, n - visible.length);
|
|
472
|
+
return s + " ".repeat(padLen);
|
|
473
|
+
}
|
package/lib/hook.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code hook handler for buildcrew.
|
|
4
|
+
*
|
|
5
|
+
* Reads the CC hook JSON from stdin and does two things:
|
|
6
|
+
* 1. Prints a compact color-coded banner to the user's terminal so the
|
|
7
|
+
* agent lifecycle is visible inline with the CC output.
|
|
8
|
+
* 2. Appends the event to .claude/buildcrew/events.jsonl so
|
|
9
|
+
* `npx buildcrew watch` (or later tooling) can show a live view.
|
|
10
|
+
*
|
|
11
|
+
* Called as: node dashboard/hooks/emit.js <kind>
|
|
12
|
+
* Kinds: pre-agent | post-agent | file-written | user-prompt
|
|
13
|
+
* | subagent-stop | session-end
|
|
14
|
+
*
|
|
15
|
+
* MUST be fast and MUST silent-fail (hooks block Claude Code if they hang).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { openSync, writeSync, closeSync, mkdirSync, appendFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
const kind = process.argv[2];
|
|
22
|
+
const EVENTS_PATH = join(process.cwd(), ".claude", "buildcrew", "events.jsonl");
|
|
23
|
+
|
|
24
|
+
// ------------------------------------------------------------------
|
|
25
|
+
// Terminal banner output — writes a compact styled line straight to
|
|
26
|
+
// /dev/tty so it appears in the user's CC terminal regardless of how
|
|
27
|
+
// the hook process's stdio is captured. NO_COLOR env disables ANSI.
|
|
28
|
+
// ------------------------------------------------------------------
|
|
29
|
+
const NO_COLOR = !!process.env.NO_COLOR || process.env.BUILDCREW_HOOK_QUIET === "1";
|
|
30
|
+
const C = NO_COLOR
|
|
31
|
+
? new Proxy({}, { get: () => "" })
|
|
32
|
+
: {
|
|
33
|
+
reset: "\x1b[0m",
|
|
34
|
+
bold: "\x1b[1m",
|
|
35
|
+
dim: "\x1b[2m",
|
|
36
|
+
red: "\x1b[31m",
|
|
37
|
+
green: "\x1b[32m",
|
|
38
|
+
gold: "\x1b[33m",
|
|
39
|
+
blue: "\x1b[34m",
|
|
40
|
+
mag: "\x1b[35m",
|
|
41
|
+
cyan: "\x1b[36m",
|
|
42
|
+
gray: "\x1b[90m",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const AGENT_EMOJI = {
|
|
46
|
+
buildcrew: "🎩", planner: "📋", designer: "🎨", developer: "💻",
|
|
47
|
+
"qa-tester": "🧪", "browser-qa": "🌐", reviewer: "🧐", "health-checker": "🩺",
|
|
48
|
+
"security-auditor": "🛡", "canary-monitor": "🐤", shipper: "🚢",
|
|
49
|
+
thinker: "🤔", architect: "📐", "design-reviewer": "👀",
|
|
50
|
+
investigator: "🕵", "qa-auditor": "⚖",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function writeTTY(line) {
|
|
54
|
+
if (process.env.BUILDCREW_HOOK_QUIET === "1") return;
|
|
55
|
+
let fd;
|
|
56
|
+
try {
|
|
57
|
+
fd = openSync("/dev/tty", "w");
|
|
58
|
+
writeSync(fd, line + "\n");
|
|
59
|
+
} catch {
|
|
60
|
+
// No TTY (CI, pipe, etc.) — fall back to stderr
|
|
61
|
+
try { process.stderr.write(line + "\n"); } catch {}
|
|
62
|
+
} finally {
|
|
63
|
+
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function banner(kind, data) {
|
|
68
|
+
switch (kind) {
|
|
69
|
+
case "pre-agent": {
|
|
70
|
+
const agent = data?.tool_input?.subagent_type ?? "agent";
|
|
71
|
+
const icon = AGENT_EMOJI[agent] ?? "●";
|
|
72
|
+
const prompt = truncate(data?.tool_input?.prompt ?? data?.tool_input?.description ?? "", 90);
|
|
73
|
+
return `${C.gold}▶${C.reset} ${icon} ${C.bold}${agent}${C.reset} ${C.gray}· ${prompt}${C.reset}`;
|
|
74
|
+
}
|
|
75
|
+
case "post-agent": {
|
|
76
|
+
const agent = data?.tool_input?.subagent_type ?? "agent";
|
|
77
|
+
const icon = AGENT_EMOJI[agent] ?? "●";
|
|
78
|
+
return `${C.green}✓${C.reset} ${icon} ${C.bold}${agent}${C.reset} ${C.gray}done${C.reset}`;
|
|
79
|
+
}
|
|
80
|
+
case "file-written": {
|
|
81
|
+
const p = data?.tool_input?.file_path;
|
|
82
|
+
if (!p) return null;
|
|
83
|
+
const rel = p.split("/").slice(-2).join("/");
|
|
84
|
+
return `${C.blue}📝${C.reset} ${rel} ${C.gray}(${data?.tool_name ?? "edit"})${C.reset}`;
|
|
85
|
+
}
|
|
86
|
+
case "session-end":
|
|
87
|
+
return `${C.mag}◼${C.reset} ${C.dim}session ended${C.reset}`;
|
|
88
|
+
default:
|
|
89
|
+
return null; // user-prompt + subagent-stop are too noisy for terminal
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function main() {
|
|
94
|
+
if (!kind) process.exit(0);
|
|
95
|
+
|
|
96
|
+
// Read stdin (hooks send JSON on stdin)
|
|
97
|
+
let input = "";
|
|
98
|
+
try {
|
|
99
|
+
process.stdin.setEncoding("utf8");
|
|
100
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
101
|
+
} catch {
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let data = {};
|
|
106
|
+
if (input.trim()) {
|
|
107
|
+
try { data = JSON.parse(input); } catch { /* ignore malformed */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Print styled banner to the user's terminal (this is the primary UX now —
|
|
111
|
+
// dashboard HTTP broadcast is optional and only works when server is up).
|
|
112
|
+
try {
|
|
113
|
+
const line = banner(kind, data);
|
|
114
|
+
if (line) writeTTY(line);
|
|
115
|
+
} catch {}
|
|
116
|
+
|
|
117
|
+
const events = toEvents(kind, data);
|
|
118
|
+
if (!events.length) process.exit(0);
|
|
119
|
+
|
|
120
|
+
// Append events to the JSONL log — silent-fail so hooks never block CC.
|
|
121
|
+
try {
|
|
122
|
+
mkdirSync(join(process.cwd(), ".claude", "buildcrew"), { recursive: true });
|
|
123
|
+
const lines = events
|
|
124
|
+
.map((e) => JSON.stringify({ ...e, at: e.at ?? new Date().toISOString() }))
|
|
125
|
+
.join("\n") + "\n";
|
|
126
|
+
appendFileSync(EVENTS_PATH, lines);
|
|
127
|
+
} catch {
|
|
128
|
+
// Disk full / permission — silently ignore; banner already printed.
|
|
129
|
+
}
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function toEvents(kind, data) {
|
|
134
|
+
// Every event carries session_id so the dashboard can disambiguate
|
|
135
|
+
// concurrent Claude Code sessions running in the same project.
|
|
136
|
+
const sessionId = data?.session_id ?? "unknown";
|
|
137
|
+
|
|
138
|
+
switch (kind) {
|
|
139
|
+
case "pre-agent": {
|
|
140
|
+
const subagent = data?.tool_input?.subagent_type ?? "agent";
|
|
141
|
+
const prompt = truncate(data?.tool_input?.prompt ?? data?.tool_input?.description ?? "", 400);
|
|
142
|
+
return [{
|
|
143
|
+
type: "agent.dispatched",
|
|
144
|
+
agent: subagent, from: "buildcrew", prompt,
|
|
145
|
+
session_id: sessionId,
|
|
146
|
+
}];
|
|
147
|
+
}
|
|
148
|
+
case "post-agent": {
|
|
149
|
+
const subagent = data?.tool_input?.subagent_type ?? "agent";
|
|
150
|
+
const resp = data?.tool_response;
|
|
151
|
+
let summary = "";
|
|
152
|
+
if (typeof resp === "string") summary = resp.slice(0, 500);
|
|
153
|
+
else if (resp?.content?.[0]?.text) summary = String(resp.content[0].text).slice(0, 500);
|
|
154
|
+
return [{
|
|
155
|
+
type: "agent.completed",
|
|
156
|
+
agent: subagent, output_summary: summary,
|
|
157
|
+
session_id: sessionId,
|
|
158
|
+
}];
|
|
159
|
+
}
|
|
160
|
+
case "file-written": {
|
|
161
|
+
const path = data?.tool_input?.file_path;
|
|
162
|
+
if (!path) return [];
|
|
163
|
+
// CC hook payload doesn't include "current subagent" info. We default to
|
|
164
|
+
// "buildcrew" (team lead) so the dashboard HONESTLY reveals when the lead
|
|
165
|
+
// is writing files directly — which is a pipeline violation per Feature
|
|
166
|
+
// mode rules. Dashboard's pipeline-integrity check warns on this.
|
|
167
|
+
return [{
|
|
168
|
+
type: "file.written",
|
|
169
|
+
agent: data?.agent ?? "buildcrew", path,
|
|
170
|
+
tool_name: data?.tool_name,
|
|
171
|
+
session_id: sessionId,
|
|
172
|
+
}];
|
|
173
|
+
}
|
|
174
|
+
case "user-prompt": {
|
|
175
|
+
const sid = sessionId === "unknown" ? `cc-${Date.now()}` : sessionId;
|
|
176
|
+
const events = [{
|
|
177
|
+
type: "session.start",
|
|
178
|
+
session_id: sid,
|
|
179
|
+
mode: "feature",
|
|
180
|
+
}];
|
|
181
|
+
// CC's @mention invocations don't fire PreToolUse:Agent — they spawn
|
|
182
|
+
// subagents directly. Parse @<agent-name> from the prompt so the town
|
|
183
|
+
// shows a bubble when the user routes via @buildcrew (or any agent).
|
|
184
|
+
const prompt = String(data?.prompt ?? "");
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const mentionRe = /(?:^|\s)@([a-z][a-z0-9-]*)/gi;
|
|
187
|
+
let m;
|
|
188
|
+
while ((m = mentionRe.exec(prompt)) !== null) {
|
|
189
|
+
const name = m[1].toLowerCase();
|
|
190
|
+
if (seen.has(name)) continue;
|
|
191
|
+
seen.add(name);
|
|
192
|
+
events.push({
|
|
193
|
+
type: "agent.dispatched",
|
|
194
|
+
agent: name,
|
|
195
|
+
from: "user",
|
|
196
|
+
prompt: truncate(prompt, 400),
|
|
197
|
+
session_id: sid,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return events;
|
|
201
|
+
}
|
|
202
|
+
case "subagent-stop": {
|
|
203
|
+
// SubagentStop fires when an @-mention subagent finishes. CC's payload
|
|
204
|
+
// doesn't tell us which agent stopped, so we emit a sweep — the client
|
|
205
|
+
// idles every currently-active agent for this session.
|
|
206
|
+
return [{
|
|
207
|
+
type: "agent.completed",
|
|
208
|
+
agent: null,
|
|
209
|
+
sweep: true,
|
|
210
|
+
session_id: sessionId,
|
|
211
|
+
}];
|
|
212
|
+
}
|
|
213
|
+
case "session-end": {
|
|
214
|
+
return [{
|
|
215
|
+
type: "session.end",
|
|
216
|
+
session_id: sessionId,
|
|
217
|
+
outcome: "success",
|
|
218
|
+
}];
|
|
219
|
+
}
|
|
220
|
+
default:
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function truncate(s, n) {
|
|
226
|
+
const t = String(s);
|
|
227
|
+
return t.length <= n ? t : t.slice(0, n - 1) + "…";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
main();
|