diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts index 438d4d9b0..ff6aefa83 100644 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ b/src/resources/extensions/gsd/bootstrap/register-hooks.ts @@ -181,14 +181,10 @@ export function registerHooks(pi: ExtensionAPI): void { // 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(discussionBasePath); - const inDiscussion = milestoneId !== null || isQueuePhaseActive(); - if (inDiscussion) { - const questions: any[] = (event.input as any)?.questions ?? []; - const questionId = questions.find((question) => typeof question?.id === "string" && isGateQuestionId(question.id))?.id; - if (typeof questionId === "string") { - setPendingGate(questionId); - } + const questions: any[] = (event.input as any)?.questions ?? []; + const questionId = questions.find((question) => typeof question?.id === "string" && isGateQuestionId(question.id))?.id; + if (typeof questionId === "string") { + setPendingGate(questionId); } } @@ -286,7 +282,6 @@ export function registerHooks(pi: ExtensionAPI): void { if (event.toolName !== "ask_user_questions") return; const milestoneId = getDiscussionMilestoneId(process.cwd()); const queueActive = isQueuePhaseActive(); - if (!milestoneId && !queueActive) return; const details = event.details as any; @@ -319,13 +314,16 @@ export function registerHooks(pi: ExtensionAPI): void { // Only unlock the gate if the user selected the first option (confirmation). // Cross-references against the question's defined options to reject free-form "Other" text. const answer = details.response?.answers?.[question.id]; + const inferredMilestoneId = extractDepthVerificationMilestoneId(question.id) ?? milestoneId; if (isDepthConfirmationAnswer(answer?.selected, question.options)) { - markDepthVerified(extractDepthVerificationMilestoneId(question.id) ?? milestoneId); + markDepthVerified(inferredMilestoneId); + clearPendingGate(); } break; } } + if (!milestoneId && !queueActive) return; if (!milestoneId) return; const basePath = process.cwd(); diff --git a/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts b/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts new file mode 100644 index 000000000..8e717234e --- /dev/null +++ b/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { registerHooks } from "../bootstrap/register-hooks.ts"; +import { + getPendingGate, + resetWriteGateState, + shouldBlockContextArtifactSave, +} from "../bootstrap/write-gate.ts"; + +function makeTempDir(prefix: string): string { + const dir = join( + tmpdir(), + `gsd-depth-gate-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +test("register-hooks unlocks milestone depth verification from question id without guided-flow state (#4047)", async (t) => { + const dir = makeTempDir("manual"); + const originalCwd = process.cwd(); + process.chdir(dir); + resetWriteGateState(); + + t.after(() => { + resetWriteGateState(); + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + }); + + const handlers = new Map Promise | void>>(); + const pi = { + on(event: string, handler: (event: any, ctx?: any) => Promise | void) { + const existing = handlers.get(event) ?? []; + existing.push(handler); + handlers.set(event, existing); + }, + } as any; + + registerHooks(pi); + + const questionId = "depth_verification_M001_confirm"; + const questions = [ + { + id: questionId, + question: "Do you agree?", + options: [ + { label: "Yes, you got it (Recommended)" }, + { label: "Needs adjustment" }, + ], + }, + ]; + + const toolCallHandlers = handlers.get("tool_call"); + const toolResultHandlers = handlers.get("tool_result"); + assert.ok(toolCallHandlers?.length, "tool_call handler should be registered"); + assert.ok(toolResultHandlers?.length, "tool_result handler should be registered"); + + for (const handler of toolCallHandlers ?? []) { + await handler({ + toolName: "ask_user_questions", + input: { questions }, + }); + } + + assert.equal(getPendingGate(), questionId, "gate should be set even without guided-flow state"); + assert.equal( + shouldBlockContextArtifactSave("CONTEXT", "M001").block, + true, + "milestone context should still be blocked before confirmation", + ); + + for (const handler of toolResultHandlers ?? []) { + await handler({ + toolName: "ask_user_questions", + input: { questions }, + details: { + response: { + answers: { + [questionId]: { selected: "Yes, you got it (Recommended)" }, + }, + }, + }, + }); + } + + assert.equal(getPendingGate(), null, "confirming the depth question should clear the pending gate"); + assert.equal( + shouldBlockContextArtifactSave("CONTEXT", "M001").block, + false, + "question-id milestone inference should unlock the matching milestone context write", + ); +});