@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.
- package/README.md +73 -59
- package/agents/dreamer.md +5 -6
- package/agents/gotcha-hunter.md +1 -1
- package/agents/prototype-codifier.md +2 -2
- package/commands/{forge.md → discover.md} +11 -9
- package/commands/feature.md +50 -8
- 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/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/hooks.json +14 -1
- package/hooks/scripts/pre-compact.sh +3 -6
- package/hooks/scripts/session-start.sh +1 -1
- 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 +42 -4
- 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 +1 -1
- package/skills/harden/SKILL.md +6 -6
- 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 +5 -5
- 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/schemas/session.md +15 -14
- 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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
3
|
-
description: "Dispatch
|
|
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
|
-
# /
|
|
7
|
+
# /parallel
|
|
8
8
|
|
|
9
|
-
Run a parallel ad-hoc
|
|
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-
|
|
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
|
|
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
|
|
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: `/
|
|
43
|
-
- Or pass a numbered/bulleted list in the next prompt after invoking `/
|
|
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/
|
|
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
|
-
/
|
|
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
|
-
/
|
|
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 —
|
|
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
|
|
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-
|
|
109
|
+
- `skills/support-parallel/SKILL.md` — full process documentation
|
|
110
110
|
- `/feature`, `/bugfix`, `/refactor` — for command-shaped work that this skill refuses to swallow
|
package/commands/resume.md
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
-
**
|
|
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
|
-
|
|
105
|
+
Detect from the Step 2 profile:
|
|
108
106
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/commands/status.md
CHANGED
|
@@ -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
|
|
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
|
|
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);
|