@jamie-tam/forge 6.0.0 → 6.1.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.
Files changed (61) hide show
  1. package/README.md +73 -59
  2. package/agents/dreamer.md +5 -6
  3. package/agents/gotcha-hunter.md +1 -1
  4. package/agents/prototype-codifier.md +2 -2
  5. package/commands/{forge.md → discover.md} +11 -9
  6. package/commands/feature.md +50 -8
  7. package/commands/{evolve.md → forge-evolve.md} +3 -3
  8. package/commands/greenfield.md +5 -5
  9. package/commands/note.md +64 -0
  10. package/commands/{task-force.md → parallel.md} +15 -15
  11. package/commands/resume.md +2 -2
  12. package/commands/setup.md +18 -17
  13. package/commands/status.md +2 -2
  14. package/dist/__tests__/hooks.test.js +334 -0
  15. package/dist/__tests__/init.test.js +110 -0
  16. package/dist/__tests__/work-manifest.test.js +48 -14
  17. package/dist/cli.js +0 -0
  18. package/dist/hooks.js +88 -6
  19. package/dist/init.js +39 -1
  20. package/dist/uninstall.js +11 -5
  21. package/dist/work-manifest.js +63 -24
  22. package/hooks/hooks.json +14 -1
  23. package/hooks/scripts/pre-compact.sh +3 -6
  24. package/hooks/scripts/session-start.sh +1 -1
  25. package/hooks/templates/CLAUDE.md.template +6 -3
  26. package/package.json +1 -1
  27. package/references/common/phases.md +8 -6
  28. package/references/common/skill-authoring.md +1 -1
  29. package/rules/common/forge-system.md +42 -4
  30. package/skills/build-prototype/SKILL.md +4 -4
  31. package/skills/build-tdd/SKILL.md +14 -0
  32. package/skills/concept-slides/SKILL.md +11 -11
  33. package/skills/deliver-deploy/SKILL.md +1 -1
  34. package/skills/harden/SKILL.md +6 -6
  35. package/skills/quality-test-execution/SKILL.md +26 -1
  36. package/skills/quality-test-plan/SKILL.md +21 -1
  37. package/skills/support-debug/SKILL.md +1 -1
  38. package/skills/support-dream/SKILL.md +5 -5
  39. package/skills/support-gotcha/SKILL.md +3 -3
  40. package/skills/{support-task-force → support-parallel}/SKILL.md +22 -22
  41. package/skills/{support-task-force → support-parallel}/references/dispatch-pattern.md +10 -10
  42. package/skills/{support-task-force → support-parallel}/references/synthesis-template.md +10 -10
  43. package/skills/support-skill-validator/SKILL.md +5 -5
  44. package/skills/support-skill-validator/references/validation-checks.md +1 -1
  45. package/skills/support-system-guide/SKILL.md +4 -3
  46. package/skills/support-wiki-lint/scripts/lint.mjs +52 -0
  47. package/templates/README.md +1 -1
  48. package/templates/aiwiki/schemas/session.md +15 -14
  49. package/templates/manifests/bugfix.yaml +1 -1
  50. package/templates/manifests/feature.yaml +1 -1
  51. package/templates/manifests/greenfield.yaml +1 -1
  52. package/templates/manifests/hotfix.yaml +1 -1
  53. package/templates/manifests/refactor.yaml +1 -1
  54. package/templates/manifests/v5/SCHEMA.md +14 -17
  55. package/templates/manifests/v5/feature.yaml +1 -1
  56. package/templates/manifests/v6/SCHEMA.md +14 -10
  57. package/commands/abort.md +0 -25
  58. package/dist/__tests__/active-manifest.test.js +0 -272
  59. package/dist/__tests__/gate-check.test.js +0 -384
  60. package/dist/active-manifest.js +0 -229
  61. package/dist/gate-check.js +0 -326
@@ -1,12 +1,12 @@
1
1
  ---
2
- name: task-force
3
- description: "Dispatch a parallel task-force per item in a user-provided punch list. Phase-aware sizing (light teams in prototype phases, full crew in production). Routes command-shaped items (features, bugfixes, refactors) to their owning commands. Include !max (or --full-power) in your prompt to force full-crew production teams regardless of detected phase."
2
+ name: parallel
3
+ description: "Dispatch parallel agents per item in a user-provided punch list. Phase-aware sizing (light teams in prototype phases, full crew in production). Routes command-shaped items (features, bugfixes, refactors) to their owning commands. Include !max (or --full-power) in your prompt to force full-crew production teams regardless of detected phase."
4
4
  argument-hint: "task1; task2; task3 (or numbered/bulleted list); append !max to force full crew"
5
5
  ---
6
6
 
7
- # /task-force
7
+ # /parallel
8
8
 
9
- Run a parallel ad-hoc task-force dispatcher over a punch list of independent tasks.
9
+ Run a parallel ad-hoc dispatcher over a punch list of independent tasks.
10
10
 
11
11
  ## When to use
12
12
 
@@ -25,26 +25,26 @@ Run a parallel ad-hoc task-force dispatcher over a punch list of independent tas
25
25
 
26
26
  ## Process
27
27
 
28
- REQUIRED SUB-SKILL: Use **support-task-force** to:
28
+ REQUIRED SUB-SKILL: Use **support-parallel** to:
29
29
  1. Run Codex consent (one-time per run, per `protocols/codex.md`)
30
30
  2. Detect repo phase (prototype vs production) from active manifest or repo state
31
31
  3. Parse the task list (numbered, bulleted, or semicolon-separated argument)
32
32
  4. Classify each task by type (dev / research / debug / docs / review / security / quick / complex / command-shaped)
33
- 5. Route command-shaped items to their owning commands (do NOT dispatch task-forces for them)
33
+ 5. Route command-shaped items to their owning commands (do NOT dispatch parallel runs for them)
34
34
  6. Assemble a team per task, sized by task type AND phase mode
35
- 7. Dispatch all task-forces in parallel (background subagents)
35
+ 7. Dispatch all per-task teams in parallel (background subagents)
36
36
  8. Aggregate per-task verdicts with Claude+Codex consensus check
37
37
  9. Surface a run-summary with conflicts highlighted
38
38
 
39
39
  ## Arguments
40
40
 
41
41
  Either:
42
- - Pass the list directly: `/task-force update README; explore caching options; add test for parser`
43
- - Or pass a numbered/bulleted list in the next prompt after invoking `/task-force` bare
42
+ - Pass the list directly: `/parallel update README; explore caching options; add test for parser`
43
+ - Or pass a numbered/bulleted list in the next prompt after invoking `/parallel` bare
44
44
 
45
45
  ## Output
46
46
 
47
- `.forge/task-force/{run-id}/` containing:
47
+ `.forge/parallel/{run-id}/` containing:
48
48
  - `codex.yaml` — Codex consent choice
49
49
  - `tasks.yaml` — task classifications
50
50
  - `task-{n}/{role}.md` — per-agent output
@@ -63,7 +63,7 @@ Either:
63
63
  ### Standard (phase-aware sizing)
64
64
 
65
65
  ```
66
- /task-force
66
+ /parallel
67
67
  1. Update README with new install steps
68
68
  2. Explore options for adding a /status command
69
69
  3. Add unit test for parser edge cases
@@ -84,7 +84,7 @@ In prototype phase, the same list dispatches ~5 agents (light templates, no Code
84
84
  ### Force full power (`!max` override)
85
85
 
86
86
  ```
87
- /task-force !max
87
+ /parallel !max
88
88
  1. Update README with new install steps
89
89
  2. Add helper function for date parsing
90
90
  3. Investigate why CI is flaky on test 17
@@ -100,11 +100,11 @@ Use `!max` (short form) or `--full-power` (canonical form) when prototype code i
100
100
 
101
101
  ## Manifest
102
102
 
103
- This command does NOT create a `.forge/work/{type}/{name}/` manifest — task-force runs are not phase-gated. State lives in `.forge/task-force/{run-id}/` only.
103
+ This command does NOT create a `.forge/work/{type}/{name}/` manifest — `/parallel` runs are not phase-gated. State lives in `.forge/parallel/{run-id}/` only.
104
104
 
105
- If an active feature/bugfix manifest exists, the task-force run-id is appended to its current phase as `task-force-runs: [...]` for audit.
105
+ If an active feature/bugfix manifest exists, the parallel run-id is appended to its current phase as `parallel-runs: [...]` for audit.
106
106
 
107
107
  ## See also
108
108
 
109
- - `skills/support-task-force/SKILL.md` — full process documentation
109
+ - `skills/support-parallel/SKILL.md` — full process documentation
110
110
  - `/feature`, `/bugfix`, `/refactor` — for command-shaped work that this skill refuses to swallow
@@ -10,7 +10,7 @@ Resume a paused work item without restarting completed phases.
10
10
 
11
11
  ## Scope
12
12
 
13
- Resumes an existing in-flight work item by reading its manifest and re-entering its owning command (`/feature`, `/greenfield`, `/bugfix`, `/hotfix`, `/refactor`) at the first unfinished phase. Does NOT create new work items, modify gate state, re-run already-passed gates, or abort/escalate work. Invoke after a session break, after a manifest update, or whenever the user wants to continue paused work.
13
+ Resumes an existing in-flight work item by reading its manifest and re-entering its owning command (`/feature`, `/greenfield`, `/bugfix`, `/hotfix`, `/refactor`) at the first unfinished phase. Does NOT create new work items, modify gate state, re-run already-passed gates, or escalate work. Invoke after a session break, after a manifest update, or whenever the user wants to continue paused work.
14
14
 
15
15
  ## Input
16
16
 
@@ -19,7 +19,7 @@ Accept `type/name` when provided, such as `feature/add-payments`. If omitted, ru
19
19
  ## Steps
20
20
 
21
21
  1. Read `.forge/work/{type}/{name}/manifest.yaml`.
22
- 2. If `status` is `completed`, `abandoned`, or `escalated`, do not resume. Surface the status and any `successor_path`.
22
+ 2. If `status` is `completed` or `escalated`, do not resume. Surface the status and any `successor_path`.
23
23
  3. Identify the last completed phase and the first incomplete phase using the same gate-skip rules as the owning command.
24
24
  4. Invoke the owning command (`/feature`, `/greenfield`, `/bugfix`, `/hotfix`, or `/refactor`) with the work item name and instruct it to resume from the manifest.
25
25
  5. Do not recreate manifests, delete files, or rerun phases whose gates already passed.
package/commands/setup.md CHANGED
@@ -96,34 +96,35 @@ After confirmation, fill both project docs. Show the filled content for review b
96
96
  STOP. Present the filled CLAUDE.md and AGENTS.md content to the user for review before writing. Do NOT write files without user confirmation.
97
97
  </GATE>
98
98
 
99
- ## Step 2a: Configure Gate Enforcement Mode
99
+ ## Step 2a: Apply Gate Enforcement Mode
100
100
 
101
101
  `npx @jamie-tam/forge init` installs PreToolUse hooks that block manifest `gate-passed: true` edits unless telemetry shows the matching skill was invoked via the Skill tool. Useful for production-grade work; high-friction for prototype iteration where most phases are explicitly skipped.
102
102
 
103
- **Default suggestion** (computed from Step 2 profile):
104
- - If signals match **prototype mode** (path under `pocs/`, `package.json` `"private": true` with no CI, no `aiwiki/architecture/`): suggest **disable**
105
- - If signals match **production mode** (CI configured, tests present, codified architecture exists or imminent): suggest **keep enabled**
103
+ **Apply automatically based on detected mode** (no prompt the user has not yet seen a gate fire and can't reasonably decide upfront; setup chooses the mode-appropriate default and tells the user how to change it later).
106
104
 
107
- Ask the user:
105
+ Detect from the Step 2 profile:
108
106
 
109
- ```
110
- Gate enforcement (PreToolUse hooks that block manifest gate-passed edits without skill-invocation telemetry):
111
-
112
- Suggested for this project: <KEEP ENABLED | DISABLE>
113
-
114
- • Keep enabled — production-grade discipline; manifests cannot lie about gate state
115
- • Disable — prototype iteration; faster loop, telemetry still recorded for retrospective audit
116
-
117
- Choice? [Y]es (keep enabled) / [d]isable / [k]eep enabled regardless of suggestion
118
- ```
107
+ | Mode signal | Decision |
108
+ |---|---|
109
+ | Working under `pocs/`, OR `package.json` has `"private": true` with no CI configured, OR `aiwiki/architecture/` is empty/missing | **Disable** — prototype iteration; faster loop |
110
+ | CI configured, tests present, codified architecture exists or imminent | **Keep enabled** — production discipline; manifests cannot lie about gate state |
119
111
 
120
112
  **On "disable"**: remove the two gate-enforcer matcher entries from `.claude/settings.local.json` (the entries with `matcher: Edit` or `matcher: Write` whose command invokes `.claude/hooks/scripts/gate-enforcer.sh`). The file is gitignored so this is a local-only change. Keep the PostToolUse telemetry hook — telemetry is still recorded for retrospective audit; only the blocking is removed.
121
113
 
122
114
  **On "keep enabled"**: leave settings.local.json as installed.
123
115
 
124
- Tell the user: "Gate enforcement <enabled / disabled>. To toggle later, edit `.claude/settings.local.json` — see `hooks/hooks.json` in the forge repo for the canonical entries."
116
+ **Then tell the user, plainly:**
117
+
118
+ ```
119
+ Gate enforcement: <enabled | disabled>
120
+ Reason: <mode signal that triggered it>
121
+ To change: remove or restore the two gate-enforcer PreToolUse entries in
122
+ .claude/settings.local.json (canonical entries in hooks/hooks.json).
123
+ First time you hit a real gate-passed edit and want to opt in (or out),
124
+ surface the toggle then — no upfront commitment.
125
+ ```
125
126
 
126
- Note in the manifest or session memo what was chosen, so future sessions don't have to re-detect.
127
+ Note in the manifest or session memo what was applied, so future sessions don't have to re-detect.
127
128
 
128
129
  ## Step 3: Create .forge Directory
129
130
 
@@ -9,12 +9,12 @@ List active work items from `.forge/work/` without modifying files.
9
9
 
10
10
  ## Scope
11
11
 
12
- Reports in-flight `.forge/work/` items and their current phase. Read-only — does not modify manifests, files, or wiki. Does NOT resume work, route between commands, or judge whether a stalled item should be aborted. Invoke when the user asks "what's in flight?", before `/resume` (to pick a target), or before `/abort` (to confirm which item).
12
+ Reports in-flight `.forge/work/` items and their current phase. Read-only — does not modify manifests, files, or wiki. Does NOT resume work or route between commands. Invoke when the user asks "what's in flight?" or before `/resume` (to pick a target).
13
13
 
14
14
  ## Steps
15
15
 
16
16
  1. Find manifests at `.forge/work/*/*/manifest.yaml`.
17
- 2. Exclude items whose `status` is `completed`, `abandoned`, or `escalated`.
17
+ 2. Exclude items whose `status` is `completed` or `escalated`.
18
18
  3. For each remaining item, infer the current phase from the first phase or gate that is not complete, locked, skipped, or not-applicable. Prefer explicit fields such as `build.current_phase` when present.
19
19
  4. Print a table:
20
20
 
@@ -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);