gsd-pi 2.79.0-dev.5c910bb05 → 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 (81) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/phases.js +6 -1
  3. package/dist/resources/extensions/gsd/auto-artifact-paths.js +2 -2
  4. package/dist/resources/extensions/gsd/auto-dispatch.js +2 -0
  5. package/dist/resources/extensions/gsd/auto-recovery.js +18 -3
  6. package/dist/resources/extensions/gsd/auto-start.js +3 -2
  7. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +8 -8
  8. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +8 -8
  9. package/dist/resources/extensions/gsd/guided-flow.js +40 -0
  10. package/dist/resources/extensions/gsd/paths.js +5 -1
  11. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +45 -4
  12. package/dist/resources/extensions/gsd/uok/audit.js +23 -9
  13. package/dist/resources/extensions/gsd/uok/contracts.js +69 -1
  14. package/dist/resources/extensions/gsd/uok/dispatch-envelope.js +3 -0
  15. package/dist/resources/extensions/gsd/uok/loop-adapter.js +48 -33
  16. package/dist/resources/extensions/gsd/uok/timeline.js +125 -0
  17. package/dist/resources/extensions/shared/gsd-phase-state.js +45 -3
  18. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  19. package/dist/web/standalone/.next/BUILD_ID +1 -1
  20. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  21. package/dist/web/standalone/.next/build-manifest.json +2 -2
  22. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  23. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.html +1 -1
  40. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  47. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  48. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  49. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  50. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  51. package/package.json +1 -1
  52. package/packages/mcp-server/src/workflow-tools.test.ts +13 -2
  53. package/src/resources/extensions/gsd/auto/phases.ts +6 -1
  54. package/src/resources/extensions/gsd/auto-artifact-paths.ts +2 -2
  55. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -0
  56. package/src/resources/extensions/gsd/auto-recovery.ts +17 -3
  57. package/src/resources/extensions/gsd/auto-start.ts +3 -2
  58. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +11 -8
  59. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-shouldblock-basepath.test.ts +97 -0
  60. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +8 -4
  61. package/src/resources/extensions/gsd/guided-flow.ts +47 -0
  62. package/src/resources/extensions/gsd/paths.ts +6 -1
  63. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +108 -1
  64. package/src/resources/extensions/gsd/tests/check-auto-start-pending-gate.test.ts +203 -0
  65. package/src/resources/extensions/gsd/tests/check-auto-start-ready-guard.test.ts +148 -0
  66. package/src/resources/extensions/gsd/tests/deep-planning-mode-dispatch.test.ts +42 -0
  67. package/src/resources/extensions/gsd/tests/deep-project-auto-loop.test.ts +63 -2
  68. package/src/resources/extensions/gsd/tests/execute-summary-save-empty-project.test.ts +109 -0
  69. package/src/resources/extensions/gsd/tests/uok-contracts.test.ts +109 -1
  70. package/src/resources/extensions/gsd/tests/uok-loop-adapter-writer.test.ts +98 -0
  71. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +36 -7
  72. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +47 -4
  73. package/src/resources/extensions/gsd/uok/audit.ts +25 -9
  74. package/src/resources/extensions/gsd/uok/contracts.ts +105 -0
  75. package/src/resources/extensions/gsd/uok/dispatch-envelope.ts +4 -0
  76. package/src/resources/extensions/gsd/uok/loop-adapter.ts +60 -45
  77. package/src/resources/extensions/gsd/uok/timeline.ts +158 -0
  78. package/src/resources/extensions/shared/gsd-phase-state.ts +56 -3
  79. package/src/resources/extensions/shared/tests/gsd-phase-state.test.ts +43 -1
  80. /package/dist/web/standalone/.next/static/{DSZPSz1kgrF8zPIrV_AMD → TzEVJ1Lh8vbez4n4Q9TqQ}/_buildManifest.js +0 -0
  81. /package/dist/web/standalone/.next/static/{DSZPSz1kgrF8zPIrV_AMD → TzEVJ1Lh8vbez4n4Q9TqQ}/_ssgManifest.js +0 -0
@@ -0,0 +1,97 @@
1
+ // GSD-2 write-gate bootstrap — regression tests for basePath threading on
2
+ // shouldBlockContextWrite / shouldBlockPendingGate (R1).
3
+ //
4
+ // The underlying bug: readers defaulted to process.cwd() and so missed the
5
+ // per-basePath state Map entry written by markDepthVerified(..., baseDirA)
6
+ // when cwd had drifted to baseDirB. With basePath threaded explicitly to
7
+ // the readers, the depth-gate sees the verified state regardless of cwd.
8
+
9
+ import { test, describe, before, after } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import {
16
+ markDepthVerified,
17
+ setPendingGate,
18
+ shouldBlockContextWrite,
19
+ shouldBlockPendingGate,
20
+ clearDiscussionFlowState,
21
+ } from "../write-gate.js";
22
+
23
+ function makeTempDir(): string {
24
+ return mkdtempSync(join(tmpdir(), "wg-shouldblock-basepath-"));
25
+ }
26
+
27
+ let originalCwd: string;
28
+ before(() => {
29
+ originalCwd = process.cwd();
30
+ });
31
+ after(() => {
32
+ if (process.cwd() !== originalCwd) {
33
+ process.chdir(originalCwd);
34
+ }
35
+ });
36
+
37
+ describe("write-gate shouldBlock readers respect explicit basePath", () => {
38
+ let baseDirA: string;
39
+ let baseDirB: string;
40
+ let prevPersist: string | undefined;
41
+
42
+ before(() => {
43
+ baseDirA = makeTempDir();
44
+ baseDirB = makeTempDir();
45
+ prevPersist = process.env.GSD_PERSIST_WRITE_GATE_STATE;
46
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = "1";
47
+ });
48
+
49
+ after(() => {
50
+ process.chdir(originalCwd);
51
+ if (prevPersist === undefined) {
52
+ delete process.env.GSD_PERSIST_WRITE_GATE_STATE;
53
+ } else {
54
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = prevPersist;
55
+ }
56
+ rmSync(baseDirA, { recursive: true, force: true });
57
+ rmSync(baseDirB, { recursive: true, force: true });
58
+ });
59
+
60
+ test("shouldBlockContextWrite with explicit basePath sees verified state after cwd drift", () => {
61
+ clearDiscussionFlowState(baseDirA);
62
+ clearDiscussionFlowState(baseDirB);
63
+
64
+ markDepthVerified("M001", baseDirA);
65
+ process.chdir(baseDirB);
66
+
67
+ const contextPath = join(baseDirA, ".gsd", "milestones", "M001", "M001-CONTEXT.md");
68
+ const result = shouldBlockContextWrite("write", contextPath, "M001", undefined, baseDirA);
69
+
70
+ assert.equal(result.block, false, "explicit basePath should resolve to baseDirA's verified state");
71
+ });
72
+
73
+ test("shouldBlockContextWrite without basePath defaults to cwd and misses verified state (bug repro)", () => {
74
+ clearDiscussionFlowState(baseDirA);
75
+ clearDiscussionFlowState(baseDirB);
76
+
77
+ markDepthVerified("M001", baseDirA);
78
+ process.chdir(baseDirB);
79
+
80
+ const contextPath = join(baseDirA, ".gsd", "milestones", "M001", "M001-CONTEXT.md");
81
+ const result = shouldBlockContextWrite("write", contextPath, "M001");
82
+
83
+ assert.equal(result.block, true, "default-to-cwd path resolves to baseDirB and misses baseDirA state");
84
+ });
85
+
86
+ test("shouldBlockPendingGate with explicit basePath sees pending gate after cwd drift", () => {
87
+ clearDiscussionFlowState(baseDirA);
88
+ clearDiscussionFlowState(baseDirB);
89
+
90
+ setPendingGate("depth_verification_M001_confirm", baseDirA);
91
+ process.chdir(baseDirB);
92
+
93
+ const result = shouldBlockPendingGate("write", "M001", false, baseDirA);
94
+
95
+ assert.equal(result.block, true, "explicit basePath should resolve to baseDirA's pending gate state");
96
+ });
97
+ });
@@ -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 });
@@ -188,9 +188,14 @@ export function resolveDir(parentDir: string, idPrefix: string): string | null {
188
188
  // Exact match first (current convention: bare ID)
189
189
  const exact = entries.find(e => e.isDirectory() && e.name === idPrefix);
190
190
  if (exact) return exact.name;
191
+ const idLower = idPrefix.toLowerCase();
192
+ const exactCaseInsensitive = entries.find(
193
+ e => e.isDirectory() && e.name.toLowerCase() === idLower
194
+ );
195
+ if (exactCaseInsensitive) return exactCaseInsensitive.name;
191
196
  // Prefix match for legacy descriptor dirs: M001-SOMETHING
192
197
  const prefixed = entries.find(
193
- e => e.isDirectory() && e.name.startsWith(idPrefix + "-")
198
+ e => e.isDirectory() && e.name.toLowerCase().startsWith(idLower + "-")
194
199
  );
195
200
  return prefixed ? prefixed.name : null;
196
201
  } catch {
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto";
7
7
 
8
8
  import { verifyExpectedArtifact, hasImplementationArtifacts, resolveExpectedArtifactPath, diagnoseExpectedArtifact, buildLoopRemediationSteps, writeBlockerPlaceholder } from "../auto-recovery.ts";
9
9
  import { resolveMilestoneFile } from "../paths.ts";
10
- import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertGateRow } from "../gsd-db.ts";
10
+ import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertGateRow, insertTask } from "../gsd-db.ts";
11
11
  import { clearParseCache } from "../files.ts";
12
12
  import { parseRoadmap } from "../parsers-legacy.ts";
13
13
  import { invalidateAllCaches } from "../cache.ts";
@@ -90,6 +90,46 @@ test("resolveExpectedArtifactPath returns correct path for plan-slice", () => {
90
90
  }
91
91
  });
92
92
 
93
+ test("plan-slice artifact resolution handles lowercase unit IDs against uppercase paths", () => {
94
+ const base = makeTmpBase();
95
+ try {
96
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
97
+ const tasksDir = join(sliceDir, "tasks");
98
+ writeFileSync(join(sliceDir, "S01-PLAN.md"), [
99
+ "# S01: Test Slice",
100
+ "",
101
+ "## Tasks",
102
+ "",
103
+ "- [ ] **T01: Implement feature** `est:1h`",
104
+ ].join("\n"));
105
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
106
+
107
+ const artifactPath = resolveExpectedArtifactPath("plan-slice", "m001/s01", base);
108
+ assert.ok(
109
+ artifactPath?.endsWith(".gsd/milestones/M001/slices/S01/S01-PLAN.md"),
110
+ "lowercase unit IDs should resolve to the existing uppercase artifact path",
111
+ );
112
+
113
+ const diagnostic = diagnoseExpectedArtifact("plan-slice", "m001/s01", base);
114
+ assert.ok(
115
+ diagnostic?.includes(".gsd/milestones/M001/slices/S01/S01-PLAN.md"),
116
+ "diagnostic should report the existing uppercase artifact path",
117
+ );
118
+ assert.ok(
119
+ diagnostic?.includes("task plans"),
120
+ "diagnostic should mention task plans because slice plan alone is insufficient",
121
+ );
122
+
123
+ assert.equal(
124
+ verifyExpectedArtifact("plan-slice", "m001/s01", base),
125
+ true,
126
+ "verification should pass when the uppercase slice plan and task plans exist",
127
+ );
128
+ } finally {
129
+ cleanup(base);
130
+ }
131
+ });
132
+
93
133
  test("resolveExpectedArtifactPath returns null for unknown type", () => {
94
134
  const base = makeTmpBase();
95
135
  try {
@@ -764,6 +804,73 @@ test("hasImplementationArtifacts finds implementation commits when .gsd/ is giti
764
804
  }
765
805
  });
766
806
 
807
+ test("hasImplementationArtifacts binds GSD-Task trailer to milestone via DB state when .gsd/ is gitignored", () => {
808
+ const base = makeGitBase();
809
+ try {
810
+ writeFileSync(join(base, ".git", "info", "exclude"), ".gsd/\n");
811
+ mkdirSync(join(base, ".gsd"), { recursive: true });
812
+ openDatabase(join(base, ".gsd", "gsd.db"));
813
+ insertMilestone({ id: "M001", title: "Milestone One", status: "active" });
814
+ insertSlice({
815
+ id: "S01",
816
+ milestoneId: "M001",
817
+ title: "Slice One",
818
+ status: "complete",
819
+ risk: "low",
820
+ depends: [],
821
+ });
822
+ insertTask({
823
+ id: "T01",
824
+ sliceId: "S01",
825
+ milestoneId: "M001",
826
+ title: "Task One",
827
+ status: "complete",
828
+ });
829
+
830
+ mkdirSync(join(base, "src"), { recursive: true });
831
+ writeFileSync(join(base, "src", "feature.ts"), "export function feature() {}\n");
832
+ execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
833
+ execFileSync(
834
+ "git",
835
+ ["commit", "-m", "feat: add feature\n\nGSD-Task: S01/T01"],
836
+ { cwd: base, stdio: "ignore" },
837
+ );
838
+
839
+ const result = hasImplementationArtifacts(base, "M001");
840
+ assert.equal(
841
+ result,
842
+ "present",
843
+ "DB task ownership should bind S01/T01 implementation commits to M001 without explicit M001 text",
844
+ );
845
+ } finally {
846
+ cleanup(base);
847
+ }
848
+ });
849
+
850
+ test("hasImplementationArtifacts does not bind GSD-Task trailer without milestone ownership evidence", () => {
851
+ const base = makeGitBase();
852
+ try {
853
+ writeFileSync(join(base, ".git", "info", "exclude"), ".gsd/\n");
854
+ mkdirSync(join(base, "src"), { recursive: true });
855
+ writeFileSync(join(base, "src", "feature.ts"), "export function feature() {}\n");
856
+ execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
857
+ execFileSync(
858
+ "git",
859
+ ["commit", "-m", "feat: add feature\n\nGSD-Task: S01/T01"],
860
+ { cwd: base, stdio: "ignore" },
861
+ );
862
+
863
+ const result = hasImplementationArtifacts(base, "M001");
864
+ assert.equal(
865
+ result,
866
+ "absent",
867
+ "S01/T01 shape alone must not bind an implementation commit to M001",
868
+ );
869
+ } finally {
870
+ cleanup(base);
871
+ }
872
+ });
873
+
767
874
  test("hasImplementationArtifacts ignores malformed milestone IDs in commit-message fallback", () => {
768
875
  const base = makeGitBase();
769
876
  try {
@@ -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
+ });