@opengsd/gsd-pi 1.0.2-dev.235ebf3 → 1.0.2-dev.2c204d3

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 (151) hide show
  1. package/README.md +63 -12
  2. package/dist/resource-loader.d.ts +7 -0
  3. package/dist/resource-loader.js +42 -9
  4. package/dist/resources/.managed-resources-content-hash +1 -1
  5. package/dist/resources/extensions/context7/index.js +12 -2
  6. package/dist/resources/extensions/gsd/auto/loop.js +19 -0
  7. package/dist/resources/extensions/gsd/auto/phases.js +1 -1
  8. package/dist/resources/extensions/gsd/auto/session.js +3 -0
  9. package/dist/resources/extensions/gsd/auto-start.js +232 -49
  10. package/dist/resources/extensions/gsd/auto-worktree.js +2 -54
  11. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +4 -3
  12. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +17 -15
  13. package/dist/resources/extensions/gsd/closeout-recovery.js +7 -1
  14. package/dist/resources/extensions/gsd/commands/handlers/auto.js +9 -1
  15. package/dist/resources/extensions/gsd/commands-handlers.js +3 -0
  16. package/dist/resources/extensions/gsd/git-conflict-state.js +26 -1
  17. package/dist/resources/extensions/gsd/tools/complete-task.js +9 -0
  18. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +40 -1
  19. package/dist/resources/extensions/gsd/worktree-lifecycle.js +24 -3
  20. package/dist/resources/extensions/gsd/worktree-post-create-hook.js +117 -0
  21. package/dist/resources/extensions/search-the-web/native-search.js +57 -8
  22. package/dist/resources/shared/package-manager-detection.js +36 -0
  23. package/dist/update-check.d.ts +6 -2
  24. package/dist/update-check.js +7 -3
  25. package/dist/web/standalone/.next/BUILD_ID +1 -1
  26. package/dist/web/standalone/.next/app-path-routes-manifest.json +7 -7
  27. package/dist/web/standalone/.next/build-manifest.json +2 -2
  28. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  29. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  46. package/dist/web/standalone/.next/server/app/api/session/events/route.js +1 -1
  47. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  48. package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
  49. package/dist/web/standalone/.next/server/app/index.html +1 -1
  50. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app-paths-manifest.json +7 -7
  57. package/dist/web/standalone/.next/server/chunks/1834.js +1 -1
  58. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  59. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  60. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  61. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  62. package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
  63. package/dist/web/standalone/package.json +0 -1
  64. package/dist/worktree-cli.d.ts +0 -2
  65. package/dist/worktree-cli.js +21 -9
  66. package/package.json +5 -2
  67. package/packages/cloud-mcp-gateway/bin/gsd-cloud-mcp-gateway.js +14 -0
  68. package/packages/cloud-mcp-gateway/package.json +4 -3
  69. package/packages/contracts/package.json +1 -1
  70. package/packages/daemon/package.json +4 -4
  71. package/packages/gsd-agent-core/package.json +5 -5
  72. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  73. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js +3 -1
  74. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js.map +1 -1
  75. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.d.ts.map +1 -1
  76. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js +0 -1
  77. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js.map +1 -1
  78. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts +1 -0
  79. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts.map +1 -1
  80. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js +1 -0
  81. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js.map +1 -1
  82. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  83. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js +2 -1
  84. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js.map +1 -1
  85. package/packages/gsd-agent-modes/package.json +7 -7
  86. package/packages/mcp-server/bin/gsd-mcp-server.js +14 -0
  87. package/packages/mcp-server/dist/workflow-tools.js +1 -1
  88. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  89. package/packages/mcp-server/package.json +5 -4
  90. package/packages/native/package.json +1 -1
  91. package/packages/pi-agent-core/dist/agent-loop.js +13 -13
  92. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  93. package/packages/pi-agent-core/package.json +1 -1
  94. package/packages/pi-ai/bin/pi-ai.js +14 -0
  95. package/packages/pi-ai/dist/models.generated.d.ts +40 -17
  96. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  97. package/packages/pi-ai/dist/models.generated.js +49 -30
  98. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  99. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  100. package/packages/pi-ai/dist/providers/anthropic.js +50 -0
  101. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  102. package/packages/pi-ai/dist/types.d.ts +2 -0
  103. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  104. package/packages/pi-ai/dist/types.js.map +1 -1
  105. package/packages/pi-ai/package.json +3 -2
  106. package/packages/pi-coding-agent/dist/core/tools/read.d.ts +2 -2
  107. package/packages/pi-coding-agent/dist/core/tools/read.d.ts.map +1 -1
  108. package/packages/pi-coding-agent/dist/core/tools/read.js +5 -3
  109. package/packages/pi-coding-agent/dist/core/tools/read.js.map +1 -1
  110. package/packages/pi-coding-agent/package.json +8 -8
  111. package/packages/pi-tui/package.json +1 -1
  112. package/packages/rpc-client/package.json +2 -2
  113. package/pkg/package.json +1 -1
  114. package/scripts/install/deps.js +10 -0
  115. package/scripts/install/detect-existing.js +17 -3
  116. package/scripts/install/npm-global.js +103 -33
  117. package/scripts/install.js +1 -0
  118. package/src/resources/extensions/context7/index.ts +15 -2
  119. package/src/resources/extensions/gsd/auto/loop.ts +22 -0
  120. package/src/resources/extensions/gsd/auto/phases.ts +1 -1
  121. package/src/resources/extensions/gsd/auto/session.ts +3 -0
  122. package/src/resources/extensions/gsd/auto-start.ts +307 -56
  123. package/src/resources/extensions/gsd/auto-worktree.ts +2 -56
  124. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +4 -3
  125. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +22 -15
  126. package/src/resources/extensions/gsd/closeout-recovery.ts +6 -1
  127. package/src/resources/extensions/gsd/commands/handlers/auto.ts +9 -1
  128. package/src/resources/extensions/gsd/commands-handlers.ts +2 -0
  129. package/src/resources/extensions/gsd/git-conflict-state.ts +25 -1
  130. package/src/resources/extensions/gsd/tests/auto-start-orphan-bootstrap.test.ts +436 -0
  131. package/src/resources/extensions/gsd/tests/closeout-recovery.test.ts +15 -0
  132. package/src/resources/extensions/gsd/tests/commands-dispatcher-workspace-git.test.ts +15 -2
  133. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +64 -0
  134. package/src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts +70 -10
  135. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +13 -2
  136. package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +24 -1
  137. package/src/resources/extensions/gsd/tests/workflow-mcp-auto-prep.test.ts +60 -0
  138. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +54 -0
  139. package/src/resources/extensions/gsd/tests/workspace-git-preflight.test.ts +16 -1
  140. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +28 -0
  141. package/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +141 -1
  142. package/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts +45 -1
  143. package/src/resources/extensions/gsd/tools/complete-task.ts +9 -0
  144. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +56 -4
  145. package/src/resources/extensions/gsd/worktree-lifecycle.ts +37 -2
  146. package/src/resources/extensions/gsd/worktree-post-create-hook.ts +127 -0
  147. package/src/resources/extensions/search-the-web/native-search.ts +60 -8
  148. package/src/resources/shared/package-manager-detection.ts +39 -0
  149. package/dist/tsconfig.extensions.tsbuildinfo +0 -1
  150. /package/dist/web/standalone/.next/static/{-P554bKh56nzavKUmvFM2 → mijI90BL1BdUcMUnhC0HU}/_buildManifest.js +0 -0
  151. /package/dist/web/standalone/.next/static/{-P554bKh56nzavKUmvFM2 → mijI90BL1BdUcMUnhC0HU}/_ssgManifest.js +0 -0
@@ -507,6 +507,21 @@ function initSessionNotifications(ctx: ExtensionContext): void {
507
507
  initNotificationWidget(ctx);
508
508
  }
509
509
 
510
+ async function prepareWorkflowMcpForHookContext(
511
+ ctx: ExtensionContext,
512
+ basePath: string,
513
+ ): Promise<void> {
514
+ // Skip MCP auto-prep when running inside an auto-worktree. The worktree
515
+ // already has .mcp.json from createAutoWorktree, and re-running the writer
516
+ // post-chdir rewrites the file mid-run (non-idempotent due to cwd-relative
517
+ // CLI path resolution), dirtying the tree and breaking the milestone merge.
518
+ const { isInAutoWorktree } = await import("../auto-worktree.js");
519
+ if (isInAutoWorktree(basePath)) return;
520
+
521
+ const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
522
+ prepareWorkflowMcpForProject(ctx, basePath);
523
+ }
524
+
510
525
  export function registerHooks(
511
526
  pi: ExtensionAPI,
512
527
  ecosystemHandlers: GSDEcosystemBeforeAgentStartHandler[],
@@ -532,12 +547,7 @@ export function registerHooks(
532
547
  await syncServiceTierStatus(ctx);
533
548
  await applyDisabledModelProviderPolicy(ctx);
534
549
  await applyCompactionThresholdOverride(ctx);
535
- // Skip MCP auto-prep when running inside an auto-worktree (see session_switch below).
536
- const { isInAutoWorktree } = await import("../auto-worktree.js");
537
- if (!isInAutoWorktree(basePath)) {
538
- const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
539
- prepareWorkflowMcpForProject(ctx, basePath);
540
- }
550
+ await prepareWorkflowMcpForHookContext(ctx, basePath);
541
551
 
542
552
  // Apply show_token_cost preference (#1515)
543
553
  try {
@@ -563,15 +573,7 @@ export function registerHooks(
563
573
  await syncServiceTierStatus(ctx);
564
574
  await applyDisabledModelProviderPolicy(ctx);
565
575
  await applyCompactionThresholdOverride(ctx);
566
- // Skip MCP auto-prep when running inside an auto-worktree. The worktree
567
- // already has .mcp.json from createAutoWorktree, and re-running the writer
568
- // post-chdir rewrites the file mid-run (non-idempotent due to cwd-relative
569
- // CLI path resolution), dirtying the tree and breaking the milestone merge.
570
- const { isInAutoWorktree } = await import("../auto-worktree.js");
571
- if (!isInAutoWorktree(basePath)) {
572
- const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
573
- prepareWorkflowMcpForProject(ctx, basePath);
574
- }
576
+ await prepareWorkflowMcpForHookContext(ctx, basePath);
575
577
  await loadToolApiKeysForSession();
576
578
  if (!isAutoActive()) {
577
579
  ctx.ui.setWidget("gsd-progress", undefined);
@@ -608,6 +610,11 @@ export function registerHooks(
608
610
  }
609
611
  clearDeferredApprovalGate(beforeAgentBasePath);
610
612
 
613
+ // session_start can fire before the active provider has settled. By
614
+ // before_agent_start, Claude Code CLI sessions should get the same
615
+ // project MCP config that /gsd mcp init would write.
616
+ await prepareWorkflowMcpForHookContext(ctx, beforeAgentBasePath);
617
+
611
618
  // GSD's own context injection (existing behavior — unchanged).
612
619
  const { buildBeforeAgentStartResult } = await import("./system-context.js");
613
620
  const gsdResult = await buildBeforeAgentStartResult(event, ctx);
@@ -205,7 +205,12 @@ export function getCloseoutManualResolveBlocker(basePath: string): string | null
205
205
  return `Unmerged paths remain in ${basePath}: ${conflictProbe.unmerged.slice(0, 5).join(", ")}`;
206
206
  }
207
207
 
208
- const status = runGit(basePath, ["status", "--porcelain"]);
208
+ let status: string;
209
+ try {
210
+ status = runGit(basePath, ["status", "--porcelain"]);
211
+ } catch {
212
+ return `Could not inspect git status in ${basePath}.`;
213
+ }
209
214
  if (status) {
210
215
  return `Working tree still has uncommitted changes in ${basePath}. Commit, stash, or run /gsd closeout retry first.`;
211
216
  }
@@ -208,7 +208,15 @@ export async function handleAutoCommand(trimmed: string, ctx: ExtensionCommandCo
208
208
 
209
209
  if (trimmed === "") {
210
210
  if (!(await guardRemoteSession(ctx, pi))) return true;
211
- if (await hasUnresolvedCloseoutBlocker(ctx, projectRoot())) return true;
211
+ const basePath = projectRoot();
212
+ const { hasGsdBootstrapArtifacts } = await import("../../detection.js");
213
+ const { gsdRoot } = await import("../../paths.js");
214
+ if (!hasGsdBootstrapArtifacts(gsdRoot(basePath))) {
215
+ const { showSmartEntry } = await import("../../guided-flow.js");
216
+ await showSmartEntry(ctx, pi, basePath, { step: true });
217
+ return true;
218
+ }
219
+ if (await hasUnresolvedCloseoutBlocker(ctx, basePath)) return true;
212
220
  const { showGsdHome } = await import("../../gsd-command-home.js");
213
221
  await showGsdHome(ctx, pi, projectRoot());
214
222
  return true;
@@ -26,6 +26,7 @@ import { isAutoActive, checkRemoteAutoSession } from "./auto.js";
26
26
  import { getAutoWorktreePath } from "./auto-worktree.js";
27
27
  import { currentDirectoryRoot, projectRoot } from "./commands/context.js";
28
28
  import { loadPrompt } from "./prompt-loader.js";
29
+ import { isPnpmInstall } from "../../shared/package-manager-detection.js";
29
30
  import {
30
31
  buildDoctorHealIssuePayload,
31
32
  buildDoctorHealSummary,
@@ -57,6 +58,7 @@ function isBunInstall(argv1: string | undefined = process.argv[1]): boolean {
57
58
 
58
59
  function resolveInstallCommand(pkg: string): string {
59
60
  if (isBunInstall()) return `bun add -g ${pkg}`;
61
+ if (isPnpmInstall()) return `pnpm add -g ${pkg}`;
60
62
  return `npm install -g ${pkg}`;
61
63
  }
62
64
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { existsSync } from "node:fs";
6
- import { join } from "node:path";
6
+ import { dirname, join, resolve } from "node:path";
7
7
 
8
8
  import { autoResolveSafeConflictPaths } from "./git-conflict-resolve.js";
9
9
  import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
@@ -14,6 +14,21 @@ function splitZeroDelimited(output: string): string[] {
14
14
  return output.split("\0").filter(Boolean);
15
15
  }
16
16
 
17
+ function hasGitMarker(basePath: string): boolean {
18
+ try {
19
+ let dir = resolve(basePath);
20
+ for (let i = 0; i < 30; i++) {
21
+ if (existsSync(join(dir, ".git"))) return true;
22
+ const parent = dirname(dir);
23
+ if (parent === dir) break;
24
+ dir = parent;
25
+ }
26
+ } catch {
27
+ // Fall through to the git probes, which will report unknown on failure.
28
+ }
29
+ return false;
30
+ }
31
+
17
32
  export function listUnmergedGitPaths(basePath: string): string[] | null {
18
33
  try {
19
34
  const output = spawnSync("git", ["diff", "--name-only", "--diff-filter=U", "-z"], {
@@ -75,6 +90,15 @@ export interface GitConflictProbeResult {
75
90
  }
76
91
 
77
92
  export function probeGitConflictState(basePath: string): GitConflictProbeResult {
93
+ if (!hasGitMarker(basePath)) {
94
+ return {
95
+ status: "clean",
96
+ unmerged: [],
97
+ checkFailures: [],
98
+ mergeStateBlockers: [],
99
+ };
100
+ }
101
+
78
102
  const unmerged = listUnmergedGitPaths(basePath);
79
103
  if (unmerged === null) {
80
104
  return {
@@ -52,6 +52,103 @@ function makeRepoWithUnmergedCompletedMilestone(): string {
52
52
  return base;
53
53
  }
54
54
 
55
+ function makeRepoWithStrandedActiveMilestone(options: { deepPlanning?: boolean } = {}): string {
56
+ const base = mkdtempSync(join(tmpdir(), "gsd-stranded-bootstrap-"));
57
+ mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
58
+ writeFileSync(
59
+ join(base, ".gsd", "PREFERENCES.md"),
60
+ options.deepPlanning
61
+ ? "---\nplanning_depth: deep\ngit:\n isolation: \"none\"\n---\n"
62
+ : "---\ngit:\n isolation: \"none\"\n---\n",
63
+ );
64
+ runGit(base, ["init"]);
65
+ runGit(base, ["config", "user.email", "test@test.com"]);
66
+ runGit(base, ["config", "user.name", "Test"]);
67
+ writeFileSync(join(base, "README.md"), "# test\n");
68
+ runGit(base, ["add", "-A"]);
69
+ runGit(base, ["commit", "-m", "init"]);
70
+ runGit(base, ["branch", "-M", "main"]);
71
+
72
+ runGit(base, ["checkout", "-b", "milestone/M001"]);
73
+ writeFileSync(join(base, "m001.txt"), "in-progress stranded work\n");
74
+ runGit(base, ["add", "-A"]);
75
+ runGit(base, ["commit", "-m", "feat: M001 in progress"]);
76
+ runGit(base, ["checkout", "main"]);
77
+
78
+ openDatabase(join(base, ".gsd", "gsd.db"));
79
+ insertMilestone({ id: "M001", title: "Active milestone", status: "active" });
80
+ closeDatabase();
81
+
82
+ return base;
83
+ }
84
+
85
+ function makeRepoWithMultipleStrandedMilestones(): string {
86
+ const base = mkdtempSync(join(tmpdir(), "gsd-multiple-stranded-bootstrap-"));
87
+ mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
88
+ mkdirSync(join(base, ".gsd", "milestones", "M002"), { recursive: true });
89
+ writeFileSync(
90
+ join(base, ".gsd", "PREFERENCES.md"),
91
+ "---\ngit:\n isolation: \"none\"\n---\n",
92
+ );
93
+ runGit(base, ["init"]);
94
+ runGit(base, ["config", "user.email", "test@test.com"]);
95
+ runGit(base, ["config", "user.name", "Test"]);
96
+ writeFileSync(join(base, "README.md"), "# test\n");
97
+ runGit(base, ["add", "-A"]);
98
+ runGit(base, ["commit", "-m", "init"]);
99
+ runGit(base, ["branch", "-M", "main"]);
100
+
101
+ runGit(base, ["checkout", "-b", "milestone/M001"]);
102
+ writeFileSync(join(base, "m001.txt"), "active stranded work\n");
103
+ runGit(base, ["add", "-A"]);
104
+ runGit(base, ["commit", "-m", "feat: M001 in progress"]);
105
+ runGit(base, ["checkout", "main"]);
106
+
107
+ runGit(base, ["checkout", "-b", "milestone/M002"]);
108
+ writeFileSync(join(base, "m002.txt"), "additional stranded work\n");
109
+ runGit(base, ["add", "-A"]);
110
+ runGit(base, ["commit", "-m", "feat: M002 in progress"]);
111
+ runGit(base, ["checkout", "main"]);
112
+
113
+ openDatabase(join(base, ".gsd", "gsd.db"));
114
+ insertMilestone({ id: "M001", title: "Active milestone", status: "active" });
115
+ insertMilestone({ id: "M002", title: "Pending milestone", status: "pending" });
116
+ closeDatabase();
117
+
118
+ return base;
119
+ }
120
+
121
+ function makeRepoWithRecoveredCleanupAndStrandedMismatch(): string {
122
+ const base = mkdtempSync(join(tmpdir(), "gsd-headless-stranded-bootstrap-"));
123
+ mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
124
+ mkdirSync(join(base, ".gsd", "milestones", "M002"), { recursive: true });
125
+ writeFileSync(
126
+ join(base, ".gsd", "PREFERENCES.md"),
127
+ "---\ngit:\n isolation: \"none\"\n---\n",
128
+ );
129
+ runGit(base, ["init"]);
130
+ runGit(base, ["config", "user.email", "test@test.com"]);
131
+ runGit(base, ["config", "user.name", "Test"]);
132
+ writeFileSync(join(base, "README.md"), "# test\n");
133
+ runGit(base, ["add", "-A"]);
134
+ runGit(base, ["commit", "-m", "init"]);
135
+ runGit(base, ["branch", "-M", "main"]);
136
+
137
+ runGit(base, ["branch", "milestone/M001"]);
138
+ runGit(base, ["checkout", "-b", "milestone/M002"]);
139
+ writeFileSync(join(base, "m002.txt"), "in-progress stranded work\n");
140
+ runGit(base, ["add", "-A"]);
141
+ runGit(base, ["commit", "-m", "feat: M002 in progress"]);
142
+ runGit(base, ["checkout", "main"]);
143
+
144
+ openDatabase(join(base, ".gsd", "gsd.db"));
145
+ insertMilestone({ id: "M001", title: "Completed milestone", status: "complete" });
146
+ insertMilestone({ id: "M002", title: "Stranded milestone", status: "active" });
147
+ closeDatabase();
148
+
149
+ return base;
150
+ }
151
+
55
152
  function makeCtx(notifications: Array<{ message: string; level?: string }>) {
56
153
  const model = { provider: "claude-code", id: "claude-sonnet-4-6", contextWindow: 128000 };
57
154
  return {
@@ -159,3 +256,342 @@ test("bootstrap aborts before starting next milestone when completed orphan merg
159
256
  rmSync(base, { recursive: true, force: true });
160
257
  }
161
258
  });
259
+
260
+ test("headless bootstrap checks stranded work before recovered-complete shortcut", async () => {
261
+ const base = makeRepoWithRecoveredCleanupAndStrandedMismatch();
262
+ const previousCwd = process.cwd();
263
+ const previousHeadless = process.env.GSD_HEADLESS;
264
+ const previousParallelWorker = process.env.GSD_PARALLEL_WORKER;
265
+ const previousMilestoneLock = process.env.GSD_MILESTONE_LOCK;
266
+ const s = new AutoSession();
267
+ const notifications: Array<{ message: string; level?: string }> = [];
268
+
269
+ try {
270
+ process.env.GSD_HEADLESS = "1";
271
+ process.env.GSD_PARALLEL_WORKER = "1";
272
+ process.env.GSD_MILESTONE_LOCK = "M001";
273
+
274
+ const ready = await bootstrapAutoSession(
275
+ s,
276
+ makeCtx(notifications) as any,
277
+ {
278
+ getThinkingLevel: () => "medium",
279
+ getActiveTools: () => [],
280
+ events: { emit: () => {} },
281
+ } as any,
282
+ base,
283
+ false,
284
+ false,
285
+ {
286
+ shouldUseWorktreeIsolation: () => false,
287
+ registerSigtermHandler: () => {},
288
+ registerAutoWorkerForSession: () => {},
289
+ lockBase: () => base,
290
+ buildLifecycle: () => ({
291
+ adoptSessionRoot: (sessionBase: string, originalBase?: string) => {
292
+ s.basePath = sessionBase;
293
+ if (originalBase !== undefined) {
294
+ s.originalBasePath = originalBase;
295
+ } else if (!s.originalBasePath) {
296
+ s.originalBasePath = sessionBase;
297
+ }
298
+ },
299
+ enterMilestone: () => ({ ok: true, mode: "none", path: base }),
300
+ adoptOrphanWorktree: <T extends { merged: boolean }>(
301
+ _mid: string,
302
+ _base: string,
303
+ run: () => T,
304
+ ): T => run(),
305
+ }) as any,
306
+ },
307
+ {
308
+ classification: "none",
309
+ lock: null,
310
+ pausedSession: null,
311
+ state: null,
312
+ recovery: null,
313
+ recoveryPrompt: null,
314
+ recoveryToolCallCount: 0,
315
+ artifactSatisfied: false,
316
+ hasResumableDiskState: false,
317
+ isBootstrapCrash: false,
318
+ },
319
+ );
320
+
321
+ const messages = notifications.map((entry) => entry.message).join("\n");
322
+ assert.equal(ready, false);
323
+ assert.match(messages, /Stranded work for M002 blocks auto-mode/);
324
+ assert.doesNotMatch(messages, /all milestones complete/);
325
+ } finally {
326
+ if (previousHeadless === undefined) {
327
+ delete process.env.GSD_HEADLESS;
328
+ } else {
329
+ process.env.GSD_HEADLESS = previousHeadless;
330
+ }
331
+ if (previousParallelWorker === undefined) {
332
+ delete process.env.GSD_PARALLEL_WORKER;
333
+ } else {
334
+ process.env.GSD_PARALLEL_WORKER = previousParallelWorker;
335
+ }
336
+ if (previousMilestoneLock === undefined) {
337
+ delete process.env.GSD_MILESTONE_LOCK;
338
+ } else {
339
+ process.env.GSD_MILESTONE_LOCK = previousMilestoneLock;
340
+ }
341
+ try {
342
+ closeDatabase();
343
+ } catch {}
344
+ process.chdir(previousCwd);
345
+ rmSync(base, { recursive: true, force: true });
346
+ }
347
+ });
348
+
349
+ test("bootstrap blocks active stranded recovery when another open milestone also has stranded work", async () => {
350
+ const base = makeRepoWithMultipleStrandedMilestones();
351
+ const previousCwd = process.cwd();
352
+ const s = new AutoSession();
353
+ const adoptCalls: string[] = [];
354
+ const notifications: Array<{ message: string; level?: string }> = [];
355
+
356
+ try {
357
+ const ready = await bootstrapAutoSession(
358
+ s,
359
+ makeCtx(notifications) as any,
360
+ {
361
+ getThinkingLevel: () => "medium",
362
+ getActiveTools: () => [],
363
+ events: { emit: () => {} },
364
+ } as any,
365
+ base,
366
+ false,
367
+ false,
368
+ {
369
+ shouldUseWorktreeIsolation: () => false,
370
+ registerSigtermHandler: () => {},
371
+ registerAutoWorkerForSession: () => {},
372
+ lockBase: () => base,
373
+ buildLifecycle: () => ({
374
+ adoptSessionRoot: (sessionBase: string, originalBase?: string) => {
375
+ s.basePath = sessionBase;
376
+ if (originalBase !== undefined) {
377
+ s.originalBasePath = originalBase;
378
+ } else if (!s.originalBasePath) {
379
+ s.originalBasePath = sessionBase;
380
+ }
381
+ },
382
+ enterMilestone: () => ({ ok: true, mode: "none", path: base }),
383
+ adoptStrandedMilestone: (milestoneId: string) => {
384
+ adoptCalls.push(milestoneId);
385
+ return { ok: true, mode: "branch", path: base };
386
+ },
387
+ adoptOrphanWorktree: <T extends { merged: boolean }>(
388
+ _mid: string,
389
+ _base: string,
390
+ run: () => T,
391
+ ): T => run(),
392
+ }) as any,
393
+ },
394
+ {
395
+ classification: "none",
396
+ lock: null,
397
+ pausedSession: null,
398
+ state: null,
399
+ recovery: null,
400
+ recoveryPrompt: null,
401
+ recoveryToolCallCount: 0,
402
+ artifactSatisfied: false,
403
+ hasResumableDiskState: false,
404
+ isBootstrapCrash: false,
405
+ },
406
+ );
407
+
408
+ const messages = notifications.map((entry) => entry.message).join("\n");
409
+ assert.equal(ready, false);
410
+ assert.deepEqual(adoptCalls, []);
411
+ assert.match(messages, /Stranded work for M002 blocks auto-mode before M001/);
412
+ } finally {
413
+ try {
414
+ closeDatabase();
415
+ } catch {}
416
+ process.chdir(previousCwd);
417
+ rmSync(base, { recursive: true, force: true });
418
+ }
419
+ });
420
+
421
+ test("bootstrap adopts stranded active branch even when isolation is none", async () => {
422
+ const base = makeRepoWithStrandedActiveMilestone();
423
+ const previousCwd = process.cwd();
424
+ const s = new AutoSession();
425
+ const adoptCalls: Array<{ milestoneId: string; mode: string }> = [];
426
+ const enterCalls: string[] = [];
427
+ const notifications: Array<{ message: string; level?: string }> = [];
428
+
429
+ try {
430
+ const ready = await bootstrapAutoSession(
431
+ s,
432
+ makeCtx(notifications) as any,
433
+ {
434
+ getThinkingLevel: () => "medium",
435
+ getActiveTools: () => [],
436
+ events: { emit: () => {} },
437
+ } as any,
438
+ base,
439
+ false,
440
+ false,
441
+ {
442
+ shouldUseWorktreeIsolation: () => false,
443
+ registerSigtermHandler: () => {},
444
+ registerAutoWorkerForSession: () => {},
445
+ lockBase: () => base,
446
+ buildLifecycle: () => ({
447
+ adoptSessionRoot: (sessionBase: string, originalBase?: string) => {
448
+ s.basePath = sessionBase;
449
+ if (originalBase !== undefined) {
450
+ s.originalBasePath = originalBase;
451
+ } else if (!s.originalBasePath) {
452
+ s.originalBasePath = sessionBase;
453
+ }
454
+ },
455
+ enterMilestone: (milestoneId: string) => {
456
+ enterCalls.push(milestoneId);
457
+ return { ok: true, mode: "none", path: base };
458
+ },
459
+ adoptStrandedMilestone: (
460
+ milestoneId: string,
461
+ sessionBase: string,
462
+ _ctx: unknown,
463
+ opts: { mode: "worktree" | "branch" },
464
+ ) => {
465
+ adoptCalls.push({ milestoneId, mode: opts.mode });
466
+ s.basePath = sessionBase;
467
+ s.originalBasePath = sessionBase;
468
+ s.strandedRecoveryIsolationMode = opts.mode;
469
+ return { ok: true, mode: opts.mode, path: sessionBase };
470
+ },
471
+ adoptOrphanWorktree: <T extends { merged: boolean }>(
472
+ _mid: string,
473
+ _base: string,
474
+ run: () => T,
475
+ ): T => run(),
476
+ }) as any,
477
+ },
478
+ {
479
+ classification: "none",
480
+ lock: null,
481
+ pausedSession: null,
482
+ state: null,
483
+ recovery: null,
484
+ recoveryPrompt: null,
485
+ recoveryToolCallCount: 0,
486
+ artifactSatisfied: false,
487
+ hasResumableDiskState: false,
488
+ isBootstrapCrash: false,
489
+ },
490
+ );
491
+
492
+ assert.equal(ready, true);
493
+ assert.deepEqual(adoptCalls, [{ milestoneId: "M001", mode: "branch" }]);
494
+ assert.deepEqual(enterCalls, []);
495
+ assert.equal(s.currentMilestoneId, "M001");
496
+ assert.equal(s.strandedRecoveryIsolationMode, "branch");
497
+ assert.match(
498
+ notifications.map((entry) => entry.message).join("\n"),
499
+ /Recovering stranded work for M001/,
500
+ );
501
+ } finally {
502
+ try {
503
+ closeDatabase();
504
+ } catch {}
505
+ process.chdir(previousCwd);
506
+ rmSync(base, { recursive: true, force: true });
507
+ }
508
+ });
509
+
510
+ test("bootstrap adopts stranded active branch before deep project setup", async () => {
511
+ const base = makeRepoWithStrandedActiveMilestone({ deepPlanning: true });
512
+ const previousCwd = process.cwd();
513
+ const s = new AutoSession();
514
+ const adoptCalls: Array<{ milestoneId: string; mode: string }> = [];
515
+ const enterCalls: string[] = [];
516
+ const notifications: Array<{ message: string; level?: string }> = [];
517
+
518
+ try {
519
+ const ready = await bootstrapAutoSession(
520
+ s,
521
+ makeCtx(notifications) as any,
522
+ {
523
+ getThinkingLevel: () => "medium",
524
+ getActiveTools: () => [],
525
+ events: { emit: () => {} },
526
+ } as any,
527
+ base,
528
+ false,
529
+ false,
530
+ {
531
+ shouldUseWorktreeIsolation: () => false,
532
+ registerSigtermHandler: () => {},
533
+ registerAutoWorkerForSession: () => {},
534
+ lockBase: () => base,
535
+ buildLifecycle: () => ({
536
+ adoptSessionRoot: (sessionBase: string, originalBase?: string) => {
537
+ s.basePath = sessionBase;
538
+ if (originalBase !== undefined) {
539
+ s.originalBasePath = originalBase;
540
+ } else if (!s.originalBasePath) {
541
+ s.originalBasePath = sessionBase;
542
+ }
543
+ },
544
+ enterMilestone: (milestoneId: string) => {
545
+ enterCalls.push(milestoneId);
546
+ return { ok: true, mode: "none", path: base };
547
+ },
548
+ adoptStrandedMilestone: (
549
+ milestoneId: string,
550
+ sessionBase: string,
551
+ _ctx: unknown,
552
+ opts: { mode: "worktree" | "branch" },
553
+ ) => {
554
+ adoptCalls.push({ milestoneId, mode: opts.mode });
555
+ s.basePath = sessionBase;
556
+ s.originalBasePath = sessionBase;
557
+ s.strandedRecoveryIsolationMode = opts.mode;
558
+ return { ok: true, mode: opts.mode, path: sessionBase };
559
+ },
560
+ adoptOrphanWorktree: <T extends { merged: boolean }>(
561
+ _mid: string,
562
+ _base: string,
563
+ run: () => T,
564
+ ): T => run(),
565
+ }) as any,
566
+ },
567
+ {
568
+ classification: "none",
569
+ lock: null,
570
+ pausedSession: null,
571
+ state: null,
572
+ recovery: null,
573
+ recoveryPrompt: null,
574
+ recoveryToolCallCount: 0,
575
+ artifactSatisfied: false,
576
+ hasResumableDiskState: false,
577
+ isBootstrapCrash: false,
578
+ },
579
+ );
580
+
581
+ assert.equal(ready, true);
582
+ assert.deepEqual(adoptCalls, [{ milestoneId: "M001", mode: "branch" }]);
583
+ assert.deepEqual(enterCalls, []);
584
+ assert.equal(s.currentMilestoneId, "M001");
585
+ assert.equal(s.strandedRecoveryIsolationMode, "branch");
586
+ assert.match(
587
+ notifications.map((entry) => entry.message).join("\n"),
588
+ /Recovering stranded work for M001/,
589
+ );
590
+ } finally {
591
+ try {
592
+ closeDatabase();
593
+ } catch {}
594
+ process.chdir(previousCwd);
595
+ rmSync(base, { recursive: true, force: true });
596
+ }
597
+ });
@@ -9,6 +9,7 @@ import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
 
11
11
  import {
12
+ getCloseoutManualResolveBlocker,
12
13
  listUnresolvedCloseoutFailures,
13
14
  markLatestCloseoutFailureResolved,
14
15
  retryLatestCloseoutFailure,
@@ -99,3 +100,17 @@ test("closeout manual resolve refuses a dirty worktree", () => {
99
100
  rmSync(base, { recursive: true, force: true });
100
101
  }
101
102
  });
103
+
104
+ test("closeout manual resolve blocks non-git project roots without throwing", () => {
105
+ const base = mkdtempSync(join(tmpdir(), "gsd-closeout-recovery-non-git-"));
106
+ try {
107
+ mkdirSync(join(base, ".gsd"), { recursive: true });
108
+
109
+ assert.equal(
110
+ getCloseoutManualResolveBlocker(base),
111
+ `Could not inspect git status in ${base}.`,
112
+ );
113
+ } finally {
114
+ rmSync(base, { recursive: true, force: true });
115
+ }
116
+ });
@@ -3,11 +3,11 @@
3
3
 
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { writeFileSync } from "node:fs";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
 
9
9
  import { getWorkspaceGitBlockMessageForBase } from "../workspace-git-guard.js";
10
- import { cleanup, git, makeTempRepo } from "./test-utils.ts";
10
+ import { cleanup, git, makeTempDir, makeTempRepo } from "./test-utils.ts";
11
11
 
12
12
  function seedProductConflict(base: string): void {
13
13
  writeFileSync(join(base, "app.ts"), "root\n");
@@ -41,6 +41,19 @@ test("getWorkspaceGitBlockMessageForBase blocks auto when product conflicts rema
41
41
  }
42
42
  });
43
43
 
44
+ test("getWorkspaceGitBlockMessageForBase does not block project setup in non-git folders", async () => {
45
+ const base = makeTempDir("gsd-dispatch-ws-git-new-project-");
46
+ try {
47
+ mkdirSync(join(base, ".gsd"), { recursive: true });
48
+
49
+ assert.equal(await getWorkspaceGitBlockMessageForBase(base, ""), null);
50
+ assert.equal(await getWorkspaceGitBlockMessageForBase(base, "init"), null);
51
+ assert.equal(await getWorkspaceGitBlockMessageForBase(base, "new-project"), null);
52
+ } finally {
53
+ cleanup(base);
54
+ }
55
+ });
56
+
44
57
  test("getWorkspaceGitBlockMessageForBase allows doctor on product conflicts", async () => {
45
58
  const base = makeTempRepo("gsd-dispatch-ws-git-doctor-");
46
59
  try {