buildcrew 1.8.7 → 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/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();