From 3efb39e321e90f35c25837262909ce962483547a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:35:50 -0400 Subject: [PATCH 01/22] feat(sdk): isPhaseUatPassed walking skeleton (#3184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle 1 of ~15: minimal end-to-end predicate. One phase dir, one UAT file, one pass result. No injection stripping, no orphan detection, no CLI registration yet — those follow in subsequent commits per TDD discipline. Also exports resolvePhaseDir from phase-list-queries.ts (was private) so the new predicate can reuse it without duplication. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-list-queries.ts | 2 +- sdk/src/query/phase-uat-passed.test.ts | 45 +++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 60 ++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 sdk/src/query/phase-uat-passed.test.ts create mode 100644 sdk/src/query/phase-uat-passed.ts diff --git a/sdk/src/query/phase-list-queries.ts b/sdk/src/query/phase-list-queries.ts index 17335cfc0..65aae7ebe 100644 --- a/sdk/src/query/phase-list-queries.ts +++ b/sdk/src/query/phase-list-queries.ts @@ -17,7 +17,7 @@ import { import type { QueryHandler } from './utils.js'; /** Resolve `.planning/phases/` for a phase token, or null. */ -async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise { +export async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise { const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); try { diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts new file mode 100644 index 000000000..46ad6993f --- /dev/null +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -0,0 +1,45 @@ +/** + * Unit tests for isPhaseUatPassed — walking skeleton (cycle 1 of ~15). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { isPhaseUatPassed } from './phase-uat-passed.js'; + +const UAT_PASS_CONTENT = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +### 1. First item +expected: thing should happen +result: pass +`; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-uat-passed-')); + const phaseDir = join(tmpDir, '.planning', 'phases', '05-walking-skeleton'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), UAT_PASS_CONTENT); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('isPhaseUatPassed', () => { + it('returns passed=true when a single UAT file contains one pass result', async () => { + const result = await isPhaseUatPassed(tmpDir, '5'); + expect(result.passed).toBe(true); + expect(result.items.length).toBe(1); + expect(result.items[0].result).toBe('pass'); + expect(result.reasons.length).toBe(0); + }); +}); diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts new file mode 100644 index 000000000..8f72eb0bf --- /dev/null +++ b/sdk/src/query/phase-uat-passed.ts @@ -0,0 +1,60 @@ +/** + * isPhaseUatPassed — SDK predicate answering "is phase N's UAT contract satisfied?" + * + * Cycle 1 of ~15 (walking skeleton): happy path only. No injection stripping, + * no orphan detection, no human_verification frontmatter merge, no REASON_CODEs. + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { resolvePhaseDir } from './phase-list-queries.js'; + +/** Regex to parse all UAT items regardless of result value. */ +const UAT_ITEM_PATTERN = + /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*(\w+)/g; + +function parseAllUatItems(content: string): Record[] { + const items: Record[] = []; + UAT_ITEM_PATTERN.lastIndex = 0; + let m: RegExpMatchArray | null; + while ((m = UAT_ITEM_PATTERN.exec(content)) !== null) { + const [, num, name, expected, result] = m; + items.push({ + test: parseInt(num, 10), + name: name.trim(), + expected: expected.trim(), + result, + }); + } + UAT_ITEM_PATTERN.lastIndex = 0; + return items; +} + +export async function isPhaseUatPassed( + projectDir: string, + phase: string, + workstream?: string, +): Promise<{ + passed: boolean; + reasons: unknown[]; + reasonsHuman: string[]; + items: Record[]; +}> { + const dir = await resolvePhaseDir(phase, projectDir, workstream); + if (!dir) { + return { passed: false, reasons: [], reasonsHuman: [], items: [] }; + } + + const files = await readdir(dir); + const uatFiles = files.filter((f) => f.endsWith('-HUMAN-UAT.md')); + + const items: Record[] = []; + for (const file of uatFiles) { + const content = await readFile(join(dir, file), 'utf-8'); + items.push(...parseAllUatItems(content)); + } + + const passed = items.length > 0 && items.every((i) => i.result === 'pass'); + + return { passed, reasons: [], reasonsHuman: [], items }; +} From 0ff6b4cc88333420fff98274a7eaad170c34c3b7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:40:54 -0400 Subject: [PATCH 02/22] feat(sdk): non-pass UAT items emit NON_PASS_RESULT reason (#3184) Cycle 2 of ~15: introduces REASON_CODE frozen enum and UatReason typed shape. Non-pass items (result not literally 'pass') now contribute a typed reason instead of being invisible to callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-uat-passed.test.ts | 33 ++++++++++++++- sdk/src/query/phase-uat-passed.ts | 57 +++++++++++++++++++++----- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 46ad6993f..ba164f15f 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { isPhaseUatPassed } from './phase-uat-passed.js'; +import { isPhaseUatPassed, REASON_CODE } from './phase-uat-passed.js'; const UAT_PASS_CONTENT = `--- status: complete @@ -42,4 +42,35 @@ describe('isPhaseUatPassed', () => { expect(result.items[0].result).toBe('pass'); expect(result.reasons.length).toBe(0); }); + + it('returns passed=false with NON_PASS_RESULT reason when single UAT item has result: issue', async () => { + const nonPassContent = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +### 1. Some item +expected: thing happens +result: issue +`; + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c2-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-non-pass'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), nonPassContent); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(false); + expect(result.items.length).toBe(1); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.NON_PASS_RESULT); + expect(result.reasons[0].capturedValue).toBe('issue'); + expect(result.reasons[0].itemName).toBe('Some item'); + } 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 8f72eb0bf..e27d8f9b5 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -1,20 +1,40 @@ /** * isPhaseUatPassed — SDK predicate answering "is phase N's UAT contract satisfied?" * - * Cycle 1 of ~15 (walking skeleton): happy path only. No injection stripping, - * no orphan detection, no human_verification frontmatter merge, no REASON_CODEs. + * Cycle 2 of ~15: introduces REASON_CODE frozen enum and UatReason typed shape. + * Non-pass items (result not literally 'pass') emit a typed NON_PASS_RESULT reason. */ import { readFile, readdir } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { resolvePhaseDir } from './phase-list-queries.js'; +export const REASON_CODE = Object.freeze({ + NON_PASS_RESULT: 'non_pass_result', +} as const); + +export type ReasonCode = typeof REASON_CODE[keyof typeof REASON_CODE]; + +export type UatReason = { + code: ReasonCode; + file?: string; + itemName?: string; + capturedValue?: string; +}; + /** Regex to parse all UAT items regardless of result value. */ const UAT_ITEM_PATTERN = /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*(\w+)/g; -function parseAllUatItems(content: string): Record[] { - const items: Record[] = []; +interface UatItem { + test: number; + name: string; + expected: string; + result: string; +} + +function parseAllUatItems(content: string): UatItem[] { + const items: UatItem[] = []; UAT_ITEM_PATTERN.lastIndex = 0; let m: RegExpMatchArray | null; while ((m = UAT_ITEM_PATTERN.exec(content)) !== null) { @@ -36,7 +56,7 @@ export async function isPhaseUatPassed( workstream?: string, ): Promise<{ passed: boolean; - reasons: unknown[]; + reasons: UatReason[]; reasonsHuman: string[]; items: Record[]; }> { @@ -48,13 +68,28 @@ export async function isPhaseUatPassed( const files = await readdir(dir); const uatFiles = files.filter((f) => f.endsWith('-HUMAN-UAT.md')); - const items: Record[] = []; + const items: UatItem[] = []; + const reasons: UatReason[] = []; + for (const file of uatFiles) { - const content = await readFile(join(dir, file), 'utf-8'); - items.push(...parseAllUatItems(content)); + const filePath = join(dir, file); + const relFile = relative(projectDir, filePath); + const content = await readFile(filePath, 'utf-8'); + const parsed = parseAllUatItems(content); + for (const item of parsed) { + items.push(item); + if (item.result !== 'pass') { + reasons.push({ + code: REASON_CODE.NON_PASS_RESULT, + file: relFile, + itemName: item.name, + capturedValue: item.result, + }); + } + } } - const passed = items.length > 0 && items.every((i) => i.result === 'pass'); + const passed = items.length > 0 && reasons.length === 0; - return { passed, reasons: [], reasonsHuman: [], items }; + return { passed, reasons, reasonsHuman: [], items }; } From c1664e52aebb5b75cf772a6a09d60eddb301f3d4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:41:25 -0400 Subject: [PATCH 03/22] feat(sdk): missing phase dir surfaces NO_PHASE_DIR reason (#3184) Cycle 3 of ~15: when resolvePhaseDir returns null, predicate returns a typed NO_PHASE_DIR reason. Previously the empty reasons array was indistinguishable from other failure modes. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-uat-passed.test.ts | 18 ++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index ba164f15f..71b4ba4f7 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -43,6 +43,24 @@ describe('isPhaseUatPassed', () => { expect(result.reasons.length).toBe(0); }); + it('returns passed=false with NO_PHASE_DIR reason when phase has no directory', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c3-')); + try { + const otherPhaseDir = join(localTmp, '.planning', 'phases', '06-other'); + await mkdir(otherPhaseDir, { recursive: true }); + await writeFile(join(otherPhaseDir, '06-HUMAN-UAT.md'), UAT_PASS_CONTENT); + + // Query phase 5 which has NO directory in this fixture + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(false); + expect(result.items.length).toBe(0); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.NO_PHASE_DIR); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + it('returns passed=false with NON_PASS_RESULT reason when single UAT item has result: issue', async () => { const nonPassContent = `--- status: complete diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index e27d8f9b5..1fd90c234 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'; export const REASON_CODE = Object.freeze({ NON_PASS_RESULT: 'non_pass_result', + NO_PHASE_DIR: 'no_phase_dir', } as const); export type ReasonCode = typeof REASON_CODE[keyof typeof REASON_CODE]; @@ -62,7 +63,12 @@ export async function isPhaseUatPassed( }> { const dir = await resolvePhaseDir(phase, projectDir, workstream); if (!dir) { - return { passed: false, reasons: [], reasonsHuman: [], items: [] }; + return { + passed: false, + reasons: [{ code: REASON_CODE.NO_PHASE_DIR }], + reasonsHuman: [], + items: [], + }; } const files = await readdir(dir); From 1a277804436fcd9d89d4a3bea781fbccbd4e33f8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:41:57 -0400 Subject: [PATCH 04/22] feat(sdk): empty phase dir surfaces NO_UAT_FILES reason (#3184) Cycle 4 of ~15: phase dir present but no *-HUMAN-UAT.md files now returns a typed NO_UAT_FILES reason instead of an empty reasons array (which prior cycles used as a 'shouldn't happen' fallback). Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-uat-passed.test.ts | 18 ++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 71b4ba4f7..6f2179ade 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -43,6 +43,24 @@ describe('isPhaseUatPassed', () => { expect(result.reasons.length).toBe(0); }); + it('returns passed=false with NO_UAT_FILES reason when phase dir has no UAT files', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c4-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-empty'); + await mkdir(phaseDir, { recursive: true }); + // Write a non-UAT file to ensure the dir exists but has no *-HUMAN-UAT.md + await writeFile(join(phaseDir, '05-PLAN.md'), '# Plan\nNothing here.\n'); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(false); + expect(result.items.length).toBe(0); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.NO_UAT_FILES); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + it('returns passed=false with NO_PHASE_DIR reason when phase has no directory', async () => { const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c3-')); try { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index 1fd90c234..7403a0aeb 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -12,6 +12,7 @@ import { resolvePhaseDir } from './phase-list-queries.js'; export const REASON_CODE = Object.freeze({ NON_PASS_RESULT: 'non_pass_result', NO_PHASE_DIR: 'no_phase_dir', + NO_UAT_FILES: 'no_uat_files', } as const); export type ReasonCode = typeof REASON_CODE[keyof typeof REASON_CODE]; @@ -74,6 +75,15 @@ export async function isPhaseUatPassed( const files = await readdir(dir); const uatFiles = files.filter((f) => f.endsWith('-HUMAN-UAT.md')); + if (uatFiles.length === 0) { + return { + passed: false, + reasons: [{ code: REASON_CODE.NO_UAT_FILES }], + reasonsHuman: [], + items: [], + }; + } + const items: UatItem[] = []; const reasons: UatReason[] = []; From a38fa6c7a6a31065be3a1b7426ac9f7a3cb5efa4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:45:14 -0400 Subject: [PATCH 05/22] feat(sdk): strip frontmatter region before UAT item scan (#3184) Cycle 5 of ~15: introduces stripMarkdownInjection helper. First pass strips the YAML frontmatter region so injected '### N. item' patterns inside frontmatter literal blocks cannot be mistaken for real UAT items. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-uat-passed.test.ts | 32 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 14 ++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 6f2179ade..d912d09b3 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -109,4 +109,36 @@ result: issue await rm(localTmp, { recursive: true, force: true }); } }); + + it('ignores ### item content inside YAML frontmatter region', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c5-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-frontmatter-injection'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +malicious_demo: | +### 1. Frontmatter-injected item +expected: nothing +result: pass +--- + +### 1. Real item +expected: real thing +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(true); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Real item'); + } 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 7403a0aeb..310f7cd4f 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -35,11 +35,23 @@ interface UatItem { result: string; } +/** + * Strip regions from file content that could contain markdown-shaped text + * but should not be treated as UAT items (frontmatter, code fences, etc.). + * Passes are applied in order; each returns a sanitised string. + */ +function stripMarkdownInjection(content: string): string { + // Pass 1: strip YAML frontmatter region (---\n...\n---) + let s = content.replace(/^---\r?\n[\s\S]*?\r?\n---/m, ''); + return s; +} + function parseAllUatItems(content: string): UatItem[] { + const sanitised = stripMarkdownInjection(content); const items: UatItem[] = []; UAT_ITEM_PATTERN.lastIndex = 0; let m: RegExpMatchArray | null; - while ((m = UAT_ITEM_PATTERN.exec(content)) !== null) { + while ((m = UAT_ITEM_PATTERN.exec(sanitised)) !== null) { const [, num, name, expected, result] = m; items.push({ test: parseInt(num, 10), From 3c0490c0218162dadd88fbcd8b276a5559cbbcc1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:45:47 -0400 Subject: [PATCH 06/22] feat(sdk): strip fenced code blocks before UAT item scan (#3184) Cycle 6 of ~15: adds fenced-block stripping pass to stripMarkdownInjection. Docs-and-examples inside code fences no longer pollute the item roster. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-uat-passed.test.ts | 36 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 2 ++ 2 files changed, 38 insertions(+) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index d912d09b3..ae55f2e1f 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -141,4 +141,40 @@ result: pass await rm(localTmp, { recursive: true, force: true }); } }); + + it('ignores ### item content inside fenced code blocks', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c6-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-fenced-injection'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +Some prose. + +\`\`\`markdown +### 1. Fenced example +expected: blah +result: pass +\`\`\` + +### 1. Real item +expected: real +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Real item'); + expect(result.passed).toBe(true); + } 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 310f7cd4f..b6803b180 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -43,6 +43,8 @@ interface UatItem { function stripMarkdownInjection(content: string): string { // Pass 1: strip YAML frontmatter region (---\n...\n---) let s = content.replace(/^---\r?\n[\s\S]*?\r?\n---/m, ''); + // Pass 2: strip fenced code blocks (``` ... ```) + s = s.replace(/```[\s\S]*?```/g, ''); return s; } From 47a7412164994a0ee2fde895bae2236872de0da8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:46:17 -0400 Subject: [PATCH 07/22] feat(sdk): strip HTML comment regions before UAT item scan (#3184) Cycle 7 of ~15: adds HTML-comment stripping pass. Operator notes inside no longer pollute the item roster. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-uat-passed.test.ts | 34 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 2 ++ 2 files changed, 36 insertions(+) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index ae55f2e1f..5677a764c 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -163,6 +163,40 @@ expected: blah result: pass \`\`\` +### 1. Real item +expected: real +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Real item'); + expect(result.passed).toBe(true); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('ignores ### item content inside HTML comments', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c7-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-html-comment-injection'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + + + ### 1. Real item expected: real result: pass diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index b6803b180..3c5cb3bea 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -45,6 +45,8 @@ function stripMarkdownInjection(content: string): string { let s = content.replace(/^---\r?\n[\s\S]*?\r?\n---/m, ''); // Pass 2: strip fenced code blocks (``` ... ```) s = s.replace(/```[\s\S]*?```/g, ''); + // Pass 3: strip HTML comment regions () + s = s.replace(//g, ''); return s; } From 7c1ffb4131c634513da77a794486cd7820921928 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:47:15 -0400 Subject: [PATCH 08/22] feat(sdk): strip blockquote-prefixed lines before UAT item scan (#3184) Cycle 8 of ~15: adds blockquote-line stripping pass. Quoted documentation snippets no longer pollute the item roster. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-uat-passed.test.ts | 32 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 2 ++ 2 files changed, 34 insertions(+) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 5677a764c..7896b748e 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -197,6 +197,38 @@ expected: blah result: pass --> +### 1. Real item +expected: real +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Real item'); + expect(result.passed).toBe(true); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('ignores ### item content on blockquote-prefixed lines', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c8-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-blockquote-injection'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +> ### 1. Quoted item +expected: blah +result: pass + ### 1. Real item expected: real result: pass diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index 3c5cb3bea..9048a07a9 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -47,6 +47,8 @@ function stripMarkdownInjection(content: string): string { s = s.replace(/```[\s\S]*?```/g, ''); // Pass 3: strip HTML comment regions () s = s.replace(//g, ''); + // Pass 4: strip blockquote-prefixed lines (any line starting with optional whitespace + >) + s = s.replace(/^\s*>.*$/gm, ''); return s; } From 42cc42de8bb7b001f8ab4eb94654858a9594923c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:47:57 -0400 Subject: [PATCH 09/22] feat(sdk): accept bold-prefixed **result:** key (#3184) Cycle 9 of ~15: regex now matches both bare 'result:' and bold-prefixed '**result:**' forms. Requester used the bold form in the issue text; canonical template uses bare. Both are equally valid; predicate is form-agnostic. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-uat-passed.test.ts | 29 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 5 +++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 7896b748e..ff33163fc 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -243,4 +243,33 @@ result: pass await rm(localTmp, { recursive: true, force: true }); } }); + + it('parses bold-prefixed **result:** key as equivalent to bare result:', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c9-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-bold-key'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +### 1. Bold-key item +expected: thing +**result:** pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Bold-key item'); + expect(result.items[0].result).toBe('pass'); + expect(result.passed).toBe(true); + } 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 9048a07a9..1dea2e76f 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -24,9 +24,10 @@ export type UatReason = { capturedValue?: string; }; -/** Regex to parse all UAT items regardless of result value. */ +/** Regex to parse all UAT items regardless of result value. + * Accepts optional bold markers (**key:**) around expected/result keys. */ const UAT_ITEM_PATTERN = - /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*(\w+)/g; + /###\s*(\d+)\.\s*([^\n]+)\n(?:\*\*)?expected:(?:\*\*)?\s*([^\n]+)\n(?:\*\*)?result:(?:\*\*)?\s*(\w+)/g; interface UatItem { test: number; From 29a48d8a78ed728eeffc003dc8718a68d14cb20d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:51:16 -0400 Subject: [PATCH 10/22] feat(sdk): emit CASE_MISMATCH reason for non-canonical pass casing (#3184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle 10 of ~15: a captured 'result:' value that lowercases to 'pass' but isn't literally 'pass' now produces a CASE_MISMATCH reason rather than a generic NON_PASS_RESULT — so operators can distinguish a real non-pass from a likely typo. --- sdk/src/query/phase-uat-passed.test.ts | 30 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 7 +++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index ff33163fc..69f247ca2 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -272,4 +272,34 @@ expected: thing await rm(localTmp, { recursive: true, force: true }); } }); + + it("emits CASE_MISMATCH reason when result value is \"PASS\" (uppercase variant of pass)", async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c10-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-case-mismatch'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +### 1. Uppercase pass item +expected: thing happens +result: PASS +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(false); + expect(result.items.length).toBe(1); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.CASE_MISMATCH); + expect(result.reasons[0].capturedValue).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 1dea2e76f..cfd1323f2 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'; export const REASON_CODE = Object.freeze({ NON_PASS_RESULT: 'non_pass_result', + CASE_MISMATCH: 'case_mismatch', NO_PHASE_DIR: 'no_phase_dir', NO_UAT_FILES: 'no_uat_files', } as const); @@ -114,8 +115,12 @@ export async function isPhaseUatPassed( for (const item of parsed) { items.push(item); if (item.result !== 'pass') { + const code = + item.result.toLowerCase() === 'pass' + ? REASON_CODE.CASE_MISMATCH + : REASON_CODE.NON_PASS_RESULT; reasons.push({ - code: REASON_CODE.NON_PASS_RESULT, + code, file: relFile, itemName: item.name, capturedValue: item.result, From d7aa4ce6e32821275b8df1413d4bcd29cdc572b8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:52:25 -0400 Subject: [PATCH 11/22] feat(sdk): merge frontmatter human_verification items into roster (#3184) Cycle 11 of ~15: reuses parseVerificationFrontmatterItems from uat.ts. Frontmatter-declared manual-verification items roll into the same items[] roster and emit HUMAN_VERIFICATION_NEEDED reasons so passed=false is justified, not silent. --- sdk/src/query/phase-uat-passed.test.ts | 33 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 18 ++++++++++++++ sdk/src/query/uat.ts | 2 +- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 69f247ca2..6b011a1d3 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -273,6 +273,39 @@ expected: thing } }); + it("human_verification items in frontmatter contribute HUMAN_VERIFICATION_NEEDED reasons", async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c11-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-human-verification'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +human_verification: + - name: manual smoke test + expected: app loads +--- + +### 1. Real pass +expected: thing +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(false); + expect(result.items.length).toBe(2); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.HUMAN_VERIFICATION_NEEDED); + expect(result.reasons[0].itemName).toBe('manual smoke test'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + it("emits CASE_MISMATCH reason when result value is \"PASS\" (uppercase variant of pass)", async () => { const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c10-')); try { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index cfd1323f2..bb41dce92 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -8,10 +8,13 @@ import { readFile, readdir } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { resolvePhaseDir } from './phase-list-queries.js'; +import { extractFrontmatter } from './frontmatter.js'; +import { parseVerificationFrontmatterItems } from './uat.js'; export const REASON_CODE = Object.freeze({ NON_PASS_RESULT: 'non_pass_result', CASE_MISMATCH: 'case_mismatch', + HUMAN_VERIFICATION_NEEDED: 'human_verification_needed', NO_PHASE_DIR: 'no_phase_dir', NO_UAT_FILES: 'no_uat_files', } as const); @@ -127,6 +130,21 @@ export async function isPhaseUatPassed( }); } } + + // Merge frontmatter human_verification items into the roster. + const fm = extractFrontmatter(content); + const fmItems = parseVerificationFrontmatterItems(fm); + for (const fmItem of fmItems) { + const name = String(fmItem.name ?? ''); + // Add a synthetic UatItem so items.length is accurate. + items.push({ test: -1, name, expected: String(fmItem.expected ?? ''), result: 'human_needed' }); + reasons.push({ + code: REASON_CODE.HUMAN_VERIFICATION_NEEDED, + file: relFile, + itemName: name, + capturedValue: 'human_needed', + }); + } } const passed = items.length > 0 && reasons.length === 0; diff --git a/sdk/src/query/uat.ts b/sdk/src/query/uat.ts index f2b9bde8d..6ecf96ebf 100644 --- a/sdk/src/query/uat.ts +++ b/sdk/src/query/uat.ts @@ -195,7 +195,7 @@ function parseUatItems(content: string): Record[] { * rather than the body, parseVerificationItems was returning [] because it * only searched the body for a "## Human Verification" heading. */ -function parseVerificationFrontmatterItems(fm: Record): Record[] { +export function parseVerificationFrontmatterItems(fm: Record): Record[] { const items: Record[] = []; const hvArray = fm.human_verification; if (!Array.isArray(hvArray)) return items; From f78f26688bafe15eea186d63a7102be4773cc346 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:53:37 -0400 Subject: [PATCH 12/22] feat(sdk): flag headings missing the result field (#3184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle 12 of ~15: heading-without-result-line was previously silently dropped by the regex — meaning a phase with an unfilled UAT item could falsely pass the predicate. We now scan for orphan headings and emit ORPHAN_ITEM_MISSING_RESULT so the operator's typo / unfilled-template is surfaced. --- sdk/src/query/phase-uat-passed.test.ts | 33 +++++++++++++ sdk/src/query/phase-uat-passed.ts | 64 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 6b011a1d3..19cc73cbd 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -306,6 +306,39 @@ result: pass } }); + it("emits ORPHAN_ITEM_MISSING_RESULT reason for headings missing the result field", async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c12-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-orphan-heading'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +### 1. Forgot to fill this in +expected: something + +### 2. Real one +expected: works +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(false); + expect(result.items.length).toBe(1); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.ORPHAN_ITEM_MISSING_RESULT); + expect(result.reasons[0].itemName).toBe('Forgot to fill this in'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + it("emits CASE_MISMATCH reason when result value is \"PASS\" (uppercase variant of pass)", async () => { const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c10-')); try { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index bb41dce92..645b10ce2 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -15,6 +15,9 @@ export const REASON_CODE = Object.freeze({ NON_PASS_RESULT: 'non_pass_result', CASE_MISMATCH: 'case_mismatch', HUMAN_VERIFICATION_NEEDED: 'human_verification_needed', + ORPHAN_ITEM_MISSING_RESULT: 'orphan_item_missing_result', + BRACKETED_PLACEHOLDER: 'bracketed_placeholder', + NO_ITEMS_EXTRACTED: 'no_items_extracted', NO_PHASE_DIR: 'no_phase_dir', NO_UAT_FILES: 'no_uat_files', } as const); @@ -75,6 +78,33 @@ function parseAllUatItems(content: string): UatItem[] { return items; } +const HEADING_PATTERN = /###\s*(\d+)\.\s*([^\n]+)/g; + +/** + * Scan stripped body for `### N. Name` headings whose number is NOT represented + * in the set of captured item numbers. Returns orphan entries. + * Headings that have a bracketed result line are excluded here — they will be + * handled by bracketed-placeholder detection (cycle 13). + */ +function findOrphanHeadings( + strippedBody: string, + capturedNumbers: Set, + brackPlaceholderNumbers: Set, +): Array<{ num: number; name: string }> { + const orphans: Array<{ num: number; name: string }> = []; + HEADING_PATTERN.lastIndex = 0; + let m: RegExpMatchArray | null; + while ((m = HEADING_PATTERN.exec(strippedBody)) !== null) { + const num = parseInt(m[1], 10); + const name = m[2].trim(); + if (!capturedNumbers.has(num) && !brackPlaceholderNumbers.has(num)) { + orphans.push({ num, name }); + } + } + HEADING_PATTERN.lastIndex = 0; + return orphans; +} + export async function isPhaseUatPassed( projectDir: string, phase: string, @@ -114,6 +144,7 @@ export async function isPhaseUatPassed( const filePath = join(dir, file); const relFile = relative(projectDir, filePath); const content = await readFile(filePath, 'utf-8'); + const strippedBody = stripMarkdownInjection(content); const parsed = parseAllUatItems(content); for (const item of parsed) { items.push(item); @@ -131,6 +162,39 @@ export async function isPhaseUatPassed( } } + // Detect bracketed placeholders (cycle 13): headings with result: [value] + const brackPlaceholderNumbers = new Set(); + const BRACK_RESULT_PATTERN = /result:\s*\[(\w+)\]/g; + let bm: RegExpMatchArray | null; + BRACK_RESULT_PATTERN.lastIndex = 0; + while ((bm = BRACK_RESULT_PATTERN.exec(strippedBody)) !== null) { + // find nearest preceding heading + const before = strippedBody.slice(0, bm.index); + const headingMatch = before.match(/###\s*(\d+)\.\s*([^\n]+)\s*$/); + if (headingMatch) { + const num = parseInt(headingMatch[1], 10); + const name = headingMatch[2].trim(); + brackPlaceholderNumbers.add(num); + reasons.push({ + code: REASON_CODE.BRACKETED_PLACEHOLDER, + file: relFile, + itemName: name, + capturedValue: `[${bm[1]}]`, + }); + } + } + + // Detect orphan headings: headings with no captured item and no bracketed result. + const capturedNumbers = new Set(parsed.map((i) => i.test)); + const orphans = findOrphanHeadings(strippedBody, capturedNumbers, brackPlaceholderNumbers); + for (const orphan of orphans) { + reasons.push({ + code: REASON_CODE.ORPHAN_ITEM_MISSING_RESULT, + file: relFile, + itemName: orphan.name, + }); + } + // Merge frontmatter human_verification items into the roster. const fm = extractFrontmatter(content); const fmItems = parseVerificationFrontmatterItems(fm); From b116bb07b96a85590af4dd8e68a69b5e64144bda Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:54:23 -0400 Subject: [PATCH 13/22] feat(sdk): flag bracketed-placeholder result values (#3184) Cycle 13 of ~15: 'result: [pending]' (template placeholder copy-pasted without being filled) was previously dropped by the \w+ regex. Now flagged as BRACKETED_PLACEHOLDER. De-conflicted against ORPHAN_ITEM_MISSING_RESULT so an item with a bracketed result doesn't double-report. --- sdk/src/query/phase-uat-passed.test.ts | 29 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 19cc73cbd..0de42700c 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -306,6 +306,35 @@ result: pass } }); + it("emits BRACKETED_PLACEHOLDER reason when result value is wrapped in brackets", async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c13-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-bracketed-placeholder'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +### 1. Forgot to fill in result +expected: thing +result: [pending] +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(false); + expect(result.items.length).toBe(0); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.BRACKETED_PLACEHOLDER); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + it("emits ORPHAN_ITEM_MISSING_RESULT reason for headings missing the result field", async () => { const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c12-')); try { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index 645b10ce2..af563f5d7 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -170,7 +170,7 @@ export async function isPhaseUatPassed( while ((bm = BRACK_RESULT_PATTERN.exec(strippedBody)) !== null) { // find nearest preceding heading const before = strippedBody.slice(0, bm.index); - const headingMatch = before.match(/###\s*(\d+)\.\s*([^\n]+)\s*$/); + const headingMatch = before.match(/###\s*(\d+)\.\s*([^\n]+)\s*$/m); if (headingMatch) { const num = parseInt(headingMatch[1], 10); const name = headingMatch[2].trim(); From 7956d324c5a5f6eaf91efff5a6fea511cc845b97 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:55:10 -0400 Subject: [PATCH 14/22] feat(sdk): flag UAT files that produce no extractable items (#3184) Cycle 14 of ~15: a UAT file present but empty of headings, orphans, and placeholders now emits NO_ITEMS_EXTRACTED rather than reporting passed=false with reasons=[] (which was indistinguishable from missing-files). --- sdk/src/query/phase-uat-passed.test.ts | 28 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 7 +++++++ 2 files changed, 35 insertions(+) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 0de42700c..ac368b5c6 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -306,6 +306,34 @@ result: pass } }); + it("emits NO_ITEMS_EXTRACTED reason when UAT file has no parseable items, orphans, or placeholders", async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c14-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-no-items'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +This phase doesn't have any UAT items yet. +Prose only. +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(false); + expect(result.items.length).toBe(0); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.NO_ITEMS_EXTRACTED); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + it("emits BRACKETED_PLACEHOLDER reason when result value is wrapped in brackets", async () => { const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c13-')); try { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index af563f5d7..381236c13 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -145,6 +145,8 @@ export async function isPhaseUatPassed( const relFile = relative(projectDir, filePath); const content = await readFile(filePath, 'utf-8'); const strippedBody = stripMarkdownInjection(content); + const itemsBeforeFile = items.length; + const reasonsBeforeFile = reasons.length; const parsed = parseAllUatItems(content); for (const item of parsed) { items.push(item); @@ -209,6 +211,11 @@ export async function isPhaseUatPassed( capturedValue: 'human_needed', }); } + + // If this file contributed no items and no diagnostic reasons, flag it. + if (items.length === itemsBeforeFile && reasons.length === reasonsBeforeFile) { + reasons.push({ code: REASON_CODE.NO_ITEMS_EXTRACTED, file: relFile }); + } } const passed = items.length > 0 && reasons.length === 0; From 99e81b2605f514d3542418972e7e61ca52de18b7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:56:04 -0400 Subject: [PATCH 15/22] feat(sdk): throw PhaseUatPassedError on missing projectDir (#3184) Cycle 15 of ~15: introduces PhaseUatPassedError (extends GSDError, classification Validation) with typed ERROR_CODE enum. Missing-projectDir now fails fast with a typed error rather than letting downstream code crash on a vague fs error. --- sdk/src/query/phase-uat-passed.test.ts | 14 +++++++++++++- sdk/src/query/phase-uat-passed.ts | 26 +++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index ac368b5c6..af7fef583 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { isPhaseUatPassed, REASON_CODE } from './phase-uat-passed.js'; +import { isPhaseUatPassed, REASON_CODE, PhaseUatPassedError, ERROR_CODE } from './phase-uat-passed.js'; const UAT_PASS_CONTENT = `--- status: complete @@ -306,6 +306,18 @@ result: pass } }); + it("throws PhaseUatPassedError with PROJECT_DIR_MISSING code when projectDir does not exist", async () => { + const missingPath = `/nonexistent/path/that/should/never/be/real-${Date.now()}`; + let thrown: unknown; + try { + await isPhaseUatPassed(missingPath, '5'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(PhaseUatPassedError); + expect((thrown as PhaseUatPassedError).code).toBe(ERROR_CODE.PROJECT_DIR_MISSING); + }); + it("emits NO_ITEMS_EXTRACTED reason when UAT file has no parseable items, orphans, or placeholders", async () => { const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c14-')); try { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index 381236c13..6474c39ac 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -5,11 +5,12 @@ * Non-pass items (result not literally 'pass') emit a typed NON_PASS_RESULT reason. */ -import { readFile, readdir } from 'node:fs/promises'; +import { readFile, readdir, stat } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { resolvePhaseDir } from './phase-list-queries.js'; import { extractFrontmatter } from './frontmatter.js'; import { parseVerificationFrontmatterItems } from './uat.js'; +import { GSDError, ErrorClassification } from '../errors.js'; export const REASON_CODE = Object.freeze({ NON_PASS_RESULT: 'non_pass_result', @@ -24,6 +25,17 @@ export const REASON_CODE = Object.freeze({ export type ReasonCode = typeof REASON_CODE[keyof typeof REASON_CODE]; +export const ERROR_CODE = Object.freeze({ + PROJECT_DIR_MISSING: 'project_dir_missing', +} as const); +export type ErrorCode = typeof ERROR_CODE[keyof typeof ERROR_CODE]; + +export class PhaseUatPassedError extends GSDError { + constructor(message: string, public readonly code: ErrorCode) { + super(message, ErrorClassification.Validation); + } +} + export type UatReason = { code: ReasonCode; file?: string; @@ -115,6 +127,18 @@ export async function isPhaseUatPassed( reasonsHuman: string[]; items: Record[]; }> { + try { + await stat(projectDir); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new PhaseUatPassedError( + `projectDir does not exist: ${projectDir}`, + ERROR_CODE.PROJECT_DIR_MISSING, + ); + } + throw err; + } + const dir = await resolvePhaseDir(phase, projectDir, workstream); if (!dir) { return { From fd74c9d68ad512ffe797a9bb76758f4d257801ba Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 23:00:11 -0400 Subject: [PATCH 16/22] 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 }; +}; From fb473f353db2d7545b909ef4483154c7c3a25863 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 23:04:52 -0400 Subject: [PATCH 17/22] fix(sdk): add index signature to UatItem so callers accepting Record[] compile (#3184) The 16 TDD cycles passed vitest but tsc --noEmit flagged TS2322: UatItem lacked a string index signature, so the consumer at phase-uat-passed.ts:249 (returning Record[]) failed to typecheck. Index signature added; behaviour unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/query/phase-uat-passed.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index 54825f624..4e622fa70 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -51,6 +51,7 @@ const UAT_ITEM_PATTERN = /###\s*(\d+)\.\s*([^\n]+)\n(?:\*\*)?expected:(?:\*\*)?\s*([^\n]+)\n(?:\*\*)?result:(?:\*\*)?\s*(\w+)/g; interface UatItem { + [key: string]: unknown; test: number; name: string; expected: string; From dde529f0b134dd283dd813ab4408333e28f3dc50 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 23:20:06 -0400 Subject: [PATCH 18/22] fix(sdk): regenerate command-aliases CJS surface for phase.uat-passed (#3184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle 16 hand-edited command-aliases.generated.ts to register the new query — but command-aliases is auto-generated and has a CJS counterpart that must stay in sync. Running `npx tsx scripts/gen-command-aliases.ts` from sdk/ syncs both surfaces; feat-3598 parity tests now pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- get-shit-done/bin/lib/command-aliases.generated.cjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/get-shit-done/bin/lib/command-aliases.generated.cjs b/get-shit-done/bin/lib/command-aliases.generated.cjs index 7c48ef134..fc5873ed5 100644 --- a/get-shit-done/bin/lib/command-aliases.generated.cjs +++ b/get-shit-done/bin/lib/command-aliases.generated.cjs @@ -389,6 +389,14 @@ const PHASE_COMMAND_ALIASES = [ "subcommand": "list-artifacts", "mutation": false }, + { + "canonical": "phase.uat-passed", + "aliases": [ + "phase uat-passed" + ], + "subcommand": "uat-passed", + "mutation": false + }, { "canonical": "phase.next-decimal", "aliases": [ From 942ed8974bf03c07e3ce0fb1a1ec8a894c3e442a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 23:30:26 -0400 Subject: [PATCH 19/22] chore(changeset): phase.uat-passed v1 (#3184) Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/3184-phase-uat-passed.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/3184-phase-uat-passed.md diff --git a/.changeset/3184-phase-uat-passed.md b/.changeset/3184-phase-uat-passed.md new file mode 100644 index 000000000..2b9fc70c1 --- /dev/null +++ b/.changeset/3184-phase-uat-passed.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: TBD +--- +**`phase.uat-passed` query and `isPhaseUatPassed` SDK export** — adds canonical read-only query `phase.uat-passed` (alias `phase uat-passed`) and programmatic export `isPhaseUatPassed(projectDir, phase, workstream?)` returning typed `{ passed, reasons, reasonsHuman, items }`. Predicate consumes `*-HUMAN-UAT.md` files; hardens against markdown injection (YAML frontmatter, fenced code blocks, HTML comments, blockquote-prefixed lines); surfaces operator-mistake signals as typed `REASON_CODE` frozen enum values (`CASE_MISMATCH`, `ORPHAN_ITEM_MISSING_RESULT`, `BRACKETED_PLACEHOLDER`, `NO_ITEMS_EXTRACTED`). Typed `PhaseUatPassedError extends GSDError` for invalid arguments. Refs #3184. From dfc1bb1a43b9c533a7e0b4d913a174fe71de489e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 23:37:32 -0400 Subject: [PATCH 20/22] chore(changeset): update pr field + docs-exempt for #3713 (#3184) Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/3184-phase-uat-passed.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/3184-phase-uat-passed.md b/.changeset/3184-phase-uat-passed.md index 2b9fc70c1..33ee1863f 100644 --- a/.changeset/3184-phase-uat-passed.md +++ b/.changeset/3184-phase-uat-passed.md @@ -1,5 +1,6 @@ --- type: Added -pr: TBD +pr: 3713 --- **`phase.uat-passed` query and `isPhaseUatPassed` SDK export** — adds canonical read-only query `phase.uat-passed` (alias `phase uat-passed`) and programmatic export `isPhaseUatPassed(projectDir, phase, workstream?)` returning typed `{ passed, reasons, reasonsHuman, items }`. Predicate consumes `*-HUMAN-UAT.md` files; hardens against markdown injection (YAML frontmatter, fenced code blocks, HTML comments, blockquote-prefixed lines); surfaces operator-mistake signals as typed `REASON_CODE` frozen enum values (`CASE_MISMATCH`, `ORPHAN_ITEM_MISSING_RESULT`, `BRACKETED_PLACEHOLDER`, `NO_ITEMS_EXTRACTED`). Typed `PhaseUatPassedError extends GSDError` for invalid arguments. Refs #3184. + From 556c1c049204193dadf46f40a525139e1b93c7a1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 20 May 2026 21:47:56 -0400 Subject: [PATCH 21/22] fix(3184): address pr-review-toolkit + codex review findings - Export isPhaseUatPassed and supporting types/enums from SDK public surface - Remove /m flag from frontmatter regex to prevent mid-body strip - Implement reasonsHuman population with per-ReasonCode humanizer - Add test for multiple UAT files in same phase - Add test for INVALID_PHASE_NUM error path - Add test for workstream routing - Eliminate redundant stripMarkdownInjection call - Update stale cycle-number comments Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/src/index.ts | 14 +++ sdk/src/query/phase-uat-passed.test.ts | 128 ++++++++++++++++++++++++- sdk/src/query/phase-uat-passed.ts | 54 ++++++++--- 3 files changed, 182 insertions(+), 14 deletions(-) diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 5e83fc430..5c4a59d93 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -343,6 +343,20 @@ export type { WSTransportOptions } from './ws-transport.js'; // Query registry argv normalization (matches `gsd-sdk query` and `GSDTools` hot path) export { createRegistry, normalizeQueryCommand } from './query/index.js'; +// Phase UAT predicate — programmatic API surface (#3184) +export { + isPhaseUatPassed, + phaseUatPassed, + REASON_CODE, + ERROR_CODE, + PhaseUatPassedError, +} from './query/phase-uat-passed.js'; +export type { + UatReason, + ReasonCode, + ErrorCode, +} from './query/phase-uat-passed.js'; + // Workstream utilities export { validateWorkstreamName, relPlanningPath } from './workstream-utils.js'; diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 6c63a8f04..15158c64a 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for isPhaseUatPassed — walking skeleton (cycle 1 of ~15). + * Tests for isPhaseUatPassed across UAT result classification paths. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -438,6 +438,132 @@ result: PASS await rm(localTmp, { recursive: true, force: true }); } }); + + it('preserves UAT body content when file contains an internal --- horizontal rule', async () => { + // Regression: frontmatter regex with /m flag treated mid-body --- as a + // second frontmatter block and stripped everything between them. + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-hrule-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-hrule'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +--- + +### 1. Item above rule +expected: works +result: pass + +--- + +### 2. Item below rule +expected: also works +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(true); + expect(result.items.length).toBe(2); + expect(result.items[0].name).toBe('Item above rule'); + expect(result.items[1].name).toBe('Item below rule'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('aggregates items and reasons across multiple UAT files in the same phase', async () => { + // Finding #4: multiple *-HUMAN-UAT.md files — all-pass + one failure + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-multi-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-multi'); + await mkdir(phaseDir, { recursive: true }); + + const passFile = `--- +status: complete +phase: 5 +--- + +### 1. Passing check +expected: it works +result: pass +`; + const failFile = `--- +status: complete +phase: 5 +--- + +### 1. Failing check +expected: it works +result: issue + +### 2. Another pass +expected: also works +result: pass +`; + await writeFile(join(phaseDir, '05a-HUMAN-UAT.md'), passFile); + await writeFile(join(phaseDir, '05b-HUMAN-UAT.md'), failFile); + + const result = await isPhaseUatPassed(localTmp, '5'); + // Total items: 1 from first file + 2 from second file + expect(result.items.length).toBe(3); + // One NON_PASS_RESULT reason from the failing item + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.NON_PASS_RESULT); + expect(result.passed).toBe(false); + // reasonsHuman must be non-empty + expect(result.reasonsHuman.length).toBe(1); + expect(typeof result.reasonsHuman[0]).toBe('string'); + expect(result.reasonsHuman[0].length).toBeGreaterThan(0); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('throws PhaseUatPassedError with INVALID_PHASE_NUM when phase arg is empty', async () => { + // Finding #5: INVALID_PHASE_NUM error path via phaseUatPassed handler + const { phaseUatPassed, ERROR_CODE: EC } = await import('./phase-uat-passed.js'); + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-invalidphase-')); + try { + let thrown: unknown; + try { + await phaseUatPassed([], localTmp); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(PhaseUatPassedError); + expect((thrown as PhaseUatPassedError).code).toBe(EC.INVALID_PHASE_NUM); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('resolves isPhaseUatPassed via workstream routing', async () => { + // Finding #6: workstream-keyed project under .planning/workstreams/ws1/phases/ + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-ws-')); + try { + const wsPhaseDir = join(localTmp, '.planning', 'workstreams', 'ws1', 'phases', '05-ws-phase'); + await mkdir(wsPhaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +--- + +### 1. WS item +expected: works in workstream +result: pass +`; + await writeFile(join(wsPhaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5', 'ws1'); + expect(result.passed).toBe(true); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('WS item'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); }); describe('phase.uat-passed registry wire-up (cycle 16)', () => { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index 4e622fa70..1a6f9f56c 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -1,8 +1,6 @@ /** * isPhaseUatPassed — SDK predicate answering "is phase N's UAT contract satisfied?" - * - * Cycle 2 of ~15: introduces REASON_CODE frozen enum and UatReason typed shape. - * Non-pass items (result not literally 'pass') emit a typed NON_PASS_RESULT reason. + * Implements the isPhaseUatPassed predicate per #3184. */ import { readFile, readdir, stat } from 'node:fs/promises'; @@ -64,8 +62,9 @@ interface UatItem { * Passes are applied in order; each returns a sanitised string. */ function stripMarkdownInjection(content: string): string { - // Pass 1: strip YAML frontmatter region (---\n...\n---) - let s = content.replace(/^---\r?\n[\s\S]*?\r?\n---/m, ''); + // Pass 1: strip YAML frontmatter region (---\n...\n---). No /m flag — ^ must + // anchor to position 0 only, preventing mid-body --- separators from triggering. + let s = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, ''); // Pass 2: strip fenced code blocks (``` ... ```) s = s.replace(/```[\s\S]*?```/g, ''); // Pass 3: strip HTML comment regions () @@ -75,8 +74,11 @@ function stripMarkdownInjection(content: string): string { return s; } -function parseAllUatItems(content: string): UatItem[] { - const sanitised = stripMarkdownInjection(content); +/** + * Parse all UAT items from already-stripped content. + * Callers must pass pre-stripped (sanitised) content — no internal strip is performed. + */ +function parseAllUatItems(sanitised: string): UatItem[] { const items: UatItem[] = []; UAT_ITEM_PATTERN.lastIndex = 0; let m: RegExpMatchArray | null; @@ -93,6 +95,30 @@ function parseAllUatItems(content: string): UatItem[] { return items; } +/** Build a human-readable string for a single UatReason. */ +function humanizeReason(r: UatReason): string { + switch (r.code) { + case REASON_CODE.CASE_MISMATCH: + return `"${r.itemName ?? 'item'}" has a case-mismatched result ("${r.capturedValue ?? ''}" — expected "pass")`; + case REASON_CODE.NON_PASS_RESULT: + return `"${r.itemName ?? 'item'}" has result "${r.capturedValue ?? 'unknown'}" (expected "pass")`; + case REASON_CODE.NO_ITEMS_EXTRACTED: + return `${r.file ?? 'file'} contained no UAT items`; + case REASON_CODE.NO_PHASE_DIR: + return 'Phase directory not found'; + case REASON_CODE.NO_UAT_FILES: + return 'No *-HUMAN-UAT.md files found in the phase directory'; + case REASON_CODE.HUMAN_VERIFICATION_NEEDED: + return `"${r.itemName ?? 'item'}" requires manual human verification`; + case REASON_CODE.BRACKETED_PLACEHOLDER: + return `"${r.itemName ?? 'item'}" has an unfilled placeholder result: ${r.capturedValue ?? ''}`; + case REASON_CODE.ORPHAN_ITEM_MISSING_RESULT: + return `"${r.itemName ?? 'item'}" is missing a result field`; + default: + return `${r.code}: ${JSON.stringify(r)}`; + } +} + const HEADING_PATTERN = /###\s*(\d+)\.\s*([^\n]+)/g; /** @@ -144,10 +170,11 @@ export async function isPhaseUatPassed( const dir = await resolvePhaseDir(phase, projectDir, workstream); if (!dir) { + const reasons: UatReason[] = [{ code: REASON_CODE.NO_PHASE_DIR }]; return { passed: false, - reasons: [{ code: REASON_CODE.NO_PHASE_DIR }], - reasonsHuman: [], + reasons, + reasonsHuman: reasons.map(humanizeReason), items: [], }; } @@ -156,10 +183,11 @@ export async function isPhaseUatPassed( const uatFiles = files.filter((f) => f.endsWith('-HUMAN-UAT.md')); if (uatFiles.length === 0) { + const reasons: UatReason[] = [{ code: REASON_CODE.NO_UAT_FILES }]; return { passed: false, - reasons: [{ code: REASON_CODE.NO_UAT_FILES }], - reasonsHuman: [], + reasons, + reasonsHuman: reasons.map(humanizeReason), items: [], }; } @@ -174,7 +202,7 @@ export async function isPhaseUatPassed( const strippedBody = stripMarkdownInjection(content); const itemsBeforeFile = items.length; const reasonsBeforeFile = reasons.length; - const parsed = parseAllUatItems(content); + const parsed = parseAllUatItems(strippedBody); for (const item of parsed) { items.push(item); if (item.result !== 'pass') { @@ -247,7 +275,7 @@ export async function isPhaseUatPassed( const passed = items.length > 0 && reasons.length === 0; - return { passed, reasons, reasonsHuman: [], items }; + return { passed, reasons, reasonsHuman: reasons.map(humanizeReason), items }; } /** From 58643ff70b71b8729c39c53be73671eeee075f6e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 20 May 2026 22:40:02 -0400 Subject: [PATCH 22/22] fix(sdk): treat empty lock file as live in isLockProcessDead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Between `open(lockPath, O_CREAT | O_EXCL)` and `fd.writeFile(pid)` there is an async await gap. If a second process reads the lock file during that window it sees empty content, which parseInt returns as NaN. The previous code returned `true` (dead) for non-finite PID values, causing the second process to unlink the live lock and steal it — both processes then entered readModifyWriteStateMd simultaneously, producing a lost-update TOCTOU. Fix: return `null` (unknown) instead of `true` when the lock file contains no parseable PID. The caller already treats `null` as "not confirmed dead" and falls through to the normal retry + timeout path, giving the first process time to finish writing its PID. The CJS acquireStateLock in state.cjs never had this race because it uses synchronous fs.openSync / fs.writeSync / fs.closeSync with no async gap. Fixes intermittent failure of: #1925 TOCTOU: state commands use readModifyWriteStateMd → state add-blocker: both concurrent calls append different blockers (observed: macos-latest / Node 22 and windows-latest / Node 22 on main) Co-Authored-By: Claude Sonnet 4.6 --- sdk/src/query/state-mutation.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/state-mutation.ts b/sdk/src/query/state-mutation.ts index d9355bc33..70890199a 100644 --- a/sdk/src/query/state-mutation.ts +++ b/sdk/src/query/state-mutation.ts @@ -127,7 +127,11 @@ async function isLockProcessDead(lockPath: string): Promise { try { const raw = await readFile(lockPath, 'utf-8'); const pid = parseInt(raw.trim(), 10); - if (!Number.isFinite(pid) || pid <= 0) return true; + // An empty or unparseable lock file means the writer opened the file with + // O_EXCL but hasn't finished writing the PID yet (async window between + // `open` and `writeFile`). Treat this as "unknown / still alive" — do NOT + // steal the lock, let the normal retry + timeout path handle it. + if (!Number.isFinite(pid) || pid <= 0) return null; try { process.kill(pid, 0); return false;