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,331 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PostToolUse hook — reacts to `gh pr create` and `gh pr merge` Bash commands.
5
+ *
6
+ * - On PR creation: links the PR to all complete requirement issues via
7
+ * attachIssuePullRequest(), then transitions the project to "In Review".
8
+ * - On PR merge: transitions the project to "Completed".
9
+ *
10
+ * All Linear API calls are best-effort — errors are logged to stderr, never crash.
11
+ */
12
+
13
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { execSync } from "node:child_process";
16
+
17
+ // ── Stdin reading ──────────────────────────────────────────────────────────────
18
+
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
+ main().catch(() => {});
26
+ });
27
+
28
+ async function main() {
29
+ let hookData;
30
+ try {
31
+ hookData = JSON.parse(input);
32
+ } catch {
33
+ process.exit(0);
34
+ }
35
+
36
+ // Only handle 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: if command doesn't match gh pr create or gh pr merge, exit immediately
44
+ const isPrCreate = command.includes("gh pr create");
45
+ const isPrMerge = command.includes("gh pr merge");
46
+ if (!isPrCreate && !isPrMerge) {
47
+ process.exit(0);
48
+ }
49
+
50
+ const toolResponse = hookData.tool_response ?? "";
51
+
52
+ // Require LINEAR_API_KEY
53
+ const apiKey = process.env.LINEAR_API_KEY;
54
+ if (!apiKey) {
55
+ process.exit(0);
56
+ }
57
+
58
+ // Discover the active graph for the current branch
59
+ const cwd = process.cwd();
60
+ let currentBranch;
61
+ try {
62
+ currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
63
+ encoding: "utf-8",
64
+ timeout: 5000,
65
+ stdio: ["pipe", "pipe", "pipe"],
66
+ }).trim();
67
+ } catch {
68
+ process.stderr.write("[forge] Could not determine current branch, skipping Linear sync\n");
69
+ process.exit(0);
70
+ }
71
+
72
+ const graphs = discoverGraphsSync(cwd);
73
+ const activeGraph = findActiveGraph(graphs, currentBranch);
74
+ if (!activeGraph) {
75
+ process.exit(0);
76
+ }
77
+
78
+ // Load the ForgeLinearClient from the compiled dist
79
+ let ForgeLinearClient;
80
+ let syncGraphProjectReview;
81
+ let syncGraphProjectCompleted;
82
+ try {
83
+ const forgePkgDir = resolveForgePkgDir();
84
+ const clientMod = await import(join(forgePkgDir, "dist", "linear", "client.js"));
85
+ ForgeLinearClient = clientMod.ForgeLinearClient;
86
+ const syncMod = await import(join(forgePkgDir, "dist", "linear", "sync.js"));
87
+ syncGraphProjectReview = syncMod.syncGraphProjectReview;
88
+ syncGraphProjectCompleted = syncMod.syncGraphProjectCompleted;
89
+ } catch (err) {
90
+ process.stderr.write(`[forge] Could not load Linear modules: ${err}\n`);
91
+ process.exit(0);
92
+ }
93
+
94
+ let client;
95
+ try {
96
+ client = new ForgeLinearClient({ apiKey });
97
+ } catch (err) {
98
+ process.stderr.write(`[forge] Could not create Linear client: ${err}\n`);
99
+ process.exit(0);
100
+ }
101
+
102
+ if (isPrCreate) {
103
+ await handlePrCreate(client, activeGraph, toolResponse, syncGraphProjectReview);
104
+ } else if (isPrMerge) {
105
+ // Only transition to Completed if the merge actually succeeded —
106
+ // failed merges (conflicts, failed checks) should not update Linear.
107
+ // gh pr merge outputs "✓ Merged" or "Merged pull request" on success.
108
+ const mergeSucceeded = toolResponse &&
109
+ (/[Mm]erged pull request/.test(toolResponse) || toolResponse.includes("✓"));
110
+ if (mergeSucceeded) {
111
+ await handlePrMerge(client, activeGraph, syncGraphProjectCompleted);
112
+ }
113
+ }
114
+ }
115
+
116
+ // ── PR Create handler ──────────────────────────────────────────────────────────
117
+
118
+ async function handlePrCreate(client, graph, toolResponse, syncGraphProjectReview) {
119
+ // Parse PR URL from tool response — gh pr create outputs the URL as the last line
120
+ const prUrl = parsePrUrl(toolResponse);
121
+ if (!prUrl) {
122
+ process.stderr.write("[forge] Could not parse PR URL from gh pr create output\n");
123
+ process.exit(0);
124
+ }
125
+
126
+ const indexContent = readFileSync(graph.indexPath, "utf-8");
127
+ const index = parseIndexYaml(indexContent, graph.slug);
128
+ if (!index) {
129
+ process.exit(0);
130
+ }
131
+
132
+ // Find all complete requirements with a linearIssueId
133
+ const completeReqs = findCompleteRequirementsWithIssues(indexContent);
134
+ let linkedCount = 0;
135
+
136
+ // Attach PR to each complete requirement's Linear issue
137
+ for (const req of completeReqs) {
138
+ try {
139
+ const result = await client.attachIssuePullRequest(req.linearIssueId, prUrl);
140
+ if (result.success) {
141
+ linkedCount++;
142
+ } else {
143
+ process.stderr.write(`[forge] Warning: could not link PR to ${req.id}: ${result.error}\n`);
144
+ }
145
+ } catch (err) {
146
+ process.stderr.write(`[forge] Warning: failed to link PR to ${req.id}: ${err}\n`);
147
+ }
148
+ }
149
+
150
+ // Transition project to In Review
151
+ let reviewTransitioned = false;
152
+ try {
153
+ const syncResult = await syncGraphProjectReview(client, index);
154
+ reviewTransitioned = syncResult.projectUpdated;
155
+ } catch (err) {
156
+ process.stderr.write(`[forge] Warning: failed to transition project to In Review: ${err}\n`);
157
+ }
158
+
159
+ const statusMsg = reviewTransitioned ? "project → In Review" : "project transition skipped";
160
+ const msg = `Linear: PR linked to ${linkedCount} issues, ${statusMsg}`;
161
+ console.log(JSON.stringify({
162
+ hookSpecificOutput: {
163
+ additionalContext: msg,
164
+ },
165
+ }));
166
+ }
167
+
168
+ // ── PR Merge handler ───────────────────────────────────────────────────────────
169
+
170
+ async function handlePrMerge(client, graph, syncGraphProjectCompleted) {
171
+ const indexContent = readFileSync(graph.indexPath, "utf-8");
172
+ const index = parseIndexYaml(indexContent, graph.slug);
173
+ if (!index) {
174
+ process.exit(0);
175
+ }
176
+
177
+ let completedTransitioned = false;
178
+ try {
179
+ const syncResult = await syncGraphProjectCompleted(client, index);
180
+ completedTransitioned = syncResult.projectUpdated;
181
+ } catch (err) {
182
+ process.stderr.write(`[forge] Warning: failed to transition project to Completed: ${err}\n`);
183
+ }
184
+
185
+ const statusMsg = completedTransitioned ? "project → Completed" : "project transition skipped";
186
+ console.log(JSON.stringify({
187
+ hookSpecificOutput: {
188
+ additionalContext: `Linear: ${statusMsg}`,
189
+ },
190
+ }));
191
+ }
192
+
193
+ // ── Graph discovery ────────────────────────────────────────────────────────────
194
+
195
+ function discoverGraphsSync(cwd) {
196
+ const graphDir = join(cwd, ".planning", "graph");
197
+ if (!existsSync(graphDir)) return [];
198
+ return readdirSync(graphDir, { withFileTypes: true })
199
+ .filter((d) => d.isDirectory())
200
+ .map((d) => ({ slug: d.name, indexPath: join(graphDir, d.name, "_index.yaml") }))
201
+ .filter((g) => existsSync(g.indexPath));
202
+ }
203
+
204
+ function findActiveGraph(graphs, currentBranch) {
205
+ for (const g of graphs) {
206
+ try {
207
+ const content = readFileSync(g.indexPath, "utf-8");
208
+ const branch = yamlField(content, "branch");
209
+ if (branch === currentBranch) return g;
210
+ } catch {
211
+ // skip unreadable graphs
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+
217
+ // ── YAML parsing helpers ───────────────────────────────────────────────────────
218
+
219
+ function yamlField(content, fieldName) {
220
+ const regex = new RegExp(`^${fieldName}:\\s*["']?(.+?)["']?\\s*$`, "m");
221
+ const match = content.match(regex);
222
+ return match ? match[1] : null;
223
+ }
224
+
225
+ /**
226
+ * Parse _index.yaml into a minimal GraphIndex-compatible object for sync functions.
227
+ * Only extracts the fields needed by syncGraphProjectReview/syncGraphProjectCompleted.
228
+ */
229
+ function parseIndexYaml(content, slug) {
230
+ const project = yamlField(content, "project") || slug;
231
+ const branch = yamlField(content, "branch") || "";
232
+
233
+ // Extract linear config
234
+ const projectIdMatch = content.match(/^ projectId:\s*["']?(.+?)["']?\s*$/m);
235
+ const teamIdMatch = content.match(/^ teamId:\s*["']?(.+?)["']?\s*$/m);
236
+
237
+ const linear =
238
+ projectIdMatch && teamIdMatch
239
+ ? { projectId: projectIdMatch[1], teamId: teamIdMatch[1] }
240
+ : undefined;
241
+
242
+ return {
243
+ project,
244
+ slug,
245
+ branch,
246
+ createdAt: yamlField(content, "createdAt") || "",
247
+ linear,
248
+ groups: {},
249
+ requirements: {},
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Find all requirements with `status: complete` and a `linearIssueId` from _index.yaml.
255
+ * Uses regex-based extraction to avoid YAML parser dependency.
256
+ */
257
+ function findCompleteRequirementsWithIssues(content) {
258
+ const results = [];
259
+
260
+ // Split the requirements section into individual requirement blocks
261
+ const reqSectionMatch = content.match(/^requirements:\s*\n([\s\S]*)$/m);
262
+ if (!reqSectionMatch) return results;
263
+
264
+ const reqSection = reqSectionMatch[1];
265
+ // Match each top-level requirement entry (e.g., " REQ-001:")
266
+ const reqBlockRegex = /^ ([\w-]+):\s*\n((?: .+\n)*)/gm;
267
+ let match;
268
+ while ((match = reqBlockRegex.exec(reqSection)) !== null) {
269
+ const reqId = match[1];
270
+ const block = match[2];
271
+
272
+ const statusMatch = block.match(/^\s+status:\s*(\S+)/m);
273
+ const issueIdMatch = block.match(/^\s+linearIssueId:\s*["']?(.+?)["']?\s*$/m);
274
+
275
+ if (statusMatch && statusMatch[1] === "complete" && issueIdMatch) {
276
+ results.push({ id: reqId, linearIssueId: issueIdMatch[1] });
277
+ }
278
+ }
279
+
280
+ return results;
281
+ }
282
+
283
+ // ── PR URL parsing ─────────────────────────────────────────────────────────────
284
+
285
+ function parsePrUrl(toolResponse) {
286
+ // gh pr create outputs the PR URL — find a GitHub PR URL in the response
287
+ const urlMatch = toolResponse.match(/(https:\/\/github\.com\/[^\s]+\/pull\/\d+)/);
288
+ return urlMatch ? urlMatch[1] : null;
289
+ }
290
+
291
+ // ── Forge package resolution ───────────────────────────────────────────────────
292
+
293
+ function resolveForgePkgDir() {
294
+ // Try local node_modules first (dev/test scenarios)
295
+ const localPath = join(process.cwd(), "node_modules", "forge-cc");
296
+ if (existsSync(join(localPath, "dist", "linear", "client.js"))) {
297
+ return localPath;
298
+ }
299
+
300
+ // Windows global install via APPDATA
301
+ if (process.env.APPDATA) {
302
+ const appDataPath = join(process.env.APPDATA, "npm", "node_modules", "forge-cc");
303
+ if (existsSync(join(appDataPath, "dist", "linear", "client.js"))) {
304
+ return appDataPath;
305
+ }
306
+ }
307
+
308
+ // Unix global: try npm root -g
309
+ try {
310
+ const globalRoot = execSync("npm root -g", {
311
+ encoding: "utf-8",
312
+ timeout: 5000,
313
+ stdio: ["pipe", "pipe", "pipe"],
314
+ }).trim();
315
+ const globalPath = join(globalRoot, "forge-cc");
316
+ if (existsSync(join(globalPath, "dist", "linear", "client.js"))) {
317
+ return globalPath;
318
+ }
319
+ } catch {
320
+ // fallback below
321
+ }
322
+
323
+ // Fallback: resolve relative to this hook file's directory
324
+ const hookDir = new URL(".", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
325
+ const pkgDir = join(hookDir, "..");
326
+ if (existsSync(join(pkgDir, "dist", "linear", "client.js"))) {
327
+ return pkgDir;
328
+ }
329
+
330
+ throw new Error("Could not locate forge-cc package with compiled dist/");
331
+ }
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Claude Code WorktreeCreate hook — branch naming + Linear issue sync.
5
+ *
6
+ * Contract:
7
+ * stdin: JSON { name: string, cwd: string }
8
+ * stdout: absolute worktree path (printed to stdout)
9
+ * exit: always 0 (graceful degradation on any failure)
10
+ *
11
+ * Behaviour:
12
+ * 1. Parse stdin to extract worktree name and cwd.
13
+ * 2. Extract reqId from the name (e.g. "req-001" in "agent-req-001-abc").
14
+ * 3. Discover _index.yaml graphs, find the requirement's linearIssueId.
15
+ * 4. Resolve the issue identifier (e.g. "FRG-132") via Linear API.
16
+ * 5. Create git worktree with branch: feat/<slug>/<identifier>-<reqId>
17
+ * 6. Call syncRequirementStart() to transition issue + project to In Progress.
18
+ * 7. Print absolute worktree path to stdout.
19
+ * Falls back to a generic worktree if any step fails.
20
+ */
21
+
22
+ import { execSync } from "node:child_process";
23
+ import { readFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
24
+ import { join, resolve, dirname } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+
29
+ let input = "";
30
+ process.stdin.setEncoding("utf-8");
31
+ process.stdin.on("data", (chunk) => { input += chunk; });
32
+ process.stdin.on("end", async () => {
33
+ try {
34
+ await handleWorktreeCreate();
35
+ } catch (err) {
36
+ // Last-resort fallback: create a plain worktree
37
+ try {
38
+ fallbackWorktree();
39
+ } catch {
40
+ // Absolute last resort — print nothing, exit 0
41
+ process.stderr.write(`[forge] WorktreeCreate hook failed entirely: ${err}\n`);
42
+ }
43
+ }
44
+ process.exit(0);
45
+ });
46
+
47
+ /** Extract reqId from a name string. Matches "req-NNN" patterns. */
48
+ function extractReqId(name) {
49
+ const match = name.match(/\b(req-\d{3})\b/i);
50
+ return match ? match[1].toLowerCase() : null;
51
+ }
52
+
53
+ /** Read and parse a YAML file using minimal parsing (no dependency needed for simple _index.yaml). */
54
+ function parseSimpleYaml(content) {
55
+ // Use the yaml package from forge-cc's dependencies if available, else try inline
56
+ try {
57
+ const forgePkgDir = resolveForgePackageDir();
58
+ if (forgePkgDir) {
59
+ const yamlMod = join(forgePkgDir, "node_modules", "yaml", "dist", "index.js");
60
+ // Dynamic import won't work synchronously, use the built-in approach
61
+ }
62
+ } catch { /* fall through */ }
63
+ // Inline minimal YAML parse for _index.yaml — handles the fields we need
64
+ return null;
65
+ }
66
+
67
+ /** Locate the forge-cc package directory (global install or local). */
68
+ function resolveForgePackageDir() {
69
+ // 1. Relative to this hook file (when running from the package)
70
+ const localPkg = join(__dirname, "..", "package.json");
71
+ if (existsSync(localPkg)) {
72
+ try {
73
+ const pkg = JSON.parse(readFileSync(localPkg, "utf-8"));
74
+ if (pkg.name === "forge-cc") return dirname(localPkg);
75
+ } catch { /* continue */ }
76
+ }
77
+
78
+ // 2. Global install via APPDATA (Windows) or npm root -g
79
+ if (process.env.APPDATA) {
80
+ const winPath = join(process.env.APPDATA, "npm", "node_modules", "forge-cc");
81
+ if (existsSync(join(winPath, "package.json"))) return winPath;
82
+ }
83
+
84
+ // 3. npm root -g fallback
85
+ try {
86
+ const globalRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
87
+ const globalPath = join(globalRoot, "forge-cc");
88
+ if (existsSync(join(globalPath, "package.json"))) return globalPath;
89
+ } catch { /* continue */ }
90
+
91
+ return null;
92
+ }
93
+
94
+ /** Discover graph slugs and load their _index.yaml files. */
95
+ function discoverGraphsSync(projectDir) {
96
+ const baseDir = join(projectDir, ".planning", "graph");
97
+ if (!existsSync(baseDir)) return [];
98
+
99
+ const entries = readdirSync(baseDir);
100
+ const graphs = [];
101
+
102
+ for (const entry of entries) {
103
+ const indexFile = join(baseDir, entry, "_index.yaml");
104
+ if (!existsSync(indexFile)) continue;
105
+ try {
106
+ const raw = readFileSync(indexFile, "utf-8");
107
+ graphs.push({ slug: entry, raw, path: indexFile });
108
+ } catch { /* skip invalid */ }
109
+ }
110
+
111
+ return graphs;
112
+ }
113
+
114
+ /** Extract a field value from raw YAML content (simple key: value parsing). */
115
+ function yamlField(raw, key) {
116
+ const regex = new RegExp(`^${key}:\\s*(.+)$`, "m");
117
+ const match = raw.match(regex);
118
+ return match ? match[1].trim().replace(/^["']|["']$/g, "") : null;
119
+ }
120
+
121
+ /** Extract a nested requirement's linearIssueId from raw _index.yaml. */
122
+ function extractLinearIssueId(raw, reqId) {
123
+ // Look for the requirement block and its linearIssueId
124
+ // YAML structure: requirements:\n req-001:\n ...\n linearIssueId: <uuid>
125
+ const reqPattern = new RegExp(
126
+ `^\\s{2}${reqId}:\\s*\\n((?:\\s{4}.+\\n)*)`,
127
+ "m"
128
+ );
129
+ const reqMatch = raw.match(reqPattern);
130
+ if (!reqMatch) return null;
131
+
132
+ const block = reqMatch[1];
133
+ const idMatch = block.match(/^\s{4}linearIssueId:\s*(.+)$/m);
134
+ return idMatch ? idMatch[1].trim().replace(/^["']|["']$/g, "") : null;
135
+ }
136
+
137
+ /** Extract linear.teamId from raw _index.yaml. */
138
+ function extractLinearTeamId(raw) {
139
+ const match = raw.match(/^\s{2}teamId:\s*(.+)$/m);
140
+ return match ? match[1].trim().replace(/^["']|["']$/g, "") : null;
141
+ }
142
+
143
+ /** Extract linear.projectId from raw _index.yaml. */
144
+ function extractLinearProjectId(raw) {
145
+ const match = raw.match(/^\s{2}projectId:\s*(.+)$/m);
146
+ return match ? match[1].trim().replace(/^["']|["']$/g, "") : null;
147
+ }
148
+
149
+ /** Get the current branch name. */
150
+ function getCurrentBranch(cwd) {
151
+ return execSync("git rev-parse --abbrev-ref HEAD", {
152
+ cwd,
153
+ encoding: "utf-8",
154
+ timeout: 5000,
155
+ }).trim();
156
+ }
157
+
158
+ /** Create a git worktree and return the absolute path. */
159
+ function createGitWorktree(cwd, worktreePath, branchName) {
160
+ const absPath = resolve(cwd, worktreePath);
161
+ const parentDir = dirname(absPath);
162
+ if (!existsSync(parentDir)) {
163
+ mkdirSync(parentDir, { recursive: true });
164
+ }
165
+
166
+ execSync(
167
+ `git worktree add "${absPath}" -b "${branchName}"`,
168
+ { cwd, encoding: "utf-8", timeout: 30000 }
169
+ );
170
+
171
+ return absPath;
172
+ }
173
+
174
+ /** Resolve issue identifier via the Linear API (using @linear/sdk from forge-cc). */
175
+ async function resolveIssueIdentifier(linearIssueId) {
176
+ const apiKey = process.env.LINEAR_API_KEY;
177
+ if (!apiKey || !linearIssueId) return null;
178
+
179
+ try {
180
+ const forgePkgDir = resolveForgePackageDir();
181
+ if (!forgePkgDir) return null;
182
+
183
+ const distSync = join(forgePkgDir, "dist", "linear", "client.js");
184
+ if (!existsSync(distSync)) return null;
185
+
186
+ const clientMod = await import(`file://${distSync.replace(/\\/g, "/")}`);
187
+ const client = new clientMod.ForgeLinearClient({ apiKey });
188
+ const result = await client.getIssueIdentifier(linearIssueId);
189
+ return result.success ? result.data : null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /** Call syncRequirementStart via the compiled dist. */
196
+ async function callSyncRequirementStart(index, reqId) {
197
+ const apiKey = process.env.LINEAR_API_KEY;
198
+ if (!apiKey) return;
199
+
200
+ try {
201
+ const forgePkgDir = resolveForgePackageDir();
202
+ if (!forgePkgDir) return;
203
+
204
+ const clientPath = join(forgePkgDir, "dist", "linear", "client.js");
205
+ const syncPath = join(forgePkgDir, "dist", "linear", "sync.js");
206
+ if (!existsSync(clientPath) || !existsSync(syncPath)) return;
207
+
208
+ const clientMod = await import(`file://${clientPath.replace(/\\/g, "/")}`);
209
+ const syncMod = await import(`file://${syncPath.replace(/\\/g, "/")}`);
210
+
211
+ const client = new clientMod.ForgeLinearClient({ apiKey });
212
+ await syncMod.syncRequirementStart(client, index, reqId);
213
+ } catch (err) {
214
+ process.stderr.write(`[forge] syncRequirementStart failed (non-blocking): ${err}\n`);
215
+ }
216
+ }
217
+
218
+ /** Build a minimal GraphIndex object from raw YAML for syncRequirementStart. */
219
+ function buildMinimalIndex(raw, slug, reqId, linearIssueId) {
220
+ return {
221
+ project: yamlField(raw, "project") || "unknown",
222
+ slug,
223
+ branch: yamlField(raw, "branch") || "main",
224
+ createdAt: yamlField(raw, "createdAt") || new Date().toISOString(),
225
+ linear: {
226
+ projectId: extractLinearProjectId(raw) || "",
227
+ teamId: extractLinearTeamId(raw) || "",
228
+ },
229
+ groups: {},
230
+ requirements: {
231
+ [reqId]: {
232
+ group: "",
233
+ status: "in_progress",
234
+ dependsOn: [],
235
+ linearIssueId: linearIssueId || undefined,
236
+ },
237
+ },
238
+ };
239
+ }
240
+
241
+ /** Create a fallback worktree with a generic branch name. */
242
+ function fallbackWorktree() {
243
+ let parsed = {};
244
+ try { parsed = JSON.parse(input); } catch { /* empty */ }
245
+ const name = parsed.name || `worktree-${Date.now()}`;
246
+ const cwd = parsed.cwd || process.cwd();
247
+
248
+ const worktreePath = join(".claude", "worktrees", name);
249
+ const branchName = `worktree/${name}`;
250
+ const absPath = createGitWorktree(cwd, worktreePath, branchName);
251
+ process.stdout.write(absPath);
252
+ }
253
+
254
+ /** Main hook handler. */
255
+ async function handleWorktreeCreate() {
256
+ const parsed = JSON.parse(input);
257
+ const name = parsed.name || "";
258
+ const cwd = parsed.cwd || process.cwd();
259
+
260
+ // 1. Extract reqId from the worktree name
261
+ let reqId = extractReqId(name);
262
+
263
+ // 2. If no reqId in name, try .forge/build-context.json
264
+ if (!reqId) {
265
+ try {
266
+ const ctxPath = join(cwd, ".forge", "build-context.json");
267
+ if (existsSync(ctxPath)) {
268
+ const ctx = JSON.parse(readFileSync(ctxPath, "utf-8"));
269
+ if (ctx.requirementId) {
270
+ reqId = ctx.requirementId;
271
+ }
272
+ }
273
+ } catch { /* continue without reqId */ }
274
+ }
275
+
276
+ // 3. If still no reqId, fall back to generic worktree
277
+ if (!reqId) {
278
+ fallbackWorktree();
279
+ return;
280
+ }
281
+
282
+ // 4. Discover graphs and find the requirement
283
+ const graphs = discoverGraphsSync(cwd);
284
+ let matchedSlug = null;
285
+ let matchedRaw = null;
286
+ let linearIssueId = null;
287
+
288
+ for (const g of graphs) {
289
+ const issueId = extractLinearIssueId(g.raw, reqId);
290
+ if (issueId || g.raw.includes(` ${reqId}:`)) {
291
+ matchedSlug = g.slug;
292
+ matchedRaw = g.raw;
293
+ linearIssueId = issueId;
294
+ break;
295
+ }
296
+ }
297
+
298
+ if (!matchedSlug || !matchedRaw) {
299
+ // reqId found but no matching graph — create worktree with reqId but no Linear identifier
300
+ const worktreePath = join(".claude", "worktrees", name);
301
+ const branchName = `feat/${name}`;
302
+ const absPath = createGitWorktree(cwd, worktreePath, branchName);
303
+ process.stdout.write(absPath);
304
+ return;
305
+ }
306
+
307
+ // 5. Resolve the Linear issue identifier (e.g. "FRG-132")
308
+ const issueIdentifier = await resolveIssueIdentifier(linearIssueId);
309
+
310
+ // 6. Build the branch name
311
+ let branchName;
312
+ if (issueIdentifier) {
313
+ // feat/<slug>/<FRG-132>-<req-001>
314
+ branchName = `feat/${matchedSlug}/${issueIdentifier}-${reqId}`;
315
+ } else {
316
+ // No Linear identifier — use slug/reqId only
317
+ branchName = `feat/${matchedSlug}/${reqId}`;
318
+ }
319
+
320
+ // Sanitize branch name (lowercase, no special chars except - and /)
321
+ branchName = branchName.toLowerCase().replace(/[^a-z0-9/\-]/g, "-");
322
+
323
+ // 7. Create the worktree
324
+ const worktreePath = join(".claude", "worktrees", name);
325
+ const absPath = createGitWorktree(cwd, worktreePath, branchName);
326
+
327
+ // 8. Sync Linear status (non-blocking — don't fail if this errors)
328
+ try {
329
+ const minimalIndex = buildMinimalIndex(matchedRaw, matchedSlug, reqId, linearIssueId);
330
+ await callSyncRequirementStart(minimalIndex, reqId);
331
+ } catch (err) {
332
+ process.stderr.write(`[forge] Linear sync failed (non-blocking): ${err}\n`);
333
+ }
334
+
335
+ // 9. Print absolute worktree path to stdout (WorktreeCreate contract)
336
+ process.stdout.write(absPath);
337
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-cc",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Forge — verification harness for Claude Code agents",
5
5
  "type": "module",
6
6
  "license": "MIT",