@opengsd/gsd-pi 1.0.2-dev.235ebf3 → 1.0.2-dev.29398d2

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 (184) hide show
  1. package/README.md +63 -12
  2. package/dist/onboarding.js +22 -3
  3. package/dist/resource-loader.d.ts +7 -0
  4. package/dist/resource-loader.js +42 -9
  5. package/dist/resources/.managed-resources-content-hash +1 -1
  6. package/dist/resources/extensions/context7/index.js +12 -2
  7. package/dist/resources/extensions/google-cli/index.js +30 -0
  8. package/dist/resources/extensions/google-cli/models.js +55 -0
  9. package/dist/resources/extensions/google-cli/package.json +11 -0
  10. package/dist/resources/extensions/google-cli/readiness.js +12 -0
  11. package/dist/resources/extensions/google-cli/stream-adapter.js +191 -0
  12. package/dist/resources/extensions/gsd/auto/loop.js +19 -0
  13. package/dist/resources/extensions/gsd/auto/phases.js +1 -1
  14. package/dist/resources/extensions/gsd/auto/session.js +3 -0
  15. package/dist/resources/extensions/gsd/auto-start.js +232 -49
  16. package/dist/resources/extensions/gsd/auto-worktree.js +2 -54
  17. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +4 -3
  18. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +17 -15
  19. package/dist/resources/extensions/gsd/closeout-recovery.js +7 -1
  20. package/dist/resources/extensions/gsd/commands/handlers/auto.js +9 -1
  21. package/dist/resources/extensions/gsd/commands-handlers.js +3 -0
  22. package/dist/resources/extensions/gsd/doctor-providers.js +54 -24
  23. package/dist/resources/extensions/gsd/git-conflict-state.js +26 -1
  24. package/dist/resources/extensions/gsd/key-manager.js +45 -13
  25. package/dist/resources/extensions/gsd/tools/complete-task.js +9 -0
  26. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +40 -1
  27. package/dist/resources/extensions/gsd/worktree-lifecycle.js +24 -3
  28. package/dist/resources/extensions/gsd/worktree-post-create-hook.js +117 -0
  29. package/dist/resources/extensions/search-the-web/native-search.js +57 -8
  30. package/dist/resources/shared/package-manager-detection.js +36 -0
  31. package/dist/update-check.d.ts +6 -2
  32. package/dist/update-check.js +7 -3
  33. package/dist/web/standalone/.next/BUILD_ID +1 -1
  34. package/dist/web/standalone/.next/app-path-routes-manifest.json +5 -5
  35. package/dist/web/standalone/.next/build-manifest.json +2 -2
  36. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  37. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  54. package/dist/web/standalone/.next/server/app/api/session/events/route.js +1 -1
  55. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  56. package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
  57. package/dist/web/standalone/.next/server/app/index.html +1 -1
  58. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app-paths-manifest.json +5 -5
  65. package/dist/web/standalone/.next/server/chunks/1834.js +2 -2
  66. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  67. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  68. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  69. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  70. package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
  71. package/dist/web/standalone/package.json +0 -1
  72. package/dist/worktree-cli.d.ts +0 -2
  73. package/dist/worktree-cli.js +21 -9
  74. package/package.json +5 -2
  75. package/packages/cloud-mcp-gateway/bin/gsd-cloud-mcp-gateway.js +14 -0
  76. package/packages/cloud-mcp-gateway/package.json +4 -3
  77. package/packages/contracts/package.json +1 -1
  78. package/packages/daemon/package.json +4 -4
  79. package/packages/gsd-agent-core/package.json +5 -5
  80. package/packages/gsd-agent-modes/dist/modes/interactive/components/login-dialog.d.ts +1 -1
  81. package/packages/gsd-agent-modes/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  82. package/packages/gsd-agent-modes/dist/modes/interactive/components/login-dialog.js +2 -2
  83. package/packages/gsd-agent-modes/dist/modes/interactive/components/login-dialog.js.map +1 -1
  84. package/packages/gsd-agent-modes/dist/modes/interactive/components/oauth-selector.d.ts +6 -1
  85. package/packages/gsd-agent-modes/dist/modes/interactive/components/oauth-selector.d.ts.map +1 -1
  86. package/packages/gsd-agent-modes/dist/modes/interactive/components/oauth-selector.js +9 -6
  87. package/packages/gsd-agent-modes/dist/modes/interactive/components/oauth-selector.js.map +1 -1
  88. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  89. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js +3 -1
  90. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js.map +1 -1
  91. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.d.ts.map +1 -1
  92. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js +0 -1
  93. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js.map +1 -1
  94. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts +1 -0
  95. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts.map +1 -1
  96. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js +1 -0
  97. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js.map +1 -1
  98. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  99. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js +2 -1
  100. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js.map +1 -1
  101. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-selectors-auth.d.ts +3 -0
  102. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-selectors-auth.d.ts.map +1 -1
  103. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-selectors-auth.js +144 -2
  104. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-selectors-auth.js.map +1 -1
  105. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-selectors-session.d.ts.map +1 -1
  106. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-selectors-session.js +2 -14
  107. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-selectors-session.js.map +1 -1
  108. package/packages/gsd-agent-modes/package.json +7 -7
  109. package/packages/mcp-server/bin/gsd-mcp-server.js +14 -0
  110. package/packages/mcp-server/dist/workflow-tools.js +1 -1
  111. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  112. package/packages/mcp-server/package.json +5 -4
  113. package/packages/native/package.json +1 -1
  114. package/packages/pi-agent-core/dist/agent-loop.js +13 -13
  115. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  116. package/packages/pi-agent-core/package.json +1 -1
  117. package/packages/pi-ai/bin/pi-ai.js +14 -0
  118. package/packages/pi-ai/dist/models.generated.d.ts +40 -17
  119. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  120. package/packages/pi-ai/dist/models.generated.js +49 -30
  121. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  122. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  123. package/packages/pi-ai/dist/providers/anthropic.js +50 -0
  124. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  125. package/packages/pi-ai/dist/types.d.ts +2 -0
  126. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  127. package/packages/pi-ai/dist/types.js.map +1 -1
  128. package/packages/pi-ai/package.json +3 -2
  129. package/packages/pi-coding-agent/dist/core/tools/read.d.ts +2 -2
  130. package/packages/pi-coding-agent/dist/core/tools/read.d.ts.map +1 -1
  131. package/packages/pi-coding-agent/dist/core/tools/read.js +5 -3
  132. package/packages/pi-coding-agent/dist/core/tools/read.js.map +1 -1
  133. package/packages/pi-coding-agent/package.json +8 -8
  134. package/packages/pi-tui/package.json +1 -1
  135. package/packages/rpc-client/package.json +2 -2
  136. package/pkg/package.json +1 -1
  137. package/scripts/install/deps.js +10 -0
  138. package/scripts/install/detect-existing.js +17 -3
  139. package/scripts/install/npm-global.js +103 -33
  140. package/scripts/install.js +1 -0
  141. package/src/resources/extensions/context7/index.ts +15 -2
  142. package/src/resources/extensions/google-cli/index.ts +34 -0
  143. package/src/resources/extensions/google-cli/models.ts +57 -0
  144. package/src/resources/extensions/google-cli/package.json +11 -0
  145. package/src/resources/extensions/google-cli/readiness.ts +15 -0
  146. package/src/resources/extensions/google-cli/stream-adapter.ts +245 -0
  147. package/src/resources/extensions/gsd/auto/loop.ts +22 -0
  148. package/src/resources/extensions/gsd/auto/phases.ts +1 -1
  149. package/src/resources/extensions/gsd/auto/session.ts +3 -0
  150. package/src/resources/extensions/gsd/auto-start.ts +307 -56
  151. package/src/resources/extensions/gsd/auto-worktree.ts +2 -56
  152. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +4 -3
  153. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +22 -15
  154. package/src/resources/extensions/gsd/closeout-recovery.ts +6 -1
  155. package/src/resources/extensions/gsd/commands/handlers/auto.ts +9 -1
  156. package/src/resources/extensions/gsd/commands-handlers.ts +2 -0
  157. package/src/resources/extensions/gsd/doctor-providers.ts +55 -27
  158. package/src/resources/extensions/gsd/git-conflict-state.ts +25 -1
  159. package/src/resources/extensions/gsd/key-manager.ts +57 -14
  160. package/src/resources/extensions/gsd/tests/auto-start-orphan-bootstrap.test.ts +436 -0
  161. package/src/resources/extensions/gsd/tests/closeout-recovery.test.ts +15 -0
  162. package/src/resources/extensions/gsd/tests/commands-context.test.ts +5 -3
  163. package/src/resources/extensions/gsd/tests/commands-dispatcher-workspace-git.test.ts +15 -2
  164. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +64 -0
  165. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +105 -0
  166. package/src/resources/extensions/gsd/tests/key-manager.test.ts +23 -4
  167. package/src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts +70 -10
  168. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +13 -2
  169. package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +24 -1
  170. package/src/resources/extensions/gsd/tests/workflow-mcp-auto-prep.test.ts +60 -0
  171. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +54 -0
  172. package/src/resources/extensions/gsd/tests/workspace-git-preflight.test.ts +16 -1
  173. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +28 -0
  174. package/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +141 -1
  175. package/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts +45 -1
  176. package/src/resources/extensions/gsd/tools/complete-task.ts +9 -0
  177. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +56 -4
  178. package/src/resources/extensions/gsd/worktree-lifecycle.ts +37 -2
  179. package/src/resources/extensions/gsd/worktree-post-create-hook.ts +127 -0
  180. package/src/resources/extensions/search-the-web/native-search.ts +60 -8
  181. package/src/resources/shared/package-manager-detection.ts +39 -0
  182. package/dist/tsconfig.extensions.tsbuildinfo +0 -1
  183. /package/dist/web/standalone/.next/static/{-P554bKh56nzavKUmvFM2 → bukT6Ux1YchPm2XqjaexX}/_buildManifest.js +0 -0
  184. /package/dist/web/standalone/.next/static/{-P554bKh56nzavKUmvFM2 → bukT6Ux1YchPm2XqjaexX}/_ssgManifest.js +0 -0
@@ -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
+ });
@@ -156,9 +156,11 @@ test("handleContext writes open reports under the command project root", async (
156
156
  process.chdir(processProject);
157
157
  try {
158
158
  const binDir = mkdtempSync(join(tmpdir(), "gsd-context-bin-"));
159
- const xdgOpen = join(binDir, "xdg-open");
160
- writeFileSync(xdgOpen, "#!/bin/sh\nexit 0\n");
161
- chmodSync(xdgOpen, 0o755);
159
+ for (const opener of ["open", "xdg-open"]) {
160
+ const openerPath = join(binDir, opener);
161
+ writeFileSync(openerPath, "#!/bin/sh\nexit 0\n");
162
+ chmodSync(openerPath, 0o755);
163
+ }
162
164
  process.env.PATH = `${binDir}:${originalPath ?? ""}`;
163
165
 
164
166
  const notifications: string[] = [];
@@ -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 {
@@ -344,6 +344,70 @@ describe("Custom engine loop integration", () => {
344
344
  );
345
345
  });
346
346
 
347
+ it("step mode stops after one custom workflow step", async () => {
348
+ _resetPendingResolve();
349
+
350
+ const runDir = makeTmpDir();
351
+ const graph = makeGraph([
352
+ makeStep({ id: "step-a" }),
353
+ makeStep({ id: "step-b", dependsOn: ["step-a"] }),
354
+ makeStep({ id: "step-c", dependsOn: ["step-b"] }),
355
+ ], "step-mode-custom");
356
+ writeGraph(runDir, graph);
357
+ writeDefinition(runDir, graph.steps, "step-mode-custom");
358
+
359
+ const ctx = makeMockCtx();
360
+ const pi = makeMockPi();
361
+ const s = makeLoopSession({
362
+ activeEngineId: "custom",
363
+ activeRunDir: runDir,
364
+ basePath: runDir,
365
+ stepMode: true,
366
+ });
367
+
368
+ const deps = makeMockDeps({
369
+ stopAuto: async (_ctx, _pi, reason) => {
370
+ deps.callLog.push(`stopAuto:${reason ?? "no-reason"}`);
371
+ s.active = false;
372
+ },
373
+ });
374
+
375
+ const loopPromise = autoLoop(ctx, pi, s, deps);
376
+ await resolveNextAgentEnd();
377
+
378
+ let timeout: NodeJS.Timeout | undefined;
379
+ try {
380
+ await Promise.race([
381
+ loopPromise,
382
+ new Promise((_, reject) =>
383
+ timeout = setTimeout(() => {
384
+ s.active = false;
385
+ if (_hasPendingResolveForTest()) {
386
+ resolveAgentEnd({ messages: [{ role: "assistant" }] });
387
+ }
388
+ reject(new Error(
389
+ `step mode did not stop after one custom workflow step; calls=${pi.calls.length}; log=${deps.callLog.join(",")}`,
390
+ ));
391
+ }, 1_000),
392
+ ),
393
+ ]);
394
+ } finally {
395
+ if (timeout) clearTimeout(timeout);
396
+ }
397
+
398
+ const finalGraph = readGraph(runDir);
399
+ assert.equal(pi.calls.length, 1, "step mode should dispatch exactly one custom step");
400
+ assert.equal(finalGraph.steps[0]?.status, "complete", "first step should complete");
401
+ assert.equal(finalGraph.steps[1]?.status, "pending", "second step should wait for the next /gsd next");
402
+ assert.equal(finalGraph.steps[2]?.status, "pending", "third step should wait for a later step");
403
+ assert.equal(
404
+ deps.callLog.some((e: string) => e.startsWith("stopAuto:")),
405
+ false,
406
+ "step-mode pause should not complete or stop the whole workflow",
407
+ );
408
+ assert.equal(s.preserveStepSurfaceAfterLoopExit, true);
409
+ });
410
+
347
411
  it("stops when engine reports isComplete on first derive", async () => {
348
412
  _resetPendingResolve();
349
413