gsd-pi 2.79.0-dev.bbb2f88ce → 2.79.0-dev.ece5fd8ba

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 (53) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +8 -8
  3. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +8 -8
  4. package/dist/resources/extensions/gsd/guided-flow.js +40 -0
  5. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +45 -4
  6. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  7. package/dist/web/standalone/.next/BUILD_ID +1 -1
  8. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  9. package/dist/web/standalone/.next/build-manifest.json +2 -2
  10. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  11. package/dist/web/standalone/.next/required-server-files.json +1 -1
  12. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/index.html +1 -1
  29. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  36. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  37. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  38. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  39. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  40. package/dist/web/standalone/server.js +1 -1
  41. package/package.json +1 -1
  42. package/packages/mcp-server/src/workflow-tools.test.ts +13 -2
  43. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +11 -8
  44. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-shouldblock-basepath.test.ts +97 -0
  45. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +8 -4
  46. package/src/resources/extensions/gsd/guided-flow.ts +47 -0
  47. package/src/resources/extensions/gsd/tests/check-auto-start-pending-gate.test.ts +203 -0
  48. package/src/resources/extensions/gsd/tests/check-auto-start-ready-guard.test.ts +148 -0
  49. package/src/resources/extensions/gsd/tests/execute-summary-save-empty-project.test.ts +109 -0
  50. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +36 -7
  51. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +47 -4
  52. /package/dist/web/standalone/.next/static/{3HYkAopiKls15zp5a8I9n → TzEVJ1Lh8vbez4n4Q9TqQ}/_buildManifest.js +0 -0
  53. /package/dist/web/standalone/.next/static/{3HYkAopiKls15zp5a8I9n → TzEVJ1Lh8vbez4n4Q9TqQ}/_ssgManifest.js +0 -0
@@ -351,8 +351,9 @@ export function shouldBlockPendingGate(
351
351
  toolName: string,
352
352
  milestoneId: string | null,
353
353
  queuePhaseActive?: boolean,
354
+ basePath: string = process.cwd(),
354
355
  ): { block: boolean; reason?: string } {
355
- return shouldBlockPendingGateInSnapshot(currentWriteGateSnapshot(), toolName, milestoneId, queuePhaseActive);
356
+ return shouldBlockPendingGateInSnapshot(currentWriteGateSnapshot(basePath), toolName, milestoneId, queuePhaseActive);
356
357
  }
357
358
 
358
359
  export function shouldBlockPendingGateInSnapshot(
@@ -386,8 +387,9 @@ export function shouldBlockPendingGateBash(
386
387
  command: string,
387
388
  milestoneId: string | null,
388
389
  queuePhaseActive?: boolean,
390
+ basePath: string = process.cwd(),
389
391
  ): { block: boolean; reason?: string } {
390
- return shouldBlockPendingGateBashInSnapshot(currentWriteGateSnapshot(), command, milestoneId, queuePhaseActive);
392
+ return shouldBlockPendingGateBashInSnapshot(currentWriteGateSnapshot(basePath), command, milestoneId, queuePhaseActive);
391
393
  }
392
394
 
393
395
  export function shouldBlockPendingGateBashInSnapshot(
@@ -444,6 +446,7 @@ export function shouldBlockContextWrite(
444
446
  inputPath: string,
445
447
  milestoneId: string | null,
446
448
  _queuePhaseActive?: boolean,
449
+ basePath: string = process.cwd(),
447
450
  ): { block: boolean; reason?: string } {
448
451
  if (toolName !== "write") return { block: false };
449
452
  if (!MILESTONE_CONTEXT_RE.test(inputPath)) return { block: false };
@@ -460,7 +463,7 @@ export function shouldBlockContextWrite(
460
463
  };
461
464
  }
462
465
 
463
- if (isMilestoneDepthVerified(targetMilestoneId)) return { block: false };
466
+ if (isMilestoneDepthVerified(targetMilestoneId, basePath)) return { block: false };
464
467
 
465
468
  return {
466
469
  block: true,
@@ -483,8 +486,9 @@ export function shouldBlockContextArtifactSave(
483
486
  artifactType: string,
484
487
  milestoneId: string | null,
485
488
  sliceId?: string | null,
489
+ basePath: string = process.cwd(),
486
490
  ): { block: boolean; reason?: string } {
487
- return shouldBlockContextArtifactSaveInSnapshot(currentWriteGateSnapshot(), artifactType, milestoneId, sliceId);
491
+ return shouldBlockContextArtifactSaveInSnapshot(currentWriteGateSnapshot(basePath), artifactType, milestoneId, sliceId);
488
492
  }
489
493
 
490
494
  export function shouldBlockContextArtifactSaveInSnapshot(
@@ -70,6 +70,7 @@ import {
70
70
  } from "./preparation.js";
71
71
  import { verifyExpectedArtifact } from "./auto-recovery.js";
72
72
  import { createWorkspace, scopeMilestone, type MilestoneScope } from "./workspace.js";
73
+ import { getPendingGate, extractDepthVerificationMilestoneId } from "./bootstrap/write-gate.js";
73
74
 
74
75
  // ─── Re-exports (preserve public API for existing importers) ────────────────
75
76
  export {
@@ -410,6 +411,15 @@ export async function checkDeepProjectSetupAfterTurn(
410
411
  }
411
412
  }
412
413
 
414
+ // R2: a depth-verification gate is still pending — the LLM emitted the
415
+ // confirmation question (via ask_user_questions or plain chat) but the user
416
+ // has not approved yet. Returning false keeps the entry in the
417
+ // pendingDeepProjectSetupMap so the next user message can resume.
418
+ const pendingGateId = getPendingGate(entry.basePath);
419
+ if (pendingGateId) {
420
+ return false;
421
+ }
422
+
413
423
  return dispatchNextDeepProjectSetupStage(entry);
414
424
  }
415
425
 
@@ -494,6 +504,25 @@ export function checkAutoStartAfterDiscuss(): boolean {
494
504
  const roadmapFile = existsSync(roadmapFilePath) ? roadmapFilePath : null;
495
505
  if (!contextFile && !roadmapFile) return false; // neither artifact yet — keep waiting
496
506
 
507
+ // Gate 1a: a depth-verification gate is still pending for THIS milestone — the
508
+ // LLM emitted the confirmation question (via ask_user_questions or plain chat)
509
+ // but the user has not answered yet. Advancing now would skip the gate and
510
+ // race ahead with unverified context.
511
+ const basePathForGate = entry.scope.workspace.projectRoot;
512
+ const pendingGateId = getPendingGate(basePathForGate);
513
+ if (pendingGateId) {
514
+ const pendingMilestoneId = extractDepthVerificationMilestoneId(pendingGateId);
515
+ // Block advancement if the gate is for THIS milestone, OR if it's a
516
+ // project/requirements gate (no milestone id encoded) for the deep setup flow.
517
+ const isProjectGate =
518
+ pendingGateId === "depth_verification_project_confirm" ||
519
+ pendingGateId === "depth_verification_requirements_confirm" ||
520
+ pendingGateId === "depth_verification_research_decision_confirm";
521
+ if (pendingMilestoneId === milestoneId || isProjectGate) {
522
+ return false;
523
+ }
524
+ }
525
+
497
526
  // Gate 1b: Discriminate plan-blocked from discuss-incomplete when the DB row is queued.
498
527
  // If the DB is available and the row is still "queued" but CONTEXT.md already exists on
499
528
  // disk, the discuss phase completed but gsd_plan_milestone was hard-blocked by the
@@ -628,6 +657,24 @@ export function checkAutoStartAfterDiscuss(): boolean {
628
657
  try { unlinkSync(manifestPath); } catch (e) { logWarning("guided", `manifest unlink failed: ${(e as Error).message}`); }
629
658
  }
630
659
 
660
+ // R3b: belt-and-suspenders for silent registration failure. The discuss flow
661
+ // finished and STATE.md exists, but the milestone may never have landed in
662
+ // the DB. Without this guard, the user sees "Milestone M001 ready." and then
663
+ // /gsd reports "No Active Milestone".
664
+ if (isDbAvailable()) {
665
+ const milestoneRow = getMilestone(milestoneId);
666
+ if (!milestoneRow) {
667
+ ctx.ui.notify(
668
+ `Milestone ${milestoneId}: discuss artifacts on disk but no DB row exists. ` +
669
+ `PROJECT.md may have failed to register milestones. ` +
670
+ `Re-save PROJECT.md with canonical "- [ ] M001: Title — One-liner" lines, ` +
671
+ `then re-run /gsd to recover.`,
672
+ "error",
673
+ );
674
+ return false;
675
+ }
676
+ }
677
+
631
678
  pendingAutoStartMap.delete(basePath);
632
679
  ctx.ui.notify(`Milestone ${milestoneId} ready.`, "success");
633
680
  startAutoDetached(ctx, pi, basePath, false, { step });
@@ -0,0 +1,203 @@
1
+ // GSD-2 + Regression tests for checkAutoStartAfterDiscuss Gate 1a (R2)
2
+ //
3
+ // When a depth-verification gate is still pending (the LLM emitted the
4
+ // confirmation question via ask_user_questions or plain chat but the user has
5
+ // not answered), checkAutoStartAfterDiscuss must NOT advance — even if
6
+ // CONTEXT.md and STATE.md are present on disk. Otherwise the LLM can render
7
+ // the question and the "Milestone M001 ready" phrase in the same turn and
8
+ // race past the gate.
9
+
10
+ import { describe, test, beforeEach, afterEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ import {
17
+ checkAutoStartAfterDiscuss,
18
+ setPendingAutoStart,
19
+ clearPendingAutoStart,
20
+ } from "../guided-flow.ts";
21
+ import { drainLogs } from "../workflow-logger.ts";
22
+ import {
23
+ openDatabase,
24
+ closeDatabase,
25
+ insertMilestone,
26
+ } from "../gsd-db.ts";
27
+ import {
28
+ setPendingGate,
29
+ clearPendingGate,
30
+ clearDiscussionFlowState,
31
+ } from "../bootstrap/write-gate.ts";
32
+
33
+ interface MockCapture {
34
+ notifies: Array<{ msg: string; level: string }>;
35
+ messages: Array<{ payload: any; options: any }>;
36
+ }
37
+
38
+ function mkCapture(): MockCapture {
39
+ return { notifies: [], messages: [] };
40
+ }
41
+
42
+ function mkCtx(cap: MockCapture): any {
43
+ return {
44
+ ui: {
45
+ notify: (msg: string, level: string) => {
46
+ cap.notifies.push({ msg, level });
47
+ },
48
+ },
49
+ };
50
+ }
51
+
52
+ function mkPi(cap: MockCapture): any {
53
+ return {
54
+ sendMessage: (payload: any, options: any) => {
55
+ cap.messages.push({ payload, options });
56
+ },
57
+ setActiveTools: () => undefined,
58
+ getActiveTools: () => [],
59
+ };
60
+ }
61
+
62
+ function mkBase(): string {
63
+ // realpathSync to normalize the macOS /var → /private/var symlink so the
64
+ // basePath we pass to setPendingGate matches what the workspace's
65
+ // realpath-normalized projectRoot will resolve to.
66
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-gate1a-pending-")));
67
+ mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
68
+ // CONTEXT.md (Gate 1) and STATE.md (Gate 2) both present so the only
69
+ // possible blocker in these tests is the new Gate 1a.
70
+ writeFileSync(
71
+ join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md"),
72
+ "# M001: Pending Gate Test\n\nContext.\n",
73
+ );
74
+ writeFileSync(
75
+ join(base, ".gsd", "STATE.md"),
76
+ "# State\n\nactive: M001\n",
77
+ );
78
+ return base;
79
+ }
80
+
81
+ describe("checkAutoStartAfterDiscuss Gate 1a (pending depth-verification gate)", () => {
82
+ let base: string;
83
+ let cap: MockCapture;
84
+
85
+ beforeEach(() => {
86
+ clearPendingAutoStart();
87
+ drainLogs();
88
+ });
89
+
90
+ afterEach(() => {
91
+ closeDatabase();
92
+ clearPendingAutoStart();
93
+ if (base) {
94
+ try { clearDiscussionFlowState(base); } catch { /* */ }
95
+ try { clearPendingGate(base); } catch { /* */ }
96
+ rmSync(base, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test("returns false while a depth_verification gate is pending for the same milestone", () => {
101
+ base = mkBase();
102
+ openDatabase(":memory:");
103
+ // DB row present + active so Gate 1b is not the blocker
104
+ insertMilestone({ id: "M001", title: "Pending Gate Test", status: "active" });
105
+
106
+ cap = mkCapture();
107
+ setPendingAutoStart(base, {
108
+ basePath: base,
109
+ milestoneId: "M001",
110
+ ctx: mkCtx(cap),
111
+ pi: mkPi(cap),
112
+ });
113
+
114
+ // The depth-verification gate for THIS milestone is still pending.
115
+ setPendingGate("depth_verification_M001_confirm", base);
116
+
117
+ const result = checkAutoStartAfterDiscuss();
118
+ assert.equal(result, false, "must not advance while the milestone gate is pending");
119
+ // Must not have announced "ready" or kicked auto.
120
+ const readyNotify = cap.notifies.find((n) => /ready\.?$/i.test(n.msg) && n.level === "success");
121
+ assert.equal(readyNotify, undefined, "must not announce 'ready' while gate pending");
122
+ });
123
+
124
+ test("returns false while a depth_verification_project_confirm gate is pending (deep setup)", () => {
125
+ base = mkBase();
126
+ openDatabase(":memory:");
127
+ insertMilestone({ id: "M001", title: "Pending Gate Test", status: "active" });
128
+
129
+ cap = mkCapture();
130
+ setPendingAutoStart(base, {
131
+ basePath: base,
132
+ milestoneId: "M001",
133
+ ctx: mkCtx(cap),
134
+ pi: mkPi(cap),
135
+ });
136
+
137
+ // A project-level depth-verification gate (no milestone id encoded) is pending —
138
+ // deep-setup interview has not been confirmed yet.
139
+ setPendingGate("depth_verification_project_confirm", base);
140
+
141
+ const result = checkAutoStartAfterDiscuss();
142
+ assert.equal(result, false, "must not advance while a project-level gate is pending");
143
+ });
144
+
145
+ test("returns false while a depth_verification_requirements_confirm gate is pending", () => {
146
+ base = mkBase();
147
+ openDatabase(":memory:");
148
+ insertMilestone({ id: "M001", title: "Pending Gate Test", status: "active" });
149
+
150
+ cap = mkCapture();
151
+ setPendingAutoStart(base, {
152
+ basePath: base,
153
+ milestoneId: "M001",
154
+ ctx: mkCtx(cap),
155
+ pi: mkPi(cap),
156
+ });
157
+
158
+ setPendingGate("depth_verification_requirements_confirm", base);
159
+
160
+ const result = checkAutoStartAfterDiscuss();
161
+ assert.equal(result, false, "must not advance while the requirements gate is pending");
162
+ });
163
+
164
+ test("Gate 1a does NOT trip when the pending gate is for a DIFFERENT milestone", () => {
165
+ base = mkBase();
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
+ insertMilestone({ id: "M001", title: "Pending Gate Test", status: "queued" });
173
+
174
+ cap = mkCapture();
175
+ setPendingAutoStart(base, {
176
+ basePath: base,
177
+ milestoneId: "M001",
178
+ ctx: mkCtx(cap),
179
+ pi: mkPi(cap),
180
+ });
181
+
182
+ setPendingGate("depth_verification_M999_confirm", base);
183
+
184
+ const result = checkAutoStartAfterDiscuss();
185
+ assert.equal(result, false, "Gate 1b returns false (expected) — but only if Gate 1a let us through");
186
+
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)
191
+ );
192
+ assert.ok(
193
+ gate1bNotify,
194
+ `expected Gate 1b warning notify about M001; got: ${JSON.stringify(cap.notifies)}`,
195
+ );
196
+
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.
200
+ const m999Notify = cap.notifies.find(n => /M999/i.test(n.msg));
201
+ assert.equal(m999Notify, undefined, "no notify should reference M999 (the pending-gate milestone)");
202
+ });
203
+ });
@@ -0,0 +1,148 @@
1
+ // GSD-2 + Regression tests for checkAutoStartAfterDiscuss "ready" notify guard (R3b)
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.
7
+
8
+ import { describe, test, beforeEach, afterEach } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+
14
+ import {
15
+ checkAutoStartAfterDiscuss,
16
+ setPendingAutoStart,
17
+ clearPendingAutoStart,
18
+ } from "../guided-flow.ts";
19
+ import { drainLogs } from "../workflow-logger.ts";
20
+ import {
21
+ openDatabase,
22
+ closeDatabase,
23
+ insertMilestone,
24
+ } from "../gsd-db.ts";
25
+ import {
26
+ clearDiscussionFlowState,
27
+ clearPendingGate,
28
+ } from "../bootstrap/write-gate.ts";
29
+
30
+ interface MockCapture {
31
+ notifies: Array<{ msg: string; level: string }>;
32
+ messages: Array<{ payload: any; options: any }>;
33
+ }
34
+
35
+ function mkCapture(): MockCapture {
36
+ return { notifies: [], messages: [] };
37
+ }
38
+
39
+ function mkCtx(cap: MockCapture): any {
40
+ return {
41
+ ui: {
42
+ notify: (msg: string, level: string) => {
43
+ cap.notifies.push({ msg, level });
44
+ },
45
+ },
46
+ };
47
+ }
48
+
49
+ function mkPi(cap: MockCapture): any {
50
+ return {
51
+ sendMessage: (payload: any, options: any) => {
52
+ cap.messages.push({ payload, options });
53
+ },
54
+ setActiveTools: () => undefined,
55
+ getActiveTools: () => [],
56
+ };
57
+ }
58
+
59
+ function mkBase(): string {
60
+ // realpathSync to normalize the macOS /var → /private/var symlink so the
61
+ // basePath we pass matches what the workspace projectRoot resolves to.
62
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-ready-guard-")));
63
+ mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
64
+ writeFileSync(
65
+ join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md"),
66
+ "# M001: Ready Guard Test\n\nContext.\n",
67
+ );
68
+ writeFileSync(
69
+ join(base, ".gsd", "STATE.md"),
70
+ "# State\n\nactive: M001\n",
71
+ );
72
+ return base;
73
+ }
74
+
75
+ describe("checkAutoStartAfterDiscuss ready-notify DB guard (R3b)", () => {
76
+ let base: string;
77
+ let cap: MockCapture;
78
+
79
+ beforeEach(() => {
80
+ clearPendingAutoStart();
81
+ drainLogs();
82
+ });
83
+
84
+ afterEach(() => {
85
+ closeDatabase();
86
+ clearPendingAutoStart();
87
+ if (base) {
88
+ try { clearDiscussionFlowState(base); } catch { /* */ }
89
+ try { clearPendingGate(base); } catch { /* */ }
90
+ rmSync(base, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test("does not announce 'ready' when the milestone DB row is absent", () => {
95
+ base = mkBase();
96
+ // Open a fresh in-memory DB but DO NOT insertMilestone for M001.
97
+ openDatabase(":memory:");
98
+
99
+ cap = mkCapture();
100
+ setPendingAutoStart(base, {
101
+ basePath: base,
102
+ milestoneId: "M001",
103
+ ctx: mkCtx(cap),
104
+ pi: mkPi(cap),
105
+ });
106
+
107
+ const result = checkAutoStartAfterDiscuss();
108
+ assert.equal(result, false, "must return false when DB row missing");
109
+
110
+ // No success "ready" notify
111
+ const successReady = cap.notifies.find(
112
+ (n) => n.level === "success" && /ready\.?$/i.test(n.msg),
113
+ );
114
+ assert.equal(successReady, undefined, "must not announce 'ready' when DB row missing");
115
+
116
+ // An error notify must explain the missing DB row
117
+ const errorNotify = cap.notifies.find((n) => n.level === "error");
118
+ assert.ok(errorNotify, "must emit an error notify when the DB row is missing");
119
+ assert.match(
120
+ errorNotify!.msg,
121
+ /no DB row exists/i,
122
+ "error notify must mention the missing DB row",
123
+ );
124
+ assert.match(errorNotify!.msg, /M001/, "error notify must mention the milestone id");
125
+ });
126
+
127
+ test("announces 'ready' when DB row exists", () => {
128
+ base = mkBase();
129
+ openDatabase(":memory:");
130
+ insertMilestone({ id: "M001", title: "Ready Guard Test", status: "active" });
131
+
132
+ cap = mkCapture();
133
+ setPendingAutoStart(base, {
134
+ basePath: base,
135
+ milestoneId: "M001",
136
+ ctx: mkCtx(cap),
137
+ pi: mkPi(cap),
138
+ });
139
+
140
+ const result = checkAutoStartAfterDiscuss();
141
+ assert.equal(result, true, "must return true on the happy path");
142
+
143
+ const successReady = cap.notifies.find(
144
+ (n) => n.level === "success" && /Milestone\s+M001\s+ready/i.test(n.msg),
145
+ );
146
+ assert.ok(successReady, "must announce 'Milestone M001 ready.' on success");
147
+ });
148
+ });
@@ -0,0 +1,109 @@
1
+ // gsd-2 / execute-summary-save PROJECT registration hard-fail tests
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { randomUUID } from "node:crypto";
8
+
9
+ import { openDatabase, closeDatabase, getAllMilestones } from "../gsd-db.ts";
10
+ import { markApprovalGateVerified, clearDiscussionFlowState } from "../bootstrap/write-gate.ts";
11
+ import { executeSummarySave } from "../tools/workflow-tool-executors.ts";
12
+
13
+ function makeTmpBase(): string {
14
+ const base = join(tmpdir(), `gsd-summary-save-empty-project-${randomUUID()}`);
15
+ mkdirSync(join(base, ".gsd"), { recursive: true });
16
+ return base;
17
+ }
18
+
19
+ function cleanup(base: string): void {
20
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* swallow */ }
21
+ }
22
+
23
+ function openTestDb(base: string): void {
24
+ openDatabase(join(base, ".gsd", "gsd.db"));
25
+ }
26
+
27
+ async function inProjectDir<T>(dir: string, fn: () => Promise<T>): Promise<T> {
28
+ const originalCwd = process.cwd();
29
+ try {
30
+ process.chdir(dir);
31
+ return await fn();
32
+ } finally {
33
+ process.chdir(originalCwd);
34
+ }
35
+ }
36
+
37
+ function setupBase(t: { after: (fn: () => void) => void }): string {
38
+ const base = makeTmpBase();
39
+ // Force deep planning so the root-artifact guard requires a verified approval gate,
40
+ // matching the production flow that surfaces the regression.
41
+ writeFileSync(join(base, ".gsd", "PREFERENCES.md"), "---\nplanning_depth: deep\n---\n");
42
+ openTestDb(base);
43
+ markApprovalGateVerified("depth_verification_project_confirm", base);
44
+ t.after(() => {
45
+ clearDiscussionFlowState(base);
46
+ closeDatabase();
47
+ cleanup(base);
48
+ });
49
+ return base;
50
+ }
51
+
52
+ test("executeSummarySave returns isError when PROJECT.md content has zero parseable milestone lines", async (t) => {
53
+ const base = setupBase(t);
54
+
55
+ const content = [
56
+ "# Project",
57
+ "",
58
+ "## What This Is",
59
+ "",
60
+ "Bad-separator regression fixture.",
61
+ "",
62
+ "## Milestone Sequence",
63
+ "",
64
+ // Wrong separator: " : " instead of em-dash / -- / - → MILESTONE_LINE_RE matches zero lines.
65
+ "- [ ] M001: Foundation : Establish the first runnable slice.",
66
+ "",
67
+ "## Next Section",
68
+ "",
69
+ "Trailing prose with no list bullets so MILESTONE_LINE_RE cannot bridge across lines.",
70
+ "",
71
+ ].join("\n");
72
+
73
+ const result = await inProjectDir(base, () => executeSummarySave({
74
+ artifact_type: "PROJECT",
75
+ content,
76
+ }, base));
77
+
78
+ assert.equal(result.isError, true);
79
+ assert.equal(result.details.error, "milestone_registration_empty_parse");
80
+ assert.match(result.content[0].text, /zero parseable milestone lines/);
81
+ assert.equal(getAllMilestones().length, 0);
82
+ });
83
+
84
+ test("executeSummarySave registers milestones when PROJECT.md uses canonical em-dash format", async (t) => {
85
+ const base = setupBase(t);
86
+
87
+ const content = [
88
+ "# Project",
89
+ "",
90
+ "## What This Is",
91
+ "",
92
+ "Canonical milestone-sequence fixture.",
93
+ "",
94
+ "## Milestone Sequence",
95
+ "",
96
+ "- [ ] M001: Foo — bar",
97
+ "- [ ] M002: Baz — qux",
98
+ "",
99
+ ].join("\n");
100
+
101
+ const result = await inProjectDir(base, () => executeSummarySave({
102
+ artifact_type: "PROJECT",
103
+ content,
104
+ }, base));
105
+
106
+ assert.notEqual(result.isError, true);
107
+ assert.deepEqual(result.details.registeredMilestones, ["M001", "M002"]);
108
+ assert.equal(getAllMilestones().length, 2);
109
+ });
@@ -692,7 +692,18 @@ test("executeSummarySave supports root-level deep planning artifacts", async ()
692
692
 
693
693
  const project = await inProjectDir(base, () => executeSummarySave({
694
694
  artifact_type: "PROJECT",
695
- content: "# Project\n\n## What This Is\n\nA root project artifact.",
695
+ content: [
696
+ "# Project",
697
+ "",
698
+ "## What This Is",
699
+ "",
700
+ "A root project artifact.",
701
+ "",
702
+ "## Milestone Sequence",
703
+ "",
704
+ "- [ ] M001: Foundation - Establish the first runnable slice.",
705
+ "",
706
+ ].join("\n"),
696
707
  }, base));
697
708
  assert.equal(project.isError, undefined);
698
709
  assert.equal(project.details.path, "PROJECT.md");
@@ -794,7 +805,7 @@ test("executeSummarySave registers PROJECT milestone sequence for the next run",
794
805
  }
795
806
  });
796
807
 
797
- test("executeSummarySave keeps PROJECT artifact save successful if milestone registration fails", async () => {
808
+ test("executeSummarySave hard-fails when milestone registration throws so silent No-Active-Milestone is impossible", async () => {
798
809
  const base = makeTmpBase();
799
810
  try {
800
811
  openTestDb(base);
@@ -824,10 +835,15 @@ test("executeSummarySave keeps PROJECT artifact save successful if milestone reg
824
835
  ].join("\n"),
825
836
  }, base));
826
837
 
827
- assert.equal(result.isError, undefined);
838
+ // The artifact is persisted before registration runs, but registration must
839
+ // surface as isError so the LLM retries (INSERT OR IGNORE makes it idempotent)
840
+ // instead of announcing "ready" while the DB has zero milestone rows.
841
+ assert.equal(result.isError, true);
828
842
  assert.equal(result.details.path, "PROJECT.md");
829
- assert.equal(result.details.registeredMilestones, undefined);
830
- assert.match(String(result.details.warning), /milestone registration failed/);
843
+ assert.equal(result.details.error, "milestone_registration_threw");
844
+ assert.match(String(result.details.registration_error), /simulated milestone registration failure/);
845
+ assert.match(result.content[0].text, /milestone registration failed/);
846
+ assert.match(result.content[0].text, /idempotent/);
831
847
  assert.ok(existsSync(join(base, ".gsd", "PROJECT.md")));
832
848
  const artifact = originalPrepare("SELECT path FROM artifacts WHERE path = ?").get("PROJECT.md");
833
849
  assert.equal(artifact?.path, "PROJECT.md");
@@ -872,9 +888,22 @@ test("executeSummarySave requires verified root approval in deep mode", async ()
872
888
  writeFileSync(join(base, ".gsd", "PREFERENCES.md"), "---\nplanning_depth: deep\n---\n");
873
889
  openTestDb(base);
874
890
 
891
+ const projectFixture = [
892
+ "# Project",
893
+ "",
894
+ "## What This Is",
895
+ "",
896
+ "A root project artifact.",
897
+ "",
898
+ "## Milestone Sequence",
899
+ "",
900
+ "- [ ] M001: Foundation - Establish the first runnable slice.",
901
+ "",
902
+ ].join("\n");
903
+
875
904
  const blocked = await inProjectDir(base, () => executeSummarySave({
876
905
  artifact_type: "PROJECT",
877
- content: "# Project\n\n## What This Is\n\nA root project artifact.",
906
+ content: projectFixture,
878
907
  }, base));
879
908
 
880
909
  assert.equal(blocked.isError, true);
@@ -886,7 +915,7 @@ test("executeSummarySave requires verified root approval in deep mode", async ()
886
915
 
887
916
  const unblocked = await inProjectDir(base, () => executeSummarySave({
888
917
  artifact_type: "PROJECT",
889
- content: "# Project\n\n## What This Is\n\nA root project artifact.",
918
+ content: projectFixture,
890
919
  }, base));
891
920
 
892
921
  assert.equal(unblocked.isError, undefined);