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.
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/phases.js +6 -1
- package/dist/resources/extensions/gsd/auto-artifact-paths.js +2 -2
- package/dist/resources/extensions/gsd/auto-dispatch.js +2 -0
- package/dist/resources/extensions/gsd/auto-recovery.js +18 -3
- package/dist/resources/extensions/gsd/auto-start.js +3 -2
- 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/paths.js +5 -1
- package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +45 -4
- package/dist/resources/extensions/gsd/uok/audit.js +23 -9
- package/dist/resources/extensions/gsd/uok/contracts.js +69 -1
- package/dist/resources/extensions/gsd/uok/dispatch-envelope.js +3 -0
- package/dist/resources/extensions/gsd/uok/loop-adapter.js +48 -33
- package/dist/resources/extensions/gsd/uok/timeline.js +125 -0
- package/dist/resources/extensions/shared/gsd-phase-state.js +45 -3
- 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/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/package.json +1 -1
- package/packages/mcp-server/src/workflow-tools.test.ts +13 -2
- package/src/resources/extensions/gsd/auto/phases.ts +6 -1
- package/src/resources/extensions/gsd/auto-artifact-paths.ts +2 -2
- package/src/resources/extensions/gsd/auto-dispatch.ts +1 -0
- package/src/resources/extensions/gsd/auto-recovery.ts +17 -3
- package/src/resources/extensions/gsd/auto-start.ts +3 -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/paths.ts +6 -1
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +108 -1
- 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/deep-planning-mode-dispatch.test.ts +42 -0
- package/src/resources/extensions/gsd/tests/deep-project-auto-loop.test.ts +63 -2
- package/src/resources/extensions/gsd/tests/execute-summary-save-empty-project.test.ts +109 -0
- package/src/resources/extensions/gsd/tests/uok-contracts.test.ts +109 -1
- package/src/resources/extensions/gsd/tests/uok-loop-adapter-writer.test.ts +98 -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/src/resources/extensions/gsd/uok/audit.ts +25 -9
- package/src/resources/extensions/gsd/uok/contracts.ts +105 -0
- package/src/resources/extensions/gsd/uok/dispatch-envelope.ts +4 -0
- package/src/resources/extensions/gsd/uok/loop-adapter.ts +60 -45
- package/src/resources/extensions/gsd/uok/timeline.ts +158 -0
- package/src/resources/extensions/shared/gsd-phase-state.ts +56 -3
- package/src/resources/extensions/shared/tests/gsd-phase-state.test.ts +43 -1
- /package/dist/web/standalone/.next/static/{DSZPSz1kgrF8zPIrV_AMD → TzEVJ1Lh8vbez4n4Q9TqQ}/_buildManifest.js +0 -0
- /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(
|
|
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
|
+
});
|