gsd-pi 2.3.8 → 2.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +5 -2
  2. package/dist/cli.js +32 -2
  3. package/dist/logo.d.ts +16 -0
  4. package/dist/logo.js +25 -0
  5. package/dist/onboarding.d.ts +43 -0
  6. package/dist/onboarding.js +425 -0
  7. package/dist/wizard.js +8 -0
  8. package/package.json +3 -1
  9. package/scripts/postinstall.js +100 -28
  10. package/src/resources/GSD-WORKFLOW.md +2 -2
  11. package/src/resources/extensions/bg-shell/index.ts +2 -1
  12. package/src/resources/extensions/google-search/index.ts +1 -1
  13. package/src/resources/extensions/gsd/auto.ts +353 -144
  14. package/src/resources/extensions/gsd/files.ts +9 -7
  15. package/src/resources/extensions/gsd/index.ts +2 -1
  16. package/src/resources/extensions/gsd/metrics.ts +7 -5
  17. package/src/resources/extensions/gsd/migrate/command.ts +4 -1
  18. package/src/resources/extensions/gsd/migrate/validator.ts +5 -3
  19. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  20. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +5 -5
  21. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +3 -3
  22. package/src/resources/extensions/gsd/tests/parsers.test.ts +94 -0
  23. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +23 -6
  24. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
  25. package/src/resources/extensions/gsd/tests/worktree.test.ts +116 -1
  26. package/src/resources/extensions/gsd/unit-runtime.ts +22 -1
  27. package/src/resources/extensions/gsd/workspace-index.ts +2 -2
  28. package/src/resources/extensions/gsd/worktree-command.ts +147 -41
  29. package/src/resources/extensions/gsd/worktree.ts +105 -8
  30. package/src/resources/extensions/mcporter/index.ts +21 -2
  31. package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
  32. package/src/resources/extensions/search-the-web/http.ts +1 -1
  33. package/src/resources/extensions/search-the-web/index.ts +9 -3
  34. package/src/resources/extensions/search-the-web/provider.ts +118 -0
  35. package/src/resources/extensions/search-the-web/tavily.ts +116 -0
  36. package/src/resources/extensions/search-the-web/tool-llm-context.ts +265 -108
  37. package/src/resources/extensions/search-the-web/tool-search.ts +161 -88
  38. package/src/resources/extensions/shared/terminal.ts +23 -0
  39. package/src/resources/extensions/subagent/index.ts +1 -1
  40. package/src/resources/extensions/voice/index.ts +2 -1
@@ -347,21 +347,23 @@ export function parseSummary(content: string): Summary {
347
347
  const [fmLines, body] = splitFrontmatter(content);
348
348
 
349
349
  const fm = fmLines ? parseFrontmatterMap(fmLines) : {};
350
+ const asStringArray = (v: unknown): string[] =>
351
+ Array.isArray(v) ? v : (typeof v === 'string' && v ? [v] : []);
350
352
  const frontmatter: SummaryFrontmatter = {
351
353
  id: (fm.id as string) || '',
352
354
  parent: (fm.parent as string) || '',
353
355
  milestone: (fm.milestone as string) || '',
354
- provides: (fm.provides as string[]) || [],
356
+ provides: asStringArray(fm.provides),
355
357
  requires: ((fm.requires as Array<Record<string, string>>) || []).map(r => ({
356
358
  slice: r.slice || '',
357
359
  provides: r.provides || '',
358
360
  })),
359
- affects: (fm.affects as string[]) || [],
360
- key_files: (fm.key_files as string[]) || [],
361
- key_decisions: (fm.key_decisions as string[]) || [],
362
- patterns_established: (fm.patterns_established as string[]) || [],
363
- drill_down_paths: (fm.drill_down_paths as string[]) || [],
364
- observability_surfaces: (fm.observability_surfaces as string[]) || [],
361
+ affects: asStringArray(fm.affects),
362
+ key_files: asStringArray(fm.key_files),
363
+ key_decisions: asStringArray(fm.key_decisions),
364
+ patterns_established: asStringArray(fm.patterns_established),
365
+ drill_down_paths: asStringArray(fm.drill_down_paths),
366
+ observability_surfaces: asStringArray(fm.observability_surfaces),
365
367
  duration: (fm.duration as string) || '',
366
368
  verification_result: (fm.verification_result as string) || 'untested',
367
369
  completed_at: (fm.completed_at as string) || '',
@@ -47,6 +47,7 @@ import {
47
47
  import { Key } from "@mariozechner/pi-tui";
48
48
  import { join } from "node:path";
49
49
  import { existsSync } from "node:fs";
50
+ import { shortcutDesc } from "../shared/terminal.js";
50
51
  import { Text } from "@mariozechner/pi-tui";
51
52
 
52
53
  // ── ASCII logo ────────────────────────────────────────────────────────────
@@ -185,7 +186,7 @@ export default function (pi: ExtensionAPI) {
185
186
 
186
187
  // ── Ctrl+Alt+G shortcut — GSD dashboard overlay ────────────────────────
187
188
  pi.registerShortcut(Key.ctrlAlt("g"), {
188
- description: "Open GSD dashboard",
189
+ description: shortcutDesc("Open GSD dashboard", "/gsd status"),
189
190
  handler: async (ctx) => {
190
191
  // Only show if .gsd/ exists
191
192
  if (!existsSync(join(process.cwd(), ".gsd"))) {
@@ -129,8 +129,9 @@ export function snapshotUnitMetrics(
129
129
  tokens.cacheRead += msg.usage.cacheRead ?? 0;
130
130
  tokens.cacheWrite += msg.usage.cacheWrite ?? 0;
131
131
  tokens.total += msg.usage.totalTokens ?? 0;
132
- if (msg.usage.cost) {
133
- cost += msg.usage.cost.total ?? 0;
132
+ if (msg.usage.cost != null) {
133
+ const c = msg.usage.cost;
134
+ cost += typeof c === "number" ? c : (c.total ?? 0);
134
135
  }
135
136
  }
136
137
  // Count tool calls in this message
@@ -296,9 +297,10 @@ export function getProjectTotals(units: UnitMetrics[]): ProjectTotals {
296
297
  // ─── Formatting helpers ───────────────────────────────────────────────────────
297
298
 
298
299
  export function formatCost(cost: number): string {
299
- if (cost < 0.01) return `$${cost.toFixed(4)}`;
300
- if (cost < 1) return `$${cost.toFixed(3)}`;
301
- return `$${cost.toFixed(2)}`;
300
+ const n = Number(cost) || 0;
301
+ if (n < 0.01) return `$${n.toFixed(4)}`;
302
+ if (n < 1) return `$${n.toFixed(3)}`;
303
+ return `$${n.toFixed(2)}`;
302
304
  }
303
305
 
304
306
  /**
@@ -96,7 +96,10 @@ export async function handleMigrate(
96
96
 
97
97
  if (!existsSync(sourcePath)) {
98
98
  ctx.ui.notify(
99
- `Directory not found: ${sourcePath}\n\nMake sure the path points to a project root with a .planning directory.`,
99
+ `Directory not found: ${sourcePath}\n\n` +
100
+ 'Migration converts a .planning/ directory (from older GSD versions) into .gsd/ format.\n' +
101
+ 'If you are starting a new project, use /gsd:new-project instead.\n' +
102
+ 'If migrating, ensure the path contains a .planning/ directory.',
100
103
  "error",
101
104
  );
102
105
  return;
@@ -14,7 +14,7 @@ function issue(file: string, severity: ValidationSeverity, message: string): Val
14
14
  /**
15
15
  * Validate that a .planning directory has the minimum required structure.
16
16
  * Returns structured issues with severity levels:
17
- * - fatal: directory doesn't exist or ROADMAP.md missing (migration cannot proceed)
17
+ * - fatal: directory doesn't exist (migration cannot proceed)
18
18
  * - warning: optional files missing (migration can proceed with reduced data)
19
19
  */
20
20
  export async function validatePlanningDirectory(path: string): Promise<ValidationResult> {
@@ -26,9 +26,11 @@ export async function validatePlanningDirectory(path: string): Promise<Validatio
26
26
  return { valid: false, issues };
27
27
  }
28
28
 
29
- // ROADMAP.md is required (fatal if missing)
29
+ // ROADMAP.md warn if missing (transformer falls back to filesystem phases)
30
30
  if (!existsSync(join(path, 'ROADMAP.md'))) {
31
- issues.push(issue('ROADMAP.md', 'fatal', 'ROADMAP.md is required for migration'));
31
+ issues.push(issue('ROADMAP.md', 'warning',
32
+ 'ROADMAP.md not found — milestone structure will be inferred from phases/ directory',
33
+ ));
32
34
  }
33
35
 
34
36
  // Optional files — warn if missing
@@ -76,7 +76,7 @@ Titles live inside file content (headings, frontmatter), not in file or director
76
76
  - **Slices** are demoable vertical increments (S01, S02, ...) ordered by risk. After each slice completes, the roadmap is reassessed before the next slice begins.
77
77
  - **Tasks** are single-context-window units of work (T01, T02, ...)
78
78
  - Checkboxes in roadmap and plan files track completion (`[ ]` → `[x]`)
79
- - Each slice gets its own git branch: `gsd/M001/S01`
79
+ - Each slice gets its own git branch: `gsd/M001/S01` (or `gsd/<worktree>/M001/S01` when inside a worktree)
80
80
  - Slices are squash-merged to main when complete
81
81
  - Summaries compress prior work — read them instead of re-reading all task details
82
82
  - `STATE.md` is the quick-glance status file — keep it updated after changes
@@ -725,8 +725,8 @@ Another orphan.
725
725
  }
726
726
  }
727
727
 
728
- // ─── Test 12: Validation — missing ROADMAP.md → fatal ─────────────────
729
- console.log('\n=== Validation: missing ROADMAP.md → fatal ===');
728
+ // ─── Test 12: Validation — missing ROADMAP.md → warning (not fatal) ───
729
+ console.log('\n=== Validation: missing ROADMAP.md → warning (not fatal) ===');
730
730
  {
731
731
  const base = createFixtureBase();
732
732
  try {
@@ -736,10 +736,10 @@ Another orphan.
736
736
 
737
737
  const result = await validatePlanningDirectory(planning);
738
738
 
739
- assertEq(result.valid, false, 'no roadmap: validation fails');
739
+ assertEq(result.valid, true, 'no roadmap: validation still passes');
740
740
  assert(
741
- result.issues.some(i => i.severity === 'fatal' && i.file.includes('ROADMAP')),
742
- 'no roadmap: fatal issue mentions ROADMAP'
741
+ result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')),
742
+ 'no roadmap: warning issue mentions ROADMAP'
743
743
  );
744
744
  } finally {
745
745
  cleanup(base);
@@ -211,15 +211,15 @@ async function main(): Promise<void> {
211
211
  }
212
212
  }
213
213
 
214
- console.log('\n=== Validator: missing ROADMAP.md → fatal ===');
214
+ console.log('\n=== Validator: missing ROADMAP.md → warning (not fatal) ===');
215
215
  {
216
216
  const base = createFixtureBase();
217
217
  try {
218
218
  const planning = createPlanningDir(base);
219
219
  writeFileSync(join(planning, 'PROJECT.md'), SAMPLE_PROJECT);
220
220
  const result = await validatePlanningDirectory(planning);
221
- assertEq(result.valid, false, 'no roadmap: validation fails');
222
- assert(result.issues.some(i => i.severity === 'fatal' && i.file.includes('ROADMAP')), 'no roadmap: fatal issue mentions ROADMAP');
221
+ assertEq(result.valid, true, 'no roadmap: validation still passes');
222
+ assert(result.issues.some(i => i.severity === 'warning' && i.file.includes('ROADMAP')), 'no roadmap: warning issue mentions ROADMAP');
223
223
  } finally {
224
224
  cleanup(base);
225
225
  }
@@ -1248,6 +1248,100 @@ console.log('\n=== parseRequirementCounts: total is sum of all section counts ==
1248
1248
  assertEq(counts.total, counts.active + counts.validated + counts.deferred + counts.outOfScope, 'total is exact sum');
1249
1249
  }
1250
1250
 
1251
+ // ═══════════════════════════════════════════════════════════════════════════
1252
+ // parseSummary: bare scalar frontmatter fields (regression test for #91)
1253
+ // ═══════════════════════════════════════════════════════════════════════════
1254
+
1255
+ console.log('\n=== parseSummary: bare scalar "none" coerced to string array (#91) ===');
1256
+ {
1257
+ const content = `---
1258
+ id: T04
1259
+ parent: S03
1260
+ milestone: M001
1261
+ provides:
1262
+ - iOS rules
1263
+ key_files:
1264
+ - .claude/rules/swift-style.md
1265
+ key_decisions: none
1266
+ patterns_established: none
1267
+ drill_down_paths: none
1268
+ observability_surfaces: none — static reference files
1269
+ affects: single-value
1270
+ ---
1271
+
1272
+ # T04: iOS Rules
1273
+
1274
+ **Created iOS-specific rules.**
1275
+
1276
+ ## What Happened
1277
+
1278
+ Added rules.
1279
+
1280
+ ## Deviations
1281
+
1282
+ None.
1283
+ `;
1284
+
1285
+ const s = parseSummary(content);
1286
+
1287
+ // Array fields should remain arrays
1288
+ assertEq(s.frontmatter.provides.length, 1, '#91: provides array preserved');
1289
+ assertEq(s.frontmatter.provides[0], 'iOS rules', '#91: provides value');
1290
+ assertEq(s.frontmatter.key_files.length, 1, '#91: key_files array preserved');
1291
+
1292
+ // Bare scalar "none" must be coerced to ["none"], not crash
1293
+ assertEq(Array.isArray(s.frontmatter.key_decisions), true, '#91: key_decisions is array');
1294
+ assertEq(s.frontmatter.key_decisions.length, 1, '#91: key_decisions has 1 element');
1295
+ assertEq(s.frontmatter.key_decisions[0], 'none', '#91: key_decisions[0] is "none"');
1296
+
1297
+ assertEq(Array.isArray(s.frontmatter.patterns_established), true, '#91: patterns_established is array');
1298
+ assertEq(s.frontmatter.patterns_established.length, 1, '#91: patterns_established coerced');
1299
+
1300
+ assertEq(Array.isArray(s.frontmatter.drill_down_paths), true, '#91: drill_down_paths is array');
1301
+ assertEq(s.frontmatter.drill_down_paths.length, 1, '#91: drill_down_paths coerced');
1302
+
1303
+ // Scalar with spaces: "none — static reference files"
1304
+ assertEq(Array.isArray(s.frontmatter.observability_surfaces), true, '#91: observability_surfaces is array');
1305
+ assertEq(s.frontmatter.observability_surfaces.length, 1, '#91: observability_surfaces coerced');
1306
+ assertEq(s.frontmatter.observability_surfaces[0], 'none — static reference files', '#91: full scalar preserved');
1307
+
1308
+ // Single value (not "none") also coerced
1309
+ assertEq(Array.isArray(s.frontmatter.affects), true, '#91: affects is array');
1310
+ assertEq(s.frontmatter.affects.length, 1, '#91: affects single value coerced');
1311
+ assertEq(s.frontmatter.affects[0], 'single-value', '#91: affects value');
1312
+
1313
+ // .slice().join() must not crash (the original bug)
1314
+ const decisions = s.frontmatter.key_decisions.slice(0, 2).join('; ');
1315
+ assertEq(decisions, 'none', '#91: .slice().join() works on coerced array');
1316
+ }
1317
+
1318
+ console.log('\n=== parseSummary: missing/empty frontmatter fields yield empty arrays ===');
1319
+ {
1320
+ const content = `---
1321
+ id: T05
1322
+ parent: S04
1323
+ milestone: M001
1324
+ ---
1325
+
1326
+ # T05: Minimal Summary
1327
+
1328
+ **Minimal.**
1329
+
1330
+ ## What Happened
1331
+
1332
+ Nothing.
1333
+ `;
1334
+
1335
+ const s = parseSummary(content);
1336
+ assertEq(s.frontmatter.provides.length, 0, 'missing provides = empty array');
1337
+ assertEq(s.frontmatter.key_decisions.length, 0, 'missing key_decisions = empty array');
1338
+ assertEq(s.frontmatter.affects.length, 0, 'missing affects = empty array');
1339
+ assertEq(s.frontmatter.key_files.length, 0, 'missing key_files = empty array');
1340
+ assertEq(s.frontmatter.patterns_established.length, 0, 'missing patterns_established = empty array');
1341
+ assertEq(s.frontmatter.drill_down_paths.length, 0, 'missing drill_down_paths = empty array');
1342
+ assertEq(s.frontmatter.observability_surfaces.length, 0, 'missing observability_surfaces = empty array');
1343
+ }
1344
+
1251
1345
  // ═══════════════════════════════════════════════════════════════════════════
1252
1346
  // Results
1253
1347
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1,17 +1,34 @@
1
1
  // ESM resolve hook: .js → .ts rewriting for test environments.
2
2
  // Only rewrites relative imports from our own source files — not from node_modules.
3
+ //
4
+ // Handles two patterns:
5
+ // 1. .js → .ts (pi bundler convention: source files use .js specifiers)
6
+ // 2. extensionless → .ts (some source files omit extensions in relative imports)
3
7
 
4
8
  export function resolve(specifier, context, nextResolve) {
5
9
  const parentURL = context.parentURL || '';
6
10
  const isFromNodeModules = parentURL.includes('/node_modules/');
7
11
 
8
- if (specifier.endsWith('.js') && !specifier.startsWith('node:') && !isFromNodeModules) {
9
- const tsSpecifier = specifier.replace(/\.js$/, '.ts');
10
- try {
11
- return nextResolve(tsSpecifier, context);
12
- } catch {
13
- // fall through to default resolution
12
+ if (!isFromNodeModules && !specifier.startsWith('node:')) {
13
+ // Rewrite .js .ts
14
+ if (specifier.endsWith('.js')) {
15
+ const tsSpecifier = specifier.replace(/\.js$/, '.ts');
16
+ try {
17
+ return nextResolve(tsSpecifier, context);
18
+ } catch {
19
+ // fall through to default resolution
20
+ }
21
+ }
22
+
23
+ // Try adding .ts to extensionless relative imports
24
+ if (specifier.startsWith('.') && !/\.[a-z]+$/i.test(specifier)) {
25
+ try {
26
+ return nextResolve(specifier + '.ts', context);
27
+ } catch {
28
+ // fall through to default resolution
29
+ }
14
30
  }
15
31
  }
32
+
16
33
  return nextResolve(specifier, context);
17
34
  }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Worktree Integration Tests
3
+ *
4
+ * Tests the full lifecycle of GSD operations inside a worktree:
5
+ * - Branch namespacing (gsd/<wt>/<M>/<S> instead of gsd/<M>/<S>)
6
+ * - getMainBranch returns worktree/<name> inside a worktree
7
+ * - switchToMain goes to worktree/<name>, not main
8
+ * - mergeSliceToMain merges into worktree/<name>
9
+ * - Parallel worktrees don't conflict on branch names
10
+ * - State derivation works correctly inside worktrees
11
+ */
12
+
13
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { execSync } from "node:child_process";
17
+
18
+ import {
19
+ createWorktree,
20
+ listWorktrees,
21
+ removeWorktree,
22
+ worktreePath,
23
+ worktreeBranchName,
24
+ } from "../worktree-manager.ts";
25
+
26
+ import {
27
+ detectWorktreeName,
28
+ ensureSliceBranch,
29
+ getActiveSliceBranch,
30
+ getCurrentBranch,
31
+ getMainBranch,
32
+ getSliceBranchName,
33
+ isOnSliceBranch,
34
+ mergeSliceToMain,
35
+ switchToMain,
36
+ autoCommitCurrentBranch,
37
+ } from "../worktree.ts";
38
+
39
+ import { deriveState } from "../state.ts";
40
+
41
+ let passed = 0;
42
+ let failed = 0;
43
+
44
+ function assert(condition: boolean, message: string): void {
45
+ if (condition) passed++;
46
+ else {
47
+ failed++;
48
+ console.error(` FAIL: ${message}`);
49
+ }
50
+ }
51
+
52
+ function assertEq<T>(actual: T, expected: T, message: string): void {
53
+ if (JSON.stringify(actual) === JSON.stringify(expected)) passed++;
54
+ else {
55
+ failed++;
56
+ console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
57
+ }
58
+ }
59
+
60
+ function run(command: string, cwd: string): string {
61
+ return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
62
+ }
63
+
64
+ // ─── Test repo setup ──────────────────────────────────────────────────────────
65
+
66
+ const base = mkdtempSync(join(tmpdir(), "gsd-wt-integration-"));
67
+ run("git init -b main", base);
68
+ run("git config user.name 'Pi Test'", base);
69
+ run("git config user.email 'pi@example.com'", base);
70
+
71
+ // Create a project with one milestone and two slices
72
+ mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
73
+ mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true });
74
+ writeFileSync(join(base, "README.md"), "# Test Project\n", "utf-8");
75
+ writeFileSync(
76
+ join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
77
+ [
78
+ "# M001: Demo",
79
+ "",
80
+ "## Slices",
81
+ "- [ ] **S01: First** `risk:low` `depends:[]`",
82
+ " > After this: part one works",
83
+ "- [ ] **S02: Second** `risk:low` `depends:[]`",
84
+ " > After this: part two works",
85
+ ].join("\n") + "\n",
86
+ "utf-8",
87
+ );
88
+ writeFileSync(
89
+ join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
90
+ "# S01: First\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n",
91
+ "utf-8",
92
+ );
93
+ writeFileSync(
94
+ join(base, ".gsd", "milestones", "M001", "slices", "S02", "S02-PLAN.md"),
95
+ "# S02: Second\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** `est:10m`\n do it\n",
96
+ "utf-8",
97
+ );
98
+ run("git add .", base);
99
+ run("git commit -m 'chore: init'", base);
100
+
101
+ async function main(): Promise<void> {
102
+ // ── Verify main tree baseline ──────────────────────────────────────────────
103
+
104
+ console.log("\n=== Main tree baseline ===");
105
+ assertEq(getMainBranch(base), "main", "main tree getMainBranch returns main");
106
+ assertEq(detectWorktreeName(base), null, "main tree not detected as worktree");
107
+
108
+ // ── Create worktree and verify detection ───────────────────────────────────
109
+
110
+ console.log("\n=== Create worktree ===");
111
+ const wt = createWorktree(base, "alpha");
112
+ assert(existsSync(wt.path), "worktree created on disk");
113
+ assertEq(wt.branch, "worktree/alpha", "worktree branch name");
114
+
115
+ console.log("\n=== Worktree detection ===");
116
+ assertEq(detectWorktreeName(wt.path), "alpha", "detectWorktreeName inside worktree");
117
+ assertEq(getMainBranch(wt.path), "worktree/alpha", "getMainBranch returns worktree branch inside worktree");
118
+
119
+ // ── Verify current branch inside worktree ──────────────────────────────────
120
+
121
+ console.log("\n=== Worktree initial branch ===");
122
+ assertEq(getCurrentBranch(wt.path), "worktree/alpha", "worktree starts on its own branch");
123
+
124
+ // ── ensureSliceBranch inside worktree ──────────────────────────────────────
125
+
126
+ console.log("\n=== ensureSliceBranch in worktree ===");
127
+ const created = ensureSliceBranch(wt.path, "M001", "S01");
128
+ assert(created, "slice branch created");
129
+ assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch");
130
+ assert(isOnSliceBranch(wt.path), "isOnSliceBranch returns true");
131
+ assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced branch");
132
+
133
+ // ── Verify branch name helper ──────────────────────────────────────────────
134
+
135
+ console.log("\n=== getSliceBranchName with worktree ===");
136
+ assertEq(getSliceBranchName("M001", "S01", "alpha"), "gsd/alpha/M001/S01", "explicit worktree param");
137
+ assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "no worktree param = plain branch");
138
+
139
+ // ── Do work on slice branch, then merge to worktree branch ─────────────────
140
+
141
+ console.log("\n=== Work and merge slice in worktree ===");
142
+ writeFileSync(join(wt.path, "feature.txt"), "new feature\n", "utf-8");
143
+ run("git add .", wt.path);
144
+ run("git commit -m 'feat: add feature'", wt.path);
145
+
146
+ // switchToMain should go to worktree/alpha, NOT main
147
+ switchToMain(wt.path);
148
+ assertEq(getCurrentBranch(wt.path), "worktree/alpha", "switchToMain goes to worktree branch, not main");
149
+
150
+ // mergeSliceToMain should merge into worktree/alpha
151
+ const merge = mergeSliceToMain(wt.path, "M001", "S01", "First");
152
+ assertEq(merge.branch, "gsd/alpha/M001/S01", "merged the namespaced branch");
153
+ assert(merge.deletedBranch, "slice branch deleted after merge");
154
+ assertEq(getCurrentBranch(wt.path), "worktree/alpha", "still on worktree branch after merge");
155
+ assert(readFileSync(join(wt.path, "feature.txt"), "utf-8").includes("new feature"), "merge brought feature to worktree branch");
156
+
157
+ // Verify slice branch is gone
158
+ const branches = run("git branch", base);
159
+ assert(!branches.includes("gsd/alpha/M001/S01"), "slice branch cleaned up");
160
+
161
+ // ── Second slice in same worktree ──────────────────────────────────────────
162
+
163
+ console.log("\n=== Second slice in worktree ===");
164
+ const created2 = ensureSliceBranch(wt.path, "M001", "S02");
165
+ assert(created2, "S02 branch created");
166
+ assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S02", "on S02 namespaced branch");
167
+
168
+ writeFileSync(join(wt.path, "feature2.txt"), "second feature\n", "utf-8");
169
+ run("git add .", wt.path);
170
+ run("git commit -m 'feat: add feature 2'", wt.path);
171
+
172
+ switchToMain(wt.path);
173
+ const merge2 = mergeSliceToMain(wt.path, "M001", "S02", "Second");
174
+ assertEq(merge2.branch, "gsd/alpha/M001/S02", "S02 merge correct");
175
+ assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
176
+
177
+ // ── Main tree can still do its own slice work independently ────────────────
178
+
179
+ console.log("\n=== Main tree independent slice work ===");
180
+ assertEq(getCurrentBranch(base), "main", "main tree still on main");
181
+ const mainCreated = ensureSliceBranch(base, "M001", "S01");
182
+ assert(mainCreated, "main tree can create S01 branch (no conflict with worktree)");
183
+ assertEq(getCurrentBranch(base), "gsd/M001/S01", "main tree on plain branch name");
184
+
185
+ writeFileSync(join(base, "main-feature.txt"), "main work\n", "utf-8");
186
+ run("git add .", base);
187
+ run("git commit -m 'feat: main work'", base);
188
+
189
+ switchToMain(base);
190
+ assertEq(getCurrentBranch(base), "main", "main tree switchToMain goes to main");
191
+ const mainMerge = mergeSliceToMain(base, "M001", "S01", "First");
192
+ assertEq(mainMerge.branch, "gsd/M001/S01", "main tree merge uses plain branch");
193
+
194
+ // ── Parallel worktrees don't conflict ──────────────────────────────────────
195
+
196
+ console.log("\n=== Parallel worktrees ===");
197
+ const wt2 = createWorktree(base, "beta");
198
+ assertEq(getMainBranch(wt2.path), "worktree/beta", "second worktree has its own base branch");
199
+
200
+ // Both worktrees can create S01 branches without conflict
201
+ const betaCreated = ensureSliceBranch(wt2.path, "M001", "S01");
202
+ assert(betaCreated, "beta worktree can create S01");
203
+ assertEq(getCurrentBranch(wt2.path), "gsd/beta/M001/S01", "beta has its own namespaced branch");
204
+
205
+ // Alpha worktree can re-create S01 too (it was already merged+deleted earlier)
206
+ const alphaReCreated = ensureSliceBranch(wt.path, "M001", "S01");
207
+ assert(alphaReCreated, "alpha worktree can re-create S01");
208
+ assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "alpha re-created S01");
209
+
210
+ // Both exist simultaneously
211
+ const allBranches = run("git branch", base);
212
+ assert(allBranches.includes("gsd/alpha/M001/S01"), "alpha S01 branch exists");
213
+ assert(allBranches.includes("gsd/beta/M001/S01"), "beta S01 branch exists");
214
+
215
+ // ── State derivation in worktree ───────────────────────────────────────────
216
+
217
+ console.log("\n=== State derivation in worktree ===");
218
+ // Switch alpha back to its base so deriveState sees milestone files
219
+ switchToMain(wt.path);
220
+ const state = await deriveState(wt.path);
221
+ assert(state.activeMilestone !== null, "worktree has active milestone");
222
+ assertEq(state.activeMilestone?.id, "M001", "correct milestone");
223
+
224
+ // ── autoCommitCurrentBranch in worktree ────────────────────────────────────
225
+
226
+ console.log("\n=== autoCommitCurrentBranch in worktree ===");
227
+ ensureSliceBranch(wt2.path, "M001", "S01"); // re-checkout if needed
228
+ writeFileSync(join(wt2.path, "dirty.txt"), "uncommitted\n", "utf-8");
229
+ const commitMsg = autoCommitCurrentBranch(wt2.path, "execute-task", "M001/S01/T01");
230
+ assert(commitMsg !== null, "auto-commit works in worktree");
231
+ assertEq(run("git status --short", wt2.path), "", "worktree clean after auto-commit");
232
+
233
+ // ── Cleanup ────────────────────────────────────────────────────────────────
234
+
235
+ console.log("\n=== Cleanup ===");
236
+ // Switch worktrees back to their base branches before removal
237
+ switchToMain(wt.path);
238
+ switchToMain(wt2.path);
239
+ removeWorktree(base, "alpha", { deleteBranch: true });
240
+ removeWorktree(base, "beta", { deleteBranch: true });
241
+ assertEq(listWorktrees(base).length, 0, "all worktrees removed");
242
+
243
+ rmSync(base, { recursive: true, force: true });
244
+
245
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
246
+ if (failed > 0) process.exit(1);
247
+ console.log("All tests passed ✓");
248
+ }
249
+
250
+ main().catch((error) => {
251
+ console.error(error);
252
+ process.exit(1);
253
+ });