@opengsd/gsd-pi 1.1.1-dev.9bb7453 → 1.1.1-dev.a5a2de8

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 (145) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto-dispatch.js +11 -0
  3. package/dist/resources/extensions/gsd/auto-prompts.js +4 -0
  4. package/dist/resources/extensions/gsd/auto-recovery.js +3 -4
  5. package/dist/resources/extensions/gsd/auto-unit-tool-scope.js +18 -66
  6. package/dist/resources/extensions/gsd/auto-worktree.js +18 -5
  7. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +16 -10
  8. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +19 -8
  9. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +18 -29
  10. package/dist/resources/extensions/gsd/closeout-consistency-gate.js +61 -0
  11. package/dist/resources/extensions/gsd/guided-flow.js +89 -107
  12. package/dist/resources/extensions/gsd/milestone-closeout.js +3 -1
  13. package/dist/resources/extensions/gsd/pending-auto-start.js +0 -1
  14. package/dist/resources/extensions/gsd/prompts/run-uat.md +3 -17
  15. package/dist/resources/extensions/gsd/recovery-classification.js +20 -0
  16. package/dist/resources/extensions/gsd/tool-contract.js +5 -0
  17. package/dist/resources/extensions/gsd/tool-presentation-plan.js +17 -7
  18. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +81 -4
  19. package/dist/resources/extensions/gsd/unit-tool-contracts.js +169 -0
  20. package/dist/resources/extensions/gsd/workflow-mcp.js +3 -75
  21. package/dist/web/standalone/.next/BUILD_ID +1 -1
  22. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  23. package/dist/web/standalone/.next/build-manifest.json +2 -2
  24. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  25. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.html +1 -1
  42. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  49. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  50. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  51. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  52. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  53. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  54. package/package.json +1 -1
  55. package/packages/cloud-mcp-gateway/package.json +2 -2
  56. package/packages/contracts/package.json +1 -1
  57. package/packages/daemon/package.json +4 -4
  58. package/packages/gsd-agent-core/package.json +5 -5
  59. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  60. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js +5 -0
  61. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js.map +1 -1
  62. package/packages/gsd-agent-modes/package.json +7 -7
  63. package/packages/mcp-server/package.json +3 -3
  64. package/packages/native/package.json +1 -1
  65. package/packages/pi-agent-core/dist/agent-loop.js +4 -3
  66. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  67. package/packages/pi-agent-core/dist/harness/agent-harness.d.ts.map +1 -1
  68. package/packages/pi-agent-core/dist/harness/agent-harness.js +3 -1
  69. package/packages/pi-agent-core/dist/harness/agent-harness.js.map +1 -1
  70. package/packages/pi-agent-core/dist/harness/types.d.ts +1 -0
  71. package/packages/pi-agent-core/dist/harness/types.d.ts.map +1 -1
  72. package/packages/pi-agent-core/dist/harness/types.js.map +1 -1
  73. package/packages/pi-agent-core/dist/types.d.ts +3 -1
  74. package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
  75. package/packages/pi-agent-core/dist/types.js.map +1 -1
  76. package/packages/pi-agent-core/package.json +1 -1
  77. package/packages/pi-ai/dist/models.generated.d.ts +6 -6
  78. package/packages/pi-ai/dist/models.generated.js +6 -6
  79. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  80. package/packages/pi-ai/package.json +1 -1
  81. package/packages/pi-coding-agent/dist/core/extensions/extension-upstream-types.d.ts +3 -0
  82. package/packages/pi-coding-agent/dist/core/extensions/extension-upstream-types.d.ts.map +1 -1
  83. package/packages/pi-coding-agent/dist/core/extensions/extension-upstream-types.js.map +1 -1
  84. package/packages/pi-coding-agent/dist/core/tools/bash.js +2 -2
  85. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/tools/edit.d.ts.map +1 -1
  87. package/packages/pi-coding-agent/dist/core/tools/edit.js +3 -2
  88. package/packages/pi-coding-agent/dist/core/tools/edit.js.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/tools/render-utils.d.ts +1 -0
  90. package/packages/pi-coding-agent/dist/core/tools/render-utils.d.ts.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/tools/render-utils.js +6 -0
  92. package/packages/pi-coding-agent/dist/core/tools/render-utils.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/tools/write.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/core/tools/write.js +3 -2
  95. package/packages/pi-coding-agent/dist/core/tools/write.js.map +1 -1
  96. package/packages/pi-coding-agent/package.json +7 -7
  97. package/packages/pi-tui/package.json +1 -1
  98. package/packages/rpc-client/package.json +2 -2
  99. package/pkg/package.json +1 -1
  100. package/src/resources/extensions/gsd/auto-dispatch.ts +14 -0
  101. package/src/resources/extensions/gsd/auto-prompts.ts +4 -0
  102. package/src/resources/extensions/gsd/auto-recovery.ts +3 -3
  103. package/src/resources/extensions/gsd/auto-unit-tool-scope.ts +43 -74
  104. package/src/resources/extensions/gsd/auto-worktree.ts +23 -5
  105. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +16 -10
  106. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +23 -8
  107. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +50 -54
  108. package/src/resources/extensions/gsd/closeout-consistency-gate.ts +137 -0
  109. package/src/resources/extensions/gsd/guided-flow.ts +124 -134
  110. package/src/resources/extensions/gsd/milestone-closeout.ts +3 -1
  111. package/src/resources/extensions/gsd/pending-auto-start.ts +0 -2
  112. package/src/resources/extensions/gsd/prompts/run-uat.md +3 -17
  113. package/src/resources/extensions/gsd/recovery-classification.ts +20 -0
  114. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +10 -2
  115. package/src/resources/extensions/gsd/tests/auto-start-bootstrap-await-3420.test.ts +4 -1
  116. package/src/resources/extensions/gsd/tests/auto-warning-noise-regression.test.ts +12 -2
  117. package/src/resources/extensions/gsd/tests/check-auto-start-pending-gate.test.ts +9 -15
  118. package/src/resources/extensions/gsd/tests/check-auto-start-ready-guard.test.ts +26 -16
  119. package/src/resources/extensions/gsd/tests/commands-dispatcher-unmerged-milestone.test.ts +21 -0
  120. package/src/resources/extensions/gsd/tests/dispatch-complete-milestone-guard.test.ts +40 -1
  121. package/src/resources/extensions/gsd/tests/gate-1b-orphan-discrimination.test.ts +31 -79
  122. package/src/resources/extensions/gsd/tests/guided-flow-session-isolation.test.ts +5 -3
  123. package/src/resources/extensions/gsd/tests/guided-flow-state-rebuild.test.ts +40 -4
  124. package/src/resources/extensions/gsd/tests/integration/auto-worktree-milestone-merge.test.ts +8 -0
  125. package/src/resources/extensions/gsd/tests/integration/parallel-merge.test.ts +16 -0
  126. package/src/resources/extensions/gsd/tests/integration/run-uat.test.ts +3 -0
  127. package/src/resources/extensions/gsd/tests/merge-closeout-consistency-gate.test.ts +63 -0
  128. package/src/resources/extensions/gsd/tests/merge-db-cycle.test.ts +10 -1
  129. package/src/resources/extensions/gsd/tests/milestone-closeout.test.ts +9 -1
  130. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +23 -5
  131. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +44 -0
  132. package/src/resources/extensions/gsd/tests/run-uat-composer.test.ts +4 -0
  133. package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +36 -0
  134. package/src/resources/extensions/gsd/tests/token-tool-gating.test.ts +4 -4
  135. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +221 -0
  136. package/src/resources/extensions/gsd/tests/write-gate-planning-unit.test.ts +15 -0
  137. package/src/resources/extensions/gsd/tool-contract.ts +6 -0
  138. package/src/resources/extensions/gsd/tool-presentation-plan.ts +38 -8
  139. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +100 -5
  140. package/src/resources/extensions/gsd/unit-tool-contracts.ts +186 -0
  141. package/src/resources/extensions/gsd/workflow-mcp.ts +3 -75
  142. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound-corrections.test.ts +0 -246
  143. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound.test.ts +0 -218
  144. /package/dist/web/standalone/.next/static/{jBtwT9v1u2lUA3UEOy_ZH → 9y3LeeR2uGr2yRj9RjY3D}/_buildManifest.js +0 -0
  145. /package/dist/web/standalone/.next/static/{jBtwT9v1u2lUA3UEOy_ZH → 9y3LeeR2uGr2yRj9RjY3D}/_ssgManifest.js +0 -0
@@ -164,17 +164,13 @@ describe("checkAutoStartAfterDiscuss Gate 1a (pending depth-verification gate)",
164
164
  test("Gate 1a does NOT trip when the pending gate is for a DIFFERENT milestone", () => {
165
165
  base = mkBase();
166
166
  openDatabase(":memory:");
167
- // status: "queued" so that Gate 1b downstream of Gate 1a fires its
168
- // recovery notify ("context file exists but milestone is still queued") —
169
- // observing that notify proves we advanced past Gate 1a. If Gate 1a
170
- // wrongly tripped on the M999 gate it would `return false` immediately
171
- // and Gate 1b would never run, so the notify would be absent.
172
167
  insertMilestone({ id: "M001", title: "Pending Gate Test", status: "queued" });
173
168
 
174
169
  cap = mkCapture();
175
170
  setPendingAutoStart(base, {
176
171
  basePath: base,
177
172
  milestoneId: "M001",
173
+ startAuto: false,
178
174
  ctx: mkCtx(cap),
179
175
  pi: mkPi(cap),
180
176
  });
@@ -182,21 +178,19 @@ describe("checkAutoStartAfterDiscuss Gate 1a (pending depth-verification gate)",
182
178
  setPendingGate("depth_verification_M999_confirm", base);
183
179
 
184
180
  const result = checkAutoStartAfterDiscuss();
185
- assert.equal(result, false, "Gate 1b returns false (expected) but only if Gate 1a let us through");
181
+ assert.equal(result, true, "different milestone gate must not block this handoff");
186
182
 
187
- // Positive proof we passed Gate 1a: Gate 1b emitted its recovery notify
188
- // about M001 (not M999 the pending-gate milestone is irrelevant here).
189
- const gate1bNotify = cap.notifies.find(n =>
190
- n.level === "warning" && /M001.*context file exists but milestone is still queued/i.test(n.msg)
183
+ const successNotify = cap.notifies.find(n =>
184
+ n.level === "success" && /M001 context captured/i.test(n.msg)
191
185
  );
192
186
  assert.ok(
193
- gate1bNotify,
194
- `expected Gate 1b warning notify about M001; got: ${JSON.stringify(cap.notifies)}`,
187
+ successNotify,
188
+ `expected context-captured success notify about M001; got: ${JSON.stringify(cap.notifies)}`,
195
189
  );
196
190
 
197
- // Negative proof: no Gate 1a notification path exists in source today, but
198
- // also assert no notify mentions M999 (the pending-gate milestone) that
199
- // would suggest Gate 1a is leaking the wrong milestone into messaging.
191
+ const retryNotify = cap.notifies.find(n => /queued|gsd_plan_milestone/i.test(n.msg));
192
+ assert.equal(retryNotify, undefined, "handoff must not mention queued state or internal plan retry");
193
+
200
194
  const m999Notify = cap.notifies.find(n => /M999/i.test(n.msg));
201
195
  assert.equal(m999Notify, undefined, "no notify should reference M999 (the pending-gate milestone)");
202
196
  });
@@ -1,9 +1,7 @@
1
- // gsd-pi + Regression tests for checkAutoStartAfterDiscuss "ready" notify guard (R3b)
1
+ // gsd-pi + Regression tests for checkAutoStartAfterDiscuss handoff copy (R3b)
2
2
  //
3
- // Belt-and-suspenders: even when CONTEXT.md and STATE.md exist on disk, the
4
- // "Milestone X ready." success notify must not fire when the milestone DB row
5
- // is absent. Otherwise the user sees "ready" and then /gsd reports
6
- // "No Active Milestone" because the milestone was never registered.
3
+ // Missing-row repair may accept a context handoff, but "Milestone X ready."
4
+ // is reserved for executable plans with persisted slices in DB mode.
7
5
 
8
6
  import { describe, test, beforeEach, afterEach } from "node:test";
9
7
  import assert from "node:assert/strict";
@@ -21,6 +19,7 @@ import {
21
19
  openDatabase,
22
20
  closeDatabase,
23
21
  insertMilestone,
22
+ insertSlice,
24
23
  getMilestone,
25
24
  } from "../gsd-db.ts";
26
25
  import {
@@ -92,49 +91,60 @@ describe("checkAutoStartAfterDiscuss ready-notify DB guard (R3b)", () => {
92
91
  }
93
92
  });
94
93
 
95
- test("does not announce 'ready' when the milestone DB row is absent recovers via Gate 1b", () => {
94
+ test("repairs a missing milestone DB row and accepts context-captured handoff", () => {
96
95
  base = mkBase();
97
- // Open a fresh in-memory DB but DO NOT insertMilestone for M001.
98
96
  openDatabase(":memory:");
99
97
 
100
98
  cap = mkCapture();
101
99
  setPendingAutoStart(base, {
102
100
  basePath: base,
103
101
  milestoneId: "M001",
102
+ startAuto: false,
104
103
  ctx: mkCtx(cap),
105
104
  pi: mkPi(cap),
106
105
  });
107
106
 
108
107
  const result = checkAutoStartAfterDiscuss();
109
- assert.equal(result, false, "must return false when DB row missing");
108
+ assert.equal(result, true, "missing row with pinned context should repair and accept handoff");
110
109
 
111
- // No success "ready" notify
112
110
  const successReady = cap.notifies.find(
113
111
  (n) => n.level === "success" && /ready\.?$/i.test(n.msg),
114
112
  );
115
113
  assert.equal(successReady, undefined, "must not announce 'ready' when DB row missing");
116
114
 
117
- // When CONTEXT.md is on disk the R3b path recovers: it inserts a placeholder
118
- // "queued" row (so Gate 1b can retry gsd_plan_milestone) and emits a warning.
119
115
  const recovered = getMilestone("M001");
120
116
  assert.ok(recovered, "R3b recovery must insert a placeholder 'queued' DB row");
121
117
  assert.equal(recovered!.status, "queued", "placeholder row must have status 'queued'");
122
118
 
123
- const warnNotify = cap.notifies.find((n) => n.level === "warning");
124
- assert.ok(warnNotify, "must emit a warning notify during R3b recovery");
125
- assert.match(warnNotify!.msg, /M001/, "warning must mention the milestone id");
126
- assert.match(warnNotify!.msg, /recovering/i, "warning must mention recovery");
119
+ assert.equal(
120
+ cap.notifies.some(n => n.level === "warning"),
121
+ false,
122
+ "successful missing-row repair must not warn the user",
123
+ );
124
+ assert.deepEqual(cap.notifies, [
125
+ {
126
+ msg: "Milestone M001 context captured. Continuing the planning pipeline.",
127
+ level: "success",
128
+ },
129
+ ]);
127
130
  });
128
131
 
129
- test("announces 'ready' when DB row exists", () => {
132
+ test("announces 'ready' when DB row has executable slices", () => {
130
133
  base = mkBase();
131
134
  openDatabase(":memory:");
132
135
  insertMilestone({ id: "M001", title: "Ready Guard Test", status: "active" });
136
+ insertSlice({
137
+ id: "S01",
138
+ milestoneId: "M001",
139
+ title: "Executable Slice",
140
+ status: "pending",
141
+ });
133
142
 
134
143
  cap = mkCapture();
135
144
  setPendingAutoStart(base, {
136
145
  basePath: base,
137
146
  milestoneId: "M001",
147
+ startAuto: false,
138
148
  ctx: mkCtx(cap),
139
149
  pi: mkPi(cap),
140
150
  });
@@ -9,6 +9,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { handleGSDCommand } from "../commands/dispatcher.ts";
10
10
  import {
11
11
  closeDatabase,
12
+ insertAssessment,
12
13
  insertMilestone,
13
14
  insertSlice,
14
15
  openDatabase,
@@ -106,6 +107,13 @@ function seedRegisteredCompletedWorktreeWithoutRoadmap(base: string): void {
106
107
  title: "Live Text Search",
107
108
  status: "complete",
108
109
  });
110
+ insertAssessment({
111
+ path: "milestones/M008/M008-VALIDATION.md",
112
+ milestoneId: "M008",
113
+ status: "pass",
114
+ scope: "milestone-validation",
115
+ fullContent: "verdict: pass",
116
+ });
109
117
  writeFileSync(
110
118
  join(base, ".gsd", "PREFERENCES.md"),
111
119
  "---\ngit:\n isolation: worktree\n---\n",
@@ -124,6 +132,19 @@ function seedRegisteredCompletedWorktree(base: string): void {
124
132
  mkdirSync(join(base, ".gsd"), { recursive: true });
125
133
  openDatabase(join(base, ".gsd", "gsd.db"));
126
134
  insertMilestone({ id: "M008", title: "Live Text Search", status: "complete" });
135
+ insertSlice({
136
+ id: "S01",
137
+ milestoneId: "M008",
138
+ title: "Live Text Search",
139
+ status: "complete",
140
+ });
141
+ insertAssessment({
142
+ path: "milestones/M008/M008-VALIDATION.md",
143
+ milestoneId: "M008",
144
+ status: "pass",
145
+ scope: "milestone-validation",
146
+ fullContent: "verdict: pass",
147
+ });
127
148
  writeWorktreePreferencesAndRoadmap(base);
128
149
 
129
150
  const worktreePath = join(base, ".gsd", "worktrees", "M008");
@@ -14,7 +14,7 @@ import { execFileSync } from "node:child_process";
14
14
 
15
15
  import { DISPATCH_RULES, resolveDispatch, type DispatchContext } from "../auto-dispatch.ts";
16
16
  import { AutoSession } from "../auto/session.ts";
17
- import { closeDatabase, insertMilestone, insertSlice, openDatabase } from "../gsd-db.ts";
17
+ import { closeDatabase, insertAssessment, insertGateRow, insertMilestone, insertSlice, openDatabase } from "../gsd-db.ts";
18
18
 
19
19
  function makeBase(): string {
20
20
  const base = mkdtempSync(join(tmpdir(), "gsd-complete-dispatch-"));
@@ -225,6 +225,14 @@ describe("complete phase dispatch guard (#5683)", () => {
225
225
  base = makeBase();
226
226
  openDatabase(join(base, ".gsd", "gsd.db"));
227
227
  insertMilestone({ id: "M001", title: "Milestone One", status: "complete" });
228
+ insertSlice({ milestoneId: "M001", id: "S01", title: "Done", status: "complete" });
229
+ insertAssessment({
230
+ path: "milestones/M001/M001-VALIDATION.md",
231
+ milestoneId: "M001",
232
+ status: "pass",
233
+ scope: "milestone-validation",
234
+ fullContent: "verdict: pass",
235
+ });
228
236
 
229
237
  const ctx = buildDispatchCtx(base);
230
238
  ctx.state.phase = "complete";
@@ -234,6 +242,37 @@ describe("complete phase dispatch guard (#5683)", () => {
234
242
  assert.equal(result?.action, "stop");
235
243
  assert.equal(result?.reason, "All milestones complete.");
236
244
  });
245
+
246
+ test("blocks terminal stop when closed milestone still has pending gates", async () => {
247
+ base = makeBase();
248
+ openDatabase(join(base, ".gsd", "gsd.db"));
249
+ insertMilestone({ id: "M001", title: "Milestone One", status: "complete" });
250
+ insertSlice({ milestoneId: "M001", id: "S01", title: "Done", status: "complete" });
251
+ insertAssessment({
252
+ path: "milestones/M001/M001-VALIDATION.md",
253
+ milestoneId: "M001",
254
+ status: "pass",
255
+ scope: "milestone-validation",
256
+ fullContent: "verdict: pass",
257
+ });
258
+ insertGateRow({
259
+ milestoneId: "M001",
260
+ sliceId: "S01",
261
+ gateId: "Q3",
262
+ scope: "slice",
263
+ status: "pending",
264
+ });
265
+
266
+ const ctx = buildDispatchCtx(base);
267
+ ctx.state.phase = "complete";
268
+
269
+ const result = await rule.match(ctx);
270
+
271
+ assert.equal(result?.action, "stop");
272
+ assert.equal(result?.level, "warning");
273
+ assert.match(result?.reason ?? "", /closeout-consistency-blocked/);
274
+ assert.match(result?.reason ?? "", /quality gate Q3 is still pending/);
275
+ });
237
276
  });
238
277
 
239
278
  describe("complete milestone context recovery guard (#5831)", () => {
@@ -1,12 +1,9 @@
1
1
  /**
2
- * gsd-pi / guided-flow — regression tests for Gate 1b orphan discrimination
2
+ * gsd-pi / guided-flow — regression tests for Gate 1b discussion handoff
3
3
  *
4
- * Gate 1b in checkAutoStartAfterDiscuss discriminates between two "queued" states:
5
- * (a) plan-blocked: discuss completed (CONTEXT.md on disk), but gsd_plan_milestone
6
- * was hard-blocked by the depth-verification gate. DB row stuck at "queued".
7
- * → emit recovery hint directing the LLM to retry gsd_plan_milestone.
8
- * (b) discuss-incomplete: discuss did not finish, no CONTEXT.md, DB row "queued".
9
- * → silent block (no recovery hint).
4
+ * Gate 1b treats queued + pinned CONTEXT.md as Discussion Complete, Planning
5
+ * Pending. It must accept the handoff without warning the user or injecting a
6
+ * hidden gsd_plan_milestone retry.
10
7
  */
11
8
 
12
9
  import { describe, test, beforeEach, afterEach } from "node:test";
@@ -27,8 +24,6 @@ import {
27
24
  insertMilestone,
28
25
  } from "../gsd-db.ts";
29
26
 
30
- // ─── Harness ───────────────────────────────────────────────────────────────
31
-
32
27
  interface MockCapture {
33
28
  notifies: Array<{ msg: string; level: string }>;
34
29
  messages: Array<{ payload: any; options: any }>;
@@ -58,24 +53,26 @@ function mkPi(cap: MockCapture): any {
58
53
  };
59
54
  }
60
55
 
61
- /**
62
- * Create a minimal temp tree with a .gsd/milestones/M001 directory.
63
- */
64
56
  function mkBase(): string {
65
57
  const base = mkdtempSync(join(tmpdir(), "gsd-gate1b-"));
66
58
  mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
67
59
  return base;
68
60
  }
69
61
 
70
- // ─── Tests ─────────────────────────────────────────────────────────────────
62
+ function writeContext(base: string): void {
63
+ writeFileSync(
64
+ join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md"),
65
+ "# M001: Test Milestone\n\nContext written by discuss phase.\n",
66
+ );
67
+ }
71
68
 
72
- describe("Gate 1b orphan discrimination in checkAutoStartAfterDiscuss", () => {
69
+ describe("Gate 1b discussion handoff in checkAutoStartAfterDiscuss", () => {
73
70
  let base: string;
74
71
  let cap: MockCapture;
75
72
 
76
73
  beforeEach(() => {
77
74
  clearPendingAutoStart();
78
- drainLogs(); // discard noise from prior tests
75
+ drainLogs();
79
76
  });
80
77
 
81
78
  afterEach(() => {
@@ -86,104 +83,59 @@ describe("Gate 1b orphan discrimination in checkAutoStartAfterDiscuss", () => {
86
83
  }
87
84
  });
88
85
 
89
- test("plan-blocked: CONTEXT.md present + DB row queued → returns false + recovery hint emitted", () => {
86
+ test("queued row + CONTEXT.md accepts context-captured handoff without hidden retry", () => {
90
87
  base = mkBase();
91
88
  openDatabase(":memory:");
92
-
93
- // DB row exists with status "queued" (plan_milestone was blocked)
94
89
  insertMilestone({ id: "M001", title: "Test Milestone", status: "queued" });
95
-
96
- // CONTEXT.md on disk (discuss phase completed)
97
- writeFileSync(
98
- join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md"),
99
- "# M001: Test Milestone\n\nContext written by discuss phase.\n",
100
- );
90
+ writeContext(base);
101
91
 
102
92
  cap = mkCapture();
103
93
  setPendingAutoStart(base, {
104
94
  basePath: base,
105
95
  milestoneId: "M001",
96
+ startAuto: false,
106
97
  ctx: mkCtx(cap),
107
98
  pi: mkPi(cap),
108
99
  });
109
100
 
110
101
  const result = checkAutoStartAfterDiscuss();
111
102
 
112
- // Must return false auto-start should not proceed
113
- assert.equal(result, false, "checkAutoStartAfterDiscuss must return false (plan still blocked)");
114
-
115
- // Recovery hint must be sent to the LLM
116
- assert.equal(
117
- cap.messages.length,
118
- 1,
119
- "exactly one sendMessage call expected for the recovery hint",
120
- );
121
- assert.equal(
122
- cap.messages[0].payload.customType,
123
- "gsd-plan-milestone-blocked-recovery",
124
- "recovery message must have customType gsd-plan-milestone-blocked-recovery",
125
- );
103
+ assert.equal(result, true, "queued + context is a valid planning-pending handoff");
104
+ assert.equal(cap.messages.length, 0, "must not inject a hidden recovery turn");
105
+ assert.equal(cap.notifies.length, 1, "must emit one success notification");
106
+ assert.deepEqual(cap.notifies[0], {
107
+ msg: "Milestone M001 context captured. Continuing the planning pipeline.",
108
+ level: "success",
109
+ });
126
110
  assert.equal(
127
- cap.messages[0].options.triggerTurn,
128
- true,
129
- "recovery message must set triggerTurn: true",
130
- );
131
- assert.match(
132
- cap.messages[0].payload.content,
133
- /gsd_plan_milestone/,
134
- "recovery message content must mention gsd_plan_milestone",
111
+ cap.notifies.some(n => /queued|gsd_plan_milestone/i.test(n.msg)),
112
+ false,
113
+ "user-visible copy must not mention queued state or internal plan tool retry",
135
114
  );
136
-
137
- // User must be notified via ctx.ui.notify
138
- assert.ok(
139
- cap.notifies.some((n) => n.level === "warning" && /queued/.test(n.msg)),
140
- "user must be notified with a warning about the queued state",
141
- );
142
-
143
- // logWarning must have recorded the Gate 1b event
144
- const logs = drainLogs();
145
- const gate1bLog = logs.find(
146
- (e) => e.component === "guided" && /Gate 1b/.test(e.message),
147
- );
148
- assert.ok(gate1bLog, "Gate 1b warning must be logged via logWarning");
149
115
  });
150
116
 
151
- test("discuss-incomplete: no CONTEXT.md + DB row queued → returns false silently (no recovery hint)", () => {
117
+ test("queued row without CONTEXT.md still waits silently for discussion output", () => {
152
118
  base = mkBase();
153
119
  openDatabase(":memory:");
154
-
155
- // DB row exists with status "queued", but NO CONTEXT.md on disk
156
120
  insertMilestone({ id: "M001", title: "Test Milestone", status: "queued" });
157
121
 
158
- // No CONTEXT.md written — discuss phase is incomplete
159
122
  cap = mkCapture();
160
123
  setPendingAutoStart(base, {
161
124
  basePath: base,
162
125
  milestoneId: "M001",
126
+ startAuto: false,
163
127
  ctx: mkCtx(cap),
164
128
  pi: mkPi(cap),
165
129
  });
166
130
 
167
- drainLogs(); // clear any noise before the call
131
+ drainLogs();
168
132
 
169
133
  const result = checkAutoStartAfterDiscuss();
170
134
 
171
- // Must return false silent block
172
- assert.equal(result, false, "checkAutoStartAfterDiscuss must return false when discuss is incomplete");
173
-
174
- // No recovery hint — Gate 1 blocks before Gate 1b is reached
175
- assert.equal(
176
- cap.messages.length,
177
- 0,
178
- "no sendMessage calls expected when CONTEXT.md is absent",
179
- );
180
- assert.equal(
181
- cap.notifies.length,
182
- 0,
183
- "no user notifications expected for discuss-incomplete case",
184
- );
135
+ assert.equal(result, false, "must keep waiting while discuss has not written context");
136
+ assert.equal(cap.messages.length, 0, "no hidden recovery turn expected");
137
+ assert.equal(cap.notifies.length, 0, "no user notifications expected");
185
138
 
186
- // No Gate 1b log entry
187
139
  const logs = drainLogs();
188
140
  const gate1bLog = logs.find(
189
141
  (e) => e.component === "guided" && /Gate 1b/.test(e.message),
@@ -178,7 +178,7 @@ test("checkAutoStartAfterDiscuss(basePath) selects the matching pending entry wh
178
178
  }
179
179
  });
180
180
 
181
- test("checkAutoStartAfterDiscuss can emit ready without scheduling auto-start", () => {
181
+ test("checkAutoStartAfterDiscuss can accept context handoff without scheduling auto-start", () => {
182
182
  const base = mkdtempSync(join(tmpdir(), "gsd-auto-start-headless-owned-"));
183
183
  let waitForIdleCalls = 0;
184
184
  const notifications: string[] = [];
@@ -209,9 +209,11 @@ test("checkAutoStartAfterDiscuss can emit ready without scheduling auto-start",
209
209
  });
210
210
 
211
211
  assert.equal(checkAutoStartAfterDiscuss(base), true);
212
- assert.deepEqual(notifications, ["Milestone M001 ready."]);
212
+ assert.deepEqual(notifications, [
213
+ "Milestone M001 context captured. Continuing the planning pipeline.",
214
+ ]);
213
215
  assert.equal(waitForIdleCalls, 0, "headless-owned auto start must not schedule guided-flow auto");
214
- assert.equal(getDiscussionMilestoneId(base), null, "ready handoff should still be cleared");
216
+ assert.equal(getDiscussionMilestoneId(base), null, "accepted handoff should still be cleared");
215
217
  } finally {
216
218
  clearPendingAutoStart();
217
219
  rmSync(base, { recursive: true, force: true });
@@ -135,7 +135,13 @@ describe("guided-flow STATE.md rebuild (#3475)", () => {
135
135
 
136
136
  assert.equal(accepted, true);
137
137
  assert.equal(notifications.some(n => n.level === "error" && n.message.includes("no DB row exists")), false);
138
- assert.ok(notifications.some(n => n.level === "success" && n.message.includes("Milestone M001 ready.")));
138
+ assert.ok(
139
+ notifications.some(
140
+ n =>
141
+ n.level === "success" &&
142
+ n.message.includes("Milestone M001 context captured. Continuing the planning pipeline."),
143
+ ),
144
+ );
139
145
  clearPendingAutoStart(base);
140
146
  });
141
147
 
@@ -157,13 +163,13 @@ describe("guided-flow STATE.md rebuild (#3475)", () => {
157
163
  };
158
164
  setPendingAutoStart(base, { basePath: base, milestoneId: "M001", ctx: ctx as any, pi: {} as any });
159
165
 
160
- for (let i = 0; i < 4; i += 1) {
166
+ for (let i = 0; i < 3; i += 1) {
161
167
  assert.equal(checkAutoStartAfterDiscuss(), false);
162
168
  }
163
169
 
164
170
  assert.equal(
165
- notifications.filter(n => n.level === "warning" && n.message.includes("recovering. Retrying gsd_plan_milestone")).length,
166
- 3,
171
+ notifications.filter(n => n.level === "warning").length,
172
+ 0,
167
173
  );
168
174
  assert.equal(
169
175
  notifications.filter(n => n.level === "error" && n.message.includes("DB row recovery failed")).length,
@@ -171,4 +177,34 @@ describe("guided-flow STATE.md rebuild (#3475)", () => {
171
177
  );
172
178
  clearPendingAutoStart(base);
173
179
  });
180
+
181
+ test("checkAutoStartAfterDiscuss does not double-notify on 4th+ call after recovery limit", () => {
182
+ base = createFixtureBase();
183
+ openDatabase(":memory:");
184
+ const db = _getAdapter();
185
+ assert.ok(db, "database should be open");
186
+ db.exec("DROP TABLE milestones");
187
+ db.exec("CREATE TABLE milestones (id TEXT PRIMARY KEY)");
188
+
189
+ writeFile(base, "milestones/M001/M001-CONTEXT.md", "# M001: Planned\n");
190
+ writeFile(base, "STATE.md", "# GSD State\n\n**Active Milestone:** M001: Planned\n");
191
+
192
+ const notifications: Array<{ message: string; level: string }> = [];
193
+ const ctx = {
194
+ ui: { notify: (message: string, level: string) => notifications.push({ message, level }) },
195
+ waitForIdle: () => new Promise<void>(() => {}),
196
+ };
197
+ setPendingAutoStart(base, { basePath: base, milestoneId: "M001", ctx: ctx as any, pi: {} as any });
198
+
199
+ for (let i = 0; i < 5; i += 1) {
200
+ assert.equal(checkAutoStartAfterDiscuss(), false);
201
+ }
202
+
203
+ assert.equal(
204
+ notifications.filter(n => n.level === "error" && n.message.includes("DB row recovery failed")).length,
205
+ 1,
206
+ "user must see exactly one error notification even after repeated calls past the recovery limit",
207
+ );
208
+ clearPendingAutoStart(base);
209
+ });
174
210
  });
@@ -29,6 +29,7 @@ import { nativeMergeSquash } from "../../native-git-bridge.ts";
29
29
  import { drainLogs, setStderrLoggingEnabled } from "../../workflow-logger.ts";
30
30
  import {
31
31
  closeDatabase,
32
+ insertAssessment,
32
33
  insertMilestone,
33
34
  insertSlice,
34
35
  insertTask,
@@ -207,6 +208,13 @@ describe("auto-worktree-milestone-merge", { timeout: 300_000 }, () => {
207
208
  });
208
209
  }
209
210
  }
211
+ insertAssessment({
212
+ path: "milestones/M020/M020-VALIDATION.md",
213
+ milestoneId: "M020",
214
+ status: "pass",
215
+ scope: "milestone-validation",
216
+ fullContent: "verdict: pass",
217
+ });
210
218
 
211
219
  const roadmap = makeRoadmap("M020", "Backend foundation", [
212
220
  { id: "S01", title: "Core API" },
@@ -41,6 +41,7 @@ import {
41
41
  import {
42
42
  openDatabase,
43
43
  closeDatabase,
44
+ insertAssessment,
44
45
  insertMilestone,
45
46
  insertSlice,
46
47
  updateMilestoneStatus,
@@ -347,6 +348,13 @@ test("mergeCompletedMilestone — synthesizes roadmap from DB when projection is
347
348
  title: "Search Bar",
348
349
  status: "complete",
349
350
  });
351
+ insertAssessment({
352
+ path: "milestones/M010/M010-VALIDATION.md",
353
+ milestoneId: "M010",
354
+ status: "pass",
355
+ scope: "milestone-validation",
356
+ fullContent: "verdict: pass",
357
+ });
350
358
 
351
359
  createMilestoneBranch(repo, "M010", [
352
360
  { name: "app.js", content: "export const app = true;\n" },
@@ -671,6 +679,14 @@ function setupCanonicalDbWithWorktree(basePath: string, mid: string): void {
671
679
  const dbPath = join(basePath, ".gsd", "gsd.db");
672
680
  openDatabase(dbPath);
673
681
  insertMilestone({ id: mid, title: `Milestone ${mid}`, status: "complete" });
682
+ insertSlice({ id: "S01", milestoneId: mid, title: "Done Slice", status: "complete" });
683
+ insertAssessment({
684
+ path: `milestones/${mid}/${mid}-VALIDATION.md`,
685
+ milestoneId: mid,
686
+ status: "pass",
687
+ scope: "milestone-validation",
688
+ fullContent: "verdict: pass",
689
+ });
674
690
  updateMilestoneStatus(mid, "complete", new Date().toISOString());
675
691
  closeDatabase();
676
692
  }
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url';
11
11
  import { extractUatType } from '../../files.ts';
12
12
  import { resolveSliceFile } from '../../paths.ts';
13
13
  import { buildRunUatPrompt, checkNeedsRunUat } from '../../auto-prompts.ts';
14
+ import { buildRunUatResultPresentation, RUN_UAT_TOOL_PRESENTATION_PLAN_ID } from '../../tool-presentation-plan.ts';
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const worktreePromptsDir = join(__dirname, '../..', 'prompts');
@@ -20,6 +21,8 @@ function loadPromptFromWorktree(name: string, vars: Record<string, string> = {})
20
21
  let content = readFileSync(path, 'utf-8');
21
22
  const effectiveVars = {
22
23
  skillActivation: 'If no installed skill clearly matches this unit, skip explicit skill activation and continue with the required workflow.',
24
+ canonicalPresentation: JSON.stringify(buildRunUatResultPresentation(), null, 2),
25
+ toolPresentationPlanId: RUN_UAT_TOOL_PRESENTATION_PLAN_ID,
23
26
  ...vars,
24
27
  };
25
28
  for (const [key, value] of Object.entries(effectiveVars)) {
@@ -0,0 +1,63 @@
1
+ // Project/App: gsd-pi
2
+ // File Purpose: Regression tests for DB-backed closeout consistency before merge.
3
+
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { existsSync, mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { execFileSync } from "node:child_process";
10
+
11
+ import { mergeMilestoneToMain } from "../auto-worktree.ts";
12
+ import { closeDatabase, insertMilestone, openDatabase } from "../gsd-db.ts";
13
+
14
+ function git(args: string[], cwd: string): string {
15
+ return execFileSync("git", args, {
16
+ cwd,
17
+ stdio: ["ignore", "pipe", "pipe"],
18
+ encoding: "utf-8",
19
+ }).trim();
20
+ }
21
+
22
+ function createRepo(): string {
23
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "merge-closeout-gate-")));
24
+ git(["init"], dir);
25
+ git(["config", "user.email", "test@test.com"], dir);
26
+ git(["config", "user.name", "Test"], dir);
27
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
28
+ writeFileSync(join(dir, "README.md"), "# test\n");
29
+ git(["add", "."], dir);
30
+ git(["commit", "-m", "init"], dir);
31
+ git(["branch", "-M", "main"], dir);
32
+
33
+ git(["checkout", "-b", "milestone/M001"], dir);
34
+ writeFileSync(join(dir, "feature.ts"), "export const feature = true;\n");
35
+ git(["add", "feature.ts"], dir);
36
+ git(["commit", "-m", "feat: milestone work"], dir);
37
+ git(["checkout", "main"], dir);
38
+ return dir;
39
+ }
40
+
41
+ test("mergeMilestoneToMain blocks when project DB closeout is still open", () => {
42
+ const savedCwd = process.cwd();
43
+ const repo = createRepo();
44
+ try {
45
+ assert.equal(openDatabase(join(repo, ".gsd", "gsd.db")), true);
46
+ insertMilestone({ id: "M001", title: "Milestone One", status: "active" });
47
+
48
+ const mainHeadBefore = git(["rev-parse", "main"], repo);
49
+ process.chdir(repo);
50
+
51
+ assert.throws(
52
+ () => mergeMilestoneToMain(repo, "M001", "# M001\n- [x] **S01: Done**\n"),
53
+ /closeout-consistency-blocked/,
54
+ );
55
+
56
+ assert.equal(git(["rev-parse", "main"], repo), mainHeadBefore);
57
+ assert.equal(git(["branch", "--show-current"], repo), "main");
58
+ } finally {
59
+ closeDatabase();
60
+ process.chdir(savedCwd);
61
+ if (existsSync(repo)) rmSync(repo, { recursive: true, force: true });
62
+ }
63
+ });