fix(gsd): fail closed for discussion gate enforcement

This commit is contained in:
Jeremy 2026-04-08 20:07:46 -05:00
parent 477bf3c3fd
commit 56fbd4d87d
9 changed files with 342 additions and 101 deletions

View file

@ -7,6 +7,16 @@ import { loadEffectiveGSDPreferences } from "../preferences.js";
import { ensureDbOpen } from "./dynamic-tools.js";
import { StringEnum } from "@gsd/pi-ai";
import { logError } from "../workflow-logger.js";
import { getErrorMessage } from "../error-utils.js";
import { shouldBlockContextArtifactSave } from "./write-gate.js";
const SUPPORTED_SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", "CONTEXT-DRAFT"] as const;
export function isSupportedSummaryArtifactType(
artifactType: string,
): artifactType is (typeof SUPPORTED_SUMMARY_ARTIFACT_TYPES)[number] {
return (SUPPORTED_SUMMARY_ARTIFACT_TYPES as readonly string[]).includes(artifactType);
}
/**
* Register an alias tool that shares the same execute function as its canonical counterpart.
@ -283,13 +293,23 @@ export function registerDbTools(pi: ExtensionAPI): void {
details: { operation: "save_summary", error: "db_unavailable" } as any,
};
}
const validTypes = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT"];
if (!validTypes.includes(params.artifact_type)) {
if (!isSupportedSummaryArtifactType(params.artifact_type)) {
return {
content: [{ type: "text" as const, text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${validTypes.join(", ")}` }],
content: [{ type: "text" as const, text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${SUPPORTED_SUMMARY_ARTIFACT_TYPES.join(", ")}` }],
details: { operation: "save_summary", error: "invalid_artifact_type" } as any,
};
}
const contextGuard = shouldBlockContextArtifactSave(
params.artifact_type,
params.milestone_id ?? null,
params.slice_id ?? null,
);
if (contextGuard.block) {
return {
content: [{ type: "text" as const, text: `Error saving artifact: ${contextGuard.reason ?? "context write blocked"}` }],
details: { operation: "save_summary", error: "context_write_blocked" } as any,
};
}
try {
let relativePath: string;
if (params.task_id && params.slice_id) {
@ -333,16 +353,17 @@ export function registerDbTools(pi: ExtensionAPI): void {
"Computes the file path from milestone/slice/task IDs automatically.",
promptSnippet: "Save a GSD artifact (summary/research/context/assessment) to DB and disk",
promptGuidelines: [
"Use gsd_summary_save to persist structured artifacts (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT).",
"Use gsd_summary_save to persist structured artifacts (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT).",
"milestone_id is required. slice_id and task_id are optional — they determine the file path.",
"The tool computes the relative path automatically: milestones/M001/M001-SUMMARY.md, milestones/M001/slices/S01/S01-SUMMARY.md, etc.",
"artifact_type must be one of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT.",
"artifact_type must be one of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT.",
"Use CONTEXT-DRAFT for incremental draft persistence; use CONTEXT for the final milestone context after depth verification.",
],
parameters: Type.Object({
milestone_id: Type.String({ description: "Milestone ID (e.g. M001)" }),
slice_id: Type.Optional(Type.String({ description: "Slice ID (e.g. S01)" })),
task_id: Type.Optional(Type.String({ description: "Task ID (e.g. T01)" })),
artifact_type: Type.String({ description: "One of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT" }),
artifact_type: Type.String({ description: "One of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT" }),
content: Type.String({ description: "The full markdown content of the artifact" }),
}),
execute: summarySaveExecute,

View file

@ -6,7 +6,7 @@ import { isToolCallEventType } from "@gsd/pi-coding-agent";
import { buildMilestoneFileName, resolveMilestonePath, resolveSliceFile, resolveSlicePath } from "../paths.js";
import { buildBeforeAgentStartResult } from "./system-context.js";
import { handleAgentEnd } from "./agent-end-recovery.js";
import { clearDiscussionFlowState, isDepthVerified, isDepthConfirmationAnswer, isQueuePhaseActive, markDepthVerified, resetWriteGateState, shouldBlockContextWrite, shouldBlockQueueExecution, isGateQuestionId, setPendingGate, clearPendingGate, getPendingGate, shouldBlockPendingGate, shouldBlockPendingGateBash } from "./write-gate.js";
import { clearDiscussionFlowState, isDepthConfirmationAnswer, isQueuePhaseActive, markDepthVerified, resetWriteGateState, shouldBlockContextWrite, shouldBlockQueueExecution, isGateQuestionId, setPendingGate, clearPendingGate, getPendingGate, shouldBlockPendingGate, shouldBlockPendingGateBash, extractDepthVerificationMilestoneId } from "./write-gate.js";
import { isBlockedStateFile, isBashWriteToStateFile, BLOCKED_WRITE_ERROR } from "../write-intercept.js";
import { cleanupQuickBranch } from "../quick.js";
import { getDiscussionMilestoneId } from "../guided-flow.js";
@ -24,6 +24,7 @@ import { logWarning as safetyLogWarning } from "../workflow-logger.js";
import { installNotifyInterceptor } from "./notify-interceptor.js";
import { initNotificationStore } from "../notification-store.js";
import { initNotificationWidget } from "../notification-widget.js";
import { initHealthWidget } from "../health-widget.js";
// Skip the welcome screen on the very first session_start — cli.ts already
// printed it before the TUI launched. Only re-print on /clear (subsequent sessions).
@ -39,6 +40,7 @@ export function registerHooks(pi: ExtensionAPI): void {
initNotificationStore(process.cwd());
installNotifyInterceptor(ctx);
initNotificationWidget(ctx);
initHealthWidget(ctx);
resetWriteGateState();
resetToolCallLoopGuard();
resetAskUserQuestionsCache();
@ -162,24 +164,25 @@ export function registerHooks(pi: ExtensionAPI): void {
});
pi.on("tool_call", async (event) => {
const discussionBasePath = process.cwd();
// ── Loop guard: block repeated identical tool calls ──
const loopCheck = checkToolCallLoop(event.toolName, event.input as Record<string, unknown>);
if (loopCheck.block) {
return { block: true, reason: loopCheck.reason };
}
// ── Discussion gate enforcement: track pending questions ─────────
// During a discussion flow, EVERY ask_user_questions call matters.
// When ask_user_questions is called, mark it as pending. It stays
// pending until the user responds. This prevents the model from
// continuing if ask_user_questions fails, errors, or is cancelled.
// ── Discussion gate enforcement: track pending gate questions ─────────
// Only gate-shaped ask_user_questions calls should block execution.
// The gate stays pending until the user selects the approval option.
if (event.toolName === "ask_user_questions") {
const milestoneId = getDiscussionMilestoneId();
const milestoneId = getDiscussionMilestoneId(discussionBasePath);
const inDiscussion = milestoneId !== null || isQueuePhaseActive();
if (inDiscussion) {
const questions: any[] = (event.input as any)?.questions ?? [];
const questionId = questions[0]?.id ?? "ask_user_questions";
setPendingGate(typeof questionId === "string" ? questionId : "ask_user_questions");
const questionId = questions.find((question) => typeof question?.id === "string" && isGateQuestionId(question.id))?.id;
if (typeof questionId === "string") {
setPendingGate(questionId);
}
}
}
@ -187,7 +190,7 @@ export function registerHooks(pi: ExtensionAPI): void {
// If ask_user_questions was called with a gate ID but hasn't been confirmed,
// block all non-read-only tool calls to prevent the model from skipping gates.
if (getPendingGate()) {
const milestoneId = getDiscussionMilestoneId();
const milestoneId = getDiscussionMilestoneId(discussionBasePath);
if (isToolCallEventType("bash", event)) {
const bashGuard = shouldBlockPendingGateBash(
event.input.command,
@ -247,8 +250,7 @@ export function registerHooks(pi: ExtensionAPI): void {
const result = shouldBlockContextWrite(
event.toolName,
event.input.path,
getDiscussionMilestoneId(),
isDepthVerified(),
getDiscussionMilestoneId(discussionBasePath),
isQueuePhaseActive(),
);
if (result.block) return result;
@ -276,7 +278,7 @@ export function registerHooks(pi: ExtensionAPI): void {
pi.on("tool_result", async (event) => {
if (event.toolName !== "ask_user_questions") return;
const milestoneId = getDiscussionMilestoneId();
const milestoneId = getDiscussionMilestoneId(process.cwd());
const queueActive = isQueuePhaseActive();
if (!milestoneId && !queueActive) return;
@ -294,9 +296,13 @@ export function registerHooks(pi: ExtensionAPI): void {
// Gate stays pending — model will be blocked from non-read-only tools
// until it re-asks and gets a valid response
} else {
// User responded (confirmed or requested adjustment) — clear the pending gate.
// The prompt-level instructions handle the "needs adjustment" re-ask flow.
clearPendingGate();
const pendingQuestion = questions.find((question) => question?.id === currentPendingGate);
if (pendingQuestion) {
const answer = details.response?.answers?.[currentPendingGate];
if (isDepthConfirmationAnswer(answer?.selected, pendingQuestion.options)) {
clearPendingGate();
}
}
}
}
@ -308,7 +314,7 @@ export function registerHooks(pi: ExtensionAPI): void {
// Cross-references against the question's defined options to reject free-form "Other" text.
const answer = details.response?.answers?.[question.id];
if (isDepthConfirmationAnswer(answer?.selected, question.options)) {
markDepthVerified();
markDepthVerified(extractDepthVerificationMilestoneId(question.id) ?? milestoneId);
}
break;
}

View file

@ -1,4 +1,6 @@
const MILESTONE_CONTEXT_RE = /M\d+(?:-[a-z0-9]{6})?-CONTEXT\.md$/;
const CONTEXT_MILESTONE_RE = /(?:^|[/\\])(M\d+(?:-[a-z0-9]{6})?)-CONTEXT\.md$/i;
const DEPTH_VERIFICATION_MILESTONE_RE = /depth_verification[_-](M\d+(?:-[a-z0-9]{6})?)/i;
/**
* Path segment that identifies .gsd/ planning artifacts.
@ -26,7 +28,7 @@ const QUEUE_SAFE_TOOLS = new Set([
*/
const BASH_READ_ONLY_RE = /^\s*(cat|head|tail|less|more|wc|file|stat|du|df|which|type|echo|printf|ls|find|grep|rg|awk|sed\b(?!.*-i)|sort|uniq|diff|comm|tr|cut|tee\s+-a\s+\/dev\/null|git\s+(log|show|diff|status|branch|tag|remote|rev-parse|ls-files|blame|shortlog|describe|stash\s+list|config\s+--get|cat-file)|gh\s+(issue|pr|api|repo|release)\s+(view|list|diff|status|checks)|mkdir\s+-p\s+\.gsd|rtk\s)/;
let depthVerificationDone = false;
const verifiedDepthMilestones = new Set<string>();
let activeQueuePhase = false;
/**
@ -64,7 +66,15 @@ const GATE_SAFE_TOOLS = new Set([
]);
export function isDepthVerified(): boolean {
return depthVerificationDone;
return verifiedDepthMilestones.size > 0;
}
/**
* Check whether a specific milestone has passed depth verification.
*/
export function isMilestoneDepthVerified(milestoneId: string | null | undefined): boolean {
if (!milestoneId) return false;
return verifiedDepthMilestones.has(milestoneId);
}
export function isQueuePhaseActive(): boolean {
@ -76,18 +86,19 @@ export function setQueuePhaseActive(active: boolean): void {
}
export function resetWriteGateState(): void {
depthVerificationDone = false;
verifiedDepthMilestones.clear();
pendingGateId = null;
}
export function clearDiscussionFlowState(): void {
depthVerificationDone = false;
verifiedDepthMilestones.clear();
activeQueuePhase = false;
pendingGateId = null;
}
export function markDepthVerified(): void {
depthVerificationDone = true;
export function markDepthVerified(milestoneId?: string | null): void {
if (!milestoneId) return;
verifiedDepthMilestones.add(milestoneId);
}
/**
@ -97,6 +108,23 @@ export function isGateQuestionId(questionId: string): boolean {
return GATE_QUESTION_PATTERNS.some(pattern => questionId.includes(pattern));
}
/**
* Extract the milestone ID embedded in a depth-verification question id.
* Prompts are expected to use ids like `depth_verification_M001_confirm`.
*/
export function extractDepthVerificationMilestoneId(questionId: string): string | null {
const match = questionId.match(DEPTH_VERIFICATION_MILESTONE_RE);
return match?.[1] ?? null;
}
/**
* Extract the milestone ID from a milestone CONTEXT file path.
*/
function extractContextMilestoneId(inputPath: string): string | null {
const match = inputPath.match(CONTEXT_MILESTONE_RE);
return match?.[1] ?? null;
}
/**
* Mark a gate as pending (called when ask_user_questions is invoked with a gate ID).
*/
@ -127,15 +155,11 @@ export function getPendingGate(): string | null {
*/
export function shouldBlockPendingGate(
toolName: string,
milestoneId: string | null,
queuePhaseActive?: boolean,
_milestoneId: string | null,
_queuePhaseActive?: boolean,
): { block: boolean; reason?: string } {
if (!pendingGateId) return { block: false };
const inDiscussion = milestoneId !== null;
const inQueue = queuePhaseActive ?? false;
if (!inDiscussion && !inQueue) return { block: false };
if (GATE_SAFE_TOOLS.has(toolName)) return { block: false };
// Bash read-only commands are also safe
@ -159,15 +183,11 @@ export function shouldBlockPendingGate(
*/
export function shouldBlockPendingGateBash(
command: string,
milestoneId: string | null,
queuePhaseActive?: boolean,
_milestoneId: string | null,
_queuePhaseActive?: boolean,
): { block: boolean; reason?: string } {
if (!pendingGateId) return { block: false };
const inDiscussion = milestoneId !== null;
const inQueue = queuePhaseActive ?? false;
if (!inDiscussion && !inQueue) return { block: false };
// Allow read-only bash commands
if (BASH_READ_ONLY_RE.test(command)) return { block: false };
@ -215,16 +235,24 @@ export function shouldBlockContextWrite(
toolName: string,
inputPath: string,
milestoneId: string | null,
depthVerified: boolean,
queuePhaseActive?: boolean,
_queuePhaseActive?: boolean,
): { block: boolean; reason?: string } {
if (toolName !== "write") return { block: false };
const inDiscussion = milestoneId !== null;
const inQueue = queuePhaseActive ?? false;
if (!inDiscussion && !inQueue) return { block: false };
if (!MILESTONE_CONTEXT_RE.test(inputPath)) return { block: false };
if (depthVerified) return { block: false };
const targetMilestoneId = extractContextMilestoneId(inputPath) ?? milestoneId;
if (!targetMilestoneId) {
return {
block: true,
reason: [
`HARD BLOCK: Cannot write milestone CONTEXT.md without knowing which milestone it belongs to.`,
`This is a mechanical gate — you MUST NOT proceed, retry, or rationalize past this block.`,
`Required action: call ask_user_questions with question id containing "depth_verification" and the milestone id.`,
].join(" "),
};
}
if (isMilestoneDepthVerified(targetMilestoneId)) return { block: false };
return {
block: true,
@ -238,6 +266,40 @@ export function shouldBlockContextWrite(
};
}
/**
* Check whether a gsd_summary_save CONTEXT artifact should be blocked.
* Slice-level CONTEXT artifacts are allowed; milestone-level CONTEXT writes
* require the milestone to be depth-verified first.
*/
export function shouldBlockContextArtifactSave(
artifactType: string,
milestoneId: string | null,
sliceId?: string | null,
): { block: boolean; reason?: string } {
if (artifactType !== "CONTEXT") return { block: false };
if (sliceId) return { block: false };
if (!milestoneId) {
return {
block: true,
reason: [
`HARD BLOCK: Cannot save milestone CONTEXT without a milestone_id.`,
`This is a mechanical gate — you MUST NOT proceed, retry, or rationalize past this block.`,
].join(" "),
};
}
if (isMilestoneDepthVerified(milestoneId)) return { block: false };
return {
block: true,
reason: [
`HARD BLOCK: Cannot save milestone CONTEXT without depth verification for ${milestoneId}.`,
`This is a mechanical gate — you MUST NOT proceed, retry, or rationalize past this block.`,
`Required action: call ask_user_questions with question id containing "depth_verification_${milestoneId}".`,
`The user MUST select the "(Recommended)" confirmation option to unlock this gate.`,
].join(" "),
};
}
/**
* Queue-mode execution guard (#2545).
*
@ -283,7 +345,10 @@ export function shouldBlockQueueExecution(
};
}
// Unknown tools — allow by default (custom extension tools, etc.)
return { block: false };
// Unknown tools — block by default in queue mode so custom tools cannot
// bypass execution restrictions.
return {
block: true,
reason: `Blocked: /gsd queue is a planning tool — it creates milestones, not executes work. Unknown tools are not permitted during queue mode.`,
};
}

View file

@ -186,12 +186,13 @@ export function checkAutoStartAfterDiscuss(): boolean {
// Parse PROJECT.md for milestone sequence, warn if any are missing context.
// Don't block — milestones can be intentionally queued without context.
const projectFile = resolveGsdRootFile(basePath, "PROJECT");
let projectIds: string[] = [];
if (projectFile) {
try {
const projectContent = readFileSync(projectFile, "utf-8");
const milestoneIds = parseMilestoneSequenceFromProject(projectContent);
if (milestoneIds.length > 1) {
const missing = milestoneIds.filter(id => {
projectIds = parseMilestoneSequenceFromProject(projectContent);
if (projectIds.length > 1) {
const missing = projectIds.filter(id => {
const hasContext = !!resolveMilestoneFile(basePath, id, "CONTEXT");
const hasDraft = !!resolveMilestoneFile(basePath, id, "CONTEXT-DRAFT");
const hasDir = existsSync(join(gsdRoot(basePath), "milestones", id));
@ -210,9 +211,17 @@ export function checkAutoStartAfterDiscuss(): boolean {
// Gate 4: Discussion manifest process verification (multi-milestone only)
// The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision.
// If the manifest exists but gates_completed < total, the LLM hasn't finished
// presenting all readiness gates to the user — block auto-start.
// If the project is multi-milestone, the manifest is required. When it is
// missing, fail closed instead of assuming the discussion finished.
const manifestPath = join(gsdRoot(basePath), "DISCUSSION-MANIFEST.json");
const requiresManifest = projectIds.length > 1 || findMilestoneIds(basePath).length > 1;
if (requiresManifest && !existsSync(manifestPath)) {
ctx.ui.notify(
"Multi-milestone discussion manifest is missing. Auto-start will remain paused until the manifest is written.",
"warning",
);
return false;
}
if (existsSync(manifestPath)) {
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
@ -225,9 +234,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
}
// Cross-check manifest milestones against PROJECT.md if available
if (projectFile) {
const projectContent = readFileSync(projectFile, "utf-8");
const projectIds = parseMilestoneSequenceFromProject(projectContent);
if (projectIds.length > 0) {
const manifestIds = Object.keys(manifest.milestones ?? {});
const untracked = projectIds.filter(id => !manifestIds.includes(id));
if (untracked.length > 0) {

View file

@ -245,6 +245,41 @@ describe('gsd-tools', () => {
}
});
test('gsd_summary_save supports CONTEXT-DRAFT persistence', async () => {
const tmpDir = makeTmpDir();
try {
const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
openDatabase(dbPath);
await saveArtifactToDb(
{
path: 'milestones/M001/M001-CONTEXT-DRAFT.md',
artifact_type: 'CONTEXT-DRAFT',
content: '# M001 Draft Context\n\nDraft notes.',
milestone_id: 'M001',
},
tmpDir,
);
const draftPath = path.join(tmpDir, '.gsd', 'milestones', 'M001', 'M001-CONTEXT-DRAFT.md');
assert.ok(fs.existsSync(draftPath), 'Draft context file should be created');
const draftContent = fs.readFileSync(draftPath, 'utf-8');
assert.ok(draftContent.includes('Draft Context'), 'Draft context file should contain draft content');
const adapter = _getAdapter();
assert.ok(adapter !== null, 'Adapter should be available');
const rows = adapter!.prepare(
"SELECT * FROM artifacts WHERE path = 'milestones/M001/M001-CONTEXT-DRAFT.md'",
).all();
assert.deepStrictEqual(rows.length, 1, 'Should have 1 draft artifact row');
assert.deepStrictEqual(rows[0]['artifact_type'] as string, 'CONTEXT-DRAFT', 'Artifact type should be CONTEXT-DRAFT');
closeDatabase();
} finally {
cleanupDir(tmpDir);
}
});
test('DB unavailable error paths', async () => {
// (d) All tools return isError when DB unavailable
// Close any open DB and don't open a new one

View file

@ -10,11 +10,15 @@
import { describe, test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
getDiscussionMilestoneId,
setPendingAutoStart,
clearPendingAutoStart,
checkAutoStartAfterDiscuss,
} from "../guided-flow.ts";
// ─── Tests ─────────────────────────────────────────────────────────────────
@ -95,3 +99,33 @@ describe("#2985 Bug 4 — getDiscussionMilestoneId must be keyed by basePath", (
assert.equal(result, "M001", "should return the only active milestone for backward compat");
});
});
test("checkAutoStartAfterDiscuss fails closed when a multi-milestone manifest is missing", () => {
const base = mkdtempSync(join(tmpdir(), "gsd-auto-start-manifest-"));
try {
const gsdDir = join(base, ".gsd");
const milestoneDir = join(gsdDir, "milestones", "M001");
mkdirSync(milestoneDir, { recursive: true });
mkdirSync(join(gsdDir, "milestones", "M002"), { recursive: true });
writeFileSync(
join(gsdDir, "PROJECT.md"),
`# Project\n\n| M001 | First milestone | active |\n| M002 | Second milestone | queued |\n`,
);
writeFileSync(join(gsdDir, "STATE.md"), "# State\n");
writeFileSync(join(milestoneDir, "M001-CONTEXT.md"), "# M001 Context\n");
clearPendingAutoStart();
setPendingAutoStart(base, {
basePath: base,
milestoneId: "M001",
ctx: { ui: { notify: () => undefined } } as any,
pi: { setActiveTools: () => undefined, getActiveTools: () => [] } as any,
});
const started = checkAutoStartAfterDiscuss();
assert.equal(started, false, "auto-start should fail closed without the manifest");
} finally {
clearPendingAutoStart();
rmSync(base, { recursive: true, force: true });
}
});

View file

@ -9,6 +9,7 @@ import {
formatRelativeTime,
type HealthWidgetData,
} from "../health-widget-core.ts";
import { registerHooks } from "../bootstrap/register-hooks.ts";
function makeTempDir(prefix: string): string {
const dir = join(
@ -177,3 +178,47 @@ test("detectHealthWidgetProjectState: metrics file alone does not imply project"
);
assert.equal(detectHealthWidgetProjectState(dir), "initialized");
});
test("session_start bootstraps the health widget alongside notifications", async (t) => {
const dir = makeTempDir("bootstrap");
mkdirSync(join(dir, ".gsd"), { recursive: true });
const originalCwd = process.cwd();
process.chdir(dir);
t.after(() => {
process.chdir(originalCwd);
cleanup(dir);
});
const widgets: string[] = [];
const handlers = new Map<string, (event: unknown, ctx: any) => Promise<void> | void>();
const pi = {
on(event: string, handler: (event: unknown, ctx: any) => Promise<void> | void) {
handlers.set(event, handler);
},
} as any;
registerHooks(pi);
const sessionStart = handlers.get("session_start");
assert.ok(sessionStart, "session_start handler is registered");
await sessionStart!({}, {
hasUI: true,
ui: {
notify: () => {},
setStatus: () => {},
setWorkingMessage: () => {},
onTerminalInput: () => () => {},
setWidget: (key: string) => {
widgets.push(key);
},
},
sessionManager: {
getSessionId: () => null,
},
model: null,
} as any);
assert.ok(widgets.includes("gsd-health"), "health widget is bootstrapped");
assert.ok(widgets.includes("gsd-notifications"), "notification widget still boots");
});

View file

@ -13,6 +13,7 @@
* (e) write/edit to source path block
* (f) bash command block (could execute work)
* (g) registered GSD tools (gsd_milestone_generate_id, gsd_summary_save) pass
* (h) unknown custom tools block
*/
import test from 'node:test';
@ -155,3 +156,11 @@ test('queue-guard: allows web search and library tools during queue mode', () =>
const r4 = shouldBlockQueueExecution('fetch_page', '', true);
assert.strictEqual(r4.block, false, 'fetch_page should pass');
});
// ─── Scenario 10: Unknown custom tools are blocked during queue mode ──
test('queue-guard: blocks unknown custom tools during queue mode', () => {
const result = shouldBlockQueueExecution('custom_codegen_tool', '', true);
assert.strictEqual(result.block, true, 'unknown custom tools should be blocked');
assert.ok(result.reason, 'should explain the queue restriction');
});

View file

@ -3,9 +3,9 @@
*
* Exercises shouldBlockContextWrite() a pure function that implements:
* (a) toolName !== "write" pass
* (b) milestoneId null pass (not in discussion)
* (b) milestone context must resolve to a verified milestone
* (c) path doesn't match /M\d+-CONTEXT\.md$/ pass
* (d) depthVerified pass
* (d) non-context files pass
* (e) else block with actionable reason
*/
@ -14,12 +14,12 @@ import assert from 'node:assert/strict';
import {
isDepthConfirmationAnswer,
shouldBlockContextWrite,
isDepthVerified,
isQueuePhaseActive,
setQueuePhaseActive,
} from '../index.ts';
import {
markDepthVerified,
isMilestoneDepthVerified,
shouldBlockContextArtifactSave,
clearDiscussionFlowState,
resetWriteGateState,
} from '../bootstrap/write-gate.ts';
@ -53,26 +53,27 @@ test('write-gate: blocks CONTEXT.md write during discussion without depth verifi
// ─── Scenario 3: Allows CONTEXT.md write after depth verification ──
test('write-gate: allows CONTEXT.md write after depth verification', () => {
clearDiscussionFlowState();
markDepthVerified('M001');
const result = shouldBlockContextWrite(
'write',
'/Users/dev/project/.gsd/milestones/M001/M001-CONTEXT.md',
'M001',
true,
);
assert.strictEqual(result.block, false, 'should not block after depth verification');
assert.strictEqual(result.reason, undefined, 'should have no reason');
clearDiscussionFlowState();
});
// ─── Scenario 4: Allows CONTEXT.md write outside discussion phase (milestoneId null) ──
// ─── Scenario 4: Ambiguous session context no longer bypasses the gate ──
test('write-gate: allows CONTEXT.md write outside discussion phase', () => {
test('write-gate: blocks CONTEXT.md write when milestoneId is ambiguous', () => {
const result = shouldBlockContextWrite(
'write',
'.gsd/milestones/M001/M001-CONTEXT.md',
null,
false,
);
assert.strictEqual(result.block, false, 'should not block outside discussion phase');
assert.strictEqual(result.block, true, 'should block when milestone context is ambiguous');
});
// ─── Scenario 5: Allows non-CONTEXT.md writes during discussion ──
@ -83,7 +84,6 @@ test('write-gate: allows non-CONTEXT.md writes during discussion', () => {
'write',
'.gsd/milestones/M001/M001-DISCUSSION.md',
'M001',
false,
);
assert.strictEqual(r1.block, false, 'DISCUSSION.md should pass');
@ -92,7 +92,6 @@ test('write-gate: allows non-CONTEXT.md writes during discussion', () => {
'write',
'.gsd/milestones/M001/slices/S01/S01-PLAN.md',
'M001',
false,
);
assert.strictEqual(r2.block, false, 'slice plan should pass');
@ -101,7 +100,6 @@ test('write-gate: allows non-CONTEXT.md writes during discussion', () => {
'write',
'src/index.ts',
'M001',
false,
);
assert.strictEqual(r3.block, false, 'regular code file should pass');
});
@ -113,7 +111,6 @@ test('write-gate: regex does not match slice context files (S01-CONTEXT.md)', ()
'write',
'.gsd/milestones/M001/slices/S01/S01-CONTEXT.md',
'M001',
false,
);
assert.strictEqual(result.block, false, 'S01-CONTEXT.md should not be blocked');
});
@ -125,7 +122,6 @@ test('write-gate: blocked reason contains depth_verification keyword and anti-by
'write',
'.gsd/milestones/M999/M999-CONTEXT.md',
'M999',
false,
);
assert.strictEqual(result.block, true);
assert.ok(result.reason!.includes('depth_verification'), 'reason should mention depth_verification question id');
@ -141,7 +137,6 @@ test('write-gate: blocks CONTEXT.md write in queue mode without depth verificati
'write',
'.gsd/milestones/M001/M001-CONTEXT.md',
null, // no milestoneId in queue mode
false, // not depth-verified
true, // queue phase active
);
assert.strictEqual(result.block, true, 'should block in queue mode without depth verification');
@ -151,46 +146,70 @@ test('write-gate: blocks CONTEXT.md write in queue mode without depth verificati
// ─── Scenario 9: Queue mode allows CONTEXT.md write after depth verification ──
test('write-gate: allows CONTEXT.md write in queue mode after depth verification', () => {
clearDiscussionFlowState();
markDepthVerified('M001');
const result = shouldBlockContextWrite(
'write',
'.gsd/milestones/M001/M001-CONTEXT.md',
null, // no milestoneId in queue mode
true, // depth-verified
true, // queue phase active
);
assert.strictEqual(result.block, false, 'should not block in queue mode after depth verification');
clearDiscussionFlowState();
});
// ─── Scenario 10: markDepthVerified works in queue-only mode (no milestoneId) ──
// This is the core regression for #1812: in queue mode, the tool_result handler
// must call markDepthVerified() even when getDiscussionMilestoneId() is null.
// ─── Scenario 10: depth verification is scoped per milestone, not global ──
test('write-gate: markDepthVerified unblocks queue-mode writes when milestoneId is null', () => {
test('write-gate: markDepthVerified unlocks only the matching milestone', () => {
clearDiscussionFlowState();
setQueuePhaseActive(true);
markDepthVerified('M001');
// Before marking: should block
const blocked = shouldBlockContextWrite(
'write',
'.gsd/milestones/M001/M001-CONTEXT.md',
null,
isDepthVerified(),
isQueuePhaseActive(),
);
assert.strictEqual(blocked.block, true, 'should block before markDepthVerified');
// Simulate what the fixed tool_result handler does
markDepthVerified();
// After marking: should pass
const allowed = shouldBlockContextWrite(
'write',
'.gsd/milestones/M001/M001-CONTEXT.md',
null,
isDepthVerified(),
isQueuePhaseActive(),
);
assert.strictEqual(allowed.block, false, 'should allow after markDepthVerified in queue mode');
assert.strictEqual(allowed.block, false, 'should allow the verified milestone');
const blockedOther = shouldBlockContextWrite(
'write',
'.gsd/milestones/M002/M002-CONTEXT.md',
null,
);
assert.strictEqual(blockedOther.block, true, 'other milestones should remain blocked');
assert.strictEqual(isMilestoneDepthVerified('M001'), true);
assert.strictEqual(isMilestoneDepthVerified('M002'), false);
clearDiscussionFlowState();
});
// ─── Scenario 11: gsd_summary_save CONTEXT contract is milestone-scoped ──
test('write-gate: gsd_summary_save only blocks final milestone CONTEXT writes', () => {
clearDiscussionFlowState();
assert.strictEqual(
shouldBlockContextArtifactSave('CONTEXT-DRAFT', 'M001').block,
false,
'draft CONTEXT should be allowed',
);
assert.strictEqual(
shouldBlockContextArtifactSave('CONTEXT', 'M001', 'S01').block,
false,
'slice CONTEXT should be allowed',
);
assert.strictEqual(
shouldBlockContextArtifactSave('CONTEXT', 'M001').block,
true,
'final milestone CONTEXT should block before verification',
);
markDepthVerified('M001');
assert.strictEqual(
shouldBlockContextArtifactSave('CONTEXT', 'M001').block,
false,
'final milestone CONTEXT should pass after verification',
);
clearDiscussionFlowState();
});
@ -281,15 +300,15 @@ test('write-gate: shouldBlockPendingGate allows read-only and ask_user_questions
clearDiscussionFlowState();
});
// ─── Scenario 23: shouldBlockPendingGate does not block outside discussion ──
// ─── Scenario 23: shouldBlockPendingGate still blocks when the session is ambiguous ──
test('write-gate: shouldBlockPendingGate does not block outside discussion', () => {
test('write-gate: shouldBlockPendingGate blocks outside discussion when a gate is pending', () => {
clearDiscussionFlowState();
setPendingGate('layer1_scope_gate');
// No milestoneId and no queue phase — not in discussion
// No milestoneId and no queue phase — still block because the gate is pending
const result = shouldBlockPendingGate('write', null, false);
assert.strictEqual(result.block, false, 'should not block outside discussion');
assert.strictEqual(result.block, true, 'should block even when milestoneId is null');
clearDiscussionFlowState();
});