@opengsd/gsd-pi 1.3.0-dev.65546769 → 1.3.0-dev.eed73bea

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 (183) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +11 -2
  3. package/dist/resources/extensions/google-cli/stream-adapter.js +82 -15
  4. package/dist/resources/extensions/gsd/auto/orchestrator.js +12 -3
  5. package/dist/resources/extensions/gsd/auto-dispatch.js +17 -14
  6. package/dist/resources/extensions/gsd/auto-prompts.js +43 -12
  7. package/dist/resources/extensions/gsd/auto-recovery.js +13 -6
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +103 -13
  9. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +6 -1
  10. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +2 -0
  11. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +8 -3
  12. package/dist/resources/extensions/gsd/bootstrap/system-context.js +46 -19
  13. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +75 -1
  14. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +1 -1
  15. package/dist/resources/extensions/gsd/commands-context.js +19 -1
  16. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +16 -10
  17. package/dist/resources/extensions/gsd/commands-worktree.js +12 -10
  18. package/dist/resources/extensions/gsd/dashboard-overlay.js +32 -3
  19. package/dist/resources/extensions/gsd/db/queries.js +60 -0
  20. package/dist/resources/extensions/gsd/doctor-providers.js +92 -8
  21. package/dist/resources/extensions/gsd/exec-sandbox.js +45 -9
  22. package/dist/resources/extensions/gsd/forensics.js +2 -32
  23. package/dist/resources/extensions/gsd/git-service.js +4 -4
  24. package/dist/resources/extensions/gsd/guided-flow-queue.js +59 -5
  25. package/dist/resources/extensions/gsd/health-widget.js +55 -29
  26. package/dist/resources/extensions/gsd/markdown-renderer.js +6 -2
  27. package/dist/resources/extensions/gsd/memory-consolidation-scanner.js +44 -21
  28. package/dist/resources/extensions/gsd/milestone-implementation-evidence.js +26 -20
  29. package/dist/resources/extensions/gsd/quick.js +45 -2
  30. package/dist/resources/extensions/gsd/session-forensics.js +11 -1
  31. package/dist/resources/extensions/gsd/state-reconciliation/drift/stale-render.js +52 -3
  32. package/dist/resources/extensions/gsd/tools/complete-slice.js +34 -3
  33. package/dist/resources/extensions/gsd/tools/complete-task.js +78 -16
  34. package/dist/resources/extensions/gsd/tools/exec-tool.js +7 -2
  35. package/dist/resources/extensions/gsd/unit-context-composer.js +23 -7
  36. package/dist/resources/extensions/gsd/unit-registry.js +25 -3
  37. package/dist/resources/extensions/gsd/unmerged-milestone-guard.js +33 -3
  38. package/dist/resources/extensions/gsd/validation-block-guard.js +9 -4
  39. package/dist/resources/extensions/gsd/workspace-git-preflight.js +30 -1
  40. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  41. package/dist/web/standalone/.next/BUILD_ID +1 -1
  42. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  43. package/dist/web/standalone/.next/build-manifest.json +3 -3
  44. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  45. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  63. package/dist/web/standalone/.next/server/app/index.html +1 -1
  64. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  71. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  73. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  74. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  75. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  76. package/dist/web/standalone/.next/static/chunks/{796.e0bdc932325d7e03.js → 796.3976108148518f7d.js} +3 -3
  77. package/dist/web/standalone/.next/static/chunks/{webpack-f46ea08200a0227e.js → webpack-7c1d97e39be2da11.js} +1 -1
  78. package/package.json +1 -1
  79. package/packages/cloud-mcp-gateway/package.json +2 -2
  80. package/packages/contracts/dist/workflow.d.ts +1 -0
  81. package/packages/contracts/dist/workflow.d.ts.map +1 -1
  82. package/packages/contracts/dist/workflow.js +2 -0
  83. package/packages/contracts/dist/workflow.js.map +1 -1
  84. package/packages/contracts/package.json +1 -1
  85. package/packages/daemon/package.json +4 -4
  86. package/packages/gsd-agent-core/package.json +5 -5
  87. package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  88. package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-controller.js +21 -9
  89. package/packages/gsd-agent-modes/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  90. package/packages/gsd-agent-modes/package.json +7 -7
  91. package/packages/mcp-server/README.md +1 -1
  92. package/packages/mcp-server/dist/server.d.ts +1 -1
  93. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  94. package/packages/mcp-server/dist/server.js +3 -3
  95. package/packages/mcp-server/dist/server.js.map +1 -1
  96. package/packages/mcp-server/dist/workflow-tools.d.ts +13 -1
  97. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  98. package/packages/mcp-server/dist/workflow-tools.js +34 -20
  99. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  100. package/packages/mcp-server/package.json +4 -4
  101. package/packages/native/package.json +1 -1
  102. package/packages/pi-agent-core/package.json +1 -1
  103. package/packages/pi-ai/package.json +1 -1
  104. package/packages/pi-coding-agent/package.json +7 -7
  105. package/packages/pi-tui/package.json +2 -2
  106. package/packages/rpc-client/package.json +2 -2
  107. package/pkg/package.json +1 -1
  108. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +20 -2
  109. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +80 -0
  110. package/src/resources/extensions/google-cli/stream-adapter.ts +106 -19
  111. package/src/resources/extensions/gsd/auto/orchestrator.ts +25 -11
  112. package/src/resources/extensions/gsd/auto-dispatch.ts +18 -17
  113. package/src/resources/extensions/gsd/auto-prompts.ts +54 -12
  114. package/src/resources/extensions/gsd/auto-recovery.ts +13 -6
  115. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +125 -12
  116. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +6 -1
  117. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +2 -0
  118. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +9 -3
  119. package/src/resources/extensions/gsd/bootstrap/system-context.ts +52 -18
  120. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +82 -1
  121. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +1 -1
  122. package/src/resources/extensions/gsd/commands-context.ts +18 -1
  123. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +14 -9
  124. package/src/resources/extensions/gsd/commands-worktree.ts +12 -10
  125. package/src/resources/extensions/gsd/dashboard-overlay.ts +32 -3
  126. package/src/resources/extensions/gsd/db/queries.ts +79 -0
  127. package/src/resources/extensions/gsd/doctor-providers.ts +103 -9
  128. package/src/resources/extensions/gsd/exec-sandbox.ts +49 -9
  129. package/src/resources/extensions/gsd/forensics.ts +2 -33
  130. package/src/resources/extensions/gsd/git-service.ts +5 -5
  131. package/src/resources/extensions/gsd/guided-flow-queue.ts +82 -4
  132. package/src/resources/extensions/gsd/health-widget.ts +69 -32
  133. package/src/resources/extensions/gsd/markdown-renderer.ts +6 -1
  134. package/src/resources/extensions/gsd/memory-consolidation-scanner.ts +51 -19
  135. package/src/resources/extensions/gsd/milestone-implementation-evidence.ts +35 -21
  136. package/src/resources/extensions/gsd/quick.ts +43 -2
  137. package/src/resources/extensions/gsd/session-forensics.ts +11 -1
  138. package/src/resources/extensions/gsd/state-reconciliation/drift/stale-render.ts +76 -8
  139. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +111 -1
  140. package/src/resources/extensions/gsd/tests/commands-context.test.ts +26 -0
  141. package/src/resources/extensions/gsd/tests/commands-worktree-clean.test.ts +80 -0
  142. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +11 -0
  143. package/src/resources/extensions/gsd/tests/complete-task-rollback-evidence.test.ts +48 -8
  144. package/src/resources/extensions/gsd/tests/complete-task.test.ts +75 -0
  145. package/src/resources/extensions/gsd/tests/dashboard-overlay.test.ts +55 -2
  146. package/src/resources/extensions/gsd/tests/dispatch-rule-coverage.test.ts +26 -1
  147. package/src/resources/extensions/gsd/tests/doctor-forensics-db-open-regression.test.ts +70 -2
  148. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +107 -0
  149. package/src/resources/extensions/gsd/tests/exec-graceful-kill.test.ts +38 -0
  150. package/src/resources/extensions/gsd/tests/exec-tool.test.ts +45 -1
  151. package/src/resources/extensions/gsd/tests/forensics-error-filter.test.ts +88 -0
  152. package/src/resources/extensions/gsd/tests/guided-discuss-milestone-prompt-rendering.test.ts +42 -0
  153. package/src/resources/extensions/gsd/tests/health-widget.test.ts +268 -3
  154. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +119 -1
  155. package/src/resources/extensions/gsd/tests/integration/queue-active-milestone-context-budget.test.ts +93 -0
  156. package/src/resources/extensions/gsd/tests/integration/quick-branch-lifecycle.test.ts +56 -9
  157. package/src/resources/extensions/gsd/tests/knowledge-cold-start.test.ts +14 -0
  158. package/src/resources/extensions/gsd/tests/memory-consolidation-scanner.test.ts +78 -0
  159. package/src/resources/extensions/gsd/tests/orchestrator-logs.test.ts +43 -1
  160. package/src/resources/extensions/gsd/tests/parallel-research-dispatch.test.ts +26 -0
  161. package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +1 -1
  162. package/src/resources/extensions/gsd/tests/prefs-wizard-coverage.test.ts +54 -1
  163. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +195 -1
  164. package/src/resources/extensions/gsd/tests/read-uat-gate-verdict.test.ts +185 -0
  165. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +87 -0
  166. package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +76 -0
  167. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +68 -0
  168. package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +26 -0
  169. package/src/resources/extensions/gsd/tests/unit-context-composer.test.ts +193 -14
  170. package/src/resources/extensions/gsd/tests/unmerged-milestone-guard.test.ts +25 -0
  171. package/src/resources/extensions/gsd/tests/validation-block-guard.test.ts +79 -0
  172. package/src/resources/extensions/gsd/tests/verify-artifact-tightened.test.ts +66 -0
  173. package/src/resources/extensions/gsd/tests/workspace-git-preflight.test.ts +151 -2
  174. package/src/resources/extensions/gsd/tools/complete-slice.ts +30 -3
  175. package/src/resources/extensions/gsd/tools/complete-task.ts +86 -16
  176. package/src/resources/extensions/gsd/tools/exec-tool.ts +7 -3
  177. package/src/resources/extensions/gsd/unit-context-composer.ts +33 -7
  178. package/src/resources/extensions/gsd/unit-registry.ts +25 -3
  179. package/src/resources/extensions/gsd/unmerged-milestone-guard.ts +41 -5
  180. package/src/resources/extensions/gsd/validation-block-guard.ts +13 -7
  181. package/src/resources/extensions/gsd/workspace-git-preflight.ts +31 -0
  182. /package/dist/web/standalone/.next/static/{BTKtGFF1Y-hvVJEGhBRo9 → SzEuqWX37DR9MEpEuQjP1}/_buildManifest.js +0 -0
  183. /package/dist/web/standalone/.next/static/{BTKtGFF1Y-hvVJEGhBRo9 → SzEuqWX37DR9MEpEuQjP1}/_ssgManifest.js +0 -0
@@ -8,6 +8,7 @@ import { join } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
9
 
10
10
  import {
11
+ CONTEXT_MODE_GUIDANCE_BY_UNIT,
11
12
  composeContractedUnitContext,
12
13
  composeContextModeInstructions,
13
14
  composeInlinedContext,
@@ -141,9 +142,13 @@ test("Context Mode composer: nested output is compact single sentence", () => {
141
142
  assert.ok(!out.startsWith("## Context Mode"));
142
143
  assert.match(out, /^Context Mode \(verification lane\): /);
143
144
  assert.strictEqual(out.split(/\n/).length, 1);
144
- assert.match(out, /`gsd_exec`/);
145
- assert.match(out, /`gsd_exec_search`/);
146
- assert.match(out, /`gsd_resume`/);
145
+ // Nested guidance is embedded into tester subagent prompts — it must instruct the tester
146
+ // to run verification and call gsd_save_gate_result, NOT to dispatch further subagents.
147
+ assert.doesNotMatch(out, /`subagent`/, "tester prompts must not be told to dispatch subagents");
148
+ assert.match(out, /`gsd_save_gate_result`/);
149
+ assert.doesNotMatch(out, /`gsd_exec`/);
150
+ assert.doesNotMatch(out, /`gsd_exec_search`/);
151
+ assert.doesNotMatch(out, /`gsd_resume`/);
147
152
  assert.ok(out.length < 240, `nested guidance should stay compact, got ${out.length} chars`);
148
153
  });
149
154
 
@@ -158,6 +163,53 @@ const laneLabelByMode: Record<string, string> = {
158
163
  triage: "triage",
159
164
  };
160
165
 
166
+ const contextModeGuidanceOverrideExpectedTools: Record<string, readonly string[]> = {
167
+ "discuss-milestone": [
168
+ "ask_user_questions",
169
+ "gsd_summary_save",
170
+ "gsd_decision_save",
171
+ "gsd_requirement_save",
172
+ "gsd_requirement_update",
173
+ "gsd_plan_milestone",
174
+ "gsd_milestone_generate_id",
175
+ ],
176
+ "discuss-project": [
177
+ "ask_user_questions",
178
+ "gsd_summary_save",
179
+ "gsd_decision_save",
180
+ "gsd_requirement_save",
181
+ ],
182
+ "discuss-requirements": [
183
+ "ask_user_questions",
184
+ "gsd_requirement_save",
185
+ "gsd_summary_save",
186
+ ],
187
+ "discuss-slice": [
188
+ "ask_user_questions",
189
+ "gsd_summary_save",
190
+ "gsd_decision_save",
191
+ ],
192
+ "replan-slice": [
193
+ "gsd_replan_slice",
194
+ "gsd_decision_save",
195
+ ],
196
+ "reassess-roadmap": [
197
+ "gsd_milestone_status",
198
+ "gsd_reassess_roadmap",
199
+ ],
200
+ "run-uat": [
201
+ "gsd_uat_exec",
202
+ "gsd_resume",
203
+ ],
204
+ // research-project uses scout subagents that write .gsd/research/ files directly;
205
+ // the parent dispatches Task calls and verifies file outputs — no GSD save tools.
206
+ "research-project": [],
207
+ "gate-evaluate": [
208
+ "subagent",
209
+ "gsd_save_gate_result",
210
+ ],
211
+ };
212
+
161
213
  test("Context Mode composer: every known eligible unit renders its configured lane and required tools", () => {
162
214
  for (const unitType of KNOWN_UNIT_TYPES) {
163
215
  const manifest = UNIT_MANIFESTS[unitType];
@@ -170,18 +222,65 @@ test("Context Mode composer: every known eligible unit renders its configured la
170
222
  assert.ok(out.startsWith("## Context Mode"), `${unitType} should render standalone Context Mode heading`);
171
223
  assert.match(out, new RegExp(`Lane: \\*\\*${laneLabelByMode[manifest.contextMode]} lane\\*\\*\\.`, "i"));
172
224
  const forbidden = getUnitToolSurfaceContract(unitType)?.forbiddenGsdTools ?? {};
173
- if ("gsd_exec" in forbidden) {
174
- // Units that forbid gsd_exec (run-uat) have it stripped from their
175
- // Claude Code dispatch surface; guidance steering to it produces
176
- // "No such tool available" loops in the dispatched agent.
177
- assert.doesNotMatch(out, /`gsd_exec`/, `${unitType} forbids gsd_exec; guidance must not steer to it`);
225
+ const overrideExpectedTools = contextModeGuidanceOverrideExpectedTools[unitType];
226
+ if ("gsd_exec" in forbidden || overrideExpectedTools) {
227
+ // Unit overrides are the contract-specific exception to lane defaults.
228
+ // Steering to the lane default here can produce unavailable-tool loops.
229
+ assert.doesNotMatch(out, /`gsd_exec`/, `${unitType} guidance must not steer to gsd_exec`);
178
230
  assert.doesNotMatch(out, /`gsd_exec_search`/, `${unitType} guidance must not steer to gsd_exec_search`);
179
- assert.match(out, /`gsd_uat_exec`/, `${unitType} guidance should steer to gsd_uat_exec instead`);
231
+ for (const toolName of overrideExpectedTools ?? []) {
232
+ assert.match(out, new RegExp(`\`${toolName}\``), `${unitType} guidance should mention ${toolName}`);
233
+ }
180
234
  } else {
181
235
  assert.match(out, /`gsd_exec`/, `${unitType} should mention gsd_exec`);
182
236
  assert.match(out, /`gsd_exec_search`/, `${unitType} should mention gsd_exec_search`);
183
237
  }
184
- assert.match(out, /`gsd_resume`/, `${unitType} should mention gsd_resume`);
238
+ if (!overrideExpectedTools || overrideExpectedTools.includes("gsd_resume")) {
239
+ assert.match(out, /`gsd_resume`/, `${unitType} should mention gsd_resume`);
240
+ } else {
241
+ assert.doesNotMatch(out, /`gsd_resume`/, `${unitType} guidance must not steer to gsd_resume`);
242
+ }
243
+ }
244
+ });
245
+
246
+ test("Context Mode composer: discuss interview overrides stay within unit contracts", () => {
247
+ const discussUnits = [
248
+ "discuss-milestone",
249
+ "discuss-project",
250
+ "discuss-requirements",
251
+ "discuss-slice",
252
+ ];
253
+
254
+ for (const unitType of discussUnits) {
255
+ const guidance = CONTEXT_MODE_GUIDANCE_BY_UNIT[unitType];
256
+ assert.ok(guidance, `${unitType} should have a Context Mode override`);
257
+ assert.doesNotMatch(guidance, /`gsd_exec`/, `${unitType} guidance must not mention gsd_exec`);
258
+ assert.doesNotMatch(guidance, /`gsd_exec_search`/, `${unitType} guidance must not mention gsd_exec_search`);
259
+ assert.doesNotMatch(guidance, /`gsd_resume`/, `${unitType} guidance must not mention gsd_resume`);
260
+
261
+ const expectedTools = contextModeGuidanceOverrideExpectedTools[unitType] ?? [];
262
+ assert.ok(expectedTools.length > 0, `${unitType} should declare expected override tools`);
263
+ const contract = getUnitToolSurfaceContract(unitType);
264
+ assert.ok(contract, `${unitType} should have a tool contract`);
265
+ const contractTools = new Set([
266
+ ...contract.allowedGsdTools,
267
+ ...contract.requiredWorkflowTools,
268
+ ]);
269
+
270
+ for (const toolName of expectedTools) {
271
+ assert.match(guidance, new RegExp(`\`${toolName}\``), `${unitType} guidance should mention ${toolName}`);
272
+ assert.ok(contractTools.has(toolName as UnitGsdToolName), `${unitType} contract should allow ${toolName}`);
273
+ const scope = shouldBlockAutoUnitToolCall(unitType, toolName);
274
+ assert.equal(scope.block, false, `${unitType} should not hard-block ${toolName}: ${scope.reason ?? ""}`);
275
+ }
276
+
277
+ const out = composeContextModeInstructions(unitType, { enabled: true, renderMode: "standalone" });
278
+ if (out) {
279
+ assert.match(out, /interview lane/i);
280
+ assert.doesNotMatch(out, /`gsd_exec`/);
281
+ assert.doesNotMatch(out, /`gsd_exec_search`/);
282
+ assert.doesNotMatch(out, /`gsd_resume`/);
283
+ }
185
284
  }
186
285
  });
187
286
 
@@ -195,8 +294,67 @@ test("Context Mode composer: run-uat guidance steers to gsd_uat_exec in both ren
195
294
  assert.doesNotMatch(standalone, /`gsd_exec`/);
196
295
  });
197
296
 
198
- test("Context Mode composer: slice planning and research guidance tools pass unit contracts", () => {
199
- const affectedUnits = ["research-slice", "plan-slice", "refine-slice"];
297
+ test("Context Mode composer: research-project guidance steers to scout orchestration", () => {
298
+ for (const renderMode of ["nested", "standalone"] as const) {
299
+ const out = composeContextModeInstructions("research-project", { enabled: true, renderMode });
300
+ assert.match(out, /research lane/i);
301
+ assert.match(out, /scout subagents/i);
302
+ assert.match(out, /\.gsd\/research\//);
303
+ assert.match(out, /STACK\.md/);
304
+ assert.match(out, /PITFALLS\.md/);
305
+ assert.doesNotMatch(out, /`gsd_summary_save`/);
306
+ assert.doesNotMatch(out, /`gsd_decision_save`/);
307
+ assert.doesNotMatch(out, /`gsd_exec`/);
308
+ assert.doesNotMatch(out, /`gsd_exec_search`/);
309
+ assert.doesNotMatch(out, /`gsd_resume`/);
310
+ }
311
+
312
+ const contract = getUnitToolSurfaceContract("research-project");
313
+ assert.deepEqual(contract?.allowedGsdTools, []);
314
+ assert.deepEqual(contract?.requiredWorkflowTools, []);
315
+ for (const toolName of ["gsd_summary_save", "gsd_decision_save"]) {
316
+ const scope = shouldBlockAutoUnitToolCall("research-project", toolName);
317
+ assert.equal(scope.block, true, `research-project should not allow ${toolName}`);
318
+ }
319
+ });
320
+
321
+ test("Context Mode composer: narrow planning guidance steers only to contracted tools", () => {
322
+ const cases = [
323
+ {
324
+ unitType: "replan-slice",
325
+ expectedTools: ["gsd_replan_slice", "gsd_decision_save"],
326
+ },
327
+ {
328
+ unitType: "reassess-roadmap",
329
+ expectedTools: ["gsd_milestone_status", "gsd_reassess_roadmap"],
330
+ },
331
+ ];
332
+ const disallowedTools = ["gsd_exec", "gsd_exec_search", "gsd_resume"];
333
+
334
+ for (const { unitType, expectedTools } of cases) {
335
+ for (const renderMode of ["nested", "standalone"] as const) {
336
+ const out = composeContextModeInstructions(unitType, { enabled: true, renderMode });
337
+ assert.match(out, /planning lane/i, `${unitType} should still render planning lane guidance`);
338
+ for (const toolName of expectedTools) {
339
+ assert.ok(out.includes(`\`${toolName}\``), `${unitType} guidance should mention ${toolName}`);
340
+ }
341
+ for (const toolName of disallowedTools) {
342
+ assert.ok(!out.includes(`\`${toolName}\``), `${unitType} guidance must not mention ${toolName}`);
343
+ }
344
+ }
345
+ }
346
+ });
347
+
348
+ test("Context Mode composer: lane guidance tools pass unit contracts", () => {
349
+ const affectedUnits = [
350
+ "research-milestone",
351
+ "research-slice",
352
+ "plan-slice",
353
+ "refine-slice",
354
+ "complete-slice",
355
+ "validate-milestone",
356
+ "complete-milestone",
357
+ ];
200
358
  const contextModeTools: UnitGsdToolName[] = ["gsd_exec", "gsd_exec_search", "gsd_resume"];
201
359
  const readOnlyOrientationTools: UnitGsdToolName[] = ["gsd_milestone_status", ...contextModeTools];
202
360
 
@@ -207,7 +365,10 @@ test("Context Mode composer: slice planning and research guidance tools pass uni
207
365
  for (const toolName of contextModeTools) {
208
366
  assert.ok(out.includes(`\`${toolName}\``), `${unitType} guidance should mention ${toolName}`);
209
367
  }
210
- for (const toolName of readOnlyOrientationTools) {
368
+ const expectedContractTools = unitType === "research-milestone"
369
+ ? contextModeTools
370
+ : readOnlyOrientationTools;
371
+ for (const toolName of expectedContractTools) {
211
372
  assert.ok(allowed.has(toolName), `${unitType} contract should allow ${toolName}`);
212
373
  const scope = shouldBlockAutoUnitToolCall(unitType, toolName);
213
374
  assert.equal(scope.block, false, `${unitType} should not hard-block ${toolName}: ${scope.reason ?? ""}`);
@@ -357,13 +518,31 @@ test("#4782 phase 2: buildReassessRoadmapPrompt emits composer-shaped context wi
357
518
  assert.ok(!prompt.includes("Slice Context (from discussion)"));
358
519
  });
359
520
 
360
- test("execute-task prompt surfaces contract-declared on-demand slice research", async (t) => {
521
+ test("execute-task prompt omits on-demand slice research when the artifact is absent", async (t) => {
522
+ const base = makeFixtureBase();
523
+ t.after(() => cleanup(base));
524
+ invalidateAllCaches();
525
+
526
+ seed(base, "M001");
527
+ writeArtifacts(base);
528
+
529
+ const prompt = await buildExecuteTaskPrompt("M001", "S01", "First", "T01", "Task", base);
530
+
531
+ assert.doesNotMatch(prompt, /## On-demand Context/);
532
+ assert.doesNotMatch(prompt, /\.gsd\/milestones\/M001\/slices\/S01\/S01-RESEARCH\.md/);
533
+ });
534
+
535
+ test("execute-task prompt surfaces on-demand slice research when the artifact exists", async (t) => {
361
536
  const base = makeFixtureBase();
362
537
  t.after(() => cleanup(base));
363
538
  invalidateAllCaches();
364
539
 
365
540
  seed(base, "M001");
366
541
  writeArtifacts(base);
542
+ writeFileSync(
543
+ join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-RESEARCH.md"),
544
+ "# S01 Research\n",
545
+ );
367
546
 
368
547
  const prompt = await buildExecuteTaskPrompt("M001", "S01", "First", "T01", "Task", base);
369
548
 
@@ -127,6 +127,31 @@ test("formatUnmergedMilestoneBlockMessage includes files, branch, and dirty over
127
127
  }
128
128
  });
129
129
 
130
+ test("findUnmergedCompletedMilestones reports dirty overlap without content fingerprints", async () => {
131
+ const base = makeTempRepo("gsd-unmerged-guard-");
132
+ try {
133
+ const relPath = "build/output.bin";
134
+ const absPath = join(base, relPath);
135
+ mkdirSync(dirname(absPath), { recursive: true });
136
+ writeFileSync(absPath, "main artifact\n");
137
+ git(base, "add", relPath);
138
+ git(base, "commit", "-m", "test: track build output fixture");
139
+
140
+ seedMilestone(base, "M012");
141
+ commitBranchFile(base, "milestone/M012", relPath, "milestone artifact\n");
142
+ writeFileSync(absPath, "dirty root artifact\n");
143
+
144
+ const [blocker] = await findUnmergedCompletedMilestones(base);
145
+ assert.ok(blocker);
146
+ assert.deepEqual(blocker.files, [relPath]);
147
+ assert.deepEqual(blocker.dirtyOverlap, [{ path: relPath, status: "M" }]);
148
+ assert.equal(Object.hasOwn(blocker.dirtyOverlap[0], "fingerprint"), false);
149
+ } finally {
150
+ closeDatabase();
151
+ cleanup(base);
152
+ }
153
+ });
154
+
130
155
  test("isUnmergedMilestoneAllowedCommand permits inspection and explicit recovery commands", () => {
131
156
  assert.equal(isUnmergedMilestoneAllowedCommand(""), false);
132
157
  assert.equal(isUnmergedMilestoneAllowedCommand("auto"), false);
@@ -3,13 +3,28 @@
3
3
 
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
+ import { createRequire } from "node:module";
7
+ import { mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ const _require = createRequire(import.meta.url);
6
11
 
7
12
  import {
8
13
  formatValidationBlockedMessage,
14
+ getValidationBlockMessageForBase,
9
15
  isValidationBlockAllowedCommand,
10
16
  isValidationBlockedState,
11
17
  } from "../validation-block-guard.ts";
18
+ import {
19
+ _getAdapter,
20
+ closeDatabase,
21
+ insertMilestone,
22
+ insertSlice,
23
+ openDatabase,
24
+ } from "../gsd-db.ts";
25
+ import { invalidateStateCache } from "../state.ts";
12
26
  import type { GSDState } from "../types.ts";
27
+ import { cleanup, makeTempDir } from "./test-utils.ts";
13
28
 
14
29
  function blockedState(): GSDState {
15
30
  return {
@@ -38,6 +53,24 @@ function blockedState(): GSDState {
38
53
  };
39
54
  }
40
55
 
56
+ function makeBase(): string {
57
+ const base = makeTempDir("gsd-validation-block-");
58
+ mkdirSync(join(base, ".gsd"), { recursive: true });
59
+ return base;
60
+ }
61
+
62
+ function openRawSqliteForTest(dbPath: string): { exec(sql: string): void; close(): void } {
63
+ try {
64
+ const mod = _require("node:sqlite") as { DatabaseSync: new (path: string) => { exec(sql: string): void; close(): void } };
65
+ return new mod.DatabaseSync(dbPath);
66
+ } catch {
67
+ type SqliteCtor = new (path: string) => { exec(sql: string): void; close(): void };
68
+ const mod = _require("better-sqlite3") as SqliteCtor | { default: SqliteCtor };
69
+ const DatabaseCtor: SqliteCtor = typeof mod === "function" ? mod : mod.default;
70
+ return new DatabaseCtor(dbPath);
71
+ }
72
+ }
73
+
41
74
  test("validation block detection only matches validation blockers", () => {
42
75
  assert.equal(isValidationBlockedState(blockedState()), true);
43
76
  assert.equal(isValidationBlockedState({
@@ -194,3 +227,49 @@ test("validation block message can guide remediation through dispatch reassess",
194
227
  assert.match(message, /\/gsd dispatch reassess/);
195
228
  assert.doesNotMatch(message, /gsd_reassess_roadmap/);
196
229
  });
230
+
231
+ test("validation block guard refreshes from disk and sees external validation blocks", async () => {
232
+ const base = makeBase();
233
+ const dbPath = join(base, ".gsd", "gsd.db");
234
+ const validationPath = join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md");
235
+ try {
236
+ openDatabase(dbPath);
237
+ insertMilestone({ id: "M001", title: "Active Milestone", status: "active" });
238
+ insertSlice({
239
+ id: "S01",
240
+ milestoneId: "M001",
241
+ title: "Done Slice",
242
+ status: "complete",
243
+ risk: "low",
244
+ depends: [],
245
+ });
246
+ invalidateStateCache();
247
+
248
+ const adapterBefore = _getAdapter();
249
+ assert.ok(adapterBefore);
250
+
251
+ const externalDb = openRawSqliteForTest(dbPath);
252
+ try {
253
+ externalDb.exec(`
254
+ INSERT OR REPLACE INTO assessments (path, milestone_id, slice_id, task_id, status, scope, full_content, created_at)
255
+ VALUES (
256
+ '${validationPath.replace(/'/g, "''")}',
257
+ 'M001', NULL, NULL, 'needs-attention', 'milestone-validation',
258
+ '---\nverdict: needs-attention\n---', datetime('now')
259
+ )
260
+ `);
261
+ } finally {
262
+ externalDb.close();
263
+ }
264
+
265
+ const message = await getValidationBlockMessageForBase(base, "next");
266
+
267
+ assert.ok(message);
268
+ assert.match(message, /cannot run because the active milestone is blocked by validation/);
269
+ assert.notEqual(_getAdapter(), adapterBefore, "guard must refresh stale database handle");
270
+ } finally {
271
+ closeDatabase();
272
+ invalidateStateCache();
273
+ cleanup(base);
274
+ }
275
+ });
@@ -292,3 +292,69 @@ test("#852: discuss-milestone fails when CONTEXT is in neither worktree nor proj
292
292
  rmSync(projectRoot, { recursive: true, force: true });
293
293
  }
294
294
  });
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // #870: discuss-milestone verify-fail when the unit runs IN the worktree.
298
+ //
299
+ // The #852 tests above all pass `projectRoot` as the base. But the real call
300
+ // site (auto-post-unit.ts:1726) passes `s.currentUnit.workspaceRoot ?? s.basePath`
301
+ // — i.e. the WORKTREE path when the unit executed in a worktree. In the
302
+ // canonical layout (`<root>/.gsd-worktrees/<MID>/`) resolveCanonicalMilestoneRoot
303
+ // round-trips the worktree path back to itself, so `artifactBase === base` and
304
+ // the worktree→project-root fallback (guarded by `artifactBase !== base`) is
305
+ // skipped. CONTEXT is written to the project root, not projected into the
306
+ // worktree, so verification finds nothing → "existsSync false" → re-dispatch
307
+ // 3× → stuck-loop stop. These tests pin the real call site.
308
+ // ---------------------------------------------------------------------------
309
+
310
+ test("#870: discuss-milestone falls back to project root when base IS the canonical-layout worktree", () => {
311
+ closeDatabase();
312
+ const projectRoot = mkdtempSync(join(tmpdir(), "gsd-canonical-wt-"));
313
+ try {
314
+ // CONTEXT lives ONLY at the project root (flat-phase layout).
315
+ const phaseDir = join(projectRoot, ".gsd", "phases", "15-m015");
316
+ mkdirSync(phaseDir, { recursive: true });
317
+ writeFileSync(join(phaseDir, "15-CONTEXT.md"), "# M015 context\n");
318
+
319
+ // Canonical-layout worktree: <root>/.gsd-worktrees/<MID>/. Registered
320
+ // with git (.git file) so resolveCanonicalMilestoneRoot treats it as the
321
+ // canonical milestone root — but it has NO phases/ projection.
322
+ const wtRoot = join(projectRoot, ".gsd-worktrees", "M015");
323
+ mkdirSync(join(wtRoot, ".gsd", "milestones", "M015"), { recursive: true });
324
+ writeFileSync(join(wtRoot, ".gsd", "milestones", "M015", "M015-META.json"), '{"branch":"milestone/M015"}');
325
+ writeFileSync(join(wtRoot, ".git"), "gitdir: /fake/path");
326
+
327
+ // Real call site: base = worktree path (workspaceRoot).
328
+ assert.equal(
329
+ verifyExpectedArtifact("discuss-milestone", "M015", wtRoot),
330
+ true,
331
+ "must fall back to project root when base is the canonical-layout worktree",
332
+ );
333
+ } finally {
334
+ closeDatabase();
335
+ rmSync(projectRoot, { recursive: true, force: true });
336
+ }
337
+ });
338
+
339
+ test("#870: discuss-milestone also falls back when base is the legacy-layout worktree", () => {
340
+ closeDatabase();
341
+ const projectRoot = mkdtempSync(join(tmpdir(), "gsd-legacy-wt-"));
342
+ try {
343
+ const phaseDir = join(projectRoot, ".gsd", "phases", "15-m015");
344
+ mkdirSync(phaseDir, { recursive: true });
345
+ writeFileSync(join(phaseDir, "15-CONTEXT.md"), "# M015 context\n");
346
+
347
+ const wtRoot = join(projectRoot, ".gsd", "worktrees", "M015");
348
+ mkdirSync(join(wtRoot, ".gsd", "milestones", "M015"), { recursive: true });
349
+ writeFileSync(join(wtRoot, ".git"), "gitdir: /fake/path");
350
+
351
+ assert.equal(
352
+ verifyExpectedArtifact("discuss-milestone", "M015", wtRoot),
353
+ true,
354
+ "must fall back to project root when base is the legacy-layout worktree",
355
+ );
356
+ } finally {
357
+ closeDatabase();
358
+ rmSync(projectRoot, { recursive: true, force: true });
359
+ }
360
+ });
@@ -3,9 +3,10 @@
3
3
 
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { mkdirSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { delimiter, join } from "node:path";
8
8
 
9
+ import { GIT_NO_PROMPT_ENV } from "../git-constants.js";
9
10
  import { probeGitConflictState } from "../git-conflict-state.js";
10
11
  import { ensureWorkspaceGitReadyForPath } from "../workspace-git-preflight.js";
11
12
  import { isWorkspaceGitAllowedCommand } from "../workspace-git-guard.js";
@@ -50,6 +51,38 @@ function seedProductConflict(base: string): void {
50
51
  }
51
52
  }
52
53
 
54
+ function installCountingGitShim(binDir: string, logPath: string): void {
55
+ const posixShim = join(binDir, "git");
56
+ writeFileSync(
57
+ posixShim,
58
+ [
59
+ "#!/bin/sh",
60
+ 'printf "%s\\n" "$*" >> "$GSD_GIT_LOG"',
61
+ 'PATH="$GSD_REAL_PATH"',
62
+ "export PATH",
63
+ 'exec git "$@"',
64
+ "",
65
+ ].join("\n"),
66
+ );
67
+ chmodSync(posixShim, 0o755);
68
+
69
+ writeFileSync(
70
+ join(binDir, "git.cmd"),
71
+ [
72
+ "@echo off",
73
+ 'echo %*>>"%GSD_GIT_LOG%"',
74
+ 'set "PATH=%GSD_REAL_PATH%"',
75
+ "git %*",
76
+ "",
77
+ ].join("\r\n"),
78
+ );
79
+ }
80
+
81
+ function countGitShimInvocations(logPath: string): number {
82
+ if (!existsSync(logPath)) return 0;
83
+ return readFileSync(logPath, "utf-8").split(/\r?\n/).filter(Boolean).length;
84
+ }
85
+
53
86
  test("probeGitConflictState reports clean repo", () => {
54
87
  const base = makeTempRepo("gsd-ws-git-clean-");
55
88
  try {
@@ -60,6 +93,122 @@ test("probeGitConflictState reports clean repo", () => {
60
93
  }
61
94
  });
62
95
 
96
+ test("ensureWorkspaceGitReadyForPath caches clean target probes briefly", async () => {
97
+ const base = makeTempRepo("gsd-ws-git-clean-cache-");
98
+ const binDir = makeTempDir("gsd-ws-git-shim-");
99
+ const logPath = join(binDir, "git.log");
100
+ const originalProcessPath = process.env.PATH;
101
+ const originalEnvPath = GIT_NO_PROMPT_ENV.PATH;
102
+ const originalEnvGitLog = GIT_NO_PROMPT_ENV.GSD_GIT_LOG;
103
+ const originalEnvRealPath = GIT_NO_PROMPT_ENV.GSD_REAL_PATH;
104
+
105
+ try {
106
+ installCountingGitShim(binDir, logPath);
107
+ const shimmedPath = `${binDir}${delimiter}${originalProcessPath ?? ""}`;
108
+ process.env.PATH = shimmedPath;
109
+ GIT_NO_PROMPT_ENV.PATH = shimmedPath;
110
+ GIT_NO_PROMPT_ENV.GSD_GIT_LOG = logPath;
111
+ GIT_NO_PROMPT_ENV.GSD_REAL_PATH = originalProcessPath ?? "";
112
+
113
+ const first = await ensureWorkspaceGitReadyForPath(base);
114
+ assert.equal(first.ok, true);
115
+ const firstCount = countGitShimInvocations(logPath);
116
+ assert.equal(firstCount, 3, "first clean probe should run the existing conflict checks");
117
+
118
+ const second = await ensureWorkspaceGitReadyForPath(base);
119
+ assert.equal(second.ok, true);
120
+ assert.equal(
121
+ countGitShimInvocations(logPath),
122
+ firstCount,
123
+ "second clean probe within the cache window must not spawn git again",
124
+ );
125
+ } finally {
126
+ if (originalProcessPath === undefined) delete process.env.PATH;
127
+ else process.env.PATH = originalProcessPath;
128
+ if (originalEnvPath === undefined) delete GIT_NO_PROMPT_ENV.PATH;
129
+ else GIT_NO_PROMPT_ENV.PATH = originalEnvPath;
130
+ if (originalEnvGitLog === undefined) delete GIT_NO_PROMPT_ENV.GSD_GIT_LOG;
131
+ else GIT_NO_PROMPT_ENV.GSD_GIT_LOG = originalEnvGitLog;
132
+ if (originalEnvRealPath === undefined) delete GIT_NO_PROMPT_ENV.GSD_REAL_PATH;
133
+ else GIT_NO_PROMPT_ENV.GSD_REAL_PATH = originalEnvRealPath;
134
+ cleanup(binDir);
135
+ cleanup(base);
136
+ }
137
+ });
138
+
139
+ test("ensureWorkspaceGitReadyForPath detects merge state that appears after a clean probe", async () => {
140
+ const base = makeTempRepo("gsd-ws-git-cache-stale-");
141
+ const binDir = makeTempDir("gsd-ws-git-shim2-");
142
+ const logPath = join(binDir, "git2.log");
143
+ const originalProcessPath = process.env.PATH;
144
+ const originalEnvPath = GIT_NO_PROMPT_ENV.PATH;
145
+ const originalEnvGitLog = GIT_NO_PROMPT_ENV.GSD_GIT_LOG;
146
+ const originalEnvRealPath = GIT_NO_PROMPT_ENV.GSD_REAL_PATH;
147
+
148
+ try {
149
+ installCountingGitShim(binDir, logPath);
150
+ const shimmedPath = `${binDir}${delimiter}${originalProcessPath ?? ""}`;
151
+ process.env.PATH = shimmedPath;
152
+ GIT_NO_PROMPT_ENV.PATH = shimmedPath;
153
+ GIT_NO_PROMPT_ENV.GSD_GIT_LOG = logPath;
154
+ GIT_NO_PROMPT_ENV.GSD_REAL_PATH = originalProcessPath ?? "";
155
+
156
+ // First call — repo is clean, cache is populated.
157
+ const first = await ensureWorkspaceGitReadyForPath(base);
158
+ assert.equal(first.ok, true);
159
+ const afterFirstCount = countGitShimInvocations(logPath);
160
+ assert.ok(afterFirstCount > 0, "first probe must have called git");
161
+
162
+ // Introduce MERGE_HEAD to simulate merge state appearing mid-TTL window.
163
+ writeFileSync(join(base, ".git", "MERGE_HEAD"), "0000000000000000000000000000000000000000\n");
164
+
165
+ // Second call — cache should be bypassed because merge state markers are present.
166
+ await ensureWorkspaceGitReadyForPath(base);
167
+ const afterSecondCount = countGitShimInvocations(logPath);
168
+ assert.ok(
169
+ afterSecondCount > afterFirstCount,
170
+ "cache must be invalidated when merge state appears, causing a fresh git probe",
171
+ );
172
+ } finally {
173
+ if (originalProcessPath === undefined) delete process.env.PATH;
174
+ else process.env.PATH = originalProcessPath;
175
+ if (originalEnvPath === undefined) delete GIT_NO_PROMPT_ENV.PATH;
176
+ else GIT_NO_PROMPT_ENV.PATH = originalEnvPath;
177
+ if (originalEnvGitLog === undefined) delete GIT_NO_PROMPT_ENV.GSD_GIT_LOG;
178
+ else GIT_NO_PROMPT_ENV.GSD_GIT_LOG = originalEnvGitLog;
179
+ if (originalEnvRealPath === undefined) delete GIT_NO_PROMPT_ENV.GSD_REAL_PATH;
180
+ else GIT_NO_PROMPT_ENV.GSD_REAL_PATH = originalEnvRealPath;
181
+ cleanup(binDir);
182
+ cleanup(base);
183
+ }
184
+ });
185
+
186
+ test("ensureWorkspaceGitReadyForPath detects conflicts after a non-git folder becomes a repo", async () => {
187
+ const base = makeTempDir("gsd-ws-git-non-repo-cache-");
188
+ try {
189
+ const first = await ensureWorkspaceGitReadyForPath(base);
190
+ assert.equal(first.ok, true);
191
+
192
+ git(base, "init");
193
+ git(base, "config", "user.email", "test@test.com");
194
+ git(base, "config", "user.name", "Test");
195
+ git(base, "config", "core.autocrlf", "false");
196
+ writeFileSync(join(base, "README.md"), "# init\n");
197
+ git(base, "add", "-A");
198
+ git(base, "commit", "-m", "init");
199
+ git(base, "branch", "-M", "main");
200
+ seedProductConflict(base);
201
+
202
+ const second = await ensureWorkspaceGitReadyForPath(base);
203
+ assert.equal(second.ok, false);
204
+ if (second.ok) return;
205
+ assert.equal(second.severity, "product-conflicts");
206
+ assert.ok(second.conflictedPaths.includes("app.ts"));
207
+ } finally {
208
+ cleanup(base);
209
+ }
210
+ });
211
+
63
212
  test("ensureWorkspaceGitReadyForPath allows fresh non-git project setup folders", async () => {
64
213
  const base = makeTempDir("gsd-ws-git-non-repo-");
65
214
  try {