From fd74c9d68ad512ffe797a9bb76758f4d257801ba Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 23:00:11 -0400 Subject: [PATCH] feat(sdk): register phase.uat-passed as canonical read-only query (#3184) Cycle 16 of 16: wires isPhaseUatPassed into the SDK query registry as 'phase.uat-passed' (alias 'phase uat-passed'). Read-only, JSON output. Handler validates the phase argument and adapts isPhaseUatPassed to the QueryHandler signature. Adds INVALID_PHASE_NUM to ERROR_CODE for argument validation. Updates command-aliases.generated.ts to keep seam coverage parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/command-aliases.generated.ts | 1 + sdk/src/query/command-family-handlers.ts | 2 ++ sdk/src/query/command-manifest.phase.ts | 1 + sdk/src/query/phase-uat-passed.test.ts | 27 ++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 20 ++++++++++++++++ 5 files changed, 51 insertions(+) diff --git a/sdk/src/query/command-aliases.generated.ts b/sdk/src/query/command-aliases.generated.ts index 6c79b91e6..93f17ac91 100644 --- a/sdk/src/query/command-aliases.generated.ts +++ b/sdk/src/query/command-aliases.generated.ts @@ -67,6 +67,7 @@ export const INIT_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [ export const PHASE_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [ { canonical: 'phase.list-plans', aliases: ['phase list-plans'], subcommand: 'list-plans', mutation: false }, { canonical: 'phase.list-artifacts', aliases: ['phase list-artifacts'], subcommand: 'list-artifacts', mutation: false }, + { canonical: 'phase.uat-passed', aliases: ['phase uat-passed'], subcommand: 'uat-passed', mutation: false }, { canonical: 'phase.next-decimal', aliases: ['phase next-decimal'], subcommand: 'next-decimal', mutation: false }, { canonical: 'phase.add', aliases: ['phase add'], subcommand: 'add', mutation: true }, { canonical: 'phase.add-batch', aliases: ['phase add-batch'], subcommand: 'add-batch', mutation: true }, diff --git a/sdk/src/query/command-family-handlers.ts b/sdk/src/query/command-family-handlers.ts index 11470e8c5..d3b00f103 100644 --- a/sdk/src/query/command-family-handlers.ts +++ b/sdk/src/query/command-family-handlers.ts @@ -24,6 +24,7 @@ import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, va import { phaseListPlans, phaseListArtifacts, } from './phase-list-queries.js'; +import { phaseUatPassed } from './phase-uat-passed.js'; import { phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete, phaseScaffold, phaseNextDecimal, phasesList, phasesClear, phasesArchive, @@ -86,6 +87,7 @@ export const FAMILY_HANDLERS: Record { + it('phase.uat-passed is registered and dispatchable through the query registry', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c16-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-walking-skeleton'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), UAT_PASS_CONTENT); + + const registry = createRegistry(); + + // The handler must be found — if not registered, this returns undefined. + expect(registry.has('phase.uat-passed'), 'phase.uat-passed handler not found in registry').toBe(true); + + // Dispatch via the real registry path; args[0] is the phase token. + const result = await registry.dispatch('phase.uat-passed', ['5'], localTmp); + + const data = result.data as { passed: boolean; items: Array>; reasons: unknown[] }; + expect(data.passed).toBe(true); + expect(data.items.length).toBe(1); + expect(data.items[0].result).toBe('pass'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); +}); diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index 6474c39ac..54825f624 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -11,6 +11,7 @@ import { resolvePhaseDir } from './phase-list-queries.js'; import { extractFrontmatter } from './frontmatter.js'; import { parseVerificationFrontmatterItems } from './uat.js'; import { GSDError, ErrorClassification } from '../errors.js'; +import type { QueryHandler } from './utils.js'; export const REASON_CODE = Object.freeze({ NON_PASS_RESULT: 'non_pass_result', @@ -27,6 +28,7 @@ export type ReasonCode = typeof REASON_CODE[keyof typeof REASON_CODE]; export const ERROR_CODE = Object.freeze({ PROJECT_DIR_MISSING: 'project_dir_missing', + INVALID_PHASE_NUM: 'invalid_phase_num', } as const); export type ErrorCode = typeof ERROR_CODE[keyof typeof ERROR_CODE]; @@ -246,3 +248,21 @@ export async function isPhaseUatPassed( return { passed, reasons, reasonsHuman: [], items }; } + +/** + * QueryHandler adapter for `phase.uat-passed` registry entry. + * + * args[0] — phase token (required, e.g. '5' or '05-walking-skeleton'). + * Matches the args convention used by phase.list-plans / phase.list-artifacts. + */ +export const phaseUatPassed: QueryHandler = async (args, projectDir, workstream) => { + const phase = args[0]; + if (typeof phase !== 'string' || phase.trim() === '') { + throw new PhaseUatPassedError( + 'phase argument is required', + ERROR_CODE.INVALID_PHASE_NUM, + ); + } + const result = await isPhaseUatPassed(projectDir, phase, workstream); + return { data: result }; +};