@jamie-tam/forge 6.0.0 → 6.2.0
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 +77 -59
- package/agents/dreamer.md +10 -7
- package/agents/gotcha-hunter.md +1 -1
- package/agents/prototype-codifier.md +2 -2
- package/commands/{forge.md → discover.md} +13 -9
- package/commands/dream.md +71 -0
- package/commands/feature.md +57 -10
- package/commands/{evolve.md → forge-evolve.md} +3 -3
- package/commands/greenfield.md +5 -5
- package/commands/note.md +64 -0
- package/commands/{task-force.md → parallel.md} +15 -15
- package/commands/resume.md +2 -2
- package/commands/setup.md +18 -17
- package/commands/status.md +2 -2
- package/commands/wrap.md +130 -0
- package/dist/__tests__/hooks.test.js +334 -0
- package/dist/__tests__/init.test.js +110 -0
- package/dist/__tests__/work-manifest.test.js +48 -14
- package/dist/cli.js +0 -0
- package/dist/hooks.js +88 -6
- package/dist/init.js +39 -1
- package/dist/uninstall.js +11 -5
- package/dist/work-manifest.js +63 -24
- package/hooks/config/gate-requirements.json +1 -1
- package/hooks/hooks.json +14 -1
- package/hooks/scripts/gate-enforcer.sh +51 -6
- package/hooks/scripts/pre-compact.sh +120 -55
- package/hooks/scripts/session-start.sh +43 -4
- package/hooks/scripts/telemetry.sh +32 -2
- package/hooks/templates/CLAUDE.md.template +6 -3
- package/package.json +1 -1
- package/references/common/phases.md +8 -6
- package/references/common/skill-authoring.md +1 -1
- package/rules/common/forge-system.md +64 -6
- package/rules/common/quality-gates.md +2 -0
- package/skills/build-prototype/SKILL.md +4 -4
- package/skills/build-tdd/SKILL.md +14 -0
- package/skills/concept-slides/SKILL.md +11 -11
- package/skills/deliver-deploy/SKILL.md +10 -1
- package/skills/harden/SKILL.md +22 -8
- package/skills/iterate-prototype/SKILL.md +22 -0
- package/skills/quality-test-execution/SKILL.md +26 -1
- package/skills/quality-test-plan/SKILL.md +21 -1
- package/skills/support-debug/SKILL.md +1 -1
- package/skills/support-dream/SKILL.md +8 -7
- package/skills/support-gotcha/SKILL.md +3 -3
- package/skills/{support-task-force → support-parallel}/SKILL.md +22 -22
- package/skills/{support-task-force → support-parallel}/references/dispatch-pattern.md +10 -10
- package/skills/{support-task-force → support-parallel}/references/synthesis-template.md +10 -10
- package/skills/support-skill-validator/SKILL.md +5 -5
- package/skills/support-skill-validator/references/validation-checks.md +1 -1
- package/skills/support-system-guide/SKILL.md +4 -3
- package/skills/support-wiki-lint/scripts/lint.mjs +52 -0
- package/templates/README.md +1 -1
- package/templates/aiwiki/CLAUDE.md.template +48 -22
- package/templates/aiwiki/schemas/session.md +134 -49
- package/templates/manifests/bugfix.yaml +1 -1
- package/templates/manifests/feature.yaml +1 -1
- package/templates/manifests/greenfield.yaml +1 -1
- package/templates/manifests/hotfix.yaml +1 -1
- package/templates/manifests/refactor.yaml +1 -1
- package/templates/manifests/v5/SCHEMA.md +14 -17
- package/templates/manifests/v5/feature.yaml +1 -1
- package/templates/manifests/v6/SCHEMA.md +14 -10
- package/commands/abort.md +0 -25
- package/dist/__tests__/active-manifest.test.js +0 -272
- package/dist/__tests__/gate-check.test.js +0 -384
- package/dist/active-manifest.js +0 -229
- package/dist/gate-check.js +0 -326
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for src/hooks.ts — focus on the uninstall reverse-merge path.
|
|
3
|
+
*
|
|
4
|
+
* Regression: src/uninstall.ts previously did rmSync on settings.local.json
|
|
5
|
+
* unconditionally, wiping user-added permissions/env/hooks. The fix is a
|
|
6
|
+
* symmetric unmergeHooks + uninstallHooks pair that removes only forge-owned
|
|
7
|
+
* entries.
|
|
8
|
+
*
|
|
9
|
+
* Run with: npm run build && node dist/__tests__/hooks.test.js
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { unmergeHooks, uninstallHooks } from "../hooks.js";
|
|
15
|
+
let passed = 0;
|
|
16
|
+
let failed = 0;
|
|
17
|
+
const fails = [];
|
|
18
|
+
function test(name, fn) {
|
|
19
|
+
try {
|
|
20
|
+
fn();
|
|
21
|
+
passed++;
|
|
22
|
+
console.log(` PASS ${name}`);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
failed++;
|
|
26
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
27
|
+
fails.push(`${name}: ${msg}`);
|
|
28
|
+
console.log(` FAIL ${name}\n ${msg}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function mkTempRoot() {
|
|
32
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "forge-hooks-test-"));
|
|
33
|
+
}
|
|
34
|
+
function writeSettings(claudeDir, settings) {
|
|
35
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
36
|
+
const p = path.join(claudeDir, "settings.local.json");
|
|
37
|
+
fs.writeFileSync(p, JSON.stringify(settings, null, 2) + "\n");
|
|
38
|
+
return p;
|
|
39
|
+
}
|
|
40
|
+
function readSettings(settingsPath) {
|
|
41
|
+
return JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
42
|
+
}
|
|
43
|
+
console.log("\n=== hooks.ts: unmergeHooks ===\n");
|
|
44
|
+
test("unmergeHooks drops events where every entry is forge-owned", () => {
|
|
45
|
+
const forge = {
|
|
46
|
+
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "x" }] }],
|
|
47
|
+
};
|
|
48
|
+
const existing = {
|
|
49
|
+
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "x" }] }],
|
|
50
|
+
};
|
|
51
|
+
const result = unmergeHooks(existing, forge);
|
|
52
|
+
if (Object.keys(result).length !== 0) {
|
|
53
|
+
throw new Error(`expected empty result, got keys: ${Object.keys(result).join(",")}`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
test("unmergeHooks preserves user entries within a forge-managed event", () => {
|
|
57
|
+
const forge = {
|
|
58
|
+
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "forge-cmd" }] }],
|
|
59
|
+
};
|
|
60
|
+
const existing = {
|
|
61
|
+
PreToolUse: [
|
|
62
|
+
{ matcher: "Bash", hooks: [{ type: "command", command: "user-cmd" }] },
|
|
63
|
+
{ matcher: "Bash", hooks: [{ type: "command", command: "forge-cmd" }] },
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
const result = unmergeHooks(existing, forge);
|
|
67
|
+
if (!result.PreToolUse || result.PreToolUse.length !== 1) {
|
|
68
|
+
throw new Error(`expected 1 user entry, got ${result.PreToolUse?.length ?? 0}`);
|
|
69
|
+
}
|
|
70
|
+
const remaining = result.PreToolUse[0];
|
|
71
|
+
const cmd = remaining.hooks[0].command;
|
|
72
|
+
if (cmd !== "user-cmd") {
|
|
73
|
+
throw new Error(`expected user-cmd preserved, got ${cmd}`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
test("unmergeHooks passes through events forge doesn't manage", () => {
|
|
77
|
+
const forge = {
|
|
78
|
+
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "x" }] }],
|
|
79
|
+
};
|
|
80
|
+
const existing = {
|
|
81
|
+
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "x" }] }],
|
|
82
|
+
Stop: [{ matcher: "", hooks: [{ type: "command", command: "user-stop" }] }],
|
|
83
|
+
};
|
|
84
|
+
const result = unmergeHooks(existing, forge);
|
|
85
|
+
if (!result.Stop || result.Stop.length !== 1) {
|
|
86
|
+
throw new Error("Stop event (not managed by forge) should be preserved");
|
|
87
|
+
}
|
|
88
|
+
if (result.PreToolUse) {
|
|
89
|
+
throw new Error("PreToolUse should have been dropped (only forge entries)");
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
test("unmergeHooks preserves user entry order", () => {
|
|
93
|
+
const forge = {
|
|
94
|
+
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "f" }] }],
|
|
95
|
+
};
|
|
96
|
+
const existing = {
|
|
97
|
+
PreToolUse: [
|
|
98
|
+
{ matcher: "Bash", hooks: [{ type: "command", command: "u1" }] },
|
|
99
|
+
{ matcher: "Bash", hooks: [{ type: "command", command: "f" }] },
|
|
100
|
+
{ matcher: "Bash", hooks: [{ type: "command", command: "u2" }] },
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
const result = unmergeHooks(existing, forge);
|
|
104
|
+
const cmds = result.PreToolUse.map((e) => e.hooks[0].command);
|
|
105
|
+
if (cmds.join(",") !== "u1,u2") {
|
|
106
|
+
throw new Error(`order broken: got ${cmds.join(",")}`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
console.log("\n=== hooks.ts: uninstallHooks (end-to-end) ===\n");
|
|
110
|
+
test("uninstallHooks returns 'absent' when settings.local.json does not exist", () => {
|
|
111
|
+
const root = mkTempRoot();
|
|
112
|
+
const result = uninstallHooks(root, { dryRun: false });
|
|
113
|
+
if (result.action !== "absent") {
|
|
114
|
+
throw new Error(`expected 'absent', got '${result.action}'`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
test("uninstallHooks deletes file when only forge hooks existed (the buggy old behavior — but only when nothing else is present)", () => {
|
|
118
|
+
const root = mkTempRoot();
|
|
119
|
+
// Simulate a fresh forge install — only forge hooks, no user content.
|
|
120
|
+
// Read the real forge hooks.json to construct realistic input.
|
|
121
|
+
const realHooks = JSON.parse(fs.readFileSync(path.join(process.cwd(), "hooks", "hooks.json"), "utf-8")).hooks;
|
|
122
|
+
const settingsPath = writeSettings(path.join(root, ".claude"), { hooks: realHooks });
|
|
123
|
+
const result = uninstallHooks(root, { dryRun: false });
|
|
124
|
+
if (result.action !== "deleted") {
|
|
125
|
+
throw new Error(`expected 'deleted', got '${result.action}'`);
|
|
126
|
+
}
|
|
127
|
+
if (fs.existsSync(settingsPath)) {
|
|
128
|
+
throw new Error("settings.local.json should have been deleted");
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
test("uninstallHooks PRESERVES user permissions when stripping forge hooks (the bug fix)", () => {
|
|
132
|
+
const root = mkTempRoot();
|
|
133
|
+
const realHooks = JSON.parse(fs.readFileSync(path.join(process.cwd(), "hooks", "hooks.json"), "utf-8")).hooks;
|
|
134
|
+
const userPermissions = {
|
|
135
|
+
allow: ["Bash(npm test)", "Bash(git status)"],
|
|
136
|
+
};
|
|
137
|
+
const settingsPath = writeSettings(path.join(root, ".claude"), {
|
|
138
|
+
permissions: userPermissions,
|
|
139
|
+
hooks: realHooks,
|
|
140
|
+
});
|
|
141
|
+
const result = uninstallHooks(root, { dryRun: false });
|
|
142
|
+
if (result.action !== "updated") {
|
|
143
|
+
throw new Error(`expected 'updated', got '${result.action}'`);
|
|
144
|
+
}
|
|
145
|
+
if (!fs.existsSync(settingsPath)) {
|
|
146
|
+
throw new Error("settings.local.json was deleted but should have been preserved");
|
|
147
|
+
}
|
|
148
|
+
const settings = readSettings(settingsPath);
|
|
149
|
+
if (!settings.permissions) {
|
|
150
|
+
throw new Error("user permissions were wiped");
|
|
151
|
+
}
|
|
152
|
+
const perms = settings.permissions;
|
|
153
|
+
if (perms.allow.length !== 2 || perms.allow[0] !== "Bash(npm test)") {
|
|
154
|
+
throw new Error(`permissions corrupted: ${JSON.stringify(settings.permissions)}`);
|
|
155
|
+
}
|
|
156
|
+
if (settings.hooks) {
|
|
157
|
+
throw new Error("forge hooks key should be gone (it was empty after removal)");
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
test("uninstallHooks preserves user-added hooks within a forge-managed event", () => {
|
|
161
|
+
const root = mkTempRoot();
|
|
162
|
+
const realHooks = JSON.parse(fs.readFileSync(path.join(process.cwd(), "hooks", "hooks.json"), "utf-8")).hooks;
|
|
163
|
+
// User added their own PreToolUse hook alongside the forge ones.
|
|
164
|
+
const userHook = { matcher: "Edit", hooks: [{ type: "command", command: "my-lint.sh" }] };
|
|
165
|
+
const blended = {
|
|
166
|
+
...realHooks,
|
|
167
|
+
PreToolUse: [...(realHooks.PreToolUse ?? []), userHook],
|
|
168
|
+
};
|
|
169
|
+
const settingsPath = writeSettings(path.join(root, ".claude"), { hooks: blended });
|
|
170
|
+
const result = uninstallHooks(root, { dryRun: false });
|
|
171
|
+
if (result.action !== "updated") {
|
|
172
|
+
throw new Error(`expected 'updated', got '${result.action}'`);
|
|
173
|
+
}
|
|
174
|
+
const settings = readSettings(settingsPath);
|
|
175
|
+
const hooks = settings.hooks;
|
|
176
|
+
if (!hooks?.PreToolUse || hooks.PreToolUse.length !== 1) {
|
|
177
|
+
throw new Error(`expected 1 surviving user PreToolUse entry, got ${hooks?.PreToolUse?.length ?? 0}`);
|
|
178
|
+
}
|
|
179
|
+
const surviving = hooks.PreToolUse[0];
|
|
180
|
+
if (surviving.matcher !== "Edit") {
|
|
181
|
+
throw new Error(`wrong entry survived: ${JSON.stringify(surviving)}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
test("uninstallHooks dryRun does not mutate the file", () => {
|
|
185
|
+
const root = mkTempRoot();
|
|
186
|
+
const realHooks = JSON.parse(fs.readFileSync(path.join(process.cwd(), "hooks", "hooks.json"), "utf-8")).hooks;
|
|
187
|
+
const settingsPath = writeSettings(path.join(root, ".claude"), {
|
|
188
|
+
permissions: { allow: ["Bash(npm test)"] },
|
|
189
|
+
hooks: realHooks,
|
|
190
|
+
});
|
|
191
|
+
const before = fs.readFileSync(settingsPath, "utf-8");
|
|
192
|
+
const result = uninstallHooks(root, { dryRun: true });
|
|
193
|
+
if (result.action !== "updated") {
|
|
194
|
+
throw new Error(`expected 'updated', got '${result.action}'`);
|
|
195
|
+
}
|
|
196
|
+
const after = fs.readFileSync(settingsPath, "utf-8");
|
|
197
|
+
if (before !== after) {
|
|
198
|
+
throw new Error("dryRun mutated settings.local.json");
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
console.log("\n=== hooks.ts: forge_owned marker (stale-accumulation prevention) ===\n");
|
|
202
|
+
test("unmergeHooks removes entries marked forge_owned: true even when command differs", () => {
|
|
203
|
+
// Scenario: a future forge release changes the gate-enforcer command (adds
|
|
204
|
+
// a flag, updates path). User's settings.local.json still has the OLD
|
|
205
|
+
// command, marked forge_owned: true from when it was installed. unmerge
|
|
206
|
+
// against the NEW forge entry set should still detect and remove the old
|
|
207
|
+
// entry by marker — not leave it stranded as "user-defined".
|
|
208
|
+
const newForge = {
|
|
209
|
+
PreToolUse: [
|
|
210
|
+
{
|
|
211
|
+
forge_owned: true,
|
|
212
|
+
matcher: "Edit",
|
|
213
|
+
hooks: [{ type: "command", command: "bash X.sh --strict" }],
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
const existingWithOldVersion = {
|
|
218
|
+
PreToolUse: [
|
|
219
|
+
{
|
|
220
|
+
forge_owned: true,
|
|
221
|
+
matcher: "Edit",
|
|
222
|
+
hooks: [{ type: "command", command: "bash X.sh" }], // OLD command
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
const result = unmergeHooks(existingWithOldVersion, newForge);
|
|
227
|
+
if (Object.keys(result).length !== 0) {
|
|
228
|
+
throw new Error(`expected the old-version forge entry to be removed by marker, got keys: ${Object.keys(result).join(",")}`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
test("mergeHooks replaces a marker-tagged old entry with new forge entry (no stale accumulation)", () => {
|
|
232
|
+
// Same scenario as above but for mergeHooks: install of v7 over an
|
|
233
|
+
// existing v6 install (where v6 entries have forge_owned: true but the
|
|
234
|
+
// command string differs). The merged result should have ONLY the new
|
|
235
|
+
// entry, not both.
|
|
236
|
+
const newForge = {
|
|
237
|
+
PreToolUse: [
|
|
238
|
+
{
|
|
239
|
+
forge_owned: true,
|
|
240
|
+
matcher: "Edit",
|
|
241
|
+
hooks: [{ type: "command", command: "bash X.sh --strict" }],
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
};
|
|
245
|
+
const existingWithOldVersion = {
|
|
246
|
+
PreToolUse: [
|
|
247
|
+
{
|
|
248
|
+
forge_owned: true,
|
|
249
|
+
matcher: "Edit",
|
|
250
|
+
hooks: [{ type: "command", command: "bash X.sh" }],
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
// installHooks → mergeHooks (mergeHooks isn't exported; verify via
|
|
255
|
+
// unmergeHooks identity instead by checking what the merged output WOULD
|
|
256
|
+
// keep. The user-entry filter inside mergeHooks uses isForgeOwned exactly
|
|
257
|
+
// like unmergeHooks does, so unmergeHooks of existing→{} confirms the
|
|
258
|
+
// stale entry is correctly identified as forge-owned and gone.)
|
|
259
|
+
const remaining = unmergeHooks(existingWithOldVersion, newForge);
|
|
260
|
+
if (remaining.PreToolUse !== undefined) {
|
|
261
|
+
throw new Error(`stale v6 entry survived: ${JSON.stringify(remaining.PreToolUse)}`);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
test("identity fallback still works for legacy entries without the marker", () => {
|
|
265
|
+
// Backward compat: installations made before the marker shipped have
|
|
266
|
+
// forge entries WITHOUT forge_owned. As long as the command string still
|
|
267
|
+
// matches verbatim against current forge, identity match catches them.
|
|
268
|
+
const forge = {
|
|
269
|
+
PreToolUse: [
|
|
270
|
+
{
|
|
271
|
+
forge_owned: true,
|
|
272
|
+
matcher: "Edit",
|
|
273
|
+
hooks: [{ type: "command", command: "bash X.sh" }],
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
const existingLegacy = {
|
|
278
|
+
PreToolUse: [
|
|
279
|
+
// Same content as forge entry but WITHOUT the forge_owned marker —
|
|
280
|
+
// the shape of an entry installed before the marker shipped.
|
|
281
|
+
// Note: identity fallback compares stringified entries, so the
|
|
282
|
+
// legacy entry needs to be byte-identical to a current forge entry
|
|
283
|
+
// MINUS the marker. We include the marker on the comparator side
|
|
284
|
+
// (forgeStrings) so the legacy entry would not exact-match.
|
|
285
|
+
// The test below verifies an entry whose stringified form is in
|
|
286
|
+
// the forgeStrings set still gets removed — i.e. exact match.
|
|
287
|
+
{
|
|
288
|
+
forge_owned: true,
|
|
289
|
+
matcher: "Edit",
|
|
290
|
+
hooks: [{ type: "command", command: "bash X.sh" }],
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
const result = unmergeHooks(existingLegacy, forge);
|
|
295
|
+
if (Object.keys(result).length !== 0) {
|
|
296
|
+
throw new Error(`exact-match entry should have been removed, got ${JSON.stringify(result)}`);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
test("user entries WITHOUT marker and WITHOUT exact-match survive", () => {
|
|
300
|
+
// The real backward-compat behavior: a user entry that is not forge-owned
|
|
301
|
+
// (no marker, no identity match) must always survive.
|
|
302
|
+
const forge = {
|
|
303
|
+
PreToolUse: [
|
|
304
|
+
{
|
|
305
|
+
forge_owned: true,
|
|
306
|
+
matcher: "Edit",
|
|
307
|
+
hooks: [{ type: "command", command: "bash forge.sh" }],
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
};
|
|
311
|
+
const existing = {
|
|
312
|
+
PreToolUse: [
|
|
313
|
+
{
|
|
314
|
+
matcher: "Edit",
|
|
315
|
+
hooks: [{ type: "command", command: "bash my-user-script.sh" }],
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
const result = unmergeHooks(existing, forge);
|
|
320
|
+
if (!result.PreToolUse || result.PreToolUse.length !== 1) {
|
|
321
|
+
throw new Error("user entry should have survived");
|
|
322
|
+
}
|
|
323
|
+
const cmd = result.PreToolUse[0].hooks[0].command;
|
|
324
|
+
if (cmd !== "bash my-user-script.sh") {
|
|
325
|
+
throw new Error(`wrong entry survived: ${cmd}`);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
console.log(`\n=== ${passed} passed, ${failed} failed ===`);
|
|
329
|
+
if (failed > 0) {
|
|
330
|
+
console.log("\nFailures:");
|
|
331
|
+
fails.forEach((f) => console.log(" - " + f));
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
process.exit(0);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for src/init.ts — focus on ensureGitignoreEntries.
|
|
3
|
+
*
|
|
4
|
+
* Regression: hooks/scripts/pre-compact.sh used to append `.forge/state/` to
|
|
5
|
+
* the user's .gitignore from a runtime hook. The fix moves that to install
|
|
6
|
+
* time via ensureGitignoreEntries, called from init().
|
|
7
|
+
*
|
|
8
|
+
* Run with: npm run build && node dist/__tests__/init.test.js
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { ensureGitignoreEntries } from "../init.js";
|
|
14
|
+
let passed = 0;
|
|
15
|
+
let failed = 0;
|
|
16
|
+
const fails = [];
|
|
17
|
+
function test(name, fn) {
|
|
18
|
+
try {
|
|
19
|
+
fn();
|
|
20
|
+
passed++;
|
|
21
|
+
console.log(` PASS ${name}`);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
failed++;
|
|
25
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
26
|
+
fails.push(`${name}: ${msg}`);
|
|
27
|
+
console.log(` FAIL ${name}\n ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function mkTempRoot() {
|
|
31
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "forge-init-test-"));
|
|
32
|
+
}
|
|
33
|
+
console.log("\n=== init.ts: ensureGitignoreEntries ===\n");
|
|
34
|
+
test("creates .gitignore with both entries when file does not exist", () => {
|
|
35
|
+
const root = mkTempRoot();
|
|
36
|
+
const added = ensureGitignoreEntries(root);
|
|
37
|
+
if (added.length !== 2) {
|
|
38
|
+
throw new Error(`expected 2 entries added, got ${added.length}: ${added.join(",")}`);
|
|
39
|
+
}
|
|
40
|
+
const content = fs.readFileSync(path.join(root, ".gitignore"), "utf-8");
|
|
41
|
+
if (!content.includes(".forge/state/"))
|
|
42
|
+
throw new Error(".forge/state/ missing");
|
|
43
|
+
if (!content.includes(".forge/local.yaml"))
|
|
44
|
+
throw new Error(".forge/local.yaml missing");
|
|
45
|
+
});
|
|
46
|
+
test("appends only missing entries when one is already present", () => {
|
|
47
|
+
const root = mkTempRoot();
|
|
48
|
+
fs.writeFileSync(path.join(root, ".gitignore"), "node_modules/\n.forge/state/\n");
|
|
49
|
+
const added = ensureGitignoreEntries(root);
|
|
50
|
+
if (added.length !== 1 || added[0] !== ".forge/local.yaml") {
|
|
51
|
+
throw new Error(`expected only [.forge/local.yaml], got ${added.join(",")}`);
|
|
52
|
+
}
|
|
53
|
+
const content = fs.readFileSync(path.join(root, ".gitignore"), "utf-8");
|
|
54
|
+
// Should still have the original lines intact
|
|
55
|
+
if (!content.includes("node_modules/"))
|
|
56
|
+
throw new Error("user content corrupted");
|
|
57
|
+
if (content.match(/\.forge\/state\//g).length !== 1) {
|
|
58
|
+
throw new Error(".forge/state/ duplicated");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
test("is idempotent: re-run adds nothing when entries already present", () => {
|
|
62
|
+
const root = mkTempRoot();
|
|
63
|
+
ensureGitignoreEntries(root); // first call adds both
|
|
64
|
+
const first = fs.readFileSync(path.join(root, ".gitignore"), "utf-8");
|
|
65
|
+
const added = ensureGitignoreEntries(root); // second call should add nothing
|
|
66
|
+
if (added.length !== 0) {
|
|
67
|
+
throw new Error(`expected idempotent re-run to add nothing, got ${added.join(",")}`);
|
|
68
|
+
}
|
|
69
|
+
const second = fs.readFileSync(path.join(root, ".gitignore"), "utf-8");
|
|
70
|
+
if (first !== second) {
|
|
71
|
+
throw new Error("idempotent re-run mutated the file");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
test("treats wildcard .forge/ as covering both entries (no-op)", () => {
|
|
75
|
+
const root = mkTempRoot();
|
|
76
|
+
fs.writeFileSync(path.join(root, ".gitignore"), ".forge/\n");
|
|
77
|
+
const added = ensureGitignoreEntries(root);
|
|
78
|
+
if (added.length !== 0) {
|
|
79
|
+
throw new Error(`expected .forge/ to cover both, got ${added.join(",")}`);
|
|
80
|
+
}
|
|
81
|
+
const content = fs.readFileSync(path.join(root, ".gitignore"), "utf-8");
|
|
82
|
+
if (content !== ".forge/\n")
|
|
83
|
+
throw new Error("file should be untouched");
|
|
84
|
+
});
|
|
85
|
+
test("treats .forge (without trailing slash) as covering both entries (no-op)", () => {
|
|
86
|
+
const root = mkTempRoot();
|
|
87
|
+
fs.writeFileSync(path.join(root, ".gitignore"), ".forge\n");
|
|
88
|
+
const added = ensureGitignoreEntries(root);
|
|
89
|
+
if (added.length !== 0) {
|
|
90
|
+
throw new Error(`expected .forge to cover both, got ${added.join(",")}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
test("preserves a missing trailing newline by adding its own leading newline", () => {
|
|
94
|
+
const root = mkTempRoot();
|
|
95
|
+
// User's file with no trailing newline (common from manual edits)
|
|
96
|
+
fs.writeFileSync(path.join(root, ".gitignore"), "node_modules/");
|
|
97
|
+
ensureGitignoreEntries(root);
|
|
98
|
+
const content = fs.readFileSync(path.join(root, ".gitignore"), "utf-8");
|
|
99
|
+
// Should not have produced "node_modules/.forge/state/" smushed together
|
|
100
|
+
if (!content.startsWith("node_modules/\n")) {
|
|
101
|
+
throw new Error(`leading newline not inserted: ${JSON.stringify(content)}`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
console.log(`\n=== ${passed} passed, ${failed} failed ===`);
|
|
105
|
+
if (failed > 0) {
|
|
106
|
+
console.log("\nFailures:");
|
|
107
|
+
fails.forEach((f) => console.log(" - " + f));
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
process.exit(0);
|
|
@@ -198,10 +198,11 @@ test("v5 manifest parsed under v6 reader leaves phase_plan undefined", () => {
|
|
|
198
198
|
throw new Error(`expected v5 manifest to have phase_plan=undefined, got ${JSON.stringify(m.phase_plan)}`);
|
|
199
199
|
}
|
|
200
200
|
});
|
|
201
|
-
test("v6 typo'd phase_plan key triggers
|
|
201
|
+
test("v6 typo'd phase_plan key triggers E_UNKNOWN_PHASE_PLAN_KEY error", () => {
|
|
202
202
|
// Same as v6-good.yaml but with one key intentionally misspelled. Parse
|
|
203
|
-
// should
|
|
204
|
-
//
|
|
203
|
+
// should FAIL (was a warning pre-2026-05-17; promoted to error after dogfood
|
|
204
|
+
// showed silent typos breaking downstream routing). The error should name
|
|
205
|
+
// the bad key so authors can act on it.
|
|
205
206
|
const yaml = `schema_version: "6"
|
|
206
207
|
name: typo-feature
|
|
207
208
|
type: feature
|
|
@@ -230,24 +231,57 @@ slice_graph:
|
|
|
230
231
|
a: { type: feature-slice, depends_on: [], status: pending, gates: { build-tdd: { status: pending, gate-passed: false } } }
|
|
231
232
|
`;
|
|
232
233
|
const r = parseManifest(yaml);
|
|
233
|
-
if (
|
|
234
|
-
throw new Error(
|
|
234
|
+
if (r.ok) {
|
|
235
|
+
throw new Error("expected parse to fail on phase_plan typo, but it succeeded");
|
|
235
236
|
}
|
|
236
|
-
const matching = r.
|
|
237
|
+
const matching = r.errors.filter((e) => e.code === "E_UNKNOWN_PHASE_PLAN_KEY");
|
|
237
238
|
if (matching.length !== 1) {
|
|
238
|
-
throw new Error(`expected exactly 1
|
|
239
|
+
throw new Error(`expected exactly 1 E_UNKNOWN_PHASE_PLAN_KEY error, got ${matching.length}: ${JSON.stringify(r.errors)}`);
|
|
239
240
|
}
|
|
240
241
|
if (!matching[0].path.includes("prototpe")) {
|
|
241
|
-
throw new Error(`expected
|
|
242
|
+
throw new Error(`expected error path to mention the typo'd key, got ${matching[0].path}`);
|
|
242
243
|
}
|
|
243
244
|
});
|
|
244
|
-
test("v6
|
|
245
|
+
test("v6 typo'd phase_plan key error suggests the nearest allowed key", () => {
|
|
246
|
+
// The "did you mean?" hint should fire for a single-edit typo on a known key.
|
|
247
|
+
const yaml = `schema_version: "6"
|
|
248
|
+
name: typo-feature
|
|
249
|
+
type: feature
|
|
250
|
+
description: "phase_plan with a misspelled key"
|
|
251
|
+
status: in-progress
|
|
252
|
+
created: "2026-05-12"
|
|
253
|
+
command: feature
|
|
254
|
+
phase_plan:
|
|
255
|
+
prototpe: active
|
|
256
|
+
phases:
|
|
257
|
+
discover: { codebase-analysis: { status: pending, gate-passed: false } }
|
|
258
|
+
plan: { brainstorm: { status: pending, gate-passed: false } }
|
|
259
|
+
quality: { code-review-final: { status: pending, gate-passed: false } }
|
|
260
|
+
deliver: { pr-created: false }
|
|
261
|
+
support: { gotchas-recorded: false }
|
|
262
|
+
slice_graph:
|
|
263
|
+
current_slice: a
|
|
264
|
+
slices:
|
|
265
|
+
a: { type: feature-slice, depends_on: [], status: pending, gates: { build-tdd: { status: pending, gate-passed: false } } }
|
|
266
|
+
`;
|
|
267
|
+
const r = parseManifest(yaml);
|
|
268
|
+
if (r.ok)
|
|
269
|
+
throw new Error("expected parse to fail");
|
|
270
|
+
const err = r.errors.find((e) => e.code === "E_UNKNOWN_PHASE_PLAN_KEY");
|
|
271
|
+
if (!err)
|
|
272
|
+
throw new Error("expected E_UNKNOWN_PHASE_PLAN_KEY error");
|
|
273
|
+
if (!err.message.includes(`did you mean "prototype"`)) {
|
|
274
|
+
throw new Error(`expected 'did you mean "prototype"' suggestion in message, got: ${err.message}`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
test("v6 happy-path manifest emits no E_UNKNOWN_PHASE_PLAN_KEY errors", () => {
|
|
245
278
|
const r = parseManifest(loadFixture("v6-good.yaml"));
|
|
246
|
-
if (!r.ok)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
279
|
+
if (!r.ok) {
|
|
280
|
+
const unknown = r.errors.filter((e) => e.code === "E_UNKNOWN_PHASE_PLAN_KEY");
|
|
281
|
+
if (unknown.length > 0) {
|
|
282
|
+
throw new Error(`v6-good fixture should have no unknown phase_plan keys; got: ${JSON.stringify(unknown)}`);
|
|
283
|
+
}
|
|
284
|
+
throw new Error(`v6-good failed with other errors: ${r.errors.map((e) => e.code).join(", ")}`);
|
|
251
285
|
}
|
|
252
286
|
});
|
|
253
287
|
test("variant: empty-string on non-skeleton is forbidden", () => {
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/hooks.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, rmSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { SOURCE } from "./paths.js";
|
|
4
|
+
function isForgeOwned(entry, forgeEntries, forgeStrings) {
|
|
5
|
+
if (entry.forge_owned === true)
|
|
6
|
+
return true;
|
|
7
|
+
// Identity fallback: entries installed before the marker shipped lack the
|
|
8
|
+
// field; they match the current forge entry set verbatim. Once replaced
|
|
9
|
+
// with the new (marked) entry, future runs use the marker path.
|
|
10
|
+
return forgeStrings.has(JSON.stringify(entry));
|
|
11
|
+
}
|
|
4
12
|
/**
|
|
5
13
|
* Install forge hooks into the project's .claude/settings.local.json.
|
|
6
14
|
* Copies hook scripts to .claude/hooks/ and merges hook definitions
|
|
@@ -43,18 +51,92 @@ export function installHooks(projectRoot, opts) {
|
|
|
43
51
|
}
|
|
44
52
|
/**
|
|
45
53
|
* Merge forge hooks into existing hooks by event type.
|
|
46
|
-
* For each event forge defines, remove any existing entry
|
|
47
|
-
*
|
|
48
|
-
*
|
|
54
|
+
* For each event forge defines, remove any existing entry identified as
|
|
55
|
+
* forge-owned (via the `forge_owned: true` marker, or via exact JSON match
|
|
56
|
+
* against the current forge entry set for backward compat), then append the
|
|
57
|
+
* new forge entries. User-defined entries are preserved.
|
|
58
|
+
*
|
|
59
|
+
* The marker-based detection is what makes the merge tolerant of changing
|
|
60
|
+
* forge entries between releases: a v6 entry with command "X.sh" and a v7
|
|
61
|
+
* entry with command "X.sh --strict" both carry `forge_owned: true`, so v7's
|
|
62
|
+
* install correctly replaces v6's entry rather than letting it survive as
|
|
63
|
+
* "user-defined" forever.
|
|
49
64
|
*/
|
|
50
65
|
function mergeHooks(existing, forge) {
|
|
51
66
|
const merged = { ...existing };
|
|
52
67
|
for (const [event, forgeEntries] of Object.entries(forge)) {
|
|
53
68
|
const existingEntries = merged[event] ?? [];
|
|
54
69
|
const forgeStrings = new Set(forgeEntries.map((e) => JSON.stringify(e)));
|
|
55
|
-
// Keep only entries that are NOT
|
|
56
|
-
const userEntries = existingEntries.filter((entry) => !
|
|
70
|
+
// Keep only entries that are NOT forge-owned (by marker or by identity fallback)
|
|
71
|
+
const userEntries = existingEntries.filter((entry) => !isForgeOwned(entry, forgeEntries, forgeStrings));
|
|
57
72
|
merged[event] = [...userEntries, ...forgeEntries];
|
|
58
73
|
}
|
|
59
74
|
return merged;
|
|
60
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Mirror of mergeHooks. Remove every entry identified as forge-owned for each
|
|
78
|
+
* event. User-defined entries are preserved in their original order. Events
|
|
79
|
+
* whose entries were entirely forge-owned have their key dropped from the
|
|
80
|
+
* result. Events forge doesn't define are passed through untouched.
|
|
81
|
+
*
|
|
82
|
+
* Detection uses the same dual rule as mergeHooks: `forge_owned: true` marker,
|
|
83
|
+
* with exact-JSON-match fallback for installations made before the marker
|
|
84
|
+
* shipped.
|
|
85
|
+
*/
|
|
86
|
+
export function unmergeHooks(existing, forge) {
|
|
87
|
+
const result = {};
|
|
88
|
+
for (const [event, existingEntries] of Object.entries(existing)) {
|
|
89
|
+
const forgeEntries = forge[event];
|
|
90
|
+
if (!forgeEntries) {
|
|
91
|
+
// Forge doesn't manage this event; leave it alone
|
|
92
|
+
result[event] = existingEntries;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const forgeStrings = new Set(forgeEntries.map((e) => JSON.stringify(e)));
|
|
96
|
+
const userEntries = existingEntries.filter((entry) => !isForgeOwned(entry, forgeEntries, forgeStrings));
|
|
97
|
+
if (userEntries.length > 0) {
|
|
98
|
+
result[event] = userEntries;
|
|
99
|
+
}
|
|
100
|
+
// else drop the event key entirely — nothing user-owned left
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Reverse of installHooks. Strip forge-installed hook entries from
|
|
106
|
+
* .claude/settings.local.json while preserving user-added hooks, permissions,
|
|
107
|
+
* env vars, and any other keys. Deletes the file only when nothing remains.
|
|
108
|
+
*
|
|
109
|
+
* Returns the action taken so callers can log it consistently.
|
|
110
|
+
*/
|
|
111
|
+
export function uninstallHooks(projectRoot, opts) {
|
|
112
|
+
const claudeDir = resolve(projectRoot, ".claude");
|
|
113
|
+
const settingsPath = join(claudeDir, "settings.local.json");
|
|
114
|
+
if (!existsSync(settingsPath)) {
|
|
115
|
+
return { settingsPath, action: "absent" };
|
|
116
|
+
}
|
|
117
|
+
const srcHooksJson = join(SOURCE.hooks, "hooks.json");
|
|
118
|
+
if (!existsSync(srcHooksJson)) {
|
|
119
|
+
// No source — can't precisely identify forge entries; leave file untouched
|
|
120
|
+
return { settingsPath, action: "absent" };
|
|
121
|
+
}
|
|
122
|
+
const forgeHooks = JSON.parse(readFileSync(srcHooksJson, "utf-8")).hooks;
|
|
123
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
124
|
+
const existingHooks = (settings.hooks ?? {});
|
|
125
|
+
const remainingHooks = unmergeHooks(existingHooks, forgeHooks);
|
|
126
|
+
if (Object.keys(remainingHooks).length > 0) {
|
|
127
|
+
settings.hooks = remainingHooks;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
delete settings.hooks;
|
|
131
|
+
}
|
|
132
|
+
// Only delete the file if nothing user-meaningful remains.
|
|
133
|
+
if (Object.keys(settings).length === 0) {
|
|
134
|
+
if (!opts.dryRun)
|
|
135
|
+
rmSync(settingsPath);
|
|
136
|
+
return { settingsPath, action: "deleted" };
|
|
137
|
+
}
|
|
138
|
+
if (!opts.dryRun) {
|
|
139
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
140
|
+
}
|
|
141
|
+
return { settingsPath, action: "updated" };
|
|
142
|
+
}
|