diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index 8a875e7d5..18634c486 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -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, diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts index c94e3f32b..4bb105f71 100644 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ b/src/resources/extensions/gsd/bootstrap/register-hooks.ts @@ -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); 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; } diff --git a/src/resources/extensions/gsd/bootstrap/write-gate.ts b/src/resources/extensions/gsd/bootstrap/write-gate.ts index 937fd4eba..959c8a78f 100644 --- a/src/resources/extensions/gsd/bootstrap/write-gate.ts +++ b/src/resources/extensions/gsd/bootstrap/write-gate.ts @@ -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(); 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.`, + }; } - diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index d187700a0..179567c49 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -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) { diff --git a/src/resources/extensions/gsd/tests/gsd-tools.test.ts b/src/resources/extensions/gsd/tests/gsd-tools.test.ts index e86f69b98..695a3b235 100644 --- a/src/resources/extensions/gsd/tests/gsd-tools.test.ts +++ b/src/resources/extensions/gsd/tests/gsd-tools.test.ts @@ -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 diff --git a/src/resources/extensions/gsd/tests/guided-flow-session-isolation.test.ts b/src/resources/extensions/gsd/tests/guided-flow-session-isolation.test.ts index 33e95e007..c104b2d22 100644 --- a/src/resources/extensions/gsd/tests/guided-flow-session-isolation.test.ts +++ b/src/resources/extensions/gsd/tests/guided-flow-session-isolation.test.ts @@ -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 }); + } +}); diff --git a/src/resources/extensions/gsd/tests/health-widget.test.ts b/src/resources/extensions/gsd/tests/health-widget.test.ts index 17893a5b5..88985af58 100644 --- a/src/resources/extensions/gsd/tests/health-widget.test.ts +++ b/src/resources/extensions/gsd/tests/health-widget.test.ts @@ -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 Promise | void>(); + const pi = { + on(event: string, handler: (event: unknown, ctx: any) => Promise | 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"); +}); diff --git a/src/resources/extensions/gsd/tests/queue-execution-guard.test.ts b/src/resources/extensions/gsd/tests/queue-execution-guard.test.ts index 0b200b12a..62662db8a 100644 --- a/src/resources/extensions/gsd/tests/queue-execution-guard.test.ts +++ b/src/resources/extensions/gsd/tests/queue-execution-guard.test.ts @@ -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'); +}); diff --git a/src/resources/extensions/gsd/tests/write-gate.test.ts b/src/resources/extensions/gsd/tests/write-gate.test.ts index 3c762e1da..48c0c5524 100644 --- a/src/resources/extensions/gsd/tests/write-gate.test.ts +++ b/src/resources/extensions/gsd/tests/write-gate.test.ts @@ -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(); });