buildcrew 1.9.0 → 1.9.2

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/setup.js CHANGED
@@ -649,6 +649,97 @@ async function runWatch() {
649
649
  });
650
650
  }
651
651
 
652
+ async function runReport() {
653
+ // Show coherence-report.md output by the coherence-auditor agent.
654
+ // Usage:
655
+ // npx buildcrew report Show latest coherence-report
656
+ // npx buildcrew report --list List all reports with timestamps
657
+ // npx buildcrew report <feature> Show specific feature's report
658
+ // npx buildcrew report --raw Print raw markdown (for piping)
659
+ const args = process.argv.slice(3);
660
+ const wantList = args.includes("--list") || args.includes("-l");
661
+ const wantRaw = args.includes("--raw");
662
+ const featureArg = args.find(a => !a.startsWith("-"));
663
+
664
+ const PIPELINE_DIR = join(process.cwd(), ".claude", "pipeline");
665
+ if (!(await exists(PIPELINE_DIR))) {
666
+ log(`${YELLOW}No pipeline runs found yet.${RESET}`);
667
+ log(`${DIM}Run ${BOLD}@buildcrew <feature>${RESET}${DIM} in Claude Code to generate one.${RESET}\n`);
668
+ return;
669
+ }
670
+
671
+ // Collect all coherence-report.md files and their mtimes
672
+ const features = await readdir(PIPELINE_DIR, { withFileTypes: true });
673
+ const reports = [];
674
+ const { stat } = await import("fs/promises");
675
+ for (const entry of features) {
676
+ if (!entry.isDirectory()) continue;
677
+ const reportPath = join(PIPELINE_DIR, entry.name, "coherence-report.md");
678
+ if (await exists(reportPath)) {
679
+ const s = await stat(reportPath);
680
+ reports.push({ feature: entry.name, path: reportPath, mtime: s.mtime });
681
+ }
682
+ }
683
+
684
+ if (reports.length === 0) {
685
+ log(`${YELLOW}No coherence-report.md found in any pipeline run.${RESET}`);
686
+ log(`${DIM}coherence-auditor runs at the end of Feature mode. If you ran a feature recently and don't see a report, check your buildcrew version (need >= 1.9.0).${RESET}\n`);
687
+ return;
688
+ }
689
+
690
+ reports.sort((a, b) => b.mtime - a.mtime);
691
+
692
+ if (wantList) {
693
+ log(`\n ${BOLD}Coherence reports${RESET} ${DIM}(newest first)${RESET}\n`);
694
+ for (const r of reports) {
695
+ const ago = ((Date.now() - r.mtime) / 1000 / 60) | 0;
696
+ const when = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${(ago/60)|0}h ago` : `${(ago/1440)|0}d ago`;
697
+ log(` ${CYAN}${r.feature.padEnd(30)}${RESET} ${DIM}${when.padStart(8)}${RESET} ${DIM}${r.path}${RESET}`);
698
+ }
699
+ log(`\n ${DIM}Show one: ${BOLD}npx buildcrew report ${CYAN}<feature>${RESET}\n`);
700
+ return;
701
+ }
702
+
703
+ // Pick target report
704
+ let target;
705
+ if (featureArg) {
706
+ target = reports.find(r => r.feature === featureArg);
707
+ if (!target) {
708
+ log(`${RED}No coherence-report for feature "${featureArg}".${RESET}`);
709
+ log(`${DIM}List all: ${BOLD}npx buildcrew report --list${RESET}\n`);
710
+ process.exit(1);
711
+ }
712
+ } else {
713
+ target = reports[0]; // latest
714
+ }
715
+
716
+ const content = await readFile(target.path, "utf8");
717
+
718
+ if (wantRaw) {
719
+ process.stdout.write(content);
720
+ return;
721
+ }
722
+
723
+ // Pretty header + content. If TTY supports it, try less for paging.
724
+ const header = `${BOLD}${CYAN}═══ ${target.feature} ═══${RESET} ${DIM}${target.path}${RESET}\n\n`;
725
+
726
+ if (process.stdout.isTTY && content.split("\n").length > process.stdout.rows) {
727
+ // Try paging through `less -R` (preserves ANSI). Fallback to direct print.
728
+ try {
729
+ const { spawn } = await import("child_process");
730
+ const less = spawn("less", ["-R", "-X"], { stdio: ["pipe", "inherit", "inherit"] });
731
+ less.stdin.write(header + content);
732
+ less.stdin.end();
733
+ await new Promise((resolve) => less.on("exit", resolve));
734
+ return;
735
+ } catch {
736
+ // fall through to direct print
737
+ }
738
+ }
739
+
740
+ process.stdout.write(header + content + "\n");
741
+ }
742
+
652
743
  async function runUninstall() {
653
744
  const files = (await readdir(AGENTS_SRC)).filter(f => f.endsWith(".md"));
654
745
  if (!(await exists(TARGET_DIR))) { log(`${YELLOW}No agents found.${RESET}`); return; }
@@ -681,6 +772,8 @@ async function main() {
681
772
  npx buildcrew add <name> Add a harness template
682
773
  npx buildcrew harness Show harness file status
683
774
  npx buildcrew watch Live terminal monitor (stays in your shell)
775
+ npx buildcrew report Show latest coherence-report (team coordination score)
776
+ npx buildcrew report --list List all coherence reports
684
777
 
685
778
  ${BOLD}Options:${RESET}
686
779
  --force, -f Overwrite existing files
@@ -700,12 +793,14 @@ async function main() {
700
793
  return;
701
794
  }
702
795
 
703
- if (args.includes("--list") || args.includes("-l")) return runList();
704
- if (args.includes("--uninstall")) return runUninstall();
796
+ // Subcommand routing takes priority over global --list (so `report --list` works)
705
797
  if (command === "init") return runInit(force);
706
798
  if (command === "add") return runAdd(subcommand, force);
707
799
  if (command === "harness") return runHarnessStatus();
708
800
  if (command === "watch") return runWatch();
801
+ if (command === "report") return runReport();
802
+ if (args.includes("--list") || args.includes("-l")) return runList();
803
+ if (args.includes("--uninstall")) return runUninstall();
709
804
 
710
805
  return runInstall(force);
711
806
  }
package/bin/watch.js CHANGED
@@ -11,9 +11,13 @@
11
11
  * Exit with q or Ctrl-C.
12
12
  */
13
13
 
14
- import { createReadStream, watchFile, statSync, existsSync, mkdirSync, closeSync, openSync } from "node:fs";
15
- import { join } from "node:path";
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";
16
17
  import readline, { createInterface } from "node:readline";
18
+ import { spawnSync } from "node:child_process";
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
21
 
18
22
  const EVENTS_PATH = process.env.BUILDCREW_EVENTS_PATH
19
23
  ?? join(process.cwd(), ".claude", "buildcrew", "events.jsonl");
@@ -28,11 +32,23 @@ const c = NO_COLOR
28
32
  reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
29
33
  black: "\x1b[30m", red: "\x1b[31m", green: "\x1b[32m",
30
34
  gold: "\x1b[33m", blue: "\x1b[34m", mag: "\x1b[35m",
31
- cyan: "\x1b[36m", gray: "\x1b[90m",
35
+ cyan: "\x1b[36m",
36
+ // Primary secondary text — readable on dark terminals (was \x1b[90m which rendered too dim)
37
+ gray: "\x1b[38;5;250m",
38
+ // Muted — for truly tertiary metadata (timestamps, separators)
39
+ muted: "\x1b[38;5;244m",
32
40
  bgWood: "\x1b[48;5;94m",
33
41
  };
34
42
 
35
- const CLEAR = "\x1b[2J\x1b[H";
43
+ // Anti-flicker rendering primitives.
44
+ // HOME moves cursor to top-left WITHOUT clearing — we overwrite in place and
45
+ // use CLR_EOL per line + CLR_BELOW at the end to erase leftovers. The old
46
+ // `\x1b[2J\x1b[H` caused a visible flash every frame (blank → redraw).
47
+ const HOME = "\x1b[H";
48
+ const CLR_EOL = "\x1b[K"; // clear from cursor to end of line
49
+ const CLR_BELOW = "\x1b[J"; // clear from cursor to end of screen
50
+ const ALT_SCREEN_ON = "\x1b[?1049h";
51
+ const ALT_SCREEN_OFF = "\x1b[?1049l";
36
52
  const HIDE_CURSOR = "\x1b[?25l";
37
53
  const SHOW_CURSOR = "\x1b[?25h";
38
54
 
@@ -88,8 +104,54 @@ const state = {
88
104
  sessionId: null,
89
105
  sessionStartAt: null,
90
106
  sessionEndAt: null,
107
+ // Coherence: loaded from .claude/pipeline/*/coherence-report.md after coherence-auditor runs
108
+ coherence: null, // { score, status, feature, gaps, fabrications, edgesActual, edgesPossible, path, ts }
91
109
  };
92
110
 
111
+ // ------------------------------------------------------------------
112
+ // Coherence report loader — reads .claude/pipeline/{feature}/coherence-report.md
113
+ // Triggered on agent.completed(coherence-auditor) or file.written(*/coherence-report.md)
114
+ // ------------------------------------------------------------------
115
+ function loadLatestCoherence() {
116
+ try {
117
+ const pipelineDir = join(process.cwd(), ".claude", "pipeline");
118
+ if (!existsSync(pipelineDir)) return;
119
+ const features = readdirSync(pipelineDir, { withFileTypes: true }).filter(d => d.isDirectory());
120
+ let newest = null;
121
+ for (const f of features) {
122
+ const p = join(pipelineDir, f.name, "coherence-report.md");
123
+ if (!existsSync(p)) continue;
124
+ const s = statSync(p);
125
+ if (!newest || s.mtimeMs > newest.mtime) {
126
+ newest = { path: p, feature: f.name, mtime: s.mtimeMs };
127
+ }
128
+ }
129
+ if (!newest) return;
130
+ const content = readFileSync(newest.path, "utf8");
131
+ // Tolerant parsing — coherence-auditor writes Korean or English.
132
+ const score = parseInt(content.match(/Coordination Score\*?\*?:?\s*\*?\*?(\d+)\s*%/)?.[1] ?? "", 10);
133
+ const edges = content.match(/\((\d+)\s*\/\s*(\d+)\s+edges?\)/);
134
+ const status = content.match(/Status:\s*([A-Za-z]+)/)?.[1] ?? "";
135
+ const fabrications = parseInt(content.match(/Fabrications?:\s*\*?\*?(\d+)/)?.[1] ?? "0", 10);
136
+ // Gap count from "## Gaps (N)" heading
137
+ const gaps = parseInt(content.match(/##\s*Gaps?\s*\((\d+)\)/)?.[1] ?? "0", 10);
138
+ state.coherence = {
139
+ score: Number.isFinite(score) ? score : null,
140
+ status,
141
+ feature: newest.feature,
142
+ gaps,
143
+ fabrications,
144
+ edgesActual: edges ? parseInt(edges[1], 10) : null,
145
+ edgesPossible: edges ? parseInt(edges[2], 10) : null,
146
+ path: newest.path,
147
+ ts: newest.mtime,
148
+ };
149
+ scheduleRender();
150
+ } catch {
151
+ // Swallow — coherence is best-effort, never crashes the watch
152
+ }
153
+ }
154
+
93
155
  function handleEvent(ev) {
94
156
  state.events += 1;
95
157
  const at = Date.parse(ev.at) || Date.now();
@@ -98,12 +160,37 @@ function handleEvent(ev) {
98
160
 
99
161
  switch (ev.type) {
100
162
  case "session.start":
163
+ // New session → clear per-session state. Watch is a live observer for the
164
+ // current session; persistent project progress belongs in docs/ (PDCA).
165
+ // Keep: coherence (file-derived), session metadata itself.
166
+ state.currentStage = null;
167
+ state.completedStages = new Set();
168
+ state.activeAgents = new Map();
169
+ state.completedAgents = new Map();
170
+ state.events = 0;
171
+ state.files = 0;
172
+ state.issues = { critical: 0, high: 0, med: 0, low: 0 };
173
+ state.recent = [];
174
+ state.recentFiles = [];
175
+ state.recentIssues = [];
101
176
  state.sessionStartAt = at;
102
177
  state.sessionEndAt = null;
103
178
  if (ev.session_id) state.sessionId = ev.session_id;
104
179
  break;
105
180
  case "session.end":
106
181
  state.sessionEndAt = at;
182
+ // Sweep any agents still marked active — completed events can be missed
183
+ // (e.g. @mentions in prompt text that hook logs as dispatched but never
184
+ // actually invoke the Agent tool). Session end implies nothing is running.
185
+ for (const id of [...state.activeAgents.keys()]) {
186
+ const a = state.activeAgents.get(id);
187
+ state.activeAgents.delete(id);
188
+ state.completedAgents.set(id, {
189
+ lastAt: at,
190
+ duration: Math.max(0, at - a.startAt),
191
+ summary: "",
192
+ });
193
+ }
107
194
  break;
108
195
  case "agent.dispatched": {
109
196
  if (!ev.agent) break;
@@ -130,10 +217,18 @@ function handleEvent(ev) {
130
217
  };
131
218
  if (ev.agent) closeAgent(ev.agent, at);
132
219
  else if (ev.sweep) for (const id of [...state.activeAgents.keys()]) closeAgent(id, at);
220
+ // Coherence: when coherence-auditor finishes, reload the latest report
221
+ if (ev.agent === "coherence-auditor") {
222
+ loadLatestCoherence();
223
+ }
133
224
  break;
134
225
  }
135
226
  case "file.written":
136
227
  state.files += 1;
228
+ // Coherence: if a coherence-report.md was just written, reload
229
+ if (ev.path && ev.path.endsWith("/coherence-report.md")) {
230
+ loadLatestCoherence();
231
+ }
137
232
  if (ev.path) {
138
233
  state.recentFiles.push({ path: ev.path, tool: ev.tool_name, agent: ev.agent, at });
139
234
  if (state.recentFiles.length > 6) state.recentFiles.shift();
@@ -226,7 +321,7 @@ function renderNow(width) {
226
321
  const emoji = (AGENTS.find(a => a.id === id)?.emoji) ?? "●";
227
322
  const elapsed = formatDuration(Math.floor((now - info.startAt) / 1000));
228
323
  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}`);
324
+ console.log(` ${c.gold}●${c.reset} ${emoji} ${c.bold}${id}${c.reset} ${c.muted}${elapsed} ·${c.reset} ${prompt}`);
230
325
  }
231
326
  }
232
327
  console.log("");
@@ -299,6 +394,40 @@ function renderIssues(width) {
299
394
  console.log("");
300
395
  }
301
396
 
397
+ function renderCoherence(width) {
398
+ if (!state.coherence) return;
399
+ const co = state.coherence;
400
+ console.log(sectionTitle("COHERENCE", width));
401
+ // Score color: 90+ green, 70-89 cyan, 50-69 gold, <50 red
402
+ let scoreColor = c.gray, statusEmoji = "○";
403
+ if (co.score == null) {
404
+ scoreColor = c.gray;
405
+ statusEmoji = "?";
406
+ } else if (co.score >= 90) {
407
+ scoreColor = c.green; statusEmoji = "✓";
408
+ } else if (co.score >= 70) {
409
+ scoreColor = c.cyan; statusEmoji = "●";
410
+ } else if (co.score >= 50) {
411
+ scoreColor = c.gold; statusEmoji = "⚠";
412
+ } else {
413
+ scoreColor = c.red; statusEmoji = "✗";
414
+ }
415
+ const scoreStr = co.score == null ? `${c.gray}—${c.reset}` : `${scoreColor}${c.bold}${co.score}%${c.reset}`;
416
+ const statusStr = co.status ? `${scoreColor}${co.status}${c.reset}` : `${c.gray}—${c.reset}`;
417
+ const edgesStr = (co.edgesActual != null && co.edgesPossible != null)
418
+ ? `${c.gray}(${co.edgesActual}/${co.edgesPossible} edges)${c.reset}`
419
+ : "";
420
+ const fabBadge = co.fabrications > 0
421
+ ? ` ${c.red}🚨 ${co.fabrications} fabrication${co.fabrications > 1 ? "s" : ""}${c.reset}`
422
+ : "";
423
+ const gapBadge = co.gaps > 0
424
+ ? ` ${c.gold}⚠ ${co.gaps} gap${co.gaps > 1 ? "s" : ""}${c.reset}`
425
+ : ` ${c.gray}no gaps${c.reset}`;
426
+ console.log(` ${statusEmoji} ${scoreStr} ${statusStr} ${edgesStr}${gapBadge}${fabBadge}`);
427
+ 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}`);
428
+ console.log("");
429
+ }
430
+
302
431
  function renderRecent(width) {
303
432
  console.log(sectionTitle("LOG", width));
304
433
  if (state.recent.length === 0) {
@@ -319,7 +448,7 @@ function formatEvent(ev, maxLen) {
319
448
  let body;
320
449
  switch (ev.type) {
321
450
  case "agent.dispatched":
322
- body = `${c.gold}▶${c.reset} ${c.bold}${ev.agent ?? "?"}${c.reset} ${c.gray}· ${truncate(ev.prompt, 60)}${c.reset}`;
451
+ body = `${c.gold}▶${c.reset} ${c.bold}${ev.agent ?? "?"}${c.reset} ${c.muted}·${c.reset} ${truncate(ev.prompt, 60)}`;
323
452
  break;
324
453
  case "agent.completed":
325
454
  body = `${c.green}✓${c.reset} ${ev.agent ?? "*"} ${c.gray}done${c.reset}`;
@@ -347,15 +476,36 @@ function formatEvent(ev, maxLen) {
347
476
 
348
477
  function render() {
349
478
  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`);
479
+
480
+ // Capture every render*() call's output into an in-memory buffer by
481
+ // monkey-patching console.log for the duration of the render. This lets us
482
+ // emit the whole frame in a single process.stdout.write — eliminating the
483
+ // per-line flicker that came from 30+ separate writes.
484
+ const lines = [];
485
+ const origLog = console.log;
486
+ console.log = (...args) => {
487
+ lines.push(args.length === 0 ? "" : args.map(String).join(" "));
488
+ };
489
+ try {
490
+ renderHeader();
491
+ renderNow(width);
492
+ renderPipeline(width);
493
+ renderAgents(width);
494
+ renderFiles(width);
495
+ renderIssues(width);
496
+ renderCoherence(width);
497
+ renderRecent(width);
498
+ } finally {
499
+ console.log = origLog;
500
+ }
501
+ lines.push("");
502
+ lines.push(`${c.gray}q quit · r show full coherence report${c.reset}`);
503
+
504
+ // Single atomic frame: cursor home → each line + clear-to-EOL (erases any
505
+ // leftover chars from a previous longer line) → clear-below (handles frame
506
+ // shrinkage). No `\x1b[2J` flash.
507
+ const frame = HOME + lines.map(l => l + CLR_EOL).join("\n") + "\n" + CLR_BELOW;
508
+ process.stdout.write(frame);
359
509
  }
360
510
 
361
511
  // ------------------------------------------------------------------
@@ -381,6 +531,13 @@ async function replayExisting() {
381
531
  tailOffset = 0;
382
532
  return;
383
533
  }
534
+ // Empty events.jsonl (first run / fresh install) — nothing to replay.
535
+ // createReadStream with end: -1 would throw RangeError.
536
+ if (tailOffset === 0) {
537
+ state.connected = true;
538
+ scheduleRender();
539
+ return;
540
+ }
384
541
  const stream = createReadStream(EVENTS_PATH, { encoding: "utf8", end: tailOffset - 1 });
385
542
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
386
543
  for await (const line of rl) {
@@ -420,12 +577,15 @@ function subscribeTail() {
420
577
  // ------------------------------------------------------------------
421
578
  // Bootstrap
422
579
  // ------------------------------------------------------------------
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
580
+ // Enter alternate screen so the dashboard doesn't scribble over the user's
581
+ // scrollback. On exit we return the terminal to its pre-watch state.
582
+ process.stdout.write(ALT_SCREEN_ON + HIDE_CURSOR);
583
+ const restoreTerm = () => process.stdout.write(SHOW_CURSOR + ALT_SCREEN_OFF);
584
+ process.on("exit", restoreTerm);
585
+ process.on("SIGINT", () => { restoreTerm(); process.exit(0); });
586
+ process.on("SIGTERM", () => { restoreTerm(); process.exit(0); });
587
+
588
+ // Keypress handlers: q/Ctrl-C quit, r open full coherence report
429
589
  if (process.stdin.isTTY) {
430
590
  readline.emitKeypressEvents(process.stdin);
431
591
  process.stdin.setRawMode(true);
@@ -434,6 +594,23 @@ if (process.stdin.isTTY) {
434
594
  process.stdout.write(SHOW_CURSOR);
435
595
  process.exit(0);
436
596
  }
597
+ if (key?.name === "r") {
598
+ // Open the full coherence report. Hand off the terminal to setup.js's
599
+ // report subcommand which uses `less -R` for paging. Leave the alt
600
+ // screen so less paints on the main buffer; re-enter on return.
601
+ process.stdout.write(SHOW_CURSOR + ALT_SCREEN_OFF);
602
+ process.stdin.setRawMode(false);
603
+ const setupEntry = resolve(__dirname, "setup.js");
604
+ spawnSync(process.execPath, [setupEntry, "report"], {
605
+ stdio: "inherit",
606
+ cwd: process.cwd(),
607
+ env: process.env,
608
+ });
609
+ // Restore raw mode + alt screen + hide cursor + redraw
610
+ process.stdin.setRawMode(true);
611
+ process.stdout.write(ALT_SCREEN_ON + HIDE_CURSOR);
612
+ scheduleRender();
613
+ }
437
614
  });
438
615
  }
439
616
 
@@ -444,6 +621,9 @@ setInterval(scheduleRender, 1000);
444
621
  (async () => {
445
622
  await ensureEventsFile();
446
623
  await replayExisting();
624
+ // Surface the latest coherence report (if any) on startup, even before any
625
+ // new events fire — so users see their last score when they open watch.
626
+ loadLatestCoherence();
447
627
  subscribeTail();
448
628
  render();
449
629
  })();
@@ -2,10 +2,18 @@
2
2
  * buildcrew CC hook installer.
3
3
  *
4
4
  * Registers hook entries in .claude/settings.json that invoke
5
- * `npx buildcrew-hook <kind>` on each agent/file event. The hook writes
5
+ * `node <abs-path>/lib/hook.js <kind>` on each agent/file event. The hook writes
6
6
  * a styled banner to the terminal AND appends to events.jsonl so that
7
7
  * `npx buildcrew watch` can show a live view in a separate pane.
8
8
  *
9
+ * We resolve an absolute path to the installed buildcrew package's hook.js at
10
+ * install time rather than using `npx buildcrew-hook` because:
11
+ * - Bare `npx buildcrew-hook` looks up a package literally named
12
+ * "buildcrew-hook" → E404 (it's a bin inside the `buildcrew` package).
13
+ * - `npx -p buildcrew buildcrew-hook` works but re-fetches on cache miss and
14
+ * adds 200-500ms latency per CC hook invocation.
15
+ * - Absolute node path is zero-overhead and immune to npx cache eviction.
16
+ *
9
17
  * Idempotent — re-install replaces prior buildcrew entries without
10
18
  * touching other hooks or permissions in the file.
11
19
  */
@@ -13,9 +21,15 @@
13
21
  import { promises as fsp } from "node:fs";
14
22
  import path from "node:path";
15
23
  import os from "node:os";
24
+ import { fileURLToPath } from "node:url";
16
25
 
17
26
  const BUILDCREW_TAG = "buildcrew-hook";
18
27
 
28
+ // Absolute path to this package's hook script — resolved at import time so the
29
+ // generated settings.json entries are self-contained (no PATH lookups needed).
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+ const HOOK_SCRIPT = path.resolve(__dirname, "hook.js");
32
+
19
33
  export function resolveSettingsPath({ scope, cwd }) {
20
34
  if (scope === "global") return path.join(os.homedir(), ".claude", "settings.json");
21
35
  return path.join(cwd, ".claude", "settings.json");
@@ -27,7 +41,9 @@ export function resolvePermissionsPath({ scope, cwd }) {
27
41
  }
28
42
 
29
43
  export function buildcrewHooks() {
30
- const cmd = (kind) => `npx buildcrew-hook ${kind}`;
44
+ // Shell-escape the path in case the install location contains spaces or
45
+ // non-ASCII characters (e.g. Korean path segments on macOS).
46
+ const cmd = (kind) => `node "${HOOK_SCRIPT}" ${kind}`;
31
47
  const mk = (kind, matcher) => ({
32
48
  [BUILDCREW_TAG]: true,
33
49
  ...(matcher ? { matcher } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buildcrew",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "15 AI agents for Claude Code — full development lifecycle from product thinking to production monitoring",
5
5
  "homepage": "https://buildcrew-landing.vercel.app",
6
6
  "author": "z1nun",