gsd-pi 2.11.0 → 2.13.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/dist/cli.js +18 -1
- package/dist/onboarding.js +3 -0
- package/dist/resource-loader.d.ts +2 -0
- package/dist/resource-loader.js +36 -1
- package/dist/resources/extensions/bg-shell/index.ts +51 -7
- package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
- package/dist/resources/extensions/gsd/auto.ts +381 -13
- package/dist/resources/extensions/gsd/commands.ts +9 -3
- package/dist/resources/extensions/gsd/doctor.ts +254 -3
- package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/dist/resources/extensions/gsd/git-service.ts +11 -0
- package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/dist/resources/extensions/gsd/preferences.ts +209 -1
- package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
- package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
- package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
- package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
- package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
- package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
- package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/dist/resources/extensions/gsd/prompts/system.md +32 -29
- package/dist/resources/extensions/gsd/templates/context.md +1 -1
- package/dist/resources/extensions/gsd/templates/state.md +3 -3
- package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
- package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
- package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
- package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
- package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
- package/dist/resources/extensions/gsd/types.ts +109 -0
- package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
- package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
- package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
- package/dist/resources/extensions/search-the-web/provider.ts +19 -2
- package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
- package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
- package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
- package/dist/wizard.js +1 -0
- package/package.json +1 -1
- package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent-loop.js +169 -55
- package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
- package/packages/pi-agent-core/dist/agent.d.ts +13 -1
- package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent.js +16 -0
- package/packages/pi-agent-core/dist/agent.js.map +1 -1
- package/packages/pi-agent-core/dist/types.d.ts +91 -1
- package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/types.js.map +1 -1
- package/packages/pi-agent-core/src/agent-loop.ts +273 -63
- package/packages/pi-agent-core/src/agent.ts +24 -0
- package/packages/pi-agent-core/src/types.ts +98 -0
- package/packages/pi-ai/dist/env-api-keys.js +1 -0
- package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +314 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +236 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/dist/types.d.ts +1 -1
- package/packages/pi-ai/dist/types.d.ts.map +1 -1
- package/packages/pi-ai/dist/types.js.map +1 -1
- package/packages/pi-ai/src/env-api-keys.ts +1 -0
- package/packages/pi-ai/src/models.generated.ts +236 -0
- package/packages/pi-ai/src/types.ts +2 -1
- package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/cli/args.js +2 -1
- package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
- package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
- package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
- package/packages/pi-coding-agent/src/cli/args.ts +2 -1
- package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
- package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
- package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
- package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
- package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
- package/packages/pi-tui/dist/components/editor.d.ts +11 -0
- package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/editor.js +64 -6
- package/packages/pi-tui/dist/components/editor.js.map +1 -1
- package/packages/pi-tui/src/components/editor.ts +71 -6
- package/src/resources/extensions/bg-shell/index.ts +51 -7
- package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
- package/src/resources/extensions/gsd/auto.ts +381 -13
- package/src/resources/extensions/gsd/commands.ts +9 -3
- package/src/resources/extensions/gsd/doctor.ts +254 -3
- package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/src/resources/extensions/gsd/git-service.ts +11 -0
- package/src/resources/extensions/gsd/guided-flow.ts +81 -9
- package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/src/resources/extensions/gsd/preferences.ts +209 -1
- package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
- package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
- package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/queue.md +3 -1
- package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/src/resources/extensions/gsd/prompts/system.md +32 -29
- package/src/resources/extensions/gsd/templates/context.md +1 -1
- package/src/resources/extensions/gsd/templates/state.md +3 -3
- package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
- package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
- package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
- package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
- package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
- package/src/resources/extensions/gsd/types.ts +109 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
- package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
- package/src/resources/extensions/search-the-web/native-search.ts +15 -10
- package/src/resources/extensions/search-the-web/provider.ts +19 -2
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
- package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-self-heal.test.ts — Integration tests for git self-healing utilities.
|
|
3
|
+
*
|
|
4
|
+
* Uses real temporary git repos with deliberately broken state.
|
|
5
|
+
* No mocks — exercises actual git operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { existsSync, mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { rmSync } from "node:fs";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import {
|
|
15
|
+
abortAndReset,
|
|
16
|
+
withMergeHeal,
|
|
17
|
+
recoverCheckout,
|
|
18
|
+
formatGitError,
|
|
19
|
+
MergeConflictError,
|
|
20
|
+
} from "../git-self-heal.js";
|
|
21
|
+
|
|
22
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function makeTempRepo(): string {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-self-heal-"));
|
|
26
|
+
execSync("git init", { cwd: dir, stdio: "pipe" });
|
|
27
|
+
execSync("git config user.email 'test@test.com'", { cwd: dir, stdio: "pipe" });
|
|
28
|
+
execSync("git config user.name 'Test'", { cwd: dir, stdio: "pipe" });
|
|
29
|
+
writeFileSync(join(dir, "README.md"), "# init\n");
|
|
30
|
+
execSync("git add -A && git commit -m 'init'", { cwd: dir, stdio: "pipe" });
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cleanup(dir: string) {
|
|
35
|
+
try {
|
|
36
|
+
rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
} catch {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── abortAndReset ───────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
console.log("── abortAndReset ──");
|
|
45
|
+
|
|
46
|
+
// Test: leftover MERGE_HEAD
|
|
47
|
+
{
|
|
48
|
+
const dir = makeTempRepo();
|
|
49
|
+
try {
|
|
50
|
+
// Create a conflicting branch
|
|
51
|
+
execSync("git checkout -b feature", { cwd: dir, stdio: "pipe" });
|
|
52
|
+
writeFileSync(join(dir, "file.txt"), "feature content\n");
|
|
53
|
+
execSync("git add -A && git commit -m 'feature'", { cwd: dir, stdio: "pipe" });
|
|
54
|
+
execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
|
|
55
|
+
writeFileSync(join(dir, "file.txt"), "main content\n");
|
|
56
|
+
execSync("git add -A && git commit -m 'main change'", { cwd: dir, stdio: "pipe" });
|
|
57
|
+
|
|
58
|
+
// Create a merge conflict → MERGE_HEAD will exist
|
|
59
|
+
try {
|
|
60
|
+
execSync("git merge feature", { cwd: dir, stdio: "pipe" });
|
|
61
|
+
} catch {
|
|
62
|
+
// expected conflict
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
assert.ok(existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD should exist before abort");
|
|
66
|
+
|
|
67
|
+
const result = abortAndReset(dir);
|
|
68
|
+
assert.ok(result.cleaned.some((s) => s.includes("aborted merge")), "should report aborted merge");
|
|
69
|
+
assert.ok(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD should be gone after abort");
|
|
70
|
+
|
|
71
|
+
console.log(" ✓ cleans up leftover MERGE_HEAD");
|
|
72
|
+
} finally {
|
|
73
|
+
cleanup(dir);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Test: leftover SQUASH_MSG (no MERGE_HEAD)
|
|
78
|
+
{
|
|
79
|
+
const dir = makeTempRepo();
|
|
80
|
+
try {
|
|
81
|
+
// Manually create a SQUASH_MSG to simulate leftover state
|
|
82
|
+
writeFileSync(join(dir, ".git", "SQUASH_MSG"), "leftover squash message\n");
|
|
83
|
+
|
|
84
|
+
const result = abortAndReset(dir);
|
|
85
|
+
assert.ok(result.cleaned.some((s) => s.includes("SQUASH_MSG")), "should report SQUASH_MSG removal");
|
|
86
|
+
assert.ok(!existsSync(join(dir, ".git", "SQUASH_MSG")), "SQUASH_MSG should be gone");
|
|
87
|
+
|
|
88
|
+
console.log(" ✓ cleans up leftover SQUASH_MSG");
|
|
89
|
+
} finally {
|
|
90
|
+
cleanup(dir);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Test: clean state (no-op)
|
|
95
|
+
{
|
|
96
|
+
const dir = makeTempRepo();
|
|
97
|
+
try {
|
|
98
|
+
const result = abortAndReset(dir);
|
|
99
|
+
assert.deepStrictEqual(result.cleaned, [], "clean repo should produce empty cleaned array");
|
|
100
|
+
|
|
101
|
+
console.log(" ✓ no-op on clean state");
|
|
102
|
+
} finally {
|
|
103
|
+
cleanup(dir);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── withMergeHeal ───────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
console.log("── withMergeHeal ──");
|
|
110
|
+
|
|
111
|
+
// Test: transient failure succeeds on retry
|
|
112
|
+
{
|
|
113
|
+
const dir = makeTempRepo();
|
|
114
|
+
try {
|
|
115
|
+
let callCount = 0;
|
|
116
|
+
const result = withMergeHeal(dir, () => {
|
|
117
|
+
callCount++;
|
|
118
|
+
if (callCount === 1) throw new Error("transient git error");
|
|
119
|
+
return "success";
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
assert.strictEqual(result, "success", "should return mergeFn result on retry");
|
|
123
|
+
assert.strictEqual(callCount, 2, "should have called mergeFn twice");
|
|
124
|
+
|
|
125
|
+
console.log(" ✓ transient failure succeeds on retry");
|
|
126
|
+
} finally {
|
|
127
|
+
cleanup(dir);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Test: real conflict escalates immediately (no retry)
|
|
132
|
+
{
|
|
133
|
+
const dir = makeTempRepo();
|
|
134
|
+
try {
|
|
135
|
+
// Set up a real merge conflict
|
|
136
|
+
execSync("git checkout -b conflict-branch", { cwd: dir, stdio: "pipe" });
|
|
137
|
+
writeFileSync(join(dir, "conflict.txt"), "branch A\n");
|
|
138
|
+
execSync("git add -A && git commit -m 'branch A'", { cwd: dir, stdio: "pipe" });
|
|
139
|
+
execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
|
|
140
|
+
writeFileSync(join(dir, "conflict.txt"), "branch B\n");
|
|
141
|
+
execSync("git add -A && git commit -m 'branch B'", { cwd: dir, stdio: "pipe" });
|
|
142
|
+
|
|
143
|
+
let callCount = 0;
|
|
144
|
+
try {
|
|
145
|
+
withMergeHeal(dir, () => {
|
|
146
|
+
callCount++;
|
|
147
|
+
// Actually perform the conflicting merge
|
|
148
|
+
execSync("git merge conflict-branch", { cwd: dir, stdio: "pipe" });
|
|
149
|
+
});
|
|
150
|
+
assert.fail("should have thrown MergeConflictError");
|
|
151
|
+
} catch (err) {
|
|
152
|
+
assert.ok(err instanceof MergeConflictError, `should throw MergeConflictError, got ${(err as Error).constructor.name}`);
|
|
153
|
+
assert.strictEqual(callCount, 1, "should NOT retry on real conflict");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log(" ✓ real conflict escalates immediately without retry");
|
|
157
|
+
} finally {
|
|
158
|
+
cleanup(dir);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── recoverCheckout ─────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
console.log("── recoverCheckout ──");
|
|
165
|
+
|
|
166
|
+
// Test: dirty index recovery
|
|
167
|
+
{
|
|
168
|
+
const dir = makeTempRepo();
|
|
169
|
+
try {
|
|
170
|
+
// Create a branch to checkout to
|
|
171
|
+
execSync("git checkout -b target-branch", { cwd: dir, stdio: "pipe" });
|
|
172
|
+
execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
|
|
173
|
+
|
|
174
|
+
// Dirty the index
|
|
175
|
+
writeFileSync(join(dir, "README.md"), "dirty changes\n");
|
|
176
|
+
execSync("git add README.md", { cwd: dir, stdio: "pipe" });
|
|
177
|
+
|
|
178
|
+
// Normal checkout would complain about dirty index
|
|
179
|
+
recoverCheckout(dir, "target-branch");
|
|
180
|
+
|
|
181
|
+
const branch = execSync("git branch --show-current", { cwd: dir, encoding: "utf-8" }).trim();
|
|
182
|
+
assert.strictEqual(branch, "target-branch", "should be on target branch after recovery");
|
|
183
|
+
|
|
184
|
+
console.log(" ✓ recovers checkout with dirty index");
|
|
185
|
+
} finally {
|
|
186
|
+
cleanup(dir);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Test: non-existent branch throws with context
|
|
191
|
+
{
|
|
192
|
+
const dir = makeTempRepo();
|
|
193
|
+
try {
|
|
194
|
+
try {
|
|
195
|
+
recoverCheckout(dir, "nonexistent-branch");
|
|
196
|
+
assert.fail("should have thrown");
|
|
197
|
+
} catch (err) {
|
|
198
|
+
assert.ok((err as Error).message.includes("recoverCheckout failed"), "should include context in error");
|
|
199
|
+
assert.ok((err as Error).message.includes("nonexistent-branch"), "should mention branch name");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log(" ✓ throws with context for non-existent branch");
|
|
203
|
+
} finally {
|
|
204
|
+
cleanup(dir);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── formatGitError ──────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
console.log("── formatGitError ──");
|
|
211
|
+
|
|
212
|
+
{
|
|
213
|
+
const cases: Array<{ input: string; shouldContain: string; label: string }> = [
|
|
214
|
+
{ input: "CONFLICT (content): Merge conflict in file.ts", shouldContain: "/gsd doctor", label: "merge conflict" },
|
|
215
|
+
{ input: "error: pathspec 'foo' did not match any file(s)", shouldContain: "/gsd doctor", label: "checkout failure" },
|
|
216
|
+
{ input: "HEAD detached at abc123", shouldContain: "/gsd doctor", label: "detached HEAD" },
|
|
217
|
+
{ input: "Unable to create '/path/.git/index.lock': File exists", shouldContain: "/gsd doctor", label: "lock file" },
|
|
218
|
+
{ input: "fatal: not a git repository", shouldContain: "/gsd doctor", label: "not a repo" },
|
|
219
|
+
{ input: "some unknown error", shouldContain: "/gsd doctor", label: "unknown error" },
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
for (const { input, shouldContain, label } of cases) {
|
|
223
|
+
const result = formatGitError(input);
|
|
224
|
+
assert.ok(result.includes(shouldContain), `${label}: should suggest /gsd doctor`);
|
|
225
|
+
console.log(` ✓ ${label} → suggests /gsd doctor`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Test with Error object
|
|
229
|
+
const result = formatGitError(new Error("CONFLICT in merge"));
|
|
230
|
+
assert.ok(result.includes("/gsd doctor"), "should handle Error objects");
|
|
231
|
+
console.log(" ✓ handles Error objects");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.log("\n✅ All git-self-heal tests passed");
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* isolation-resolver.test.ts -- Tests for shouldUseWorktreeIsolation resolver.
|
|
3
|
+
*
|
|
4
|
+
* Tests three resolution paths:
|
|
5
|
+
* 1. Explicit git.isolation preference overrides everything
|
|
6
|
+
* 2. Legacy detection: existing gsd/*\/* branches = branch mode
|
|
7
|
+
* 3. Default: new project = worktree mode
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdtempSync, writeFileSync, rmSync, realpathSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
import { shouldUseWorktreeIsolation } from "../auto-worktree.ts";
|
|
16
|
+
import { createTestContext } from "./test-helpers.ts";
|
|
17
|
+
|
|
18
|
+
const { assertEq, report } = createTestContext();
|
|
19
|
+
|
|
20
|
+
function run(command: string, cwd: string): string {
|
|
21
|
+
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createTempRepo(): string {
|
|
25
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "iso-resolver-test-")));
|
|
26
|
+
run("git init", dir);
|
|
27
|
+
run("git config user.email test@test.com", dir);
|
|
28
|
+
run("git config user.name Test", dir);
|
|
29
|
+
writeFileSync(join(dir, "README.md"), "# test\n");
|
|
30
|
+
run("git add .", dir);
|
|
31
|
+
run("git commit -m init", dir);
|
|
32
|
+
run("git branch -M main", dir);
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function main(): Promise<void> {
|
|
37
|
+
const savedCwd = process.cwd();
|
|
38
|
+
|
|
39
|
+
console.log("\n=== shouldUseWorktreeIsolation ===");
|
|
40
|
+
|
|
41
|
+
// Test 1: New project with no gsd branches → defaults to worktree (true)
|
|
42
|
+
{
|
|
43
|
+
const dir = createTempRepo();
|
|
44
|
+
try {
|
|
45
|
+
const result = shouldUseWorktreeIsolation(dir);
|
|
46
|
+
assertEq(result, true, "new project defaults to worktree isolation");
|
|
47
|
+
} finally {
|
|
48
|
+
process.chdir(savedCwd);
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Test 2: Legacy project with gsd/*/* branches → returns false (branch mode)
|
|
54
|
+
{
|
|
55
|
+
const dir = createTempRepo();
|
|
56
|
+
try {
|
|
57
|
+
// Create a legacy gsd/*/* branch
|
|
58
|
+
run("git checkout -b gsd/M001/S01", dir);
|
|
59
|
+
writeFileSync(join(dir, "slice.md"), "# S01\n");
|
|
60
|
+
run("git add .", dir);
|
|
61
|
+
run("git commit -m 'slice work'", dir);
|
|
62
|
+
run("git checkout main", dir);
|
|
63
|
+
|
|
64
|
+
const result = shouldUseWorktreeIsolation(dir);
|
|
65
|
+
assertEq(result, false, "legacy project with gsd branches → branch mode");
|
|
66
|
+
} finally {
|
|
67
|
+
process.chdir(savedCwd);
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Test 3: Explicit preference override -- isolation: "worktree"
|
|
73
|
+
{
|
|
74
|
+
const dir = createTempRepo();
|
|
75
|
+
try {
|
|
76
|
+
// Create legacy branches that would normally trigger branch mode
|
|
77
|
+
run("git checkout -b gsd/M001/S01", dir);
|
|
78
|
+
writeFileSync(join(dir, "slice.md"), "# S01\n");
|
|
79
|
+
run("git add .", dir);
|
|
80
|
+
run("git commit -m 'slice work'", dir);
|
|
81
|
+
run("git checkout main", dir);
|
|
82
|
+
|
|
83
|
+
const result = shouldUseWorktreeIsolation(dir, { isolation: "worktree" });
|
|
84
|
+
assertEq(result, true, "explicit isolation: worktree overrides legacy detection");
|
|
85
|
+
} finally {
|
|
86
|
+
process.chdir(savedCwd);
|
|
87
|
+
rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Test 4: Explicit preference override -- isolation: "branch"
|
|
92
|
+
{
|
|
93
|
+
const dir = createTempRepo();
|
|
94
|
+
try {
|
|
95
|
+
// No legacy branches -- would normally default to worktree
|
|
96
|
+
const result = shouldUseWorktreeIsolation(dir, { isolation: "branch" });
|
|
97
|
+
assertEq(result, false, "explicit isolation: branch overrides default");
|
|
98
|
+
} finally {
|
|
99
|
+
process.chdir(savedCwd);
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
report();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
main();
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// GSD Extension — Hook Engine Tests (Post-Unit, Pre-Dispatch, State Persistence)
|
|
2
|
+
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
|
3
|
+
|
|
4
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { createTestContext } from "./test-helpers.ts";
|
|
8
|
+
import {
|
|
9
|
+
checkPostUnitHooks,
|
|
10
|
+
getActiveHook,
|
|
11
|
+
resetHookState,
|
|
12
|
+
isRetryPending,
|
|
13
|
+
consumeRetryTrigger,
|
|
14
|
+
resolveHookArtifactPath,
|
|
15
|
+
runPreDispatchHooks,
|
|
16
|
+
persistHookState,
|
|
17
|
+
restoreHookState,
|
|
18
|
+
clearPersistedHookState,
|
|
19
|
+
getHookStatus,
|
|
20
|
+
formatHookStatus,
|
|
21
|
+
} from "../post-unit-hooks.ts";
|
|
22
|
+
|
|
23
|
+
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
|
24
|
+
|
|
25
|
+
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function createFixtureBase(): string {
|
|
28
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-hook-test-"));
|
|
29
|
+
mkdirSync(join(base, ".gsd", "M001", "slices", "S01", "tasks"), { recursive: true });
|
|
30
|
+
return base;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
34
|
+
// Phase 1: Post-Unit Hook Tests
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
// ─── resolveHookArtifactPath ───────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
console.log("\n=== resolveHookArtifactPath ===");
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
const base = "/project";
|
|
43
|
+
|
|
44
|
+
// Task-level
|
|
45
|
+
const taskPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-PASS.md");
|
|
46
|
+
assertEq(
|
|
47
|
+
taskPath,
|
|
48
|
+
join(base, ".gsd", "M001", "slices", "S01", "tasks", "T01-REVIEW-PASS.md"),
|
|
49
|
+
"task-level artifact path",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Slice-level
|
|
53
|
+
const slicePath = resolveHookArtifactPath(base, "M001/S01", "REVIEW-PASS.md");
|
|
54
|
+
assertEq(
|
|
55
|
+
slicePath,
|
|
56
|
+
join(base, ".gsd", "M001", "slices", "S01", "REVIEW-PASS.md"),
|
|
57
|
+
"slice-level artifact path",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Milestone-level
|
|
61
|
+
const milestonePath = resolveHookArtifactPath(base, "M001", "REVIEW-PASS.md");
|
|
62
|
+
assertEq(
|
|
63
|
+
milestonePath,
|
|
64
|
+
join(base, ".gsd", "M001", "REVIEW-PASS.md"),
|
|
65
|
+
"milestone-level artifact path",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── resetHookState ────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
console.log("\n=== resetHookState ===");
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
resetHookState();
|
|
75
|
+
assertEq(getActiveHook(), null, "no active hook after reset");
|
|
76
|
+
assertTrue(!isRetryPending(), "no retry pending after reset");
|
|
77
|
+
assertEq(consumeRetryTrigger(), null, "no retry trigger after reset");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── checkPostUnitHooks with no hooks configured ───────────────────────────
|
|
81
|
+
|
|
82
|
+
console.log("\n=== No hooks configured ===");
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
resetHookState();
|
|
86
|
+
const base = createFixtureBase();
|
|
87
|
+
try {
|
|
88
|
+
const result = checkPostUnitHooks("execute-task", "M001/S01/T01", base);
|
|
89
|
+
assertEq(result, null, "returns null when no hooks configured");
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(base, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Hook units don't trigger hooks (no hook-on-hook) ──────────────────────
|
|
96
|
+
|
|
97
|
+
console.log("\n=== Hook-on-hook prevention ===");
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
resetHookState();
|
|
101
|
+
const base = createFixtureBase();
|
|
102
|
+
try {
|
|
103
|
+
const result = checkPostUnitHooks("hook/code-review", "M001/S01/T01", base);
|
|
104
|
+
assertEq(result, null, "hook units don't trigger other hooks");
|
|
105
|
+
} finally {
|
|
106
|
+
rmSync(base, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── consumeRetryTrigger clears state ──────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
console.log("\n=== consumeRetryTrigger clears state ===");
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
resetHookState();
|
|
116
|
+
assertEq(consumeRetryTrigger(), null, "no trigger initially");
|
|
117
|
+
assertTrue(!isRetryPending(), "no retry initially");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Variable substitution in prompts ──────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
console.log("\n=== Variable substitution ===");
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
const base = "/project";
|
|
126
|
+
|
|
127
|
+
// 3-part ID
|
|
128
|
+
const path3 = resolveHookArtifactPath(base, "M002/S03/T05", "result.md");
|
|
129
|
+
assertTrue(path3.includes("M002"), "3-part ID extracts milestoneId");
|
|
130
|
+
assertTrue(path3.includes("S03"), "3-part ID extracts sliceId");
|
|
131
|
+
assertTrue(path3.includes("T05"), "3-part ID extracts taskId");
|
|
132
|
+
|
|
133
|
+
// 2-part ID
|
|
134
|
+
const path2 = resolveHookArtifactPath(base, "M002/S03", "result.md");
|
|
135
|
+
assertTrue(path2.includes("M002"), "2-part ID extracts milestoneId");
|
|
136
|
+
assertTrue(path2.includes("S03"), "2-part ID extracts sliceId");
|
|
137
|
+
|
|
138
|
+
// 1-part ID
|
|
139
|
+
const path1 = resolveHookArtifactPath(base, "M002", "result.md");
|
|
140
|
+
assertTrue(path1.includes("M002"), "1-part ID extracts milestoneId");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
144
|
+
// Phase 2: Pre-Dispatch Hook Tests
|
|
145
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
146
|
+
|
|
147
|
+
console.log("\n=== Pre-dispatch: no hooks configured ===");
|
|
148
|
+
|
|
149
|
+
{
|
|
150
|
+
const base = createFixtureBase();
|
|
151
|
+
try {
|
|
152
|
+
const result = runPreDispatchHooks("execute-task", "M001/S01/T01", "original prompt", base);
|
|
153
|
+
assertEq(result.action, "proceed", "proceeds when no hooks");
|
|
154
|
+
assertEq(result.prompt, "original prompt", "prompt unchanged");
|
|
155
|
+
assertEq(result.firedHooks.length, 0, "no hooks fired");
|
|
156
|
+
} finally {
|
|
157
|
+
rmSync(base, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log("\n=== Pre-dispatch: hook units bypass ===");
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
const base = createFixtureBase();
|
|
165
|
+
try {
|
|
166
|
+
const result = runPreDispatchHooks("hook/review", "M001/S01/T01", "hook prompt", base);
|
|
167
|
+
assertEq(result.action, "proceed", "hook units always proceed");
|
|
168
|
+
assertEq(result.prompt, "hook prompt", "hook prompt unchanged");
|
|
169
|
+
assertEq(result.firedHooks.length, 0, "no hooks fired for hook units");
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(base, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
176
|
+
// Phase 3: State Persistence Tests
|
|
177
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
178
|
+
|
|
179
|
+
console.log("\n=== State persistence: persist and restore ===");
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
const base = createFixtureBase();
|
|
183
|
+
try {
|
|
184
|
+
resetHookState();
|
|
185
|
+
|
|
186
|
+
// Persist empty state
|
|
187
|
+
persistHookState(base);
|
|
188
|
+
const filePath = join(base, ".gsd", "hook-state.json");
|
|
189
|
+
assertTrue(existsSync(filePath), "hook-state.json created");
|
|
190
|
+
|
|
191
|
+
const content = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
192
|
+
assertEq(typeof content.savedAt, "string", "savedAt is a string");
|
|
193
|
+
assertEq(Object.keys(content.cycleCounts).length, 0, "empty cycle counts");
|
|
194
|
+
} finally {
|
|
195
|
+
rmSync(base, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log("\n=== State persistence: restore from disk ===");
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
const base = createFixtureBase();
|
|
203
|
+
try {
|
|
204
|
+
resetHookState();
|
|
205
|
+
|
|
206
|
+
// Write a state file with some cycle counts
|
|
207
|
+
const stateFile = join(base, ".gsd", "hook-state.json");
|
|
208
|
+
writeFileSync(stateFile, JSON.stringify({
|
|
209
|
+
cycleCounts: {
|
|
210
|
+
"review/execute-task/M001/S01/T01": 2,
|
|
211
|
+
"simplify/execute-task/M001/S01/T02": 1,
|
|
212
|
+
},
|
|
213
|
+
savedAt: new Date().toISOString(),
|
|
214
|
+
}), "utf-8");
|
|
215
|
+
|
|
216
|
+
// Restore
|
|
217
|
+
restoreHookState(base);
|
|
218
|
+
|
|
219
|
+
// Verify by persisting and reading back
|
|
220
|
+
persistHookState(base);
|
|
221
|
+
const restored = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
222
|
+
assertEq(restored.cycleCounts["review/execute-task/M001/S01/T01"], 2, "cycle count restored for review");
|
|
223
|
+
assertEq(restored.cycleCounts["simplify/execute-task/M001/S01/T02"], 1, "cycle count restored for simplify");
|
|
224
|
+
} finally {
|
|
225
|
+
rmSync(base, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log("\n=== State persistence: clear ===");
|
|
230
|
+
|
|
231
|
+
{
|
|
232
|
+
const base = createFixtureBase();
|
|
233
|
+
try {
|
|
234
|
+
resetHookState();
|
|
235
|
+
|
|
236
|
+
// Write then clear
|
|
237
|
+
const stateFile = join(base, ".gsd", "hook-state.json");
|
|
238
|
+
writeFileSync(stateFile, JSON.stringify({
|
|
239
|
+
cycleCounts: { "review/execute-task/M001/S01/T01": 3 },
|
|
240
|
+
savedAt: new Date().toISOString(),
|
|
241
|
+
}), "utf-8");
|
|
242
|
+
|
|
243
|
+
clearPersistedHookState(base);
|
|
244
|
+
|
|
245
|
+
const cleared = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
246
|
+
assertEq(Object.keys(cleared.cycleCounts).length, 0, "cycle counts cleared");
|
|
247
|
+
} finally {
|
|
248
|
+
rmSync(base, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
console.log("\n=== State persistence: restore handles missing file ===");
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
const base = createFixtureBase();
|
|
256
|
+
try {
|
|
257
|
+
resetHookState();
|
|
258
|
+
// Should not throw
|
|
259
|
+
restoreHookState(base);
|
|
260
|
+
assertEq(getActiveHook(), null, "no active hook after restore from missing file");
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(base, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
console.log("\n=== State persistence: restore handles corrupt file ===");
|
|
267
|
+
|
|
268
|
+
{
|
|
269
|
+
const base = createFixtureBase();
|
|
270
|
+
try {
|
|
271
|
+
resetHookState();
|
|
272
|
+
writeFileSync(join(base, ".gsd", "hook-state.json"), "not json", "utf-8");
|
|
273
|
+
// Should not throw
|
|
274
|
+
restoreHookState(base);
|
|
275
|
+
assertEq(getActiveHook(), null, "no active hook after corrupt restore");
|
|
276
|
+
} finally {
|
|
277
|
+
rmSync(base, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
282
|
+
// Phase 3: Hook Status Reporting Tests
|
|
283
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
284
|
+
|
|
285
|
+
console.log("\n=== Hook status: no hooks ===");
|
|
286
|
+
|
|
287
|
+
{
|
|
288
|
+
resetHookState();
|
|
289
|
+
const entries = getHookStatus();
|
|
290
|
+
// No preferences file = no hooks
|
|
291
|
+
assertEq(entries.length, 0, "no entries when no hooks configured");
|
|
292
|
+
|
|
293
|
+
const formatted = formatHookStatus();
|
|
294
|
+
assertMatch(formatted, /No hooks configured/, "status message says no hooks");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
report();
|