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/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();
@@ -0,0 +1,165 @@
1
+ /**
2
+ * buildcrew CC hook installer.
3
+ *
4
+ * Registers hook entries in .claude/settings.json that invoke
5
+ * `npx buildcrew-hook <kind>` on each agent/file event. The hook writes
6
+ * a styled banner to the terminal AND appends to events.jsonl so that
7
+ * `npx buildcrew watch` can show a live view in a separate pane.
8
+ *
9
+ * Idempotent โ€” re-install replaces prior buildcrew entries without
10
+ * touching other hooks or permissions in the file.
11
+ */
12
+
13
+ import { promises as fsp } from "node:fs";
14
+ import path from "node:path";
15
+ import os from "node:os";
16
+
17
+ const BUILDCREW_TAG = "buildcrew-hook";
18
+
19
+ export function resolveSettingsPath({ scope, cwd }) {
20
+ if (scope === "global") return path.join(os.homedir(), ".claude", "settings.json");
21
+ return path.join(cwd, ".claude", "settings.json");
22
+ }
23
+
24
+ export function resolvePermissionsPath({ scope, cwd }) {
25
+ if (scope === "global") return path.join(os.homedir(), ".claude", "settings.local.json");
26
+ return path.join(cwd, ".claude", "settings.local.json");
27
+ }
28
+
29
+ export function buildcrewHooks() {
30
+ const cmd = (kind) => `npx buildcrew-hook ${kind}`;
31
+ const mk = (kind, matcher) => ({
32
+ [BUILDCREW_TAG]: true,
33
+ ...(matcher ? { matcher } : {}),
34
+ hooks: [{ type: "command", command: cmd(kind) }],
35
+ });
36
+ return {
37
+ PreToolUse: [mk("pre-agent", "Agent")],
38
+ PostToolUse: [
39
+ mk("post-agent", "Agent"),
40
+ mk("file-written", "Write|Edit|MultiEdit"),
41
+ ],
42
+ UserPromptSubmit: [mk("user-prompt")],
43
+ Stop: [mk("session-end")],
44
+ };
45
+ }
46
+
47
+ export function buildcrewPermissions() {
48
+ return {
49
+ allow: [
50
+ "Agent", "Task", "Read", "Glob", "Grep", "Write", "Edit", "MultiEdit",
51
+ "NotebookEdit", "WebFetch", "WebSearch",
52
+ "Bash(npm *)", "Bash(npx *)", "Bash(node *)",
53
+ "Bash(git status*)", "Bash(git diff*)", "Bash(git log*)",
54
+ "Bash(git branch*)", "Bash(git show*)", "Bash(git add:*)", "Bash(git commit:*)",
55
+ "Bash(ls *)", "Bash(pwd)", "Bash(which *)",
56
+ "Bash(cat *)", "Bash(head *)", "Bash(tail *)",
57
+ "Bash(mkdir *)", "Bash(touch *)", "Bash(echo *)",
58
+ ],
59
+ deny: [
60
+ "Bash(rm -rf *)", "Bash(sudo *)",
61
+ "Bash(git push --force*)", "Bash(git reset --hard*)",
62
+ "Bash(curl * -X POST*)",
63
+ ],
64
+ };
65
+ }
66
+
67
+ export async function install({ scope = "project", cwd = process.cwd(), withPermissions = false } = {}) {
68
+ const settingsPath = resolveSettingsPath({ scope, cwd });
69
+ const { current, existed } = await readJson(settingsPath);
70
+ const next = mergeHooks(current, buildcrewHooks());
71
+
72
+ let permissionsResult = null;
73
+ if (withPermissions) {
74
+ permissionsResult = await installPermissions({ scope, cwd });
75
+ }
76
+
77
+ if (existed) await backup(settingsPath);
78
+ await atomicWrite(settingsPath, JSON.stringify(next, null, 2) + "\n");
79
+ return { action: "installed", settingsPath, existed, permissions: permissionsResult };
80
+ }
81
+
82
+ export async function installPermissions({ scope = "project", cwd = process.cwd() } = {}) {
83
+ const permPath = resolvePermissionsPath({ scope, cwd });
84
+ const { current, existed } = await readJson(permPath);
85
+ const rec = buildcrewPermissions();
86
+
87
+ const nextPerms = current.permissions && typeof current.permissions === "object"
88
+ ? { ...current.permissions } : {};
89
+ nextPerms.allow = mergeUnique(nextPerms.allow, rec.allow);
90
+ nextPerms.deny = mergeUnique(nextPerms.deny, rec.deny);
91
+
92
+ if (existed) await backup(permPath);
93
+ await atomicWrite(permPath, JSON.stringify({ ...current, permissions: nextPerms }, null, 2) + "\n");
94
+ return { action: "installed", permPath, existed };
95
+ }
96
+
97
+ export async function uninstall({ scope = "project", cwd = process.cwd() } = {}) {
98
+ const settingsPath = resolveSettingsPath({ scope, cwd });
99
+ const { current, existed } = await readJson(settingsPath);
100
+ if (!existed) return { action: "noop", reason: "no settings file", settingsPath };
101
+ const next = stripBuildcrewHooks(current);
102
+ await backup(settingsPath);
103
+ await atomicWrite(settingsPath, JSON.stringify(next, null, 2) + "\n");
104
+ return { action: "uninstalled", settingsPath };
105
+ }
106
+
107
+ // ------------ internals ------------
108
+
109
+ async function readJson(p) {
110
+ try {
111
+ const buf = await fsp.readFile(p, "utf8");
112
+ return { current: JSON.parse(buf), existed: true };
113
+ } catch (err) {
114
+ if (err.code === "ENOENT") return { current: {}, existed: false };
115
+ throw new Error(`failed to read ${p}: ${err.message}`);
116
+ }
117
+ }
118
+
119
+ function mergeHooks(current, add) {
120
+ const out = { ...current };
121
+ const existing = out.hooks && typeof out.hooks === "object" ? { ...out.hooks } : {};
122
+ for (const [event, entries] of Object.entries(add)) {
123
+ const prev = Array.isArray(existing[event]) ? existing[event] : [];
124
+ // Remove prior buildcrew entries to stay idempotent
125
+ const filtered = prev.filter((e) => !(e && e[BUILDCREW_TAG]));
126
+ existing[event] = [...filtered, ...entries];
127
+ }
128
+ out.hooks = existing;
129
+ return out;
130
+ }
131
+
132
+ function stripBuildcrewHooks(current) {
133
+ const out = { ...current };
134
+ if (!out.hooks || typeof out.hooks !== "object") return out;
135
+ const cleaned = {};
136
+ for (const [event, entries] of Object.entries(out.hooks)) {
137
+ if (!Array.isArray(entries)) { cleaned[event] = entries; continue; }
138
+ const filtered = entries.filter((e) => !(e && e[BUILDCREW_TAG]));
139
+ if (filtered.length > 0) cleaned[event] = filtered;
140
+ }
141
+ if (Object.keys(cleaned).length === 0) delete out.hooks;
142
+ else out.hooks = cleaned;
143
+ return out;
144
+ }
145
+
146
+ function mergeUnique(existing, additions) {
147
+ const base = Array.isArray(existing) ? existing.slice() : [];
148
+ const seen = new Set(base);
149
+ for (const a of additions) if (!seen.has(a)) { base.push(a); seen.add(a); }
150
+ return base;
151
+ }
152
+
153
+ async function backup(p) {
154
+ try {
155
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
156
+ await fsp.copyFile(p, `${p}.buildcrew-backup-${ts}`);
157
+ } catch { /* best-effort */ }
158
+ }
159
+
160
+ async function atomicWrite(p, content) {
161
+ await fsp.mkdir(path.dirname(p), { recursive: true });
162
+ const tmp = `${p}.tmp-${process.pid}`;
163
+ await fsp.writeFile(tmp, content, "utf8");
164
+ await fsp.rename(tmp, p);
165
+ }
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "buildcrew",
3
- "version": "1.8.7",
3
+ "version": "1.9.1",
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",
7
7
  "license": "MIT",
8
8
  "type": "module",
9
9
  "bin": {
10
- "buildcrew": "./bin/setup.js"
10
+ "buildcrew": "./bin/setup.js",
11
+ "buildcrew-hook": "./bin/hook.js",
12
+ "buildcrew-watch": "./bin/watch.js"
11
13
  },
12
14
  "files": [
13
15
  "bin/",
16
+ "lib/",
14
17
  "agents/",
15
18
  "templates/",
16
19
  "README.md",
@@ -34,7 +37,8 @@
34
37
  "url": "https://github.com/z1nun/buildcrew.git"
35
38
  },
36
39
  "scripts": {
37
- "test": "vitest run"
40
+ "test": "vitest run",
41
+ "watch": "node bin/watch.js"
38
42
  },
39
43
  "devDependencies": {
40
44
  "vitest": "^4.1.0"