forge-cc 2.0.0 → 2.0.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.
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PreToolUse hook — Bash command interceptor for branch name rewriting.
5
+ *
6
+ * When an agent creates a branch via `git checkout -b`, `git switch -c`,
7
+ * or `git branch <name>`, this hook rewrites the branch name to include
8
+ * the Linear issue identifier (e.g. FRG-132) if one can be resolved.
9
+ *
10
+ * Hook type: PreToolUse (matcher: Bash)
11
+ * Always exits 0 — never blocks the agent's command.
12
+ */
13
+
14
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { execSync } from "node:child_process";
17
+
18
+ // ── stdin reading ──────────────────────────────────────────────────────
19
+ let input = "";
20
+ process.stdin.setEncoding("utf-8");
21
+ process.stdin.on("data", (chunk) => {
22
+ input += chunk;
23
+ });
24
+ process.stdin.on("end", () => {
25
+ try {
26
+ const hookData = JSON.parse(input);
27
+ handleHook(hookData);
28
+ } catch {
29
+ // Never block — exit silently on any error
30
+ process.exit(0);
31
+ }
32
+ });
33
+
34
+ // ── main handler ───────────────────────────────────────────────────────
35
+ function handleHook(hookData) {
36
+ // Only intercept Bash tool calls
37
+ if (hookData.tool_name !== "Bash") {
38
+ process.exit(0);
39
+ }
40
+
41
+ const command = hookData.tool_input?.command ?? "";
42
+
43
+ // Fast bail-out: only match branch creation commands
44
+ const branchInfo = parseBranchCreation(command);
45
+ if (!branchInfo) {
46
+ process.exit(0);
47
+ }
48
+
49
+ const { branchName, prefix, suffix } = branchInfo;
50
+
51
+ // Already contains a Linear identifier (e.g. FRG-123) — no rewriting needed
52
+ if (/[A-Z]+-\d+/.test(branchName)) {
53
+ process.exit(0);
54
+ }
55
+
56
+ // Resolve the Linear issue identifier from project context
57
+ const identifier = resolveIdentifier();
58
+ if (!identifier) {
59
+ // Can't resolve context — allow original command through
60
+ process.exit(0);
61
+ }
62
+
63
+ // Rewrite the branch name to include the identifier
64
+ const rewrittenBranch = injectIdentifier(branchName, identifier);
65
+ const rewrittenCommand = prefix + rewrittenBranch + suffix;
66
+
67
+ // Output the rewritten command via updatedInput
68
+ const output = {
69
+ hookSpecificOutput: {
70
+ hookEventName: "PreToolUse",
71
+ permissionDecision: "allow",
72
+ updatedInput: {
73
+ command: rewrittenCommand,
74
+ },
75
+ },
76
+ };
77
+
78
+ console.log(JSON.stringify(output));
79
+ process.exit(0);
80
+ }
81
+
82
+ // ── branch parsing ─────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Parse a git branch creation command. Returns null for non-matching commands.
86
+ *
87
+ * Matches:
88
+ * git checkout -b <branch> [start-point] [-- ...]
89
+ * git switch -c <branch> [start-point]
90
+ * git branch <name> (but NOT git branch -d/-D/-m/-M/--list etc.)
91
+ *
92
+ * Returns { branchName, prefix, suffix } where:
93
+ * prefix = everything before the branch name
94
+ * suffix = everything after the branch name
95
+ */
96
+ function parseBranchCreation(command) {
97
+ // git checkout -b <branch>
98
+ const checkoutMatch = command.match(
99
+ /^(.*git\s+checkout\s+(?:-[a-zA-Z]*b[a-zA-Z]*\s+|-b\s+))(\S+)(.*)/,
100
+ );
101
+ if (checkoutMatch) {
102
+ // Normalize: handle flags like -Bb or -b
103
+ return {
104
+ prefix: checkoutMatch[1],
105
+ branchName: checkoutMatch[2],
106
+ suffix: checkoutMatch[3],
107
+ };
108
+ }
109
+
110
+ // git switch -c <branch>
111
+ const switchMatch = command.match(
112
+ /^(.*git\s+switch\s+(?:-[a-zA-Z]*c[a-zA-Z]*\s+|-c\s+))(\S+)(.*)/,
113
+ );
114
+ if (switchMatch) {
115
+ return {
116
+ prefix: switchMatch[1],
117
+ branchName: switchMatch[2],
118
+ suffix: switchMatch[3],
119
+ };
120
+ }
121
+
122
+ // git branch <name> — but not git branch -d/-D/-m/-M/--list/--delete etc.
123
+ const branchMatch = command.match(
124
+ /^(.*git\s+branch\s+)(\S+)(.*)/,
125
+ );
126
+ if (branchMatch) {
127
+ const name = branchMatch[2];
128
+ // Skip if the "name" is actually a flag
129
+ if (name.startsWith("-")) {
130
+ return null;
131
+ }
132
+ return {
133
+ prefix: branchMatch[1],
134
+ branchName: name,
135
+ suffix: branchMatch[3],
136
+ };
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ // ── identifier resolution ──────────────────────────────────────────────
143
+
144
+ /**
145
+ * Try to resolve the Linear issue identifier for the current build context.
146
+ *
147
+ * Strategy:
148
+ * 1. Read .forge/build-context.json for slug + reqId
149
+ * 2. If no build context, try extracting reqId from current branch name
150
+ * 3. Look up _index.yaml → find linearIssueId for the requirement
151
+ * 4. Resolve identifier via Linear API (getIssueIdentifier)
152
+ *
153
+ * Returns identifier string (e.g. "FRG-132") or null.
154
+ */
155
+ function resolveIdentifier() {
156
+ const cwd = process.cwd();
157
+
158
+ // Step 1: Try build context
159
+ let slug = null;
160
+ let reqId = null;
161
+ const buildCtxPath = join(cwd, ".forge", "build-context.json");
162
+ if (existsSync(buildCtxPath)) {
163
+ try {
164
+ const ctx = JSON.parse(readFileSync(buildCtxPath, "utf-8"));
165
+ slug = ctx.slug || null;
166
+ reqId = ctx.reqId || null;
167
+ } catch {
168
+ // ignore
169
+ }
170
+ }
171
+
172
+ // Step 2: If no reqId from build context, try extracting from branch name
173
+ if (!reqId) {
174
+ try {
175
+ const branch = execSync("git branch --show-current", {
176
+ encoding: "utf-8",
177
+ timeout: 3000,
178
+ stdio: ["pipe", "pipe", "pipe"],
179
+ }).trim();
180
+ reqId = extractReqId(branch);
181
+ } catch {
182
+ // ignore
183
+ }
184
+ }
185
+
186
+ if (!reqId) return null;
187
+
188
+ // Step 3: Find linearIssueId from _index.yaml
189
+ const linearIssueId = findLinearIssueId(cwd, reqId, slug);
190
+ if (!linearIssueId) return null;
191
+
192
+ // Step 4: Resolve identifier via Linear API
193
+ return resolveIdentifierFromApi(linearIssueId);
194
+ }
195
+
196
+ /**
197
+ * Extract a requirement ID (e.g. "req-002") from a string.
198
+ */
199
+ function extractReqId(name) {
200
+ const match = name.match(/\b(req-\d{3})\b/i);
201
+ return match ? match[1].toLowerCase() : null;
202
+ }
203
+
204
+ /**
205
+ * Find the linearIssueId for a requirement by scanning _index.yaml files.
206
+ * If slug is provided, look there first; otherwise scan all graphs.
207
+ */
208
+ function findLinearIssueId(cwd, reqId, slug) {
209
+ const graphDir = join(cwd, ".planning", "graph");
210
+
211
+ if (slug) {
212
+ const indexPath = join(graphDir, slug, "_index.yaml");
213
+ const issueId = extractLinearIssueId(indexPath, reqId);
214
+ if (issueId) return issueId;
215
+ }
216
+
217
+ // Scan all graph directories
218
+ const graphs = discoverGraphsSync(cwd);
219
+ for (const graphPath of graphs) {
220
+ const issueId = extractLinearIssueId(graphPath, reqId);
221
+ if (issueId) return issueId;
222
+ }
223
+
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * Discover all _index.yaml paths under .planning/graph/
229
+ */
230
+ function discoverGraphsSync(cwd) {
231
+ const graphDir = join(cwd, ".planning", "graph");
232
+ if (!existsSync(graphDir)) return [];
233
+
234
+ const paths = [];
235
+ try {
236
+ const entries = readdirSync(graphDir, { withFileTypes: true });
237
+ for (const entry of entries) {
238
+ if (entry.isDirectory()) {
239
+ const indexPath = join(graphDir, entry.name, "_index.yaml");
240
+ if (existsSync(indexPath)) {
241
+ paths.push(indexPath);
242
+ }
243
+ }
244
+ }
245
+ } catch {
246
+ // ignore
247
+ }
248
+ return paths;
249
+ }
250
+
251
+ /**
252
+ * Extract the linearIssueId for a specific requirement from a _index.yaml file.
253
+ * Uses line-by-line YAML parsing (inline, no dependencies).
254
+ */
255
+ function extractLinearIssueId(indexPath, reqId) {
256
+ if (!existsSync(indexPath)) return null;
257
+
258
+ try {
259
+ const content = readFileSync(indexPath, "utf-8");
260
+ const lines = content.split("\n");
261
+
262
+ // Find the line " <reqId>:" then scan indented lines for linearIssueId
263
+ let inReqBlock = false;
264
+ let reqIndent = -1;
265
+
266
+ for (const line of lines) {
267
+ // Match the requirement key line (e.g. " req-002:")
268
+ const keyMatch = line.match(/^(\s*)(\S+):\s*$/);
269
+ if (keyMatch) {
270
+ const indent = keyMatch[1].length;
271
+ const key = keyMatch[2];
272
+ if (key === reqId) {
273
+ inReqBlock = true;
274
+ reqIndent = indent;
275
+ continue;
276
+ } else if (inReqBlock && indent <= reqIndent) {
277
+ // Left the requirement block — not found
278
+ return null;
279
+ }
280
+ }
281
+
282
+ if (inReqBlock) {
283
+ // Check if this is a field at deeper indent
284
+ const fieldMatch = line.match(/^\s+linearIssueId:\s*"?([^"\n]+)"?/);
285
+ if (fieldMatch) {
286
+ return fieldMatch[1].trim();
287
+ }
288
+
289
+ // If we encounter a line at same or lesser indent that's not empty, we left the block
290
+ const trimmed = line.trim();
291
+ if (trimmed && !trimmed.startsWith("-")) {
292
+ const currentIndent = line.length - line.trimStart().length;
293
+ if (currentIndent <= reqIndent) {
294
+ return null;
295
+ }
296
+ }
297
+ }
298
+ }
299
+
300
+ return null;
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Resolve a Linear issue UUID to its human-readable identifier (e.g. "FRG-42")
308
+ * via the compiled forge-cc dist/ Linear client.
309
+ */
310
+ function resolveIdentifierFromApi(linearIssueId) {
311
+ const apiKey = process.env.LINEAR_API_KEY;
312
+ if (!apiKey) return null;
313
+
314
+ try {
315
+ const forgeDir = resolveForgePackageDir();
316
+ if (!forgeDir) return null;
317
+
318
+ // Synchronous workaround: spawn a child process with ESM-compatible import()
319
+ const clientPath = join(forgeDir, "dist", "linear", "client.js").replace(/\\/g, "/");
320
+ const script = `
321
+ import(${JSON.stringify("file:///" + clientPath)}).then(mod => {
322
+ const client = new mod.ForgeLinearClient({ apiKey: ${JSON.stringify(apiKey)} });
323
+ return client.getIssueIdentifier(${JSON.stringify(linearIssueId)});
324
+ }).then(r => {
325
+ if (r.success) process.stdout.write(r.data);
326
+ }).catch(() => {});
327
+ `;
328
+
329
+ const result = execSync(`node --input-type=module -e ${JSON.stringify(script)}`, {
330
+ encoding: "utf-8",
331
+ timeout: 5000,
332
+ stdio: ["pipe", "pipe", "pipe"],
333
+ env: { ...process.env },
334
+ }).trim();
335
+
336
+ return result || null;
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Resolve the forge-cc package directory from the global npm install.
344
+ * On Windows: APPDATA/npm/node_modules/forge-cc
345
+ * On Unix: use npm root -g
346
+ */
347
+ function resolveForgePackageDir() {
348
+ // Try APPDATA first (Windows)
349
+ if (process.env.APPDATA) {
350
+ const winPath = join(process.env.APPDATA, "npm", "node_modules", "forge-cc");
351
+ if (existsSync(winPath)) return winPath;
352
+ }
353
+
354
+ // Try npm root -g (Unix / fallback)
355
+ try {
356
+ const globalRoot = execSync("npm root -g", {
357
+ encoding: "utf-8",
358
+ timeout: 3000,
359
+ stdio: ["pipe", "pipe", "pipe"],
360
+ }).trim();
361
+ const globalPath = join(globalRoot, "forge-cc");
362
+ if (existsSync(globalPath)) return globalPath;
363
+ } catch {
364
+ // ignore
365
+ }
366
+
367
+ // Try local node_modules (dev scenario)
368
+ const localPath = join(process.cwd(), "node_modules", "forge-cc");
369
+ if (existsSync(localPath)) return localPath;
370
+
371
+ return null;
372
+ }
373
+
374
+ // ── branch name rewriting ──────────────────────────────────────────────
375
+
376
+ /**
377
+ * Inject the Linear identifier into a branch name.
378
+ *
379
+ * Examples:
380
+ * "feat/my-feature" + "FRG-132" → "feat/FRG-132-my-feature"
381
+ * "my-feature" + "FRG-132" → "FRG-132-my-feature"
382
+ * "feat/slug/req-001" + "FRG-132" → "feat/slug/FRG-132-req-001"
383
+ */
384
+ function injectIdentifier(branchName, identifier) {
385
+ // Split on last slash to find the leaf segment
386
+ const lastSlashIdx = branchName.lastIndexOf("/");
387
+ if (lastSlashIdx === -1) {
388
+ // No prefix path — prepend identifier
389
+ return `${identifier}-${branchName}`;
390
+ }
391
+
392
+ const pathPrefix = branchName.slice(0, lastSlashIdx + 1);
393
+ const leaf = branchName.slice(lastSlashIdx + 1);
394
+ return `${pathPrefix}${identifier}-${leaf}`;
395
+ }