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.
- package/README.md +234 -133
- package/dist/cli.js +127 -1
- package/dist/cli.js.map +1 -1
- package/dist/codex-poll.d.ts +17 -1
- package/dist/codex-poll.js +3 -7
- package/dist/codex-poll.js.map +1 -1
- package/dist/linear/client.d.ts +9 -0
- package/dist/linear/client.js +50 -0
- package/dist/linear/client.js.map +1 -1
- package/dist/linear/sync.d.ts +23 -4
- package/dist/linear/sync.js +66 -30
- package/dist/linear/sync.js.map +1 -1
- package/dist/runner/loop.js +34 -23
- package/dist/runner/loop.js.map +1 -1
- package/dist/setup.js +107 -3
- package/dist/setup.js.map +1 -1
- package/hooks/linear-branch-enforce.js +395 -0
- package/hooks/linear-post-action.js +331 -0
- package/hooks/linear-worktree-create.js +337 -0
- package/package.json +1 -1
- package/skills/forge-build.md +97 -49
- package/skills/forge-capture.md +6 -6
- package/skills/forge-fix.md +5 -0
- package/skills/forge-plan.md +16 -4
- package/skills/forge-quick.md +4 -0
- package/skills/ref/codex-review.md +158 -0
|
@@ -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
|
+
}
|