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,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
|
+
}
|