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.
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +8 -8
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +8 -8
- package/dist/resources/extensions/gsd/guided-flow.js +40 -0
- package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +45 -4
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/required-server-files.json +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/server.js +1 -1
- package/package.json +1 -1
- package/packages/mcp-server/src/workflow-tools.test.ts +13 -2
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +11 -8
- package/src/resources/extensions/gsd/bootstrap/tests/write-gate-shouldblock-basepath.test.ts +97 -0
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +8 -4
- package/src/resources/extensions/gsd/guided-flow.ts +47 -0
- package/src/resources/extensions/gsd/tests/check-auto-start-pending-gate.test.ts +203 -0
- package/src/resources/extensions/gsd/tests/check-auto-start-ready-guard.test.ts +148 -0
- package/src/resources/extensions/gsd/tests/execute-summary-save-empty-project.test.ts +109 -0
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +36 -7
- package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +47 -4
- /package/dist/web/standalone/.next/static/{3HYkAopiKls15zp5a8I9n → TzEVJ1Lh8vbez4n4Q9TqQ}/_buildManifest.js +0 -0
- /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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
830
|
-
assert.match(String(result.details.
|
|
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:
|
|
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:
|
|
918
|
+
content: projectFixture,
|
|
890
919
|
}, base));
|
|
891
920
|
|
|
892
921
|
assert.equal(unblocked.isError, undefined);
|