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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-18 23:00:11 -04:00
parent 99e81b2605
commit fd74c9d68a
5 changed files with 51 additions and 0 deletions

View File

@@ -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 },

View File

@@ -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<string, Readonly<Record<string, QueryHandle
phase: {
'phase.list-plans': phaseListPlans,
'phase.list-artifacts': phaseListArtifacts,
'phase.uat-passed': phaseUatPassed,
'phase.add': phaseAdd,
'phase.add-batch': phaseAddBatch,
'phase.insert': phaseInsert,

View File

@@ -6,6 +6,7 @@ import type { CommandManifestEntry } from './command-manifest.types.js';
export const PHASE_COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
{ family: 'phase', canonical: 'phase.list-plans', aliases: ['phase list-plans'], mutation: false, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.list-artifacts', aliases: ['phase list-artifacts'], mutation: false, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.uat-passed', aliases: ['phase uat-passed'], mutation: false, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.next-decimal', aliases: ['phase next-decimal'], mutation: false, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.add', aliases: ['phase add'], mutation: true, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.add-batch', aliases: ['phase add-batch'], mutation: true, outputMode: 'json' },

View File

@@ -7,6 +7,7 @@ import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { isPhaseUatPassed, REASON_CODE, PhaseUatPassedError, ERROR_CODE } from './phase-uat-passed.js';
import { createRegistry } from './index.js';
const UAT_PASS_CONTENT = `---
status: complete
@@ -438,3 +439,29 @@ result: PASS
}
});
});
describe('phase.uat-passed registry wire-up (cycle 16)', () => {
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<Record<string, unknown>>; 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 });
}
});
});

View File

@@ -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 };
};