gsd-pi 2.37.1-dev.d3ace49 → 2.38.0-dev.96dc7fb
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 +9 -0
- package/dist/extension-discovery.d.ts +5 -3
- package/dist/extension-discovery.js +14 -9
- package/dist/resources/extensions/browser-tools/package.json +3 -1
- package/dist/resources/extensions/cmux/index.js +55 -1
- package/dist/resources/extensions/context7/package.json +1 -1
- package/dist/resources/extensions/google-search/package.json +3 -1
- package/dist/resources/extensions/gsd/auto-loop.js +7 -1
- package/dist/resources/extensions/gsd/auto-start.js +6 -1
- package/dist/resources/extensions/gsd/auto-worktree-sync.js +11 -4
- package/dist/resources/extensions/gsd/captures.js +9 -1
- package/dist/resources/extensions/gsd/commands-handlers.js +16 -3
- package/dist/resources/extensions/gsd/commands.js +20 -1
- package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
- package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
- package/dist/resources/extensions/gsd/doctor-format.js +15 -0
- package/dist/resources/extensions/gsd/doctor.js +184 -11
- package/dist/resources/extensions/gsd/package.json +1 -1
- package/dist/resources/extensions/gsd/worktree.js +35 -16
- package/dist/resources/extensions/subagent/index.js +12 -3
- package/dist/resources/extensions/universal-config/package.json +1 -1
- package/dist/welcome-screen.d.ts +12 -0
- package/dist/welcome-screen.js +53 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
- package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
- package/pkg/package.json +1 -1
- package/src/resources/extensions/cmux/index.ts +57 -1
- package/src/resources/extensions/gsd/auto-loop.ts +13 -1
- package/src/resources/extensions/gsd/auto-start.ts +7 -1
- package/src/resources/extensions/gsd/auto-worktree-sync.ts +12 -3
- package/src/resources/extensions/gsd/captures.ts +10 -1
- package/src/resources/extensions/gsd/commands-handlers.ts +17 -2
- package/src/resources/extensions/gsd/commands.ts +21 -1
- package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
- package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
- package/src/resources/extensions/gsd/doctor-format.ts +20 -0
- package/src/resources/extensions/gsd/doctor-types.ts +16 -1
- package/src/resources/extensions/gsd/doctor.ts +177 -13
- package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
- package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
- package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
- package/src/resources/extensions/gsd/worktree.ts +35 -15
- package/src/resources/extensions/subagent/index.ts +12 -3
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
import { runGSDDoctor } from "../doctor.js";
|
|
6
|
+
import { formatDoctorReportJson } from "../doctor-format.js";
|
|
7
|
+
import { createTestContext } from "./test-helpers.ts";
|
|
8
|
+
|
|
9
|
+
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
|
10
|
+
|
|
11
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function makeBase(): { base: string; gsd: string; mDir: string } {
|
|
14
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-enh-"));
|
|
15
|
+
const gsd = join(base, ".gsd");
|
|
16
|
+
const mDir = join(gsd, "milestones", "M001");
|
|
17
|
+
mkdirSync(join(mDir, "slices"), { recursive: true });
|
|
18
|
+
return { base, gsd, mDir };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeRoadmap(mDir: string, content: string): void {
|
|
22
|
+
writeFileSync(join(mDir, "M001-ROADMAP.md"), content);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeSlice(mDir: string, sliceId: string, planContent: string): string {
|
|
26
|
+
const sDir = join(mDir, "slices", sliceId);
|
|
27
|
+
const tDir = join(sDir, "tasks");
|
|
28
|
+
mkdirSync(tDir, { recursive: true });
|
|
29
|
+
writeFileSync(join(sDir, `${sliceId}-PLAN.md`), planContent);
|
|
30
|
+
return sDir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main(): Promise<void> {
|
|
34
|
+
// ── 1. Circular dependency detection ──────────────────────────────────────
|
|
35
|
+
console.log("\n=== circular dependency detection ===");
|
|
36
|
+
{
|
|
37
|
+
const { base, mDir } = makeBase();
|
|
38
|
+
writeRoadmap(mDir, `# M001: Circular Test\n\n## Slices\n- [ ] **S01: Slice A** \`risk:low\` \`depends:[S02]\`\n > After this: done\n- [ ] **S02: Slice B** \`risk:low\` \`depends:[S01]\`\n > After this: done\n`);
|
|
39
|
+
writeSlice(mDir, "S01", "# S01: Slice A\n\n**Goal:** A\n**Demo:** A\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
|
|
40
|
+
writeSlice(mDir, "S02", "# S02: Slice B\n\n**Goal:** B\n**Demo:** B\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
|
|
41
|
+
|
|
42
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
43
|
+
assertTrue(
|
|
44
|
+
result.issues.some(i => i.code === "circular_slice_dependency"),
|
|
45
|
+
"detects circular dependency S01 → S02 → S01",
|
|
46
|
+
);
|
|
47
|
+
rmSync(base, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── 2. Duplicate task IDs ──────────────────────────────────────────────────
|
|
51
|
+
console.log("\n=== duplicate task IDs ===");
|
|
52
|
+
{
|
|
53
|
+
const { base, mDir } = makeBase();
|
|
54
|
+
writeRoadmap(mDir, `# M001: Dup Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
55
|
+
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: First** `est:10m`\n Task one.\n- [ ] **T01: Duplicate** `est:10m`\n Task dup.\n");
|
|
56
|
+
|
|
57
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
58
|
+
assertTrue(
|
|
59
|
+
result.issues.some(i => i.code === "duplicate_task_id"),
|
|
60
|
+
"detects duplicate task ID T01",
|
|
61
|
+
);
|
|
62
|
+
rmSync(base, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── 3. Orphaned slice directory ──────────────────────────────────────────
|
|
66
|
+
console.log("\n=== orphaned slice directory ===");
|
|
67
|
+
{
|
|
68
|
+
const { base, mDir } = makeBase();
|
|
69
|
+
writeRoadmap(mDir, `# M001: Orphan Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
70
|
+
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
|
|
71
|
+
// Create an extra slice directory not in roadmap
|
|
72
|
+
mkdirSync(join(mDir, "slices", "S99"), { recursive: true });
|
|
73
|
+
|
|
74
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
75
|
+
assertTrue(
|
|
76
|
+
result.issues.some(i => i.code === "orphaned_slice_directory" && i.message.includes("S99")),
|
|
77
|
+
"detects orphaned slice directory S99",
|
|
78
|
+
);
|
|
79
|
+
rmSync(base, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── 4. Task file not in plan ───────────────────────────────────────────────
|
|
83
|
+
console.log("\n=== task file not in plan ===");
|
|
84
|
+
{
|
|
85
|
+
const { base, mDir } = makeBase();
|
|
86
|
+
writeRoadmap(mDir, `# M001: Extra Task Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
87
|
+
const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
|
|
88
|
+
// T01 summary (matches plan)
|
|
89
|
+
writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), "---\nstatus: done\n---\n# T01\nDone.\n");
|
|
90
|
+
// T99 summary (NOT in plan)
|
|
91
|
+
writeFileSync(join(sDir, "tasks", "T99-SUMMARY.md"), "---\nstatus: done\n---\n# T99\nExtra.\n");
|
|
92
|
+
|
|
93
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
94
|
+
assertTrue(
|
|
95
|
+
result.issues.some(i => i.code === "task_file_not_in_plan" && i.message.includes("T99")),
|
|
96
|
+
"detects task summary T99 not in plan",
|
|
97
|
+
);
|
|
98
|
+
rmSync(base, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 5. Stale REPLAN file ────────────────────────────────────────────────────
|
|
102
|
+
console.log("\n=== stale REPLAN detection ===");
|
|
103
|
+
{
|
|
104
|
+
const { base, mDir } = makeBase();
|
|
105
|
+
writeRoadmap(mDir, `# M001: Replan Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
106
|
+
const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
|
|
107
|
+
writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), "---\nstatus: done\ncompleted_at: 2026-01-01T00:00:00Z\n---\n# T01\nDone.\n");
|
|
108
|
+
// Add a REPLAN file even though all tasks are done
|
|
109
|
+
writeFileSync(join(sDir, "S01-REPLAN.md"), "# S01 REPLAN\nSomething changed.\n");
|
|
110
|
+
|
|
111
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
112
|
+
assertTrue(
|
|
113
|
+
result.issues.some(i => i.code === "stale_replan_file"),
|
|
114
|
+
"detects stale REPLAN when all tasks are done",
|
|
115
|
+
);
|
|
116
|
+
rmSync(base, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── 6. Metrics ledger corrupt ───────────────────────────────────────────────
|
|
120
|
+
console.log("\n=== metrics ledger corrupt ===");
|
|
121
|
+
{
|
|
122
|
+
const { base, gsd, mDir } = makeBase();
|
|
123
|
+
writeRoadmap(mDir, `# M001: Metrics Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
124
|
+
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
|
|
125
|
+
// Write invalid metrics.json
|
|
126
|
+
writeFileSync(join(gsd, "metrics.json"), '{"version":2,"data":[]}');
|
|
127
|
+
|
|
128
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
129
|
+
assertTrue(
|
|
130
|
+
result.issues.some(i => i.code === "metrics_ledger_corrupt"),
|
|
131
|
+
"detects corrupt metrics ledger (version != 1)",
|
|
132
|
+
);
|
|
133
|
+
rmSync(base, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── 7. Large planning file ──────────────────────────────────────────────────
|
|
137
|
+
console.log("\n=== large planning file ===");
|
|
138
|
+
{
|
|
139
|
+
const { base, mDir } = makeBase();
|
|
140
|
+
writeRoadmap(mDir, `# M001: Large File Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
141
|
+
const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
|
|
142
|
+
// Write a 101KB .md file
|
|
143
|
+
const bigContent = "# Big File\n" + "x".repeat(101 * 1024);
|
|
144
|
+
writeFileSync(join(sDir, "BIGFILE.md"), bigContent);
|
|
145
|
+
|
|
146
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
147
|
+
assertTrue(
|
|
148
|
+
result.issues.some(i => i.code === "large_planning_file"),
|
|
149
|
+
"detects large planning file over 100KB",
|
|
150
|
+
);
|
|
151
|
+
rmSync(base, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── 8. Future timestamp ─────────────────────────────────────────────────────
|
|
155
|
+
console.log("\n=== future timestamp ===");
|
|
156
|
+
{
|
|
157
|
+
const { base, mDir } = makeBase();
|
|
158
|
+
writeRoadmap(mDir, `# M001: Timestamp Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
159
|
+
const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
|
|
160
|
+
// completed_at is 2 days in the future
|
|
161
|
+
const futureDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
|
|
162
|
+
writeFileSync(
|
|
163
|
+
join(sDir, "tasks", "T01-SUMMARY.md"),
|
|
164
|
+
`---\nstatus: done\ncompleted_at: ${futureDate}\n---\n# T01\nDone.\n`,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
168
|
+
assertTrue(
|
|
169
|
+
result.issues.some(i => i.code === "future_timestamp"),
|
|
170
|
+
"detects future completed_at timestamp",
|
|
171
|
+
);
|
|
172
|
+
rmSync(base, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── 9. JSON output format ───────────────────────────────────────────────────
|
|
176
|
+
console.log("\n=== JSON output format ===");
|
|
177
|
+
{
|
|
178
|
+
const { base, mDir } = makeBase();
|
|
179
|
+
writeRoadmap(mDir, `# M001: JSON Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
180
|
+
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
|
|
181
|
+
|
|
182
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
183
|
+
const json = formatDoctorReportJson(result);
|
|
184
|
+
|
|
185
|
+
let parsed: unknown;
|
|
186
|
+
try {
|
|
187
|
+
parsed = JSON.parse(json);
|
|
188
|
+
} catch {
|
|
189
|
+
parsed = null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
assertTrue(parsed !== null, "formatDoctorReportJson produces valid JSON");
|
|
193
|
+
assertTrue(typeof (parsed as Record<string, unknown>)?.ok === "boolean", "JSON has ok field");
|
|
194
|
+
assertTrue(Array.isArray((parsed as Record<string, unknown>)?.issues), "JSON has issues array");
|
|
195
|
+
assertTrue(Array.isArray((parsed as Record<string, unknown>)?.fixesApplied), "JSON has fixesApplied array");
|
|
196
|
+
assertTrue(typeof (parsed as Record<string, unknown>)?.generatedAt === "string", "JSON has generatedAt field");
|
|
197
|
+
assertTrue(typeof (parsed as Record<string, unknown>)?.summary === "object", "JSON has summary object");
|
|
198
|
+
|
|
199
|
+
rmSync(base, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── 10. Dry-run mode ────────────────────────────────────────────────────────
|
|
203
|
+
console.log("\n=== dry-run mode ===");
|
|
204
|
+
{
|
|
205
|
+
const { base, mDir } = makeBase();
|
|
206
|
+
writeRoadmap(mDir, `# M001: Dry Run Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
207
|
+
const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
|
|
208
|
+
|
|
209
|
+
const result = await runGSDDoctor(base, { fix: true, dryRun: true });
|
|
210
|
+
// In dry-run mode, no actual files should be created
|
|
211
|
+
assertTrue(!existsSync(join(sDir, "S01-SUMMARY.md")), "dry-run does not create slice summary");
|
|
212
|
+
assertTrue(
|
|
213
|
+
result.fixesApplied.some(f => f.startsWith("[dry-run]")),
|
|
214
|
+
"dry-run mode reports would-fix entries",
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
rmSync(base, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── 11. Per-check timing ─────────────────────────────────────────────────────
|
|
221
|
+
console.log("\n=== per-check timing ===");
|
|
222
|
+
{
|
|
223
|
+
const { base, mDir } = makeBase();
|
|
224
|
+
writeRoadmap(mDir, `# M001: Timing Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
225
|
+
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
|
|
226
|
+
|
|
227
|
+
const result = await runGSDDoctor(base, { fix: false });
|
|
228
|
+
assertTrue(result.timing !== undefined, "report includes timing");
|
|
229
|
+
assertTrue(typeof result.timing?.git === "number", "timing.git is a number");
|
|
230
|
+
assertTrue(typeof result.timing?.runtime === "number", "timing.runtime is a number");
|
|
231
|
+
assertTrue(typeof result.timing?.environment === "number", "timing.environment is a number");
|
|
232
|
+
assertTrue(typeof result.timing?.gsdState === "number", "timing.gsdState is a number");
|
|
233
|
+
|
|
234
|
+
rmSync(base, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── 12. Doctor history ───────────────────────────────────────────────────────
|
|
238
|
+
console.log("\n=== doctor history ===");
|
|
239
|
+
{
|
|
240
|
+
const { base, gsd, mDir } = makeBase();
|
|
241
|
+
writeRoadmap(mDir, `# M001: History Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
|
|
242
|
+
writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
|
|
243
|
+
|
|
244
|
+
await runGSDDoctor(base, { fix: false });
|
|
245
|
+
|
|
246
|
+
const historyPath = join(gsd, "doctor-history.jsonl");
|
|
247
|
+
assertTrue(existsSync(historyPath), "doctor-history.jsonl is created after run");
|
|
248
|
+
|
|
249
|
+
const { readDoctorHistory } = await import("../doctor.js");
|
|
250
|
+
const history = await readDoctorHistory(base);
|
|
251
|
+
assertTrue(history.length >= 1, "history has at least one entry");
|
|
252
|
+
assertTrue(typeof history[0]?.ts === "string", "history entry has ts field");
|
|
253
|
+
assertTrue(typeof history[0]?.ok === "boolean", "history entry has ok field");
|
|
254
|
+
assertTrue(typeof history[0]?.errors === "number", "history entry has errors count");
|
|
255
|
+
assertTrue(Array.isArray(history[0]?.codes), "history entry has codes array");
|
|
256
|
+
|
|
257
|
+
rmSync(base, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
report();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
main().catch(err => {
|
|
264
|
+
console.error(err);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getMainBranch,
|
|
12
12
|
getSliceBranchName,
|
|
13
13
|
parseSliceBranch,
|
|
14
|
+
resolveProjectRoot,
|
|
14
15
|
setActiveMilestoneId,
|
|
15
16
|
SLICE_BRANCH_RE,
|
|
16
17
|
} from "../worktree.ts";
|
|
@@ -165,6 +166,52 @@ async function main(): Promise<void> {
|
|
|
165
166
|
rmSync(repo, { recursive: true, force: true });
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
// ── detectWorktreeName: symlink-resolved paths ───────────────────────────
|
|
170
|
+
console.log("\n=== detectWorktreeName (symlink-resolved paths) ===");
|
|
171
|
+
assertEq(
|
|
172
|
+
detectWorktreeName("/Users/fran/.gsd/projects/89e1c9ad49bf/worktrees/M001"),
|
|
173
|
+
"M001",
|
|
174
|
+
"detects milestone in symlink-resolved path",
|
|
175
|
+
);
|
|
176
|
+
assertEq(
|
|
177
|
+
detectWorktreeName("/Users/fran/.gsd/projects/abc123/worktrees/M002/subdir"),
|
|
178
|
+
"M002",
|
|
179
|
+
"detects milestone with trailing subdir in symlink-resolved path",
|
|
180
|
+
);
|
|
181
|
+
assertEq(
|
|
182
|
+
detectWorktreeName("/Users/fran/.gsd/projects/abc123"),
|
|
183
|
+
null,
|
|
184
|
+
"returns null for project root without worktrees segment",
|
|
185
|
+
);
|
|
186
|
+
assertEq(
|
|
187
|
+
detectWorktreeName("/foo/.gsd/worktrees/M001"),
|
|
188
|
+
"M001",
|
|
189
|
+
"still detects direct layout path",
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// ── resolveProjectRoot: symlink-resolved paths ──────────────────────────
|
|
193
|
+
console.log("\n=== resolveProjectRoot (symlink-resolved paths) ===");
|
|
194
|
+
assertEq(
|
|
195
|
+
resolveProjectRoot("/Users/fran/.gsd/projects/89e1c9ad49bf/worktrees/M001"),
|
|
196
|
+
"/Users/fran",
|
|
197
|
+
"resolves to user home for symlink-resolved path",
|
|
198
|
+
);
|
|
199
|
+
assertEq(
|
|
200
|
+
resolveProjectRoot("/foo/.gsd/worktrees/M001"),
|
|
201
|
+
"/foo",
|
|
202
|
+
"still resolves direct layout path",
|
|
203
|
+
);
|
|
204
|
+
assertEq(
|
|
205
|
+
resolveProjectRoot("/some/repo"),
|
|
206
|
+
"/some/repo",
|
|
207
|
+
"returns unchanged for non-worktree path",
|
|
208
|
+
);
|
|
209
|
+
assertEq(
|
|
210
|
+
resolveProjectRoot("/data/.gsd/projects/deadbeef/worktrees/M003/nested"),
|
|
211
|
+
"/data",
|
|
212
|
+
"resolves correctly with nested subdirs after worktree name",
|
|
213
|
+
);
|
|
214
|
+
|
|
168
215
|
rmSync(base, { recursive: true, force: true });
|
|
169
216
|
report();
|
|
170
217
|
}
|
|
@@ -67,40 +67,60 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string,
|
|
|
67
67
|
|
|
68
68
|
// ─── Pure Utility Functions (unchanged) ────────────────────────────────────
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Find the worktrees segment in a path, supporting both direct
|
|
72
|
+
* (`/.gsd/worktrees/`) and symlink-resolved (`/.gsd/projects/<hash>/worktrees/`)
|
|
73
|
+
* layouts. When `.gsd` is a symlink to `~/.gsd/projects/<hash>`, resolved
|
|
74
|
+
* paths contain the intermediate `projects/<hash>/` segment that the old
|
|
75
|
+
* single-marker check missed.
|
|
76
|
+
*/
|
|
77
|
+
function findWorktreeSegment(normalizedPath: string): { gsdIdx: number; afterWorktrees: number } | null {
|
|
78
|
+
// Direct layout: /.gsd/worktrees/<name>
|
|
79
|
+
const directMarker = "/.gsd/worktrees/";
|
|
80
|
+
const idx = normalizedPath.indexOf(directMarker);
|
|
81
|
+
if (idx !== -1) {
|
|
82
|
+
return { gsdIdx: idx, afterWorktrees: idx + directMarker.length };
|
|
83
|
+
}
|
|
84
|
+
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/<name>
|
|
85
|
+
const symlinkRe = /\/\.gsd\/projects\/[a-f0-9]+\/worktrees\//;
|
|
86
|
+
const match = normalizedPath.match(symlinkRe);
|
|
87
|
+
if (match && match.index !== undefined) {
|
|
88
|
+
return { gsdIdx: match.index, afterWorktrees: match.index + match[0].length };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
70
93
|
/**
|
|
71
94
|
* Detect the active worktree name from the current working directory.
|
|
72
95
|
* Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
|
|
73
96
|
*/
|
|
74
97
|
export function detectWorktreeName(basePath: string): string | null {
|
|
75
98
|
const normalizedPath = basePath.replaceAll("\\", "/");
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const afterMarker = normalizedPath.slice(idx + marker.length);
|
|
99
|
+
const seg = findWorktreeSegment(normalizedPath);
|
|
100
|
+
if (!seg) return null;
|
|
101
|
+
const afterMarker = normalizedPath.slice(seg.afterWorktrees);
|
|
80
102
|
const name = afterMarker.split("/")[0];
|
|
81
103
|
return name || null;
|
|
82
104
|
}
|
|
83
105
|
|
|
84
106
|
/**
|
|
85
107
|
* Resolve the project root from a path that may be inside a worktree.
|
|
86
|
-
* If the path contains
|
|
87
|
-
*
|
|
108
|
+
* If the path contains a worktrees segment, returns the portion before
|
|
109
|
+
* `/.gsd/`. Otherwise returns the input unchanged.
|
|
88
110
|
*
|
|
89
111
|
* Use this in commands that call `process.cwd()` to ensure they always
|
|
90
112
|
* operate against the real project root, not a worktree subdirectory.
|
|
91
113
|
*/
|
|
92
114
|
export function resolveProjectRoot(basePath: string): string {
|
|
93
115
|
const normalizedPath = basePath.replaceAll("\\", "/");
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// Return the original path up to the .gsd/ marker (un-normalized)
|
|
98
|
-
// Account for potential OS-specific separators
|
|
116
|
+
const seg = findWorktreeSegment(normalizedPath);
|
|
117
|
+
if (!seg) return basePath;
|
|
118
|
+
// Return the original path up to the /.gsd/ boundary
|
|
99
119
|
const sep = basePath.includes("\\") ? "\\" : "/";
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
return basePath.slice(0,
|
|
120
|
+
const gsdMarker = `${sep}.gsd${sep}`;
|
|
121
|
+
const gsdIdx = basePath.indexOf(gsdMarker);
|
|
122
|
+
if (gsdIdx !== -1) return basePath.slice(0, gsdIdx);
|
|
123
|
+
return basePath.slice(0, seg.gsdIdx);
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
/**
|
|
@@ -452,7 +452,7 @@ async function runSingleAgent(
|
|
|
452
452
|
|
|
453
453
|
async function runSingleAgentInCmuxSplit(
|
|
454
454
|
cmuxClient: CmuxClient,
|
|
455
|
-
|
|
455
|
+
directionOrSurfaceId: "right" | "down" | string,
|
|
456
456
|
defaultCwd: string,
|
|
457
457
|
agents: AgentConfig[],
|
|
458
458
|
agentName: string,
|
|
@@ -503,7 +503,12 @@ async function runSingleAgentInCmuxSplit(
|
|
|
503
503
|
const stdoutPath = path.join(tmpOutputDir, "stdout.jsonl");
|
|
504
504
|
const stderrPath = path.join(tmpOutputDir, "stderr.log");
|
|
505
505
|
const exitPath = path.join(tmpOutputDir, "exit.code");
|
|
506
|
-
|
|
506
|
+
// Accept either a pre-created surface ID or a direction to create a new split
|
|
507
|
+
const isDirection = directionOrSurfaceId === "right" || directionOrSurfaceId === "down"
|
|
508
|
+
|| directionOrSurfaceId === "left" || directionOrSurfaceId === "up";
|
|
509
|
+
const cmuxSurfaceId = isDirection
|
|
510
|
+
? await cmuxClient.createSplit(directionOrSurfaceId as "right" | "down" | "left" | "up")
|
|
511
|
+
: directionOrSurfaceId;
|
|
507
512
|
if (!cmuxSurfaceId) {
|
|
508
513
|
return runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails);
|
|
509
514
|
}
|
|
@@ -806,12 +811,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
806
811
|
const MAX_RETRIES = 1; // Retry failed tasks once
|
|
807
812
|
const batchId = crypto.randomUUID();
|
|
808
813
|
const batchSize = params.tasks.length;
|
|
814
|
+
// Pre-create a grid layout for cmux splits so agents get a clean tiled arrangement
|
|
815
|
+
const gridSurfaces = cmuxSplitsEnabled
|
|
816
|
+
? await cmuxClient.createGridLayout(Math.min(batchSize, MAX_CONCURRENCY))
|
|
817
|
+
: [];
|
|
809
818
|
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
810
819
|
const workerId = registerWorker(t.agent, t.task, index, batchSize, batchId);
|
|
811
820
|
const runTask = () => cmuxSplitsEnabled
|
|
812
821
|
? runSingleAgentInCmuxSplit(
|
|
813
822
|
cmuxClient,
|
|
814
|
-
index % 2 === 0 ? "right" : "down",
|
|
823
|
+
gridSurfaces[index] ?? (index % 2 === 0 ? "right" : "down"),
|
|
815
824
|
ctx.cwd,
|
|
816
825
|
agents,
|
|
817
826
|
t.agent,
|