gsd-pi 2.75.0-dev.a44b82572 → 2.75.0-dev.e41b70b10

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 (111) hide show
  1. package/dist/resources/extensions/gsd/auto/phases.js +2 -0
  2. package/dist/resources/extensions/gsd/auto-dashboard.js +22 -1
  3. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +8 -2
  4. package/dist/resources/extensions/gsd/auto-dispatch.js +11 -11
  5. package/dist/resources/extensions/gsd/auto-model-selection.js +3 -1
  6. package/dist/resources/extensions/gsd/auto-prompts.js +19 -9
  7. package/dist/resources/extensions/gsd/auto-worktree.js +16 -1
  8. package/dist/resources/extensions/gsd/doctor-git-checks.js +22 -2
  9. package/dist/resources/extensions/gsd/pre-execution-checks.js +12 -8
  10. package/dist/resources/extensions/gsd/prompts/add-tests.md +1 -0
  11. package/dist/resources/extensions/gsd/prompts/execute-task.md +1 -1
  12. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -0
  13. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +14 -0
  14. package/dist/resources/extensions/search-the-web/command-search-provider.js +4 -1
  15. package/dist/resources/extensions/search-the-web/native-search.js +13 -2
  16. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  17. package/dist/web/standalone/.next/BUILD_ID +1 -1
  18. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  19. package/dist/web/standalone/.next/build-manifest.json +2 -2
  20. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  21. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.html +1 -1
  38. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  45. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  46. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  47. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  48. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  49. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  50. package/package.json +1 -1
  51. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  52. package/packages/mcp-server/dist/workflow-tools.js +102 -65
  53. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  54. package/packages/mcp-server/src/workflow-tools.test.ts +255 -0
  55. package/packages/mcp-server/src/workflow-tools.ts +108 -65
  56. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  57. package/packages/pi-agent-core/tsconfig.tsbuildinfo +1 -1
  58. package/packages/pi-ai/dist/index.d.ts +1 -0
  59. package/packages/pi-ai/dist/index.d.ts.map +1 -1
  60. package/packages/pi-ai/dist/index.js +1 -0
  61. package/packages/pi-ai/dist/index.js.map +1 -1
  62. package/packages/pi-ai/dist/providers/api-family.d.ts +27 -0
  63. package/packages/pi-ai/dist/providers/api-family.d.ts.map +1 -0
  64. package/packages/pi-ai/dist/providers/api-family.js +47 -0
  65. package/packages/pi-ai/dist/providers/api-family.js.map +1 -0
  66. package/packages/pi-ai/dist/providers/api-family.test.d.ts +2 -0
  67. package/packages/pi-ai/dist/providers/api-family.test.d.ts.map +1 -0
  68. package/packages/pi-ai/dist/providers/api-family.test.js +101 -0
  69. package/packages/pi-ai/dist/providers/api-family.test.js.map +1 -0
  70. package/packages/pi-ai/src/index.ts +1 -0
  71. package/packages/pi-ai/src/providers/api-family.test.ts +129 -0
  72. package/packages/pi-ai/src/providers/api-family.ts +57 -0
  73. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
  74. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +1 -0
  75. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  76. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -1
  78. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  79. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  80. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts.map +1 -1
  81. package/packages/pi-coding-agent/dist/core/retry-handler.js +4 -1
  82. package/packages/pi-coding-agent/dist/core/retry-handler.js.map +1 -1
  83. package/packages/pi-coding-agent/src/core/extensions/runner.ts +4 -1
  84. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -2
  85. package/packages/pi-coding-agent/src/core/retry-handler.ts +4 -1
  86. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  87. package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -10
  88. package/src/resources/extensions/gsd/auto/phases.ts +3 -0
  89. package/src/resources/extensions/gsd/auto-dashboard.ts +25 -1
  90. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +15 -2
  91. package/src/resources/extensions/gsd/auto-dispatch.ts +21 -7
  92. package/src/resources/extensions/gsd/auto-model-selection.ts +3 -1
  93. package/src/resources/extensions/gsd/auto-prompts.ts +33 -9
  94. package/src/resources/extensions/gsd/auto-worktree.ts +16 -1
  95. package/src/resources/extensions/gsd/doctor-git-checks.ts +23 -2
  96. package/src/resources/extensions/gsd/pre-execution-checks.ts +12 -8
  97. package/src/resources/extensions/gsd/prompts/add-tests.md +1 -0
  98. package/src/resources/extensions/gsd/prompts/execute-task.md +1 -1
  99. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -0
  100. package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +49 -0
  101. package/src/resources/extensions/gsd/tests/integration/doctor-git-symlink-cwd.test.ts +79 -0
  102. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +66 -0
  103. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +132 -8
  104. package/src/resources/extensions/gsd/tests/prompts-no-gitignored-test-refs.test.ts +56 -0
  105. package/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts +54 -0
  106. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +97 -0
  107. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +14 -0
  108. package/src/resources/extensions/search-the-web/command-search-provider.ts +4 -1
  109. package/src/resources/extensions/search-the-web/native-search.ts +13 -3
  110. /package/dist/web/standalone/.next/static/{iBwPQUj73sn8jxegTo320 → By_yegSJ-AA1OP0QjYbSl}/_buildManifest.js +0 -0
  111. /package/dist/web/standalone/.next/static/{iBwPQUj73sn8jxegTo320 → By_yegSJ-AA1OP0QjYbSl}/_ssgManifest.js +0 -0
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import { execSync } from "node:child_process";
3
+ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { test } from "node:test";
7
+
8
+ import { runGSDDoctor } from "../../doctor.ts";
9
+
10
+ function run(cmd: string, cwd: string): string {
11
+ return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
12
+ }
13
+
14
+ function createRepoWithCompletedMilestone(): string {
15
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-symlink-cwd-")));
16
+ run("git init", dir);
17
+ run("git config user.email test@test.com", dir);
18
+ run("git config user.name Test", dir);
19
+
20
+ writeFileSync(join(dir, "README.md"), "# test\n");
21
+ run("git add .", dir);
22
+ run("git commit -m init", dir);
23
+ run("git branch -M main", dir);
24
+
25
+ const milestoneDir = join(dir, ".gsd", "milestones", "M001");
26
+ mkdirSync(milestoneDir, { recursive: true });
27
+ writeFileSync(join(milestoneDir, "ROADMAP.md"), `---
28
+ id: M001
29
+ title: "Test Milestone"
30
+ ---
31
+
32
+ # M001: Test Milestone
33
+
34
+ ## Vision
35
+ Test
36
+
37
+ ## Success Criteria
38
+ - Done
39
+
40
+ ## Slices
41
+ - [x] **S01: Test slice** \`risk:low\` \`depends:[]\`
42
+ > After this: done
43
+
44
+ ## Boundary Map
45
+ _None_
46
+ `);
47
+
48
+ run("git add -A", dir);
49
+ run("git commit -m \"add milestone\"", dir);
50
+
51
+ return dir;
52
+ }
53
+
54
+ test("doctor removes orphaned milestone worktree when cwd uses a symlink alias", { skip: process.platform === "win32" }, async (t) => {
55
+ const previousCwd = process.cwd();
56
+ const dir = createRepoWithCompletedMilestone();
57
+ const alias = join(tmpdir(), `doc-git-alias-${Date.now()}-${Math.random().toString(16).slice(2)}`);
58
+
59
+ t.after(() => {
60
+ try { process.chdir(previousCwd); } catch { process.chdir(tmpdir()); }
61
+ rmSync(alias, { recursive: true, force: true });
62
+ rmSync(dir, { recursive: true, force: true });
63
+ });
64
+
65
+ mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
66
+ run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
67
+
68
+ symlinkSync(dir, alias);
69
+ process.chdir(join(alias, ".gsd", "worktrees", "M001"));
70
+
71
+ const fixed = await runGSDDoctor(dir, { fix: true, isolationMode: "worktree" });
72
+ assert.ok(
73
+ fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")),
74
+ `removes orphaned worktree even when cwd uses a symlink alias (got: ${JSON.stringify(fixed.fixesApplied)})`,
75
+ );
76
+
77
+ const wtList = run("git worktree list", dir);
78
+ assert.ok(!wtList.includes("milestone/M001"), "worktree removed after symlink-cwd fix");
79
+ });
@@ -1579,3 +1579,69 @@ describe("checkTaskOrdering directory inputs (#4446)", () => {
1579
1579
  );
1580
1580
  });
1581
1581
  });
1582
+
1583
+ describe("checkFilePathConsistency self-referential inputs (#4459)", () => {
1584
+ test("input that is also in the same task's expected_output is not blocking when missing on disk", (t) => {
1585
+ const tempDir = join(tmpdir(), `pre-exec-self-output-${Date.now()}`);
1586
+ mkdirSync(tempDir, { recursive: true });
1587
+ t.after(() => rmSync(tempDir, { recursive: true, force: true }));
1588
+
1589
+ const tasks = [
1590
+ createTask({
1591
+ id: "T02",
1592
+ sequence: 0,
1593
+ inputs: ["src/components/email/SnoozePopover.jsx"],
1594
+ expected_output: ["src/components/email/SnoozePopover.jsx"],
1595
+ }),
1596
+ ];
1597
+
1598
+ const results = checkFilePathConsistency(tasks, tempDir);
1599
+ assert.deepEqual(
1600
+ results,
1601
+ [],
1602
+ "File declared as both input and expected_output of the same task should not block — the task itself produces it",
1603
+ );
1604
+ });
1605
+
1606
+ test("input missing from disk, missing from prior outputs, and missing from own expected_output still blocks", (t) => {
1607
+ const tempDir = join(tmpdir(), `pre-exec-self-output-missing-${Date.now()}`);
1608
+ mkdirSync(tempDir, { recursive: true });
1609
+ t.after(() => rmSync(tempDir, { recursive: true, force: true }));
1610
+
1611
+ const tasks = [
1612
+ createTask({
1613
+ id: "T02",
1614
+ sequence: 0,
1615
+ inputs: ["src/components/email/SnoozePopover.jsx"],
1616
+ expected_output: ["src/other/unrelated.jsx"],
1617
+ }),
1618
+ ];
1619
+
1620
+ const results = checkFilePathConsistency(tasks, tempDir);
1621
+ assert.equal(results.length, 1, "Genuinely missing input should still be reported");
1622
+ assert.equal(results[0].blocking, true);
1623
+ assert.equal(results[0].target, "src/components/email/SnoozePopover.jsx");
1624
+ });
1625
+
1626
+ test("self-output exemption matches across path normalization (./ prefix)", (t) => {
1627
+ const tempDir = join(tmpdir(), `pre-exec-self-output-norm-${Date.now()}`);
1628
+ mkdirSync(tempDir, { recursive: true });
1629
+ t.after(() => rmSync(tempDir, { recursive: true, force: true }));
1630
+
1631
+ const tasks = [
1632
+ createTask({
1633
+ id: "T02",
1634
+ sequence: 0,
1635
+ inputs: ["./src/generated.ts"],
1636
+ expected_output: ["src/generated.ts"],
1637
+ }),
1638
+ ];
1639
+
1640
+ const results = checkFilePathConsistency(tasks, tempDir);
1641
+ assert.deepEqual(
1642
+ results,
1643
+ [],
1644
+ "./src/generated.ts and src/generated.ts should compare equal after normalization",
1645
+ );
1646
+ });
1647
+ });
@@ -1,13 +1,8 @@
1
1
  /**
2
2
  * Prompt budget enforcement tests — verifies that budget-aware prompt builders
3
- * truncate content at section boundaries and that plan-slice includes executor
4
- * context constraints.
5
- *
6
- * Tests:
7
- * 1. inlineDependencySummaries() truncates when budget is small, passes through when large
8
- * 2. plan-slice.md template includes {{executorContextConstraints}} placeholder
9
- * 3. Executor constraints formatting varies with context window size
10
- * 4. Different context windows produce different budget-constrained outputs
3
+ * truncate content at section boundaries, that plan-slice includes executor
4
+ * context constraints, and that prompt builders thread the real executor
5
+ * context window through to the budget engine (issue #4142).
11
6
  */
12
7
 
13
8
  import { describe, it, beforeEach, afterEach } from "node:test";
@@ -488,4 +483,133 @@ describe("prompt-budget: execute-task builder truncation pattern", () => {
488
483
  assert.ok(result.droppedSections > 0, "should report dropped sections");
489
484
  }
490
485
  });
486
+ });
487
+
488
+ // ─── Regression: prompt builders must thread modelRegistry + sessionContextWindow (issue #4142) ───
489
+ //
490
+ // `resolveExecutorContextWindow()` resolves the executor context window in 3
491
+ // steps: (1) look up the configured executor model in `modelRegistry`, (2) fall
492
+ // back to `sessionContextWindow`, (3) fall back to `DEFAULT_CONTEXT_WINDOW`
493
+ // (200K). Before this fix, prompt-builder call sites passed `undefined` for
494
+ // both knobs and always landed on Step 3 — even on 1M-token models. These
495
+ // source-level assertions pin the wiring so future refactors cannot regress it.
496
+
497
+ describe("prompt-budget: modelRegistry + sessionContextWindow wiring", () => {
498
+ const autoPromptsSrc = readFileSync(join(__dirname, "..", "auto-prompts.ts"), "utf-8");
499
+ const autoDispatchSrc = readFileSync(join(__dirname, "..", "auto-dispatch.ts"), "utf-8");
500
+ const autoDirectDispatchSrc = readFileSync(join(__dirname, "..", "auto-direct-dispatch.ts"), "utf-8");
501
+ const phasesSrc = readFileSync(join(__dirname, "..", "auto", "phases.ts"), "utf-8");
502
+
503
+ it("formatExecutorConstraints accepts and forwards both knobs", () => {
504
+ assert.match(
505
+ autoPromptsSrc,
506
+ /function formatExecutorConstraints\([^)]*sessionContextWindow[^)]*modelRegistry[^)]*\)/s,
507
+ "formatExecutorConstraints must accept sessionContextWindow and modelRegistry",
508
+ );
509
+ assert.match(
510
+ autoPromptsSrc,
511
+ /resolveExecutorContextWindow\(\s*modelRegistry\s*,\s*prefs\?\.preferences\s*,\s*sessionContextWindow/,
512
+ "formatExecutorConstraints must forward both to resolveExecutorContextWindow",
513
+ );
514
+ });
515
+
516
+ it("renderSlicePrompt options declare both knobs and forward to formatExecutorConstraints", () => {
517
+ assert.match(
518
+ autoPromptsSrc,
519
+ /async function renderSlicePrompt\(options:\s*\{[^}]*sessionContextWindow\?[^}]*modelRegistry\?/s,
520
+ "renderSlicePrompt options must declare both fields",
521
+ );
522
+ assert.match(
523
+ autoPromptsSrc,
524
+ /formatExecutorConstraints\(sessionContextWindow,\s*modelRegistry\)/,
525
+ "renderSlicePrompt must forward both to formatExecutorConstraints",
526
+ );
527
+ });
528
+
529
+ it("buildPlanSlicePrompt options declare both knobs and thread them into renderSlicePrompt", () => {
530
+ assert.match(
531
+ autoPromptsSrc,
532
+ /export async function buildPlanSlicePrompt\([\s\S]*?options\?:\s*\{[^}]*sessionContextWindow\?[^}]*modelRegistry\?/,
533
+ "buildPlanSlicePrompt options must declare both fields",
534
+ );
535
+ assert.match(
536
+ autoPromptsSrc,
537
+ /sessionContextWindow:\s*options\?\.sessionContextWindow,\s*modelRegistry:\s*options\?\.modelRegistry/,
538
+ "buildPlanSlicePrompt must forward both into renderSlicePrompt",
539
+ );
540
+ });
541
+
542
+ it("ExecuteTaskPromptOptions declares both knobs", () => {
543
+ assert.match(
544
+ autoPromptsSrc,
545
+ /interface ExecuteTaskPromptOptions\s*\{[^}]*sessionContextWindow\?[^}]*modelRegistry\?/s,
546
+ "ExecuteTaskPromptOptions must declare both fields",
547
+ );
548
+ });
549
+
550
+ it("buildExecuteTaskPrompt forwards opts.modelRegistry + opts.sessionContextWindow to resolveExecutorContextWindow", () => {
551
+ assert.match(
552
+ autoPromptsSrc,
553
+ /resolveExecutorContextWindow\(\s*opts\.modelRegistry\s*,\s*prefs\?\.preferences\s*,\s*opts\.sessionContextWindow/,
554
+ "buildExecuteTaskPrompt must forward both into resolveExecutorContextWindow",
555
+ );
556
+ });
557
+
558
+ it("buildReactiveExecutePrompt accepts opts and forwards to embedded buildExecuteTaskPrompt", () => {
559
+ assert.match(
560
+ autoPromptsSrc,
561
+ /export async function buildReactiveExecutePrompt\([\s\S]*?opts\?:\s*\{[^}]*sessionContextWindow\?[^}]*modelRegistry\?/,
562
+ "buildReactiveExecutePrompt must accept sessionContextWindow + modelRegistry",
563
+ );
564
+ assert.match(
565
+ autoPromptsSrc,
566
+ /sessionContextWindow:\s*opts\?\.sessionContextWindow,\s*modelRegistry:\s*opts\?\.modelRegistry/,
567
+ "buildReactiveExecutePrompt must forward both into the embedded buildExecuteTaskPrompt call",
568
+ );
569
+ });
570
+
571
+ it("DispatchContext declares both knobs", () => {
572
+ assert.match(
573
+ autoDispatchSrc,
574
+ /interface DispatchContext\s*\{[^}]*sessionContextWindow\?[^}]*modelRegistry\?/s,
575
+ "DispatchContext must declare both fields so dispatch rules can thread them",
576
+ );
577
+ });
578
+
579
+ it("DISPATCH_RULES destructure and forward both knobs at every prompt-builder call site", () => {
580
+ // Every plan-slice / execute-task / reactive-execute rule must destructure
581
+ // both names from its match context so new call sites can't silently drop them.
582
+ const matchLines = autoDispatchSrc.match(/match:\s*async\s*\(\{[^}]*sessionContextWindow[^}]*modelRegistry[^}]*\}/g) ?? [];
583
+ assert.ok(
584
+ matchLines.length >= 5,
585
+ `expected ≥5 dispatch rules destructuring both knobs, got ${matchLines.length}`,
586
+ );
587
+
588
+ const forwardCount = (autoDispatchSrc.match(/sessionContextWindow,\s*modelRegistry/g) ?? []).length;
589
+ assert.ok(
590
+ forwardCount >= 5,
591
+ `expected ≥5 forward sites of { sessionContextWindow, modelRegistry }, got ${forwardCount}`,
592
+ );
593
+ });
594
+
595
+ it("runDispatch populates both knobs from ctx.model / ctx.modelRegistry", () => {
596
+ assert.match(
597
+ phasesSrc,
598
+ /sessionContextWindow:\s*ctx\.model\?\.contextWindow/,
599
+ "runDispatch must populate sessionContextWindow from ctx.model?.contextWindow",
600
+ );
601
+ assert.match(
602
+ phasesSrc,
603
+ /modelRegistry:\s*ctx\.modelRegistry/,
604
+ "runDispatch must populate modelRegistry from ctx.modelRegistry",
605
+ );
606
+ });
607
+
608
+ it("dispatchDirectPhase forwards both knobs to buildPlanSlicePrompt and buildExecuteTaskPrompt", () => {
609
+ const passes = (autoDirectDispatchSrc.match(/sessionContextWindow:\s*ctx\.model\?\.contextWindow[\s\S]*?modelRegistry:\s*ctx\.modelRegistry/g) ?? []).length;
610
+ assert.ok(
611
+ passes >= 2,
612
+ `dispatchDirectPhase must forward the pair at both prompt-builder call sites (≥2), got ${passes}`,
613
+ );
614
+ });
491
615
  });
@@ -0,0 +1,56 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const promptsDir = join(__dirname, "..", "prompts");
9
+
10
+ function readPrompt(name: string): string {
11
+ return readFileSync(join(promptsDir, name), "utf-8");
12
+ }
13
+
14
+ test("add-tests prompt forbids referencing gitignored paths", () => {
15
+ const prompt = readPrompt("add-tests.md");
16
+
17
+ assert.match(
18
+ prompt,
19
+ /gitignore/i,
20
+ "add-tests prompt should mention .gitignore to rule out referencing local-only files",
21
+ );
22
+ assert.match(prompt, /\.gsd\//, "add-tests prompt should name .gsd/ as off-limits for tests");
23
+ assert.match(prompt, /\.planning\//, "add-tests prompt should name .planning/ as off-limits for tests");
24
+ assert.match(prompt, /\.audits\//, "add-tests prompt should name .audits/ as off-limits for tests");
25
+ assert.match(
26
+ prompt,
27
+ /tracked/i,
28
+ "add-tests prompt should frame the rule in terms of tracked files",
29
+ );
30
+ });
31
+
32
+ test("plan-slice prompt warns against planning tests that depend on gitignored files", () => {
33
+ const prompt = readPrompt("plan-slice.md");
34
+
35
+ assert.match(
36
+ prompt,
37
+ /gitignore/i,
38
+ "plan-slice prompt should warn against planning tests that depend on .gitignore paths",
39
+ );
40
+ assert.match(prompt, /\.gsd\//, "plan-slice prompt should name .gsd/ as off-limits for planned tests");
41
+ assert.match(prompt, /\.planning\//, "plan-slice prompt should name .planning/ as off-limits for planned tests");
42
+ assert.match(prompt, /\.audits\//, "plan-slice prompt should name .audits/ as off-limits for planned tests");
43
+ });
44
+
45
+ test("execute-task prompt forbids tests that reference gitignored paths", () => {
46
+ const prompt = readPrompt("execute-task.md");
47
+
48
+ assert.match(
49
+ prompt,
50
+ /gitignore/i,
51
+ "execute-task prompt should forbid referencing gitignored paths from tests",
52
+ );
53
+ assert.match(prompt, /\.gsd\//, "execute-task prompt should name .gsd/ as off-limits for tests");
54
+ assert.match(prompt, /\.planning\//, "execute-task prompt should name .planning/ as off-limits for tests");
55
+ assert.match(prompt, /\.audits\//, "execute-task prompt should name .audits/ as off-limits for tests");
56
+ });
@@ -21,6 +21,8 @@ import {
21
21
  existsSync,
22
22
  readFileSync,
23
23
  realpathSync,
24
+ symlinkSync,
25
+ lstatSync,
24
26
  } from "node:fs";
25
27
  import { join } from "node:path";
26
28
  import { tmpdir } from "node:os";
@@ -76,6 +78,20 @@ function createTempRepo(): string {
76
78
  return dir;
77
79
  }
78
80
 
81
+ function createTempRepoWithSymlinkedGsd(): { repo: string; stateDir: string } {
82
+ const repo = realpathSync(mkdtempSync(join(tmpdir(), "wt-symlink-stash-test-")));
83
+ const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "wt-symlink-state-")));
84
+ run("git init", repo);
85
+ run("git config user.email test@test.com", repo);
86
+ run("git config user.name Test", repo);
87
+ writeFileSync(join(repo, "README.md"), "# test\n");
88
+ symlinkSync(stateDir, join(repo, ".gsd"));
89
+ run("git add README.md", repo);
90
+ run("git commit -m init", repo);
91
+ run("git branch -M main", repo);
92
+ return { repo, stateDir };
93
+ }
94
+
79
95
  function makeRoadmap(
80
96
  milestoneId: string,
81
97
  title: string,
@@ -259,6 +275,44 @@ test("#2505: mergeMilestoneToMain preserves queued CONTEXT files (not swept into
259
275
  }
260
276
  });
261
277
 
278
+ test("#2505: pre-merge stash handles symlinked .gsd without traversing it", () => {
279
+ const { repo, stateDir } = createTempRepoWithSymlinkedGsd();
280
+ try {
281
+ const wtPath = createAutoWorktree(repo, "M016");
282
+ const normalizedPath = wtPath.replaceAll("\\", "/");
283
+ const worktreeName = normalizedPath.split("/").pop() || "M016";
284
+ const sliceBranch = `slice/${worktreeName}/S01`;
285
+ run(`git checkout -b "${sliceBranch}"`, wtPath);
286
+ writeFileSync(join(wtPath, "app.ts"), "export const app = true;\n");
287
+ run("git add app.ts", wtPath);
288
+ run('git commit -m "add app feature"', wtPath);
289
+ run("git checkout milestone/M016", wtPath);
290
+ run(`git merge --no-ff "${sliceBranch}" -m "merge S01"`, wtPath);
291
+
292
+ const queuedDir = join(stateDir, "milestones", "M017");
293
+ mkdirSync(queuedDir, { recursive: true });
294
+ writeFileSync(join(queuedDir, "M017-CONTEXT.md"), "# M017: Queued\n");
295
+
296
+ // Trigger the pre-merge stash with both tracked and untracked project files.
297
+ writeFileSync(join(repo, "README.md"), "# test\n\nDirty change.\n");
298
+ writeFileSync(join(repo, "local-note.txt"), "local scratch\n");
299
+
300
+ const result = mergeMilestoneToMain(repo, "M016", makeRoadmap("M016", "App Feature", [
301
+ { id: "S01", title: "Feature" },
302
+ ]));
303
+
304
+ assert.ok(result.commitMessage.includes("GSD-Milestone: M016"), "merge should succeed");
305
+ assert.ok(existsSync(join(repo, "app.ts")), "milestone code merged to main");
306
+ assert.equal(lstatSync(join(repo, ".gsd")).isSymbolicLink(), true, ".gsd symlink remains in place");
307
+ assert.ok(existsSync(join(queuedDir, "M017-CONTEXT.md")), "queued context remains in external state");
308
+ assert.equal(readFileSync(join(repo, "README.md"), "utf-8").replace(/\r\n/g, "\n"), "# test\n\nDirty change.\n");
309
+ assert.equal(readFileSync(join(repo, "local-note.txt"), "utf-8"), "local scratch\n");
310
+ } finally {
311
+ rmSync(repo, { recursive: true, force: true });
312
+ rmSync(stateDir, { recursive: true, force: true });
313
+ }
314
+ });
315
+
262
316
  test("#2505: back-to-back merges preserve queued CONTEXT files", () => {
263
317
  const repo = createTempRepo();
264
318
  try {
@@ -11,6 +11,7 @@ import {
11
11
  _getAdapter,
12
12
  insertGateRow,
13
13
  } from "../gsd-db.ts";
14
+ import { markDepthVerified, clearDiscussionFlowState } from "../bootstrap/write-gate.ts";
14
15
  import {
15
16
  executeCompleteMilestone,
16
17
  executePlanMilestone,
@@ -645,3 +646,99 @@ test("executeReplanSlice rewrites pending tasks and renders replan artifacts", a
645
646
  cleanup(base);
646
647
  }
647
648
  });
649
+
650
+ test("executeSummarySave removes sibling CONTEXT-DRAFT when writing milestone CONTEXT (#4442)", async () => {
651
+ const base = makeTmpBase();
652
+ try {
653
+ openTestDb(base);
654
+ markDepthVerified("M001", base);
655
+
656
+ const milestoneDir = join(base, ".gsd", "milestones", "M001");
657
+ mkdirSync(milestoneDir, { recursive: true });
658
+ const draftPath = join(milestoneDir, "M001-CONTEXT-DRAFT.md");
659
+ writeFileSync(draftPath, "# Draft\n\nincremental notes");
660
+ assert.ok(existsSync(draftPath), "precondition: draft exists");
661
+
662
+ const result = await inProjectDir(base, () => executeSummarySave({
663
+ milestone_id: "M001",
664
+ artifact_type: "CONTEXT",
665
+ content: "# Context\n\nfinal discussion output",
666
+ }, base));
667
+
668
+ assert.equal(result.details.operation, "save_summary");
669
+ assert.equal(result.details.artifact_type, "CONTEXT");
670
+
671
+ const contextPath = join(milestoneDir, "M001-CONTEXT.md");
672
+ assert.ok(existsSync(contextPath), "CONTEXT.md should be written");
673
+ assert.equal(
674
+ existsSync(draftPath),
675
+ false,
676
+ "CONTEXT-DRAFT.md should be removed after final CONTEXT.md is written",
677
+ );
678
+ } finally {
679
+ clearDiscussionFlowState();
680
+ closeDatabase();
681
+ cleanup(base);
682
+ }
683
+ });
684
+
685
+ test("executeSummarySave removes sibling CONTEXT-DRAFT when writing slice CONTEXT (#4442)", async () => {
686
+ const base = makeTmpBase();
687
+ try {
688
+ openTestDb(base);
689
+
690
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
691
+ mkdirSync(sliceDir, { recursive: true });
692
+ const draftPath = join(sliceDir, "S01-CONTEXT-DRAFT.md");
693
+ writeFileSync(draftPath, "# Slice Draft\n\nincremental slice notes");
694
+ assert.ok(existsSync(draftPath), "precondition: slice draft exists");
695
+
696
+ const result = await inProjectDir(base, () => executeSummarySave({
697
+ milestone_id: "M001",
698
+ slice_id: "S01",
699
+ artifact_type: "CONTEXT",
700
+ content: "# Slice Context\n\nfinal slice output",
701
+ }, base));
702
+
703
+ assert.equal(result.details.operation, "save_summary");
704
+ assert.equal(result.details.artifact_type, "CONTEXT");
705
+
706
+ const contextPath = join(sliceDir, "S01-CONTEXT.md");
707
+ assert.ok(existsSync(contextPath), "slice CONTEXT.md should be written");
708
+ assert.equal(
709
+ existsSync(draftPath),
710
+ false,
711
+ "slice CONTEXT-DRAFT.md should be removed after final CONTEXT.md is written",
712
+ );
713
+ } finally {
714
+ closeDatabase();
715
+ cleanup(base);
716
+ }
717
+ });
718
+
719
+ test("executeSummarySave leaves sibling CONTEXT-DRAFT intact for non-CONTEXT artifacts (#4442)", async () => {
720
+ const base = makeTmpBase();
721
+ try {
722
+ openTestDb(base);
723
+
724
+ const milestoneDir = join(base, ".gsd", "milestones", "M001");
725
+ mkdirSync(milestoneDir, { recursive: true });
726
+ const draftPath = join(milestoneDir, "M001-CONTEXT-DRAFT.md");
727
+ writeFileSync(draftPath, "# Draft\n\nstill in progress");
728
+
729
+ const result = await inProjectDir(base, () => executeSummarySave({
730
+ milestone_id: "M001",
731
+ artifact_type: "RESEARCH",
732
+ content: "# Research\n\nresearch notes",
733
+ }, base));
734
+
735
+ assert.equal(result.details.artifact_type, "RESEARCH");
736
+ assert.ok(
737
+ existsSync(draftPath),
738
+ "CONTEXT-DRAFT.md must survive RESEARCH/SUMMARY/ASSESSMENT writes",
739
+ );
740
+ } finally {
741
+ closeDatabase();
742
+ cleanup(base);
743
+ }
744
+ });
@@ -10,6 +10,8 @@ import {
10
10
  } from "../gsd-db.js";
11
11
  import { GATE_REGISTRY } from "../gate-registry.js";
12
12
  import { saveArtifactToDb } from "../db-writer.js";
13
+ import { resolveMilestoneFile, resolveSliceFile } from "../paths.js";
14
+ import { unlinkSync } from "node:fs";
13
15
  import type { CompleteMilestoneParams } from "./complete-milestone.js";
14
16
  import { handleCompleteMilestone } from "./complete-milestone.js";
15
17
  import { handleCompleteTask } from "./complete-task.js";
@@ -103,6 +105,18 @@ export async function executeSummarySave(
103
105
  },
104
106
  basePath,
105
107
  );
108
+
109
+ if (params.artifact_type === "CONTEXT" && !params.task_id) {
110
+ try {
111
+ const draftFile = params.slice_id
112
+ ? resolveSliceFile(basePath, params.milestone_id, params.slice_id, "CONTEXT-DRAFT")
113
+ : resolveMilestoneFile(basePath, params.milestone_id, "CONTEXT-DRAFT");
114
+ if (draftFile) unlinkSync(draftFile);
115
+ } catch (e) {
116
+ logWarning("tool", `CONTEXT-DRAFT.md unlink failed: ${(e as Error).message}`);
117
+ }
118
+ }
119
+
106
120
  return {
107
121
  content: [{ type: "text", text: `Saved ${params.artifact_type} artifact to ${relativePath}` }],
108
122
  details: { operation: "save_summary", path: relativePath, artifact_type: params.artifact_type },
@@ -8,6 +8,7 @@
8
8
  * All provider logic lives in provider.ts (S01) — this is pure UI wiring.
9
9
  */
10
10
 
11
+ import { isAnthropicApi } from '@gsd/pi-ai'
11
12
  import type { ExtensionAPI } from '@gsd/pi-coding-agent'
12
13
  import type { AutocompleteItem } from '@gsd/pi-tui'
13
14
  import {
@@ -90,7 +91,9 @@ export function registerSearchProviderCommand(pi: ExtensionAPI): void {
90
91
 
91
92
  setSearchProviderPreference(chosen)
92
93
  const effective = resolveSearchProvider()
93
- const isAnthropic = ctx.model?.provider === 'anthropic'
94
+ // Gate on api (#4478 / ADR-012): covers claude-code, anthropic-vertex, and
95
+ // other Anthropic-fronting transports — not just the plain `anthropic` provider.
96
+ const isAnthropic = isAnthropicApi(ctx.model)
94
97
  const nativeNote = isAnthropic ? '\nNote: Native Anthropic web search is also active (automatic, no API key needed).' : ''
95
98
  ctx.ui.notify(
96
99
  `Search provider set to ${chosen}. Effective provider: ${effective ?? 'none (no API keys)'}${nativeNote}`,
@@ -5,6 +5,7 @@
5
5
  * the heavy tool-registration modules.
6
6
  */
7
7
 
8
+ import { isAnthropicApi } from "@gsd/pi-ai";
8
9
  import { resolveSearchProviderFromPreferences } from "../gsd/preferences.js";
9
10
 
10
11
  /** Tool names for the Brave-backed custom search tools */
@@ -94,7 +95,10 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
94
95
  pi.on("model_select", async (event: any, ctx: any) => {
95
96
  modelSelectFired = true;
96
97
  const wasAnthropic = isAnthropicProvider;
97
- isAnthropicProvider = event.model.provider === "anthropic";
98
+ // Gate on `api` not `provider` (#4478 / ADR-012): covers claude-code OAuth,
99
+ // anthropic-vertex, and Vercel-gateway-hosted Anthropic — all serve the
100
+ // Messages API and accept the native web_search tool.
101
+ isAnthropicProvider = isAnthropicApi(event.model);
98
102
 
99
103
  const hasBrave = !!process.env.BRAVE_API_KEY;
100
104
 
@@ -139,9 +143,15 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
139
143
  // the model_select flag, then to the model name heuristic (last resort).
140
144
  // The model name heuristic is needed for session restores where
141
145
  // modelsAreEqual suppresses model_select AND the SDK doesn't pass model.
142
- const eventModel = event.model as { provider: string } | undefined;
146
+ const eventModel = event.model as { provider?: string; api?: string } | undefined;
143
147
  let isAnthropic: boolean;
144
- if (eventModel?.provider) {
148
+ if (eventModel?.api) {
149
+ // Preferred path: gate on wire protocol (#4478 / ADR-012).
150
+ isAnthropic = isAnthropicApi(eventModel);
151
+ } else if (eventModel?.provider) {
152
+ // Fallback for event shapes that carry provider but not api — only plain
153
+ // `anthropic` maps unambiguously without the api field. Other Anthropic
154
+ // transports will arrive via the modelSelectFired or model-name branch.
145
155
  isAnthropic = eventModel.provider === "anthropic";
146
156
  } else if (modelSelectFired) {
147
157
  isAnthropic = isAnthropicProvider;