From a046c2fb9e6d743f1e17a5d291efc0d0e3225d45 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 6 Jul 2026 23:35:57 +0200 Subject: [PATCH 01/21] wip(m0a): baseline snapshot before front-door rewrite (conflict markers still present; resolved in next commit) --- .gitignore | 1 + README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/.gitignore b/.gitignore index 6682fff..63da5dd 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ MILESTONES.md .codebase-context/ _bmad/ .cursor/ +# Local Workspine dogfood continuity. If .work/ exists here, start at .work/README.md; keep .work/ untracked and out of generated consumer surfaces. .work/ # Local temp files (slides, previews, scratch) diff --git a/README.md b/README.md index cd2cf9e..197810e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,47 @@ Workspine began as a fork of Get Shit Done and keeps the verification-first deli npx -y gsdd-cli init ``` +<<<<<<< ours `init` is the guided install wizard for repo-local setup. It creates `.agents/skills/gsdd-*` workflow entrypoints and the `.planning/bin/gsdd*` helper runtime in the current repo; optional runtime adapters improve native discovery. +======= +This creates: + +1. `.planning/` — durable workspace with templates, role contracts, and config +2. `.agents/skills/gsdd-*` — compact open-standard workflow entrypoints for agents +3. `.planning/bin/gsdd.mjs` — repo-local helper runtime for deterministic workflow commands inside generated skills (run helper commands from the repo root) +4. Optional tool-specific adapters you choose in the install wizard (Claude skills/commands/agents, OpenCode commands/agents, Codex CLI agents, optional governance) + +Then pick the first workflow lane that matches your situation: + +- `gsdd-new-project` for greenfield work, fuzzy brownfield work, or milestone-shaped work +- `gsdd-quick` for a concrete bounded brownfield change +- `gsdd-map-codebase` first when the repo is unfamiliar, risky, or needs a deeper baseline before choosing a lane + +In a terminal, `npx -y gsdd-cli init` opens a guided install wizard. If you installed the package globally, `gsdd init` is the equivalent shorthand: + +- Step 1: select the runtimes/vendors you want to support +- Step 2: decide separately whether repo-wide `AGENTS.md` governance is worth installing +- Step 3: configure planning defaults in the same guided flow + +Portable `.agents/skills/gsdd-*` skills and the repo-local `.planning/bin/gsdd.mjs` helper runtime are always generated. The wizard controls extra native adapters and optional governance, not the portable baseline. Workflow helper commands assume the repo root as the current working directory. +When those generated surfaces exist locally, `npx -y gsdd-cli health` checks them against current render output instead of asking you to trust manual review. If installed globally, `gsdd health` is equivalent. + +### Launch Proof Status + +- **Directly validated:** Claude Code, Codex CLI, and OpenCode have recorded `plan -> execute -> verify` evidence for the core lifecycle. +- **Qualified support:** Cursor, Copilot, and Gemini CLI can use the shared `.agents/skills/` surface when their skill or slash discovery sees it; this release does not claim the same runtime proof or ergonomics. +- **Runtime-surface freshness:** Installed generated skills and native adapters are renderer-checked locally; repair stays deterministic through `npx -y gsdd-cli update` or, when globally installed, `gsdd update`. + +Start with the public proof pack: + +- [Brownfield proof](docs/BROWNFIELD-PROOF.md) +- [Exported consumer proof pack](docs/proof/consumer-node-cli/README.md) +- [Runtime support matrix](docs/RUNTIME-SUPPORT.md) +- [Verification discipline](docs/VERIFICATION-DISCIPLINE.md) +- [Agentic research MVP harness](docs/AGENTIC-RESEARCH-MVP.md) + +### Quickstart (after init) +>>>>>>> theirs Invoke after init: From 7bd751e8c60bffbf913efc02bcfa5a5e2bac2f45 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 6 Jul 2026 23:40:08 +0200 Subject: [PATCH 02/21] =?UTF-8?q?docs(m0a):=20front=20door=20=E2=80=94=20r?= =?UTF-8?q?esolve=20README=20conflict,=20kill=20jargon,=20fix=20dead=20lin?= =?UTF-8?q?k,=20truthful=20proof=20claims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 ++++++----------- docs/RUNTIME-SUPPORT.md | 2 +- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 197810e..d81ade8 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ # Workspine -Workspine is a repo-native delivery spine for AI-assisted software work: planning, checking, execution, verification, and handoff live in the repo so any agent or runtime can pick up where the last one stopped. +Workspine is a Spec Driven Development framework for AI-assisted software work: planning, checking, execution, verification, and handoff live in the repo, so any agent or runtime can pick up where the last one stopped. Decisions keep their why. -Directly validated today: Claude Code, Codex CLI, OpenCode. Qualified support: Cursor, Copilot, Gemini. +Proof status: one real consumer lifecycle with Codex checker support. Qualified support: Cursor, Copilot, Gemini. The public product name is Workspine. The retained technical contracts remain `gsdd-cli`, `gsdd`, `gsdd-*`, and `.planning/`. @@ -46,9 +46,9 @@ Workspine ships 14 workflows. The package and CLI are `gsdd-cli` / `gsdd-*` — ## What This Is -Workspine gives coding agents a durable workflow spine for work that spans sessions, agents, or runtimes. It does not host a control plane; it writes portable planning and proof artifacts into the repo. +Workspine gives coding agents a durable workflow for work that spans sessions, agents, or runtimes. There is no hosted service; it writes portable planning and proof artifacts into the repo. -Workspine began as a fork of Get Shit Done and keeps the verification-first delivery spine while stripping runtime lock-in. +Workspine began as a fork of Get Shit Done and keeps the verification-first workflow while stripping runtime lock-in. --- @@ -60,9 +60,6 @@ Workspine began as a fork of Get Shit Done and keeps the verification-first deli npx -y gsdd-cli init ``` -<<<<<<< ours -`init` is the guided install wizard for repo-local setup. It creates `.agents/skills/gsdd-*` workflow entrypoints and the `.planning/bin/gsdd*` helper runtime in the current repo; optional runtime adapters improve native discovery. -======= This creates: 1. `.planning/` — durable workspace with templates, role contracts, and config @@ -87,7 +84,7 @@ When those generated surfaces exist locally, `npx -y gsdd-cli health` checks the ### Launch Proof Status -- **Directly validated:** Claude Code, Codex CLI, and OpenCode have recorded `plan -> execute -> verify` evidence for the core lifecycle. +- **Recorded proof:** the proof pack below records a full plan -> execute -> verify lifecycle on a real consumer project, including Codex checker support. Claude Code and OpenCode run the same generated workflow surface. - **Qualified support:** Cursor, Copilot, and Gemini CLI can use the shared `.agents/skills/` surface when their skill or slash discovery sees it; this release does not claim the same runtime proof or ergonomics. - **Runtime-surface freshness:** Installed generated skills and native adapters are renderer-checked locally; repair stays deterministic through `npx -y gsdd-cli update` or, when globally installed, `gsdd update`. @@ -97,10 +94,8 @@ Start with the public proof pack: - [Exported consumer proof pack](docs/proof/consumer-node-cli/README.md) - [Runtime support matrix](docs/RUNTIME-SUPPORT.md) - [Verification discipline](docs/VERIFICATION-DISCIPLINE.md) -- [Agentic research MVP harness](docs/AGENTIC-RESEARCH-MVP.md) ### Quickstart (after init) ->>>>>>> theirs Invoke after init: @@ -184,7 +179,7 @@ Use Workspine when a feature takes more than one session, or when you need to sw | [LeanSpec](https://www.lean-spec.dev/docs/guide/first-principles) | Minimal specs that fit LLM context | Workspine adds workflow gates and runtime entrypoints for when you need the full structure | | [GitHub Spec Kit](https://github.com/github/spec-kit) | Spec-first planning workflows in `.specify/` | Similar space; Workspine is one CLI with one delivery loop instead of a broader ecosystem | | [Kiro](https://kiro.dev/docs/) | IDE-native agent dev with specs, steering, hooks, and MCP | Kiro is IDE-only; Workspine works across terminal and IDE agents that can read repo files | -| [Tessl](https://tessl.io/enterprise/) | Hosted platform for distributing agent skills across teams | Tessl needs a control plane; Workspine is local-first with no hosted infrastructure | +| [Tessl](https://tessl.io/enterprise/) | Hosted platform for distributing agent skills across teams | Tessl needs a hosted service; Workspine is local-first with no hosted infrastructure | Based on each tool's public docs as of May 2026. Open an issue if anything reads inaccurately. diff --git a/docs/RUNTIME-SUPPORT.md b/docs/RUNTIME-SUPPORT.md index 157b7d4..41d0fef 100644 --- a/docs/RUNTIME-SUPPORT.md +++ b/docs/RUNTIME-SUPPORT.md @@ -1,6 +1,6 @@ # Runtime Support Matrix -Workspine is a repo-native delivery spine with portable multi-runtime workflow surfaces, but the proof bar is not the same for every runtime today. +Workspine is a Spec Driven Development framework with portable multi-runtime workflow surfaces, but the proof bar is not the same for every runtime today. This matrix is the release-floor truth surface. From 320dac140617a24d2ac41b32c48d049e12afb7cf Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 7 Jul 2026 08:50:36 +0200 Subject: [PATCH 03/21] refactor(m0b): rescue proof refs and rename config module --- bin/gsdd.mjs | 2 +- bin/lib/{models.mjs => config.mjs} | 0 bin/lib/init-flow.mjs | 2 +- bin/lib/init-prompts.mjs | 2 +- bin/lib/runtime-freshness.mjs | 2 +- distilled/references/observation-record.md | 20 ++++++++++++++++++++ distilled/references/proof-rules.md | 15 +++++++++++++++ tests/gsdd.consumer-ceremony.test.cjs | 4 ++-- tests/gsdd.guards.test.cjs | 2 +- tests/gsdd.models.test.cjs | 16 ++++++++-------- 10 files changed, 50 insertions(+), 15 deletions(-) rename bin/lib/{models.mjs => config.mjs} (100%) create mode 100644 distilled/references/observation-record.md create mode 100644 distilled/references/proof-rules.md diff --git a/bin/gsdd.mjs b/bin/gsdd.mjs index fbc58f8..d450149 100644 --- a/bin/gsdd.mjs +++ b/bin/gsdd.mjs @@ -11,7 +11,7 @@ import { upsertBoundedBlock, getDelegateContent, } from './lib/rendering.mjs'; -import { loadProjectModelConfig, getRuntimeModelOverride, resolveRuntimeAgentModel, cmdModels, cmdRigor } from './lib/models.mjs'; +import { loadProjectModelConfig, getRuntimeModelOverride, resolveRuntimeAgentModel, cmdModels, cmdRigor } from './lib/config.mjs'; import { createCmdInit, createCmdUpdate, cmdHelp } from './lib/init.mjs'; import { createCmdInstall } from './lib/global-install.mjs'; import { cmdFindPhase, cmdVerify, cmdScaffold, cmdPhaseStatus } from './lib/phase.mjs'; diff --git a/bin/lib/models.mjs b/bin/lib/config.mjs similarity index 100% rename from bin/lib/models.mjs rename to bin/lib/config.mjs diff --git a/bin/lib/init-flow.mjs b/bin/lib/init-flow.mjs index aa893f0..bf8d7c5 100644 --- a/bin/lib/init-flow.mjs +++ b/bin/lib/init-flow.mjs @@ -3,7 +3,7 @@ import { dirname, join, isAbsolute } from 'path'; import { buildPlanningCliHelperEntries, renderSkillContent } from './rendering.mjs'; import { buildManifest, readManifest, writeManifest } from './manifest.mjs'; import { parseFlagValue, parseToolsFlag, parseAutoFlag } from './cli-utils.mjs'; -import { buildDefaultConfig, COST_PROFILES, RIGOR_PROFILES } from './models.mjs'; +import { buildDefaultConfig, COST_PROFILES, RIGOR_PROFILES } from './config.mjs'; import { installProjectTemplates, refreshTemplates } from './templates.mjs'; import { detectPlatforms, diff --git a/bin/lib/init-prompts.mjs b/bin/lib/init-prompts.mjs index 1b4fedd..49be96d 100644 --- a/bin/lib/init-prompts.mjs +++ b/bin/lib/init-prompts.mjs @@ -1,5 +1,5 @@ import * as readline from 'readline'; -import { DEFAULT_GIT_PROTOCOL, resolveCost, resolveRigor } from './models.mjs'; +import { DEFAULT_GIT_PROTOCOL, resolveCost, resolveRigor } from './config.mjs'; import { buildRuntimeChoices, INIT_VERSION, resolveWizardAdapterTargets } from './init-runtime.mjs'; const ANSI = { diff --git a/bin/lib/runtime-freshness.mjs b/bin/lib/runtime-freshness.mjs index 4207eba..0a8d495 100644 --- a/bin/lib/runtime-freshness.mjs +++ b/bin/lib/runtime-freshness.mjs @@ -28,7 +28,7 @@ import { getRuntimeModelOverride, loadProjectModelConfig, resolveRuntimeAgentModel, -} from './models.mjs'; +} from './config.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/distilled/references/observation-record.md b/distilled/references/observation-record.md new file mode 100644 index 0000000..40023f1 --- /dev/null +++ b/distilled/references/observation-record.md @@ -0,0 +1,20 @@ +# Observation record + +When a step claims UI or runtime behavior was verified, write down what was +actually observed. Plain markdown, one record per checked flow: + +- **flow**: which screen/route/behavior was checked (plans name these up front: + a plan touching UI lists the flows that need a real-browser look). +- **tool**: what did the looking (`agent-browser`, `playwright`, `manual`, + or a project command). +- **observed**: what actually happened — rendered DOM state, behavior on + interaction, console/network errors seen or absent. +- **artifacts**: paths to screenshot / console log / network log, if captured. +- **safe to publish**: yes/no — may the artifacts leave the repo (no secrets, + no private data)? +- **stale after**: what change would invalidate this observation (optional). +- **result**: worked / failed (say why) / partly (say what is missing). + +This replaces the retired machine-validated UI-proof bundle (`ui_proof_slots`, +`gsdd ui-proof validate/compare`). The structure survives; the JSON schema +validator does not. diff --git a/distilled/references/proof-rules.md b/distilled/references/proof-rules.md new file mode 100644 index 0000000..ce2ebb3 --- /dev/null +++ b/distilled/references/proof-rules.md @@ -0,0 +1,15 @@ +# Proof rules + +One rule, stated once: **a claim backed only by reading code, or only by a human +saying so, cannot close a delivery claim.** Something must have actually run. + +Evidence kinds (plain names): `code` (read the diff), `test` (a test ran), +`runtime` (the thing was executed and observed), `delivery` (merged / published / +installed), `human` (a person confirmed). + +- Closing "this works" needs at least one of `test` or `runtime`. +- Closing "this shipped" needs `delivery`. +- `code` and `human` support a claim; alone they close nothing. +- UI work: proof means opening a real browser (agent-browser preferred, + Playwright fallback), inspecting the rendered DOM and behavior — not + screenshots alone. Record what was observed (see observation-record.md). diff --git a/tests/gsdd.consumer-ceremony.test.cjs b/tests/gsdd.consumer-ceremony.test.cjs index b90114f..608ffe5 100644 --- a/tests/gsdd.consumer-ceremony.test.cjs +++ b/tests/gsdd.consumer-ceremony.test.cjs @@ -18,7 +18,7 @@ async function importModule(filePath) { async function runWizardInit(tmpDir, { selectedRuntimes = ['claude'], adapterTargets = ['claude'], rigor = 'balanced', cost = 'balanced', commitDocs = true } = {}) { const gsddMod = await importModule(path.join(__dirname, '..', 'bin', 'gsdd.mjs')); const initMod = await importModule(path.join(__dirname, '..', 'bin', 'lib', 'init.mjs')); - const models = await importModule(path.join(__dirname, '..', 'bin', 'lib', 'models.mjs')); + const models = await importModule(path.join(__dirname, '..', 'bin', 'lib', 'config.mjs')); const ctx = gsddMod.createCliContext(tmpDir); const callLog = []; @@ -99,7 +99,7 @@ describe('consumer ceremony reduction', () => { }); test('wizard resolves all 9 rigor/cost combinations correctly', async () => { - const models = await importModule(path.join(__dirname, '..', 'bin', 'lib', 'models.mjs')); + const models = await importModule(path.join(__dirname, '..', 'bin', 'lib', 'config.mjs')); for (const rigor of Object.keys(models.RIGOR_PROFILES)) { for (const cost of Object.keys(models.COST_PROFILES)) { const comboDir = createTempProject(); diff --git a/tests/gsdd.guards.test.cjs b/tests/gsdd.guards.test.cjs index 80283c7..72f5b26 100644 --- a/tests/gsdd.guards.test.cjs +++ b/tests/gsdd.guards.test.cjs @@ -10,7 +10,7 @@ const path = require('path'); const ROOT = path.join(__dirname, '..'); const GSDD_PATH = path.join(ROOT, 'bin', 'gsdd.mjs'); -const MODELS_MODULE = path.join(ROOT, 'bin', 'lib', 'models.mjs'); +const MODELS_MODULE = path.join(ROOT, 'bin', 'lib', 'config.mjs'); const MANIFEST_MODULE = path.join(ROOT, 'bin', 'lib', 'manifest.mjs'); const HEALTH_MODULE = path.join(ROOT, 'bin', 'lib', 'health.mjs'); const HEALTH_TRUTH_MODULE = path.join(ROOT, 'bin', 'lib', 'health-truth.mjs'); diff --git a/tests/gsdd.models.test.cjs b/tests/gsdd.models.test.cjs index da97d30..4bff1c8 100644 --- a/tests/gsdd.models.test.cjs +++ b/tests/gsdd.models.test.cjs @@ -657,7 +657,7 @@ describe('gsdd models and model propagation', () => { describe('rigor and cost resolvers', () => { test('RIGOR_PROFILES has the four canonical levels with the six-flag workflow shape', async () => { - const models = await import('../bin/lib/models.mjs'); + const models = await import('../bin/lib/config.mjs'); assert.deepStrictEqual(Object.keys(models.RIGOR_PROFILES), ['low', 'medium', 'high', 'max']); for (const level of ['low', 'medium', 'high', 'max']) { const w = models.RIGOR_PROFILES[level].workflow; @@ -672,14 +672,14 @@ describe('gsdd models and model propagation', () => { }); test('legacy rigor names alias to the new levels', async () => { - const models = await import('../bin/lib/models.mjs'); + const models = await import('../bin/lib/config.mjs'); assert.strictEqual(models.resolveRigor('quick'), models.RIGOR_PROFILES.low); assert.strictEqual(models.resolveRigor('balanced'), models.RIGOR_PROFILES.medium); assert.strictEqual(models.resolveRigor('thorough'), models.RIGOR_PROFILES.high); }); test('resolveStepRigor honors per-step overrides then the base profile', async () => { - const models = await import('../bin/lib/models.mjs'); + const models = await import('../bin/lib/config.mjs'); const config = { rigorProfile: 'low', rigorOverrides: { verify: 'max' } }; assert.strictEqual(models.resolveStepRigor(config, 'plan'), models.RIGOR_PROFILES.low); assert.strictEqual(models.resolveStepRigor(config, 'verify'), models.RIGOR_PROFILES.max); @@ -689,12 +689,12 @@ describe('gsdd models and model propagation', () => { }); test('COST_PROFILES has exactly keys budget, balanced, quality', async () => { - const models = await import('../bin/lib/models.mjs'); + const models = await import('../bin/lib/config.mjs'); assert.deepStrictEqual(Object.keys(models.COST_PROFILES), ['budget', 'balanced', 'quality']); }); test('resolveRigor unknown returns balanced profile', async () => { - const models = await import('../bin/lib/models.mjs'); + const models = await import('../bin/lib/config.mjs'); const result = models.resolveRigor('unknown'); assert.strictEqual(result.researchDepth, 'balanced'); assert.strictEqual(result.workflow.research, true); @@ -702,21 +702,21 @@ describe('gsdd models and model propagation', () => { }); test('resolveCost unknown returns balanced profile', async () => { - const models = await import('../bin/lib/models.mjs'); + const models = await import('../bin/lib/config.mjs'); const result = models.resolveCost('unknown'); assert.strictEqual(result.modelProfile, 'balanced'); assert.strictEqual(result.parallelization, true); }); test('every RIGOR_PROFILES entry has workflow.verifier === true', async () => { - const models = await import('../bin/lib/models.mjs'); + const models = await import('../bin/lib/config.mjs'); for (const [key, profile] of Object.entries(models.RIGOR_PROFILES)) { assert.strictEqual(profile.workflow.verifier, true, `${key} has verifier=true`); } }); test('buildDefaultConfig output schema has all legacy keys', async () => { - const models = await import('../bin/lib/models.mjs'); + const models = await import('../bin/lib/config.mjs'); const config = models.buildDefaultConfig(); assert.ok('researchDepth' in config); assert.ok('parallelization' in config); From 5bb5ef40bd6a343fbd2d8e080f3c3d26ec27bab1 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 7 Jul 2026 09:01:13 +0200 Subject: [PATCH 04/21] refactor(m0b): delete retired ceremony modules --- bin/gsdd.mjs | 11 +- bin/lib/closeout-report.mjs | 318 --------- bin/lib/control-map.mjs | 474 ------------- bin/lib/evidence-contract.mjs | 325 --------- bin/lib/health-truth.mjs | 13 +- bin/lib/health.mjs | 20 +- bin/lib/init-runtime.mjs | 18 - bin/lib/lifecycle-preflight.mjs | 383 +--------- bin/lib/phase.mjs | 310 +-------- bin/lib/provenance.mjs | 390 ----------- bin/lib/rendering.mjs | 25 +- bin/lib/session-fingerprint.mjs | 223 ------ bin/lib/ui-proof.mjs | 1007 --------------------------- package.json | 2 +- tests/gsdd.closeout-report.test.cjs | 274 -------- tests/gsdd.control-map.test.cjs | 567 --------------- tests/phase.test.cjs | 172 ----- tests/session-fingerprint.test.cjs | 253 ------- 18 files changed, 8 insertions(+), 4777 deletions(-) delete mode 100644 bin/lib/closeout-report.mjs delete mode 100644 bin/lib/evidence-contract.mjs delete mode 100644 bin/lib/provenance.mjs delete mode 100644 bin/lib/session-fingerprint.mjs delete mode 100644 bin/lib/ui-proof.mjs delete mode 100644 tests/gsdd.closeout-report.test.cjs delete mode 100644 tests/gsdd.control-map.test.cjs delete mode 100644 tests/session-fingerprint.test.cjs diff --git a/bin/gsdd.mjs b/bin/gsdd.mjs index d450149..186cd5c 100644 --- a/bin/gsdd.mjs +++ b/bin/gsdd.mjs @@ -18,10 +18,6 @@ import { cmdFindPhase, cmdVerify, cmdScaffold, cmdPhaseStatus } from './lib/phas import { cmdFileOp } from './lib/file-ops.mjs'; import { createCmdHealth } from './lib/health.mjs'; import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs'; -import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs'; -import { cmdUiProof } from './lib/ui-proof.mjs'; -import { cmdControlMap } from './lib/control-map.mjs'; -import { createCmdCloseoutReport } from './lib/closeout-report.mjs'; import { createCmdNext } from './lib/next.mjs'; import { resolveWorkspaceContext } from './lib/workspace-root.mjs'; import { FRAMEWORK_VERSION, WORKFLOWS } from './lib/workflows.mjs'; @@ -67,7 +63,6 @@ const INIT_CONTEXT = createCliContext(process.cwd()); const cmdInit = createCmdInit(INIT_CONTEXT); const cmdInstall = createCmdInstall(INIT_CONTEXT); const cmdHealth = createCmdHealth(INIT_CONTEXT); -const cmdCloseoutReport = createCmdCloseoutReport(INIT_CONTEXT); const cmdNext = createCmdNext(INIT_CONTEXT); const cmdUpdate = (...updateArgs) => { @@ -89,10 +84,6 @@ const COMMANDS = { next: cmdNext, 'file-op': cmdFileOp, 'lifecycle-preflight': cmdLifecyclePreflight, - 'session-fingerprint': cmdSessionFingerprint, - 'ui-proof': cmdUiProof, - 'control-map': cmdControlMap, - 'closeout-report': cmdCloseoutReport, 'find-phase': cmdFindPhase, 'phase-status': cmdPhaseStatus, verify: cmdVerify, @@ -119,4 +110,4 @@ async function runCli(cliCommand = command, ...cliArgs) { } if (IS_MAIN) await runCli(); -export { cmdHelp, cmdInit, cmdInstall, cmdUpdate, cmdModels, cmdRigor, cmdHealth, cmdNext, cmdFileOp, cmdLifecyclePreflight, cmdSessionFingerprint, cmdUiProof, cmdControlMap, cmdCloseoutReport, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext }; +export { cmdHelp, cmdInit, cmdInstall, cmdUpdate, cmdModels, cmdRigor, cmdHealth, cmdNext, cmdFileOp, cmdLifecyclePreflight, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext }; diff --git a/bin/lib/closeout-report.mjs b/bin/lib/closeout-report.mjs deleted file mode 100644 index e774403..0000000 --- a/bin/lib/closeout-report.mjs +++ /dev/null @@ -1,318 +0,0 @@ -// closeout-report.mjs - read-only post-merge closure replay report - -import { output, parseFlagValue } from './cli-utils.mjs'; -import { buildControlMap } from './control-map.mjs'; -import { evaluateLifecyclePreflight } from './lifecycle-preflight.mjs'; -import { evaluateLifecycleState, normalizePhaseToken } from './lifecycle-state.mjs'; -import { buildPhaseVerificationReport } from './phase.mjs'; -import { resolveWorkspaceContext } from './workspace-root.mjs'; - -const USAGE = 'Usage: gsdd closeout-report [--json] [--phase ]'; - -function severityFromRisk(risk) { - if (risk.severity === 'block') return 'blocker'; - if (risk.severity === 'warn') return 'warn'; - return risk.severity || 'info'; -} - -function notice(source, severity, entry) { - return { - source, - severity, - code: entry.code || entry.id || 'unknown', - message: entry.message, - fix: entry.fix_hint || entry.fix || null, - path: entry.path || null, - }; -} - -function latestCompletedPhase(lifecycle) { - const completed = lifecycle.phases.filter((phase) => phase.status === 'done'); - return completed.length > 0 ? completed[completed.length - 1] : null; -} - -async function buildHealthReportSafe(ctx, args) { - try { - const health = await import('./health.mjs'); - return health.buildHealthReport(ctx, args); - } catch (error) { - return { - status: 'degraded', - errors: [], - warnings: [{ - id: 'W_CLOSEOUT_HEALTH_UNAVAILABLE', - severity: 'WARN', - message: `Health report builder unavailable in this helper runtime: ${error.message}`, - fix: 'Run the source CLI `gsdd health --json` for full health diagnostics, or refresh generated helper support.', - }], - info: [], - }; - } -} - -function summarizeControlMap(map) { - return { - status: map.risks.some((risk) => risk.severity === 'block') - ? 'blocked' - : map.risks.some((risk) => risk.severity === 'warn') - ? 'warnings' - : 'clear', - authority: map.authority, - canonical_worktree: { - branch: map.canonical_worktree.branch, - head: map.canonical_worktree.head, - dirty: map.canonical_worktree.dirty, - ahead_behind: map.canonical_worktree.ahead_behind, - }, - worktree_count: map.worktrees.length, - risks: map.risks, - interventions: map.interventions, - }; -} - -function summarizePreflight(preflight) { - return { - status: preflight.status, - allowed: preflight.allowed, - reason: preflight.reason, - blockers: preflight.blockers, - warnings: preflight.warnings, - }; -} - -function summarizePhaseVerification(report) { - if (!report.ok) { - return { - status: 'blocked', - verified: false, - error: report.error, - blocked_on: ['verification'], - }; - } - return { - status: report.result.verified ? 'passed' : 'blocked', - verified: report.result.verified, - phase_artifacts_present: report.result.phase_artifacts_present, - prerequisite_status: report.result.prerequisite_status, - artifact_status: report.result.artifact_status, - blocked_on: report.result.blocked_on, - blocks_verification: report.result.blocks_verification, - }; -} - -function collectBlockers({ health, preflight, phaseReport }) { - const blockers = []; - const uiProofErrors = phaseReport.result?.uiProof?.errors || []; - const uiProofGate = phaseReport.result?.ui_proof || {}; - - blockers.push(...health.errors.map((entry) => notice('health', 'blocker', entry))); - blockers.push(...preflight.blockers.map((entry) => notice('preflight', 'blocker', entry))); - - if (!phaseReport.ok) { - blockers.push(notice('phase_verification', 'blocker', { - code: 'phase_verification_error', - message: phaseReport.error, - fix_hint: 'Run the direct verify command for the selected phase and repair the reported prerequisite.', - })); - } else { - for (const blockerEntry of phaseReport.result.prerequisite_status.blockers || []) { - blockers.push(notice('phase_verification', 'blocker', blockerEntry)); - } - for (const artifact of phaseReport.result.artifact_status.unsatisfied || []) { - blockers.push(notice('phase_verification', 'blocker', { - code: 'unsatisfied_plan_artifact', - message: `${artifact.file} is not ${artifact.expected}.`, - fix_hint: artifact.fix_hint, - path: artifact.file, - })); - } - for (const entry of uiProofErrors) { - blockers.push(notice('ui_proof', entry.severity === 'warn' ? 'warn' : 'blocker', entry)); - } - if (uiProofErrors.length === 0 && uiProofGate.blocks_verification) { - blockers.push(notice('ui_proof', 'blocker', { - code: uiProofGate.required_block || 'ui_proof_verification_failed', - message: `UI proof verification is required for phase ${phaseReport.result.phase} but reported status ${uiProofGate.status}.`, - fix_hint: 'Run the phase-specific UI proof checks and supply a passing observed proof bundle for each declared slot.', - })); - } - } - - return blockers.filter((entry) => entry.severity === 'blocker'); -} - -function collectWarnings({ controlMap, health, preflight, phaseReport }) { - const warnings = []; - warnings.push(...controlMap.risks - .filter((risk) => risk.severity !== 'block') - .map((risk) => notice('control_map', severityFromRisk(risk), risk))); - warnings.push(...health.warnings.map((entry) => notice('health', 'warn', entry))); - warnings.push(...preflight.warnings - .filter((entry) => entry.source !== 'control-map') - .map((entry) => notice('preflight', entry.severity === 'info' ? 'info' : 'warn', entry))); - if (phaseReport.ok) { - warnings.push(...(phaseReport.result.uiProof?.warnings || []).map((entry) => notice('ui_proof', 'warn', entry))); - } - return warnings; -} - -function nextSafeAction({ blockers, warnings, phaseNumber }) { - if (blockers.length > 0) { - return { - command: `gsdd verify ${phaseNumber}`, - reason: 'Fix blockers first, then re-run closeout replay.', - }; - } - const hasWarn = warnings.some((entry) => entry.severity === 'warn'); - if (warnings.length > 0 && hasWarn) { - const sources = new Set(warnings.filter((entry) => entry.severity === 'warn').map((entry) => entry.source)); - if (sources.has('health')) { - return { - command: 'gsdd health --json', - reason: 'Resolve workspace health warnings before claiming the environment is clean.', - }; - } - if (sources.has('ui_proof') || sources.has('phase_verification')) { - return { - command: `gsdd verify ${phaseNumber}`, - reason: 'Resolve phase verification warnings before claiming closeout is replay-clean.', - }; - } - return { - command: 'gsdd control-map --json', - reason: 'Resolve local state warnings before claiming the environment is clean.', - }; - } - if (warnings.length > 0) { - return { - command: 'gsdd control-map --json', - reason: 'Review the informational notices before claiming the local environment is clean.', - }; - } - return { - command: `gsdd verify ${phaseNumber}`, - reason: 'Closeout replay is clean; run formal verification for closure if it has not already been recorded.', - }; -} - -export async function buildCloseoutReport(ctx = {}, args = []) { - const context = resolveWorkspaceContext(args, { cwd: ctx.cwd || process.cwd() }); - if (context.invalid) { - return { - ok: false, - error: context.error, - exitCode: 1, - }; - } - - const phaseArg = parseFlagValue(context.args, '--phase'); - if (phaseArg.invalid) { - return { ok: false, error: USAGE, exitCode: 1 }; - } - - const filteredArgs = context.args.filter((arg, index) => { - if (arg === '--json') return false; - if (arg === '--phase') return false; - if (index > 0 && context.args[index - 1] === '--phase') return false; - return true; - }); - if (filteredArgs.length > 0) { - return { ok: false, error: USAGE, exitCode: 1 }; - } - - const lifecycle = evaluateLifecycleState({ planningDir: context.planningDir }); - const selectedPhase = phaseArg.present - ? normalizePhaseToken(phaseArg.value) - : latestCompletedPhase(lifecycle)?.number || null; - if (!selectedPhase) { - return { - ok: false, - error: 'No completed phase found. Pass --phase to replay a specific phase.', - exitCode: 1, - }; - } - - const controlMap = buildControlMap({ - workspaceRoot: context.workspaceRoot, - planningDir: context.planningDir, - }); - const health = await buildHealthReportSafe(ctx, ['--workspace-root', context.workspaceRoot]); - const preflight = evaluateLifecyclePreflight({ - planningDir: context.planningDir, - surface: 'verify', - phaseNumber: selectedPhase, - expectsMutation: 'phase-status', - controlMapReport: controlMap, - }); - const phaseReport = buildPhaseVerificationReport('--workspace-root', context.workspaceRoot, selectedPhase); - const blockers = collectBlockers({ health, preflight, phaseReport }); - const warnings = collectWarnings({ controlMap, health, preflight, phaseReport }); - const status = blockers.length > 0 ? 'blocked' : warnings.length > 0 ? 'warnings' : 'clear'; - - return { - ok: true, - exitCode: blockers.length > 0 ? 1 : 0, - result: { - schema_version: 1, - operation: 'closeout-report', - generated_at: new Date().toISOString(), - workspace_root: context.workspaceRoot.replace(/\\/g, '/'), - phase: selectedPhase, - scope: { - defaulted_to_latest_completed: !phaseArg.present, - latest_completed_phase: latestCompletedPhase(lifecycle)?.number || null, - }, - status, - blockers, - warnings, - next_safe_action: nextSafeAction({ blockers, warnings, phaseNumber: selectedPhase }), - control_map: summarizeControlMap(controlMap), - health: { - status: health.status, - errors: health.errors, - warnings: health.warnings, - info: health.info, - }, - preflight: summarizePreflight(preflight), - phase_verification: summarizePhaseVerification(phaseReport), - ui_proof: phaseReport.ok ? phaseReport.result.ui_proof : null, - }, - }; -} - -function printHuman(report) { - console.log('gsdd closeout-report - read-only closure replay\n'); - console.log(`Phase: ${report.phase}`); - console.log(`Status: ${report.status}`); - if (report.blockers.length > 0) { - console.log('\nBlockers:'); - for (const blocker of report.blockers) { - console.log(` - [${blocker.source}] ${blocker.code}: ${blocker.message}`); - if (blocker.fix) console.log(` Fix: ${blocker.fix}`); - } - } - if (report.warnings.length > 0) { - console.log('\nWarnings:'); - for (const warning of report.warnings) { - console.log(` - [${warning.source}] ${warning.code}: ${warning.message}`); - if (warning.fix) console.log(` Fix: ${warning.fix}`); - } - } - console.log(`\nNext safe action: ${report.next_safe_action.command}`); - console.log(`Reason: ${report.next_safe_action.reason}`); -} - -export function createCmdCloseoutReport(ctx = {}) { - return async function cmdCloseoutReport(...args) { - const jsonMode = args.includes('--json'); - const report = await buildCloseoutReport(ctx, args); - if (!report.ok) { - console.error(report.error); - process.exitCode = report.exitCode; - return; - } - if (jsonMode) output(report.result); - else printHuman(report.result); - if (report.exitCode !== 0) process.exitCode = report.exitCode; - }; -} diff --git a/bin/lib/control-map.mjs b/bin/lib/control-map.mjs index 6883959..323ac95 100644 --- a/bin/lib/control-map.mjs +++ b/bin/lib/control-map.mjs @@ -5,16 +5,12 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from import { spawnSync } from 'child_process'; import { output, parseFlagValue } from './cli-utils.mjs'; import { evaluateLifecycleState } from './lifecycle-state.mjs'; -import { checkDrift } from './session-fingerprint.mjs'; import { resolveWorkspaceContext } from './workspace-root.mjs'; const DEFAULT_ANNOTATIONS_RELATIVE_PATH = '.planning/.local/control-map.annotations.json'; const MAX_DIRTY_BUCKET_ENTRIES = 200; const CLEANUP_STATES = Object.freeze(['active', 'paused', 'merged', 'abandoned', 'superseded', 'cleanup_deferred']); const ACTIVE_ANNOTATION_STATES = Object.freeze(['active', 'paused', 'cleanup_deferred']); -const CONTROL_MAP_USAGE = 'Usage: gsdd control-map [--json] [--with-ignored] [--annotations ]'; -const CONTROL_MAP_ANNOTATE_SET_USAGE = 'Usage: gsdd control-map annotate set [--id ] [--path ] --write-set [--owner ] [--scope ] [--cleanup-state ] [--next-step ] [--refresh] [--annotations ]'; -const CONTROL_MAP_ANNOTATE_CLEAR_USAGE = 'Usage: gsdd control-map annotate clear (--id | --path ) [--annotations ]'; const AUTHORITY_ORDER = Object.freeze([ 'repo_truth', 'planning_artifacts', @@ -497,383 +493,6 @@ function loadAnnotations(workspaceRoot, planningDir, annotationPathArg = null) { }; } -function parseAnnotationMutationFlags(args, usage, { valueFlags, booleanFlags = [] }) { - const allowedValueFlags = new Set(valueFlags); - const allowedBooleanFlags = new Set(booleanFlags); - const values = new Map(); - const listValues = new Map([['--write-set', []]]); - const booleans = new Set(); - const unknown = []; - - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (allowedBooleanFlags.has(arg)) { - booleans.add(arg); - continue; - } - if (!allowedValueFlags.has(arg)) { - unknown.push(arg); - continue; - } - - const value = args[index + 1]; - if (!value || value.startsWith('--')) { - return { valid: false, usage, error: `Missing value for ${arg}.` }; - } - if (arg === '--write-set') listValues.get(arg).push(value); - else values.set(arg, value); - index += 1; - } - - if (unknown.length > 0) { - return { valid: false, usage, error: `Unsupported argument(s): ${unknown.join(', ')}` }; - } - - return { - valid: true, - values, - listValues, - booleans, - }; -} - -function parseWriteSet(values, worktreePath) { - const entries = []; - for (const rawValue of values || []) { - for (const part of String(rawValue).split(',')) { - const normalized = normalizeRepoPath(part, worktreePath); - if (normalized && !entries.includes(normalized)) entries.push(normalized); - } - } - return entries; -} - -function annotationPathLabelForWrite(workspaceRoot, worktreePath) { - return workspacePathLabel(workspaceRoot, worktreePath); -} - -function annotationIdForWorktree(worktree) { - if (worktree.id === '.') return 'canonical'; - return worktree.id; -} - -function serializeAnnotationEntry(workspaceRoot, entry) { - const serialized = { - id: entry.id, - path: entry.path || annotationPathLabelForWrite(workspaceRoot, entry.normalized_path), - cleanup_state: entry.cleanup_state || 'active', - }; - if (entry.runtime_owner) serialized.runtime_owner = entry.runtime_owner; - if (entry.branch) serialized.branch = entry.branch; - if (entry.last_known_head) serialized.last_known_head = entry.last_known_head; - if (entry.intended_scope) serialized.intended_scope = entry.intended_scope; - if (Array.isArray(entry.write_set) && entry.write_set.length > 0) serialized.write_set = entry.write_set; - if (entry.next_step) serialized.next_step = entry.next_step; - if (entry.updated_at) serialized.updated_at = entry.updated_at; - return serialized; -} - -function writeAnnotationsDocument(workspaceRoot, annotationFile, worktrees) { - mkdirSync(dirname(annotationFile.path), { recursive: true }); - const document = { - schema_version: 1, - worktrees: worktrees.map((entry) => serializeAnnotationEntry(workspaceRoot, entry)), - }; - writeFileSync(annotationFile.path, `${JSON.stringify(document, null, 2)}\n`); -} - -function buildMutationContext(context, annotationPathArg) { - const annotationFile = resolveAnnotationFilePath(context.workspaceRoot, context.planningDir, annotationPathArg); - if (!annotationFile.inside_workspace) { - return { - ok: false, - result: { - status: 'blocked', - reason: 'annotations_path_outside_workspace', - annotations_path: annotationFile.label, - message: 'Control-map annotations path must stay inside the workspace.', - }, - }; - } - - const annotations = loadAnnotations(context.workspaceRoot, context.planningDir, annotationPathArg); - if (!annotations.valid) { - return { - ok: false, - result: { - status: 'blocked', - reason: 'invalid_annotations', - annotations_path: annotations.path, - errors: annotations.errors, - }, - }; - } - - const discovered = discoverGitWorktrees(context.workspaceRoot); - return { - ok: true, - annotationFile, - annotations, - worktrees: discovered.worktrees, - }; -} - -function findWorktreeByPath(worktrees, pathValue) { - const normalized = normalizeSlashes(resolve(pathValue)); - return worktrees.find((worktree) => normalizeSlashes(resolve(worktree.path)) === normalized) || null; -} - -function findAnnotationIndex(annotations, { id = null, normalizedPath = null } = {}) { - if (id) { - const byId = annotations.worktrees.findIndex((entry) => entry.id === id); - if (byId !== -1) return byId; - } - if (normalizedPath) { - return annotations.worktrees.findIndex((entry) => ( - entry.normalized_path && normalizeSlashes(resolve(entry.normalized_path)) === normalizeSlashes(resolve(normalizedPath)) - )); - } - return -1; -} - -function staleAnnotationIssues(annotation, worktree) { - const issues = []; - if (annotation.branch && worktree.branch && annotation.branch !== worktree.branch) { - issues.push({ - code: 'branch_mismatch', - saved: annotation.branch, - live: worktree.branch, - }); - } - if (annotation.last_known_head && worktree.head && annotation.last_known_head !== worktree.head) { - issues.push({ - code: 'head_mismatch', - saved: annotation.last_known_head, - live: worktree.head, - }); - } - return issues; -} - -function annotationMutationSummary(annotation) { - return { - id: annotation.id, - path: annotation.path, - runtime_owner: annotation.runtime_owner || null, - branch: annotation.branch || null, - last_known_head: annotation.last_known_head || null, - intended_scope: annotation.intended_scope || null, - write_set: annotation.write_set || [], - cleanup_state: annotation.cleanup_state || 'active', - next_step: annotation.next_step || null, - updated_at: annotation.updated_at || null, - }; -} - -function setAnnotation(context, args) { - const parsed = parseAnnotationMutationFlags(args, CONTROL_MAP_ANNOTATE_SET_USAGE, { - valueFlags: [ - '--annotations', - '--cleanup-state', - '--id', - '--next-step', - '--owner', - '--path', - '--runtime-owner', - '--scope', - '--write-set', - ], - booleanFlags: ['--refresh'], - }); - if (!parsed.valid) return { ok: false, result: { status: 'blocked', reason: 'invalid_arguments', message: parsed.error, usage: parsed.usage } }; - - const annotationPathArg = parsed.values.get('--annotations') || null; - const mutation = buildMutationContext(context, annotationPathArg); - if (!mutation.ok) return mutation; - - const explicitId = parsed.values.get('--id') || null; - const pathValue = parsed.values.get('--path') || '.'; - const targetPath = resolve(context.workspaceRoot, pathValue); - const worktree = findWorktreeByPath(mutation.worktrees, targetPath); - if (!worktree || !worktree.git_valid) { - return { - ok: false, - result: { - status: 'blocked', - reason: 'worktree_not_found', - message: `No live git worktree found for ${pathValue}.`, - path: workspacePathLabel(context.workspaceRoot, targetPath), - }, - }; - } - - const writeSet = parseWriteSet(parsed.listValues.get('--write-set'), worktree.path); - if (writeSet.length === 0) { - return { - ok: false, - result: { - status: 'blocked', - reason: 'missing_write_set', - message: 'Annotation set requires at least one --write-set value.', - usage: CONTROL_MAP_ANNOTATE_SET_USAGE, - }, - }; - } - - const normalizedPath = normalizeSlashes(resolve(worktree.path)); - const existingIndex = findAnnotationIndex(mutation.annotations, { id: explicitId, normalizedPath }); - const existing = existingIndex === -1 ? null : mutation.annotations.worktrees[existingIndex]; - - const cleanupState = parsed.values.get('--cleanup-state') || existing?.cleanup_state || 'active'; - if (!CLEANUP_STATES.includes(cleanupState)) { - return { - ok: false, - result: { - status: 'blocked', - reason: 'invalid_cleanup_state', - message: `Unsupported cleanup_state: ${cleanupState}`, - supported_cleanup_states: CLEANUP_STATES, - }, - }; - } - - const staleIssues = existing ? staleAnnotationIssues(existing, worktree) : []; - const refresh = parsed.booleans.has('--refresh'); - if (staleIssues.length > 0 && !refresh) { - return { - ok: false, - result: { - operation: 'control-map annotate set', - status: 'blocked', - reason: 'stale_annotation', - annotations_path: mutation.annotationFile.label, - annotation_id: existing.id, - stale_issues: staleIssues, - message: 'Existing annotation is stale against live worktree truth; rerun with --refresh to update it or use annotate clear to remove it.', - }, - }; - } - - const now = new Date().toISOString(); - const annotation = { - id: explicitId || existing?.id || annotationIdForWorktree(worktree), - path: annotationPathLabelForWrite(context.workspaceRoot, worktree.path), - runtime_owner: parsed.values.get('--runtime-owner') || parsed.values.get('--owner') || existing?.runtime_owner || null, - branch: worktree.branch, - last_known_head: worktree.head, - intended_scope: parsed.values.get('--scope') || existing?.intended_scope || null, - write_set: writeSet, - cleanup_state: cleanupState, - next_step: parsed.values.get('--next-step') || existing?.next_step || null, - updated_at: now, - }; - const nextWorktrees = mutation.annotations.worktrees.slice(); - if (existingIndex === -1) nextWorktrees.push(annotation); - else nextWorktrees[existingIndex] = annotation; - writeAnnotationsDocument(context.workspaceRoot, mutation.annotationFile, nextWorktrees); - - return { - ok: true, - result: { - operation: 'control-map annotate set', - changed: true, - status: existing ? (refresh && staleIssues.length > 0 ? 'refreshed' : 'updated') : 'created', - annotations_path: mutation.annotationFile.label, - annotation: annotationMutationSummary(annotation), - stale_check: { - status: staleIssues.length > 0 ? 'refreshed' : 'passed', - issues: staleIssues, - }, - }, - }; -} - -function clearAnnotation(context, args) { - const parsed = parseAnnotationMutationFlags(args, CONTROL_MAP_ANNOTATE_CLEAR_USAGE, { - valueFlags: ['--annotations', '--id', '--path'], - }); - if (!parsed.valid) return { ok: false, result: { status: 'blocked', reason: 'invalid_arguments', message: parsed.error, usage: parsed.usage } }; - - const id = parsed.values.get('--id') || null; - const pathValue = parsed.values.get('--path') || null; - if (!id && !pathValue) { - return { - ok: false, - result: { - status: 'blocked', - reason: 'missing_selector', - message: 'Annotation clear requires --id or --path.', - usage: CONTROL_MAP_ANNOTATE_CLEAR_USAGE, - }, - }; - } - - const annotationPathArg = parsed.values.get('--annotations') || null; - const mutation = buildMutationContext(context, annotationPathArg); - if (!mutation.ok) return mutation; - if (!mutation.annotations.exists) { - return { - ok: true, - result: { - operation: 'control-map annotate clear', - changed: false, - status: 'missing', - annotations_path: mutation.annotationFile.label, - }, - }; - } - - const normalizedPath = pathValue ? normalizeSlashes(resolve(context.workspaceRoot, pathValue)) : null; - const existingIndex = findAnnotationIndex(mutation.annotations, { id, normalizedPath }); - if (existingIndex === -1) { - return { - ok: true, - result: { - operation: 'control-map annotate clear', - changed: false, - status: 'not_found', - annotations_path: mutation.annotationFile.label, - selector: id ? { id } : { path: workspacePathLabel(context.workspaceRoot, normalizedPath) }, - }, - }; - } - - const removed = mutation.annotations.worktrees[existingIndex]; - const nextWorktrees = mutation.annotations.worktrees.filter((_, index) => index !== existingIndex); - writeAnnotationsDocument(context.workspaceRoot, mutation.annotationFile, nextWorktrees); - - return { - ok: true, - result: { - operation: 'control-map annotate clear', - changed: true, - status: 'cleared', - annotations_path: mutation.annotationFile.label, - annotation: annotationMutationSummary(removed), - }, - }; -} - -function cmdControlMapAnnotate(context, args) { - const [operation, ...operationArgs] = args; - let result; - if (operation === 'set') result = setAnnotation(context, operationArgs); - else if (operation === 'clear') result = clearAnnotation(context, operationArgs); - else { - result = { - ok: false, - result: { - status: 'blocked', - reason: 'invalid_operation', - message: 'Usage: gsdd control-map annotate ...', - }, - }; - } - - output(result.result); - if (!result.ok) process.exitCode = 1; -} - function reconcileAnnotations(worktrees, annotations) { const warnings = []; const byPath = new Map(worktrees.map((entry) => [normalizeSlashes(resolve(entry.path)), entry])); @@ -923,7 +542,6 @@ function reconcileAnnotations(worktrees, annotations) { function classifyWorkflowState(planningDir) { const lifecycle = evaluateLifecycleState({ planningDir }); const checkpointPath = join(planningDir, '.continue-here.md'); - const drift = existsSync(planningDir) ? checkDrift(planningDir) : { drifted: false, details: [] }; const milestoneVersion = lifecycle.currentMilestone?.version || null; const milestoneTitle = lifecycle.currentMilestone?.title || null; const milestone = milestoneVersion || milestoneTitle @@ -938,10 +556,6 @@ function classifyWorkflowState(planningDir) { exists: existsSync(checkpointPath), path: '.planning/.continue-here.md', }, - planning_drift: { - drifted: drift.drifted, - details: drift.details || [], - }, }; } @@ -1008,8 +622,6 @@ function buildRisks({ canonical, worktrees, annotations, rawAnnotations, runtime return 'Resolve overlapping local annotation write sets before starting another owned-write workflow.'; case 'dirty_path_write_set_overlap': return 'Checkpoint or classify dirty paths that overlap annotated write sets before owned-write transitions.'; - case 'planning_state_drift': - return 'Review drift and rebaseline with session-fingerprint only after confirming the planning changes are intentional.'; default: return null; } @@ -1134,16 +746,6 @@ function buildRisks({ canonical, worktrees, annotations, rawAnnotations, runtime path: runtimeDir.path_relative, }); } - if (workflowState.planning_drift.drifted) { - const risk = { - code: 'planning_state_drift', - severity: 'warn', - message: `Planning state drifted since the last fingerprint: ${workflowState.planning_drift.details.join('; ')}`, - }; - risk.fix_hint = fixHintForRisk(risk); - risks.push(risk); - } - // Ensure the common closure risks expose actionable fix guidance even when // the originating helper (for example branch-state risks) didn't attach it. for (const risk of risks) { @@ -1167,7 +769,6 @@ function buildInterventions(risks) { if (codes.has('canonical_dirty')) interventions.push('Checkpoint or classify canonical dirty work before planning, cleanup, merge, or broad execution.'); if (codes.has('sibling_worktree_dirty') || codes.has('unannotated_candidate_worktree')) interventions.push('Review sibling worktree ownership and write set before starting overlapping implementation.'); if (codes.has('stale_annotation_missing_worktree') || codes.has('stale_annotation_branch_mismatch') || codes.has('stale_annotation_head_mismatch')) interventions.push('Refresh or remove stale local control-map annotations after reviewing repo truth.'); - if (codes.has('planning_state_drift')) interventions.push('Review planning drift and rebaseline with session-fingerprint only after confirming the changes are intentional.'); if (interventions.length === 0) interventions.push('No control-map intervention required before read-only status work.'); return interventions; } @@ -1224,78 +825,3 @@ export function buildControlMap({ }; } -function printHuman(map) { - const milestone = map.workflow_state.current_milestone - ? [map.workflow_state.current_milestone.version, map.workflow_state.current_milestone.title].filter(Boolean).join(' ') - : 'none'; - const ignoredLabel = map.canonical_worktree.dirty.ignored_count_included - ? String(map.canonical_worktree.dirty.counts.ignored) - : 'not scanned'; - console.log('gsdd control-map - computed workspace control map\n'); - console.log(`Workspace: ${map.workspace_root}`); - console.log(`Authority: ${map.authority.join(' > ')}`); - console.log(`Canonical: ${map.canonical_worktree.branch || 'unknown'} @ ${map.canonical_worktree.head || 'unknown'}`); - console.log(`Workflow: milestone=${milestone}, phase=${map.workflow_state.current_phase || 'none'}, next=${map.workflow_state.next_phase || 'none'}`); - console.log(`Checkpoint: ${map.workflow_state.checkpoint.path} (${map.workflow_state.checkpoint.exists ? 'present' : 'missing'})`); - console.log(`Dirty buckets: ${map.canonical_worktree.dirty.counts.tracked} tracked, ${map.canonical_worktree.dirty.counts.untracked} untracked, ${ignoredLabel} ignored`); - console.log(`Worktrees: ${map.worktrees.length}`); - for (const worktree of map.worktrees) { - const marker = worktree.path === map.canonical_worktree.path ? '*' : '-'; - const annotation = worktree.annotation ? ` owner=${worktree.annotation.runtime_owner || 'unspecified'} cleanup=${worktree.annotation.cleanup_state}` : ''; - const ignoredCount = worktree.dirty.ignored_count_included ? String(worktree.dirty.counts.ignored) : 'not-scanned'; - console.log(` ${marker} ${worktree.id} branch=${worktree.branch || 'unknown'} dirty=${worktree.dirty.counts.tracked}/${worktree.dirty.counts.untracked}/${ignoredCount}${annotation}`); - } - if (map.risks.length > 0) { - console.log('\nRisks:'); - for (const risk of map.risks) { - console.log(` - [${risk.severity || 'info'}] ${risk.code}: ${risk.message}`); - if (risk.fix_hint) console.log(` Fix: ${risk.fix_hint}`); - } - } - console.log('\nInterventions:'); - for (const intervention of map.interventions) console.log(` - ${intervention}`); -} - -export function cmdControlMap(...args) { - const jsonMode = args.includes('--json'); - const contextArgs = args.filter((arg) => arg !== '--json'); - const context = resolveWorkspaceContext(contextArgs); - if (context.invalid) { - console.error(context.error); - process.exitCode = 1; - return; - } - - if (context.args[0] === 'annotate') { - cmdControlMapAnnotate(context, context.args.slice(1)); - return; - } - - const annotations = parseFlagValue(context.args, '--annotations'); - if (annotations.invalid) { - console.error(CONTROL_MAP_USAGE); - process.exitCode = 1; - return; - } - const filteredArgs = context.args.filter((arg, index) => { - if (arg === '--annotations') return false; - if (index > 0 && context.args[index - 1] === '--annotations') return false; - if (arg === '--with-ignored') return false; - return true; - }); - if (filteredArgs.length > 0) { - console.error(CONTROL_MAP_USAGE); - process.exitCode = 1; - return; - } - - const includeIgnoredPaths = context.args.includes('--with-ignored'); - const map = buildControlMap({ - workspaceRoot: context.workspaceRoot, - planningDir: context.planningDir, - annotationPath: annotations.value, - includeIgnoredPaths, - }); - if (jsonMode) output(map); - else printHuman(map); -} diff --git a/bin/lib/evidence-contract.mjs b/bin/lib/evidence-contract.mjs deleted file mode 100644 index 34b9f99..0000000 --- a/bin/lib/evidence-contract.mjs +++ /dev/null @@ -1,325 +0,0 @@ -const EVIDENCE_KINDS = Object.freeze(['code', 'test', 'runtime', 'delivery', 'human']); -const DELIVERY_POSTURES = Object.freeze(['repo_only', 'delivery_sensitive']); -const CLOSURE_SURFACES = Object.freeze(['verify', 'audit-milestone', 'complete-milestone']); -const RELEASE_CLAIM_POSTURES = Object.freeze([ - 'repo_closeout', - 'runtime_validated_closeout', - 'delivery_supported_closeout', -]); - -const LEGACY_EVIDENCE_ALIASES = Object.freeze({ - code: 'code', - test: 'test', - runtime: 'runtime', - delivery: 'delivery', - human: 'human', - 'code-evidence': 'code', - 'repo-test': 'test', - 'runtime-check': 'runtime', - 'user-confirmation': 'human', -}); - -const EVIDENCE_MATRIX = Object.freeze({ - verify: Object.freeze({ - repo_only: Object.freeze({ - requiredKinds: Object.freeze(['code']), - recommendedKinds: Object.freeze(['test']), - blockedSoloKinds: Object.freeze(['human', 'delivery']), - }), - delivery_sensitive: Object.freeze({ - requiredKinds: Object.freeze(['code', 'runtime', 'delivery']), - recommendedKinds: Object.freeze(['test', 'human']), - blockedSoloKinds: Object.freeze(['code', 'human']), - }), - }), - 'audit-milestone': Object.freeze({ - repo_only: Object.freeze({ - requiredKinds: Object.freeze(['code', 'test']), - recommendedKinds: Object.freeze(['runtime', 'human']), - blockedSoloKinds: Object.freeze(['human', 'delivery']), - }), - delivery_sensitive: Object.freeze({ - requiredKinds: Object.freeze(['code', 'test', 'runtime', 'delivery']), - recommendedKinds: Object.freeze(['human']), - blockedSoloKinds: Object.freeze(['code', 'human']), - }), - }), - 'complete-milestone': Object.freeze({ - repo_only: Object.freeze({ - requiredKinds: Object.freeze(['code', 'test']), - recommendedKinds: Object.freeze(['runtime']), - blockedSoloKinds: Object.freeze(['human', 'delivery']), - }), - delivery_sensitive: Object.freeze({ - requiredKinds: Object.freeze(['code', 'test', 'runtime', 'delivery']), - recommendedKinds: Object.freeze(['human']), - blockedSoloKinds: Object.freeze(['code', 'human']), - }), - }), -}); - -const CONTRADICTION_CATEGORIES = Object.freeze([ - 'evidence', - 'public_surface', - 'runtime', - 'delivery', - 'planning_drift', - 'generated_surface', -]); - -const CONTRADICTION_STATUSES = Object.freeze(['passed', 'failed', 'not_applicable']); - -const RELEASE_CLAIM_MATRIX = Object.freeze({ - repo_closeout: Object.freeze({ - deliveryPosture: 'repo_only', - requiredClaimKinds: Object.freeze([]), - allowedClaim: 'Repo-local milestone or phase closeout is supported by planning and repository artifacts only.', - invalidClaim: 'Do not imply runtime validation, delivery, publication, or public support from repo-local closeout alone.', - }), - runtime_validated_closeout: Object.freeze({ - deliveryPosture: 'repo_only', - requiredClaimKinds: Object.freeze(['runtime']), - allowedClaim: 'Runtime behavior or a runtime surface was directly executed and observed for the named runtime or surface.', - invalidClaim: 'Do not generalize validation from one runtime or generated surface to another.', - }), - delivery_supported_closeout: Object.freeze({ - deliveryPosture: 'delivery_sensitive', - requiredClaimKinds: Object.freeze([]), - allowedClaim: 'Externally consumed release, support, install, or delivery claims are supported by the delivery-sensitive evidence bar.', - invalidClaim: 'Do not imply merge, package, tag, GitHub Release, publication, generated-surface freshness, or public support without matching delivery evidence.', - }), -}); - -const CONTRADICTION_BLOCKERS_BY_POSTURE = Object.freeze({ - repo_closeout: Object.freeze(['evidence', 'public_surface', 'planning_drift']), - runtime_validated_closeout: Object.freeze(['evidence', 'runtime', 'generated_surface', 'planning_drift']), - delivery_supported_closeout: CONTRADICTION_CATEGORIES, -}); - -export { CLOSURE_SURFACES, DELIVERY_POSTURES, EVIDENCE_KINDS, RELEASE_CLAIM_POSTURES }; - -export function normalizeEvidenceKind(kind) { - if (!kind) { - return null; - } - - return LEGACY_EVIDENCE_ALIASES[kind] ?? null; -} - -export function normalizeEvidenceKinds(kinds = []) { - const normalized = []; - for (const kind of kinds) { - const resolved = normalizeEvidenceKind(kind); - if (resolved && !normalized.includes(resolved)) { - normalized.push(resolved); - } - } - return normalized; -} - -export function isClosureSurface(surface) { - return CLOSURE_SURFACES.includes(surface); -} - -export function normalizeReleaseClaimPosture(posture) { - if (!posture) return 'repo_closeout'; - return RELEASE_CLAIM_POSTURES.includes(posture) ? posture : null; -} - -export function getEvidenceContract(surface, deliveryPosture) { - const matrix = EVIDENCE_MATRIX[surface]; - if (!matrix) { - throw new Error(`Unsupported closure evidence surface: ${surface}`); - } - - const posture = matrix[deliveryPosture]; - if (!posture) { - throw new Error(`Unsupported delivery posture for ${surface}: ${deliveryPosture}`); - } - - return { - surface, - deliveryPosture, - supportedKinds: [...EVIDENCE_KINDS], - requiredKinds: [...posture.requiredKinds], - recommendedKinds: [...posture.recommendedKinds], - blockedSoloKinds: [...posture.blockedSoloKinds], - }; -} - -export function describeEvidenceSurface(surface) { - if (!isClosureSurface(surface)) { - return null; - } - - return { - surface, - supportedKinds: [...EVIDENCE_KINDS], - deliveryPostures: DELIVERY_POSTURES.map((deliveryPosture) => getEvidenceContract(surface, deliveryPosture)), - releaseClaimPostures: RELEASE_CLAIM_POSTURES.map((releaseClaimPosture) => getReleaseClaimContract(surface, releaseClaimPosture)), - }; -} - -function uniqueKinds(kinds) { - return [...new Set(kinds)]; -} - -function getDowngradePosture(observedKinds) { - if (observedKinds.includes('runtime')) { - return 'runtime_validated_closeout'; - } - return 'repo_closeout'; -} - -export function getReleaseClaimContract(surface, releaseClaimPosture = 'repo_closeout') { - const posture = normalizeReleaseClaimPosture(releaseClaimPosture); - if (!posture) { - throw new Error(`Unsupported release claim posture: ${releaseClaimPosture}`); - } - const claim = RELEASE_CLAIM_MATRIX[posture]; - const evidence = getEvidenceContract(surface, claim.deliveryPosture); - const requiredKinds = uniqueKinds([...evidence.requiredKinds, ...claim.requiredClaimKinds]); - - return { - surface, - releaseClaimPosture: posture, - deliveryPosture: claim.deliveryPosture, - supportedKinds: [...EVIDENCE_KINDS], - requiredKinds, - requiredClaimKinds: [...claim.requiredClaimKinds], - allowedClaim: claim.allowedClaim, - invalidClaim: claim.invalidClaim, - waiverRule: 'Waivers may only narrow the release claim posture or defer an unsupported claim; they never satisfy missing required evidence for the stronger claim.', - deferralRule: 'Deferrals must name the unsupported claim, missing evidence kinds, and later workflow or milestone candidate when known.', - contradictionCategories: [...CONTRADICTION_CATEGORIES], - }; -} - -export function evaluateReleaseClaimPosture({ - surface, - releaseClaimPosture = 'repo_closeout', - observedKinds = [], - waivedKinds = [], -} = {}) { - const contract = getReleaseClaimContract(surface, releaseClaimPosture); - const observed = normalizeEvidenceKinds(observedKinds); - const waived = normalizeEvidenceKinds(waivedKinds); - const missingKinds = contract.requiredKinds.filter((kind) => !observed.includes(kind)); - const invalidWaivers = waived.filter((kind) => missingKinds.includes(kind)); - const hasUnsupportedStrongClaim = contract.releaseClaimPosture !== 'repo_closeout' && missingKinds.length > 0; - - return { - surface: contract.surface, - releaseClaimPosture: contract.releaseClaimPosture, - deliveryPosture: contract.deliveryPosture, - requiredKinds: [...contract.requiredKinds], - observedKinds: observed, - missingKinds, - invalidWaivers, - status: missingKinds.length === 0 && invalidWaivers.length === 0 ? 'supported' : 'unsupported', - disposition: hasUnsupportedStrongClaim ? 'downgrade_or_defer' : missingKinds.length > 0 ? 'block_or_defer' : 'proceed', - downgradeTo: hasUnsupportedStrongClaim ? getDowngradePosture(observed) : null, - deferredClaims: hasUnsupportedStrongClaim - ? [{ claim: contract.releaseClaimPosture, missingKinds }] - : [], - }; -} - -export function evaluateReleaseClaimCloseoutContract({ - surface, - deliveryPosture = null, - releaseClaimPosture = 'repo_closeout', - observedKinds = [], - waivedKinds = [], - unsupportedClaims = [], - deferrals = [], - contradictionChecks = {}, -} = {}) { - const posture = evaluateReleaseClaimPosture({ - surface, - releaseClaimPosture, - observedKinds, - waivedKinds, - }); - const missingContradictionChecks = CONTRADICTION_CATEGORIES.filter((name) => !(name in contradictionChecks)); - const failedContradictionChecks = Object.entries(contradictionChecks) - .filter(([, status]) => status === 'failed') - .map(([name]) => name); - const unknownContradictionChecks = Object.keys(contradictionChecks) - .filter((name) => !CONTRADICTION_CATEGORIES.includes(name)); - const invalidContradictionChecks = Object.entries(contradictionChecks) - .filter(([, status]) => !CONTRADICTION_STATUSES.includes(status)) - .map(([name]) => name); - const blockingContradictionChecks = failedContradictionChecks.filter((name) => - CONTRADICTION_BLOCKERS_BY_POSTURE[posture.releaseClaimPosture].includes(name) - ); - const unresolvedUnsupportedClaims = unsupportedClaims.filter((claim) => - !deferrals.some((deferral) => namesUnsupportedClaim(deferral, claim)) - ); - const blockers = []; - - if (deliveryPosture && deliveryPosture !== posture.deliveryPosture) { - blockers.push({ - code: 'incompatible_release_claim_posture', - details: [`${deliveryPosture} cannot support ${posture.releaseClaimPosture}; expected ${posture.deliveryPosture}`], - }); - } - - if (posture.missingKinds.length > 0) { - blockers.push({ code: 'missing_required_release_evidence', details: posture.missingKinds }); - } - if (posture.invalidWaivers.length > 0) { - blockers.push({ code: 'invalid_release_waivers', details: posture.invalidWaivers }); - } - if (unresolvedUnsupportedClaims.length > 0) { - blockers.push({ code: 'unsupported_release_claims', details: unresolvedUnsupportedClaims }); - } - if (missingContradictionChecks.length > 0) { - blockers.push({ code: 'missing_release_contradiction_checks', details: missingContradictionChecks }); - } - if (unknownContradictionChecks.length > 0) { - blockers.push({ code: 'unknown_release_contradiction_checks', details: unknownContradictionChecks }); - } - if (invalidContradictionChecks.length > 0) { - blockers.push({ code: 'invalid_release_contradiction_checks', details: invalidContradictionChecks }); - } - if (blockingContradictionChecks.length > 0) { - blockers.push({ code: 'failed_release_contradiction_checks', details: blockingContradictionChecks }); - } - - return { - ...posture, - unsupportedClaims: [...unsupportedClaims], - deferrals: [...deferrals], - failedContradictionChecks: blockingContradictionChecks, - allFailedContradictionChecks: failedContradictionChecks, - missingContradictionChecks, - unknownContradictionChecks, - invalidContradictionChecks, - unresolvedUnsupportedClaims, - blockers, - status: blockers.length === 0 ? 'supported' : 'unsupported', - }; -} - -function namesUnsupportedClaim(deferral, claim) { - const normalizedDeferral = normalizeClaimText(deferral); - const normalizedClaim = normalizeClaimText(claim); - if (!normalizedDeferral || !normalizedClaim) return false; - return normalizedDeferral.includes(normalizedClaim) && isStructuredDeferral(normalizedDeferral); -} - -function isStructuredDeferral(normalizedDeferral) { - const namesEvidenceKind = EVIDENCE_KINDS.some((kind) => normalizedDeferral.includes(kind)); - const namesLaterTarget = /\b(later|next|future|workflow|milestone|phase|gsdd)\b/.test(normalizedDeferral); - return namesEvidenceKind && namesLaterTarget; -} - -function normalizeClaimText(value) { - return String(value || '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} diff --git a/bin/lib/health-truth.mjs b/bin/lib/health-truth.mjs index b795c5e..f5a045d 100644 --- a/bin/lib/health-truth.mjs +++ b/bin/lib/health-truth.mjs @@ -5,9 +5,8 @@ import { getRuntimeFreshnessRepairGuidance, summarizeRuntimeFreshnessIssues, } from './runtime-freshness.mjs'; -import { checkDrift } from './session-fingerprint.mjs'; -export const TRUTH_CHECK_IDS = ['W7', 'W8', 'W9', 'W10', 'W11', 'W12']; +export const TRUTH_CHECK_IDS = ['W7', 'W8', 'W9', 'W10', 'W11']; export function runTruthChecks(planningDir, frameworkDir, actualCheckIds, options = {}) { const warnings = []; @@ -99,16 +98,6 @@ export function runTruthChecks(planningDir, frameworkDir, actualCheckIds, option }); } - const drift = checkDrift(planningDir); - if (drift.drifted) { - warnings.push({ - id: 'W12', - severity: 'WARN', - message: `Planning state drifted since last recorded session (${drift.details.join('; ')})`, - fix: 'Review the changed planning files. If the drift is intentional, rebaseline with `node .planning/bin/gsdd.mjs session-fingerprint write`, then rerun the blocked lifecycle preflight.', - }); - } - return warnings; } diff --git a/bin/lib/health.mjs b/bin/lib/health.mjs index 4e20b77..56549a8 100644 --- a/bin/lib/health.mjs +++ b/bin/lib/health.mjs @@ -10,7 +10,6 @@ import { output } from './cli-utils.mjs'; import { runTruthChecks, TRUTH_CHECK_IDS } from './health-truth.mjs'; import { evaluateLifecycleState } from './lifecycle-state.mjs'; import { evaluateRuntimeFreshness } from './runtime-freshness.mjs'; -import { findUiProofBundleFiles, readUiProofBundleFile, validateUiProofBundle } from './ui-proof.mjs'; import { resolveWorkspaceContext } from './workspace-root.mjs'; /** @@ -30,7 +29,7 @@ export function buildHealthReport(ctx, healthArgs = []) { } const cwd = workspaceRoot; const frameworkSourceMode = isFrameworkSourceRepo(cwd); - const healthCheckIds = ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9', 'E10', 'W1', 'W2', 'W3', 'W4', 'W5', 'W6', ...TRUTH_CHECK_IDS, 'I1', 'I2', 'I3']; + const healthCheckIds = ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9', 'W1', 'W2', 'W3', 'W4', 'W5', 'W6', ...TRUTH_CHECK_IDS, 'I1', 'I2', 'I3']; // Pre-init guard if (!existsSync(join(planningDir, 'config.json'))) { @@ -139,23 +138,6 @@ export function buildHealthReport(ctx, healthArgs = []) { } } - // E10: known UI proof bundles must satisfy deterministic metadata/privacy validation. - for (const bundlePath of findUiProofBundleFiles(planningDir)) { - const relativePath = relative(cwd, bundlePath).replace(/\\/g, '/'); - const parsed = readUiProofBundleFile(bundlePath); - const validation = parsed.errors.length > 0 - ? { valid: false, errors: parsed.errors } - : validateUiProofBundle(parsed.bundle, { requireLocalArtifactExists: true, workspaceRoot: cwd }); - if (!validation.valid) { - errors.push({ - id: 'E10', - severity: 'ERROR', - message: `${relativePath} has invalid UI proof metadata (${validation.errors.map((entry) => entry.code).join(', ')})`, - fix: 'Run `gsdd ui-proof validate ` and add required privacy metadata, claim limits, fixed evidence kinds, concise tool provenance, failure classification when failed or partial, observation artifact references, existing local artifact paths, and safe-to-publish handling.', - }); - } - } - // --- WARNING checks --- // W1: generation-manifest.json missing diff --git a/bin/lib/init-runtime.mjs b/bin/lib/init-runtime.mjs index 2e0f47d..a1b7751 100644 --- a/bin/lib/init-runtime.mjs +++ b/bin/lib/init-runtime.mjs @@ -192,18 +192,6 @@ Commands: phase-status Update ROADMAP.md phase status ([ ] / [-] / [x]) lifecycle-preflight [phase] Inspect deterministic lifecycle gate results for a workflow surface - session-fingerprint write [--allow-changed ] - Rebaseline planning-state drift after reviewing changed planning files - ui-proof validate [--claim ] - Validate UI proof metadata; use --claim for stronger proof uses - ui-proof compare [observed-bundle-json ...] - Compare planned UI proof slots against observed bundles - control-map [--json] [--with-ignored] [--annotations ] - Report computed repo/worktree/planning state and local annotations - control-map annotate - Maintain optional local intent annotations under .planning/.local/ - closeout-report [--json] [--phase ] - Replay read-only closeout status from control-map, health, preflight, verify, and UI-proof signals help Show this summary Platforms (for --tools): @@ -266,8 +254,6 @@ Examples: npx -y gsdd-cli next --init npx -y gsdd-cli find-phase npx -y gsdd-cli verify 1 - npx -y gsdd-cli control-map annotate set --id canonical --write-set src/app.ts - npx -y gsdd-cli control-map annotate clear --id canonical npx -y gsdd-cli scaffold phase 4 Payments Workflows (run via skills/adapters generated by init, not direct CLI): @@ -293,11 +279,7 @@ Starting lanes after init: Advanced/internal helpers (kept available, but not the primary first-run user story): lifecycle-preflight Inspect deterministic lifecycle gate results for a workflow surface - session-fingerprint Rebaseline the local planning-state fingerprint after review phase-status Update ROADMAP.md phase status through the local helper surface - ui-proof Validate UI proof metadata and compare planned slots to observed bundles - control-map Report computed repo/worktree/planning state; annotate only records local intent - closeout-report Read-only post-merge closure replay; reports blockers, warnings, and next safe action next Read-only \`.work\` continuity router for the next coherent agent action file-op Deterministic workspace-confined file copy/delete/text mutation `; diff --git a/bin/lib/lifecycle-preflight.mjs b/bin/lib/lifecycle-preflight.mjs index b0dc2ed..ede6dd2 100644 --- a/bin/lib/lifecycle-preflight.mjs +++ b/bin/lib/lifecycle-preflight.mjs @@ -2,16 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs'; import { join, resolve } from 'path'; import { output } from './cli-utils.mjs'; import { buildControlMap } from './control-map.mjs'; -import { - DELIVERY_POSTURES, - EVIDENCE_KINDS, - RELEASE_CLAIM_POSTURES, - describeEvidenceSurface, - evaluateReleaseClaimCloseoutContract, - getEvidenceContract, -} from './evidence-contract.mjs'; import { evaluateLifecycleState, normalizePhaseToken } from './lifecycle-state.mjs'; -import { checkDrift } from './session-fingerprint.mjs'; import { resolveWorkspaceContext } from './workspace-root.mjs'; const SURFACE_POLICIES = { @@ -65,17 +56,6 @@ const SURFACE_POLICIES = { }, }; -const RELEASE_CONTRADICTION_CHECKS = Object.freeze([ - 'evidence', - 'public_surface', - 'runtime', - 'delivery', - 'planning_drift', - 'generated_surface', -]); - -const RELEASE_CONTRADICTION_STATUSES = Object.freeze(['passed', 'failed', 'not_applicable']); -const PREFLIGHT_CONTROL_MAP_SKIP_CODES = Object.freeze(['planning_state_drift']); const WORK_PHASE_LINE_RE = /^\s*[-*]\s*\[([ x-])\]\s*\*\*Phase\s+(\d+(?:\.\d+)*[A-Za-z]?):\s*(.+?)\*\*/i; export function evaluateLifecyclePreflight({ @@ -189,46 +169,7 @@ export function evaluateLifecyclePreflight({ } const warnings = []; - let planningState = null; - - if (existsSync(planningDir)) { - const drift = checkDrift(planningDir); - planningState = { - classification: drift.classification, - drifted: drift.drifted, - noBaseline: drift.noBaseline, - details: drift.details, - files: drift.files, - }; - if (drift.drifted) { - const driftNotice = { - code: 'planning_state_drift', - message: `${surface} cannot proceed because planning state drifted since the last recorded session: ${drift.details.join('; ')}`, - artifacts: ['.planning/ROADMAP.md', '.planning/SPEC.md', '.planning/config.json'], - details: drift.details, - files: drift.files, - }; - if (policy.classification === 'owned_write' && !usesAlternateAuthority) { - blockers.push(driftNotice); - } else if (policy.classification === 'owned_write' && usesBrownfieldAuthority && hasMaterialBrownfieldPlanningDrift(drift)) { - blockers.push(driftNotice); - } else { - const workWarningContext = usesBrownfieldAuthority - ? 'by the active bounded brownfield-change lane' - : resumeWorkCheckpoint - ? 'because the checkpoint points at .work/milestone continuity' - : `by .work/milestone for Phase ${normalizedPhase}`; - warnings.push({ - ...driftNotice, - message: usesWorkAuthority - ? `Planning state has drifted since the last recorded session, but ${surface} is using work_milestone authority ${workWarningContext}: ${drift.details.join('; ')}` - : usesBrownfieldAuthority - ? `Planning state has drifted since the last recorded session, but ${surface} is using brownfield_change authority ${workWarningContext}: ${drift.details.join('; ')}` - : `Planning state has drifted since the last recorded session: ${drift.details.join('; ')}`, - }); - } - } - } + const planningState = null; const controlMap = buildPreflightControlMap({ planningDir, @@ -255,7 +196,6 @@ export function evaluateLifecyclePreflight({ classification: policy.classification, ownedWrites: policy.ownedWrites, explicitLifecycleMutation: policy.explicitLifecycleMutation, - closureEvidence: describeEvidenceSurface(surface), mutationRequest: expectsMutation, authority: usesBrownfieldAuthority ? 'brownfield_change' : usesWorkAuthority ? 'work_milestone' : 'planning', allowed: blockers.length === 0, @@ -304,8 +244,7 @@ function buildPreflightControlMap({ planningDir, policy, existingBlockerCodes, c planningDir, }); const risks = (map.risks || []).filter((risk) => ( - !PREFLIGHT_CONTROL_MAP_SKIP_CODES.includes(risk.code) - && !(existingBlockerCodes.has(risk.code) && risk.severity !== 'block') + !(existingBlockerCodes.has(risk.code) && risk.severity !== 'block') )); const notices = risks.map((risk) => ({ ...controlMapNotice(risk), @@ -671,189 +610,9 @@ function buildCompletionBlockers(planningDir, lifecycle) { ]; } - const releaseContractBlockers = buildReleaseClaimCompletionBlockers(auditContent, auditPath); - if (releaseContractBlockers.length > 0) return releaseContractBlockers; - return []; } -function buildReleaseClaimCompletionBlockers(auditContent, auditPath) { - const frontmatter = extractFrontmatter(auditContent); - const deliveryPosture = readTopLevelScalar(frontmatter, 'delivery_posture'); - const releaseClaimPosture = readTopLevelScalar(frontmatter, 'release_claim_posture'); - const evidenceBlock = extractYamlBlock(frontmatter, 'evidence_contract'); - const releaseBlock = extractYamlBlock(frontmatter, 'release_claim_contract'); - const missing = []; - - if (!deliveryPosture) missing.push('delivery_posture'); - if (!releaseClaimPosture) missing.push('release_claim_posture'); - if (!evidenceBlock) missing.push('evidence_contract'); - if (!releaseBlock) missing.push('release_claim_contract'); - - if (missing.length > 0) { - return [blocker( - 'missing_release_claim_contract', - `Milestone audit is missing release closeout metadata (${missing.join(', ')}). Re-run audit before completion.`, - [auditPath] - )]; - } - - const requiredKinds = readBlockList(evidenceBlock, 'required_kinds'); - const observedKinds = readBlockList(evidenceBlock, 'observed_kinds'); - const missingKinds = readBlockList(evidenceBlock, 'missing_kinds'); - const unsupportedClaims = readBlockList(releaseBlock, 'unsupported_claims'); - const waivedKinds = readBlockList(releaseBlock, 'waivers'); - const deferrals = readBlockList(releaseBlock, 'deferrals'); - const contradictionChecks = readNestedStatusBlock(releaseBlock, 'contradiction_checks'); - const blockers = []; - const invalidEvidenceKinds = [ - ...findInvalidEvidenceKinds('required_kinds', requiredKinds), - ...findInvalidEvidenceKinds('observed_kinds', observedKinds), - ...findInvalidEvidenceKinds('missing_kinds', missingKinds), - ...findInvalidEvidenceKinds('waivers', waivedKinds), - ]; - - if (requiredKinds.length === 0 && observedKinds.length === 0) { - blockers.push(blocker( - 'missing_release_evidence_contract', - 'Milestone audit evidence_contract must include required_kinds and observed_kinds before completion.', - [auditPath] - )); - } - - if (!DELIVERY_POSTURES.includes(deliveryPosture)) { - blockers.push(blocker( - 'invalid_delivery_posture', - `Milestone audit has invalid delivery_posture (${deliveryPosture}). Re-run audit before completion.`, - [auditPath] - )); - } - - if (!RELEASE_CLAIM_POSTURES.includes(releaseClaimPosture)) { - blockers.push(blocker( - 'invalid_release_claim_posture', - `Milestone audit has invalid release_claim_posture (${releaseClaimPosture}). Re-run audit before completion.`, - [auditPath] - )); - } - - if (invalidEvidenceKinds.length > 0) { - blockers.push(blocker( - 'invalid_release_evidence_kinds', - `Milestone audit has invalid release evidence kind values (${invalidEvidenceKinds.join(', ')}). Supported values are ${EVIDENCE_KINDS.join(', ')}.`, - [auditPath] - )); - } - - const missingContradictionChecks = RELEASE_CONTRADICTION_CHECKS.filter((name) => !(name in contradictionChecks)); - const unknownContradictionChecks = Object.keys(contradictionChecks) - .filter((name) => !RELEASE_CONTRADICTION_CHECKS.includes(name)); - const invalidContradictionChecks = Object.entries(contradictionChecks) - .filter(([, status]) => !RELEASE_CONTRADICTION_STATUSES.includes(status)) - .map(([name]) => name); - - if (missingContradictionChecks.length > 0) { - blockers.push(blocker( - 'missing_release_contradiction_checks', - `Milestone audit release_claim_contract.contradiction_checks is missing required checks (${missingContradictionChecks.join(', ')}).`, - [auditPath] - )); - } - - if (invalidContradictionChecks.length > 0) { - blockers.push(blocker( - 'invalid_release_contradiction_checks', - `Milestone audit release_claim_contract.contradiction_checks has invalid statuses (${invalidContradictionChecks.join(', ')}).`, - [auditPath] - )); - } - - if (unknownContradictionChecks.length > 0) { - blockers.push(blocker( - 'unknown_release_contradiction_checks', - `Milestone audit release_claim_contract.contradiction_checks has unknown checks (${unknownContradictionChecks.join(', ')}). Supported checks are ${RELEASE_CONTRADICTION_CHECKS.join(', ')}.`, - [auditPath] - )); - } - - if (!DELIVERY_POSTURES.includes(deliveryPosture) || !RELEASE_CLAIM_POSTURES.includes(releaseClaimPosture)) { - return blockers; - } - - const releaseEvaluation = evaluateReleaseClaimCloseoutContract({ - surface: 'complete-milestone', - deliveryPosture, - releaseClaimPosture, - observedKinds, - waivedKinds, - unsupportedClaims, - deferrals, - contradictionChecks, - }); - - if (DELIVERY_POSTURES.includes(deliveryPosture)) { - const evidenceContract = getEvidenceContract('complete-milestone', deliveryPosture); - const enforcedRequiredKinds = [...new Set([...evidenceContract.requiredKinds, ...releaseEvaluation.requiredKinds])]; - const undeclaredRequiredKinds = enforcedRequiredKinds.filter((kind) => !requiredKinds.includes(kind)); - const recomputedMissingKinds = enforcedRequiredKinds.filter((kind) => !observedKinds.includes(kind)); - - if (undeclaredRequiredKinds.length > 0) { - blockers.push(blocker( - 'invalid_release_evidence_contract', - `Milestone audit evidence_contract.required_kinds omits required closeout evidence (${undeclaredRequiredKinds.join(', ')}).`, - [auditPath] - )); - } - - if (recomputedMissingKinds.length > 0) { - blockers.push(blocker( - 'missing_required_release_evidence', - `Milestone audit observed evidence is missing required closeout kinds (${recomputedMissingKinds.join(', ')}).`, - [auditPath] - )); - } - } - - if (missingKinds.length > 0) { - blockers.push(blocker( - 'missing_required_release_evidence', - `Milestone audit is missing required evidence kinds for closeout (${missingKinds.join(', ')}).`, - [auditPath] - )); - } - - if (releaseEvaluation.invalidWaivers.length > 0) { - blockers.push(blocker( - 'invalid_release_waivers', - `Milestone audit has invalid waivers for missing required evidence (${releaseEvaluation.invalidWaivers.join(', ')}).`, - [auditPath] - )); - } - if (releaseEvaluation.blockers.some((releaseBlocker) => releaseBlocker.code === 'incompatible_release_claim_posture')) { - blockers.push(blocker( - 'incompatible_release_claim_posture', - `Milestone audit release_claim_posture (${releaseClaimPosture}) is incompatible with delivery_posture (${deliveryPosture}).`, - [auditPath] - )); - } - if (releaseEvaluation.unresolvedUnsupportedClaims.length > 0) { - blockers.push(blocker( - 'unsupported_release_claims', - `Milestone audit has unsupported release claims without downgrade or deferral (${releaseEvaluation.unresolvedUnsupportedClaims.join(', ')}).`, - [auditPath] - )); - } - if (releaseEvaluation.failedContradictionChecks.length > 0) { - blockers.push(blocker( - 'failed_release_contradiction_checks', - `Milestone audit has failed release contradiction checks (${releaseEvaluation.failedContradictionChecks.join(', ')}).`, - [auditPath] - )); - } - - return blockers; -} - function extractFrontmatter(content) { const match = String(content || '').replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---/); return match ? match[1] : ''; @@ -864,136 +623,6 @@ function readTopLevelScalar(frontmatter, key) { return match ? cleanYamlValue(match[1]) : null; } -function extractYamlBlock(frontmatter, key) { - const lines = String(frontmatter || '').replace(/\r\n/g, '\n').split('\n'); - const startIndex = lines.findIndex((line) => new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line.trim())); - if (startIndex === -1) return ''; - - const collected = []; - for (const line of lines.slice(startIndex + 1)) { - if (/^[A-Za-z0-9_-]+:\s*/.test(line)) break; - collected.push(line); - } - return collected.join('\n'); -} - -function readBlockList(block, key) { - const lines = String(block || '').replace(/\r\n/g, '\n').split('\n'); - const startIndex = lines.findIndex((line) => new RegExp(`^\\s+${key}:`).test(line)); - if (startIndex === -1) return []; - const baseIndent = lines[startIndex].match(/^\s*/)[0].length; - - const inline = lines[startIndex].match(/^\s+[^:]+:\s*\[([^\]]*)\]/); - if (inline) return splitInlineList(inline[1]); - - const collected = []; - for (const line of lines.slice(startIndex + 1)) { - const indent = line.match(/^\s*/)[0].length; - if (line.trim() && indent <= baseIndent && /^\s*[A-Za-z0-9_-]+:\s*/.test(line)) break; - collected.push(line); - } - - return parseYamlListItems(collected, baseIndent) - .map((item) => item.join(' ')) - .map(cleanYamlValue); -} - -function parseYamlListItems(lines, baseIndent) { - const items = []; - let current = null; - let itemIndent = null; - - for (const line of lines) { - const match = line.match(/^(\s*)-\s*(.+?)\s*$/); - const indent = line.match(/^\s*/)[0].length; - - if (match && indent > baseIndent && (itemIndent === null || indent === itemIndent)) { - if (current) items.push(current); - current = [match[2]]; - itemIndent = indent; - continue; - } - - if (current && line.trim()) { - current.push(line.trim()); - } - } - - if (current) items.push(current); - return items; -} - -function findInvalidEvidenceKinds(field, kinds) { - return kinds - .filter((kind) => !EVIDENCE_KINDS.includes(kind)) - .map((kind) => `${field}: ${kind}`); -} - -function readNestedStatusBlock(block, key) { - const nested = extractIndentedBlock(block, key); - const statuses = {}; - for (const line of nested.split('\n')) { - const match = line.match(/^\s+([A-Za-z0-9_-]+):\s*(.+)$/); - if (match) statuses[match[1]] = cleanYamlValue(match[2]); - } - return statuses; -} - -function extractIndentedBlock(block, key) { - const lines = String(block || '').replace(/\r\n/g, '\n').split('\n'); - const startIndex = lines.findIndex((line) => new RegExp(`^\\s+${key}:`).test(line)); - if (startIndex === -1) return ''; - const baseIndent = lines[startIndex].match(/^\s*/)[0].length; - - const collected = []; - for (const line of lines.slice(startIndex + 1)) { - const indent = line.match(/^\s*/)[0].length; - if (line.trim() && indent <= baseIndent && /^\s*[A-Za-z0-9_-]+:\s*/.test(line)) break; - collected.push(line); - } - return collected.join('\n'); -} - -function splitInlineList(value) { - return splitCommaAware(value) - .map(cleanYamlValue) - .filter(Boolean); -} - -function splitCommaAware(value) { - const items = []; - let current = ''; - let quote = null; - let escaped = false; - - for (const char of String(value || '')) { - if (escaped) { - current += char; - escaped = false; - continue; - } - if (char === '\\' && quote) { - current += char; - escaped = true; - continue; - } - if ((char === '"' || char === "'") && (!quote || quote === char)) { - quote = quote ? null : char; - current += char; - continue; - } - if (char === ',' && !quote) { - items.push(current); - current = ''; - continue; - } - current += char; - } - - items.push(current); - return items; -} - function cleanYamlValue(value) { return stripInlineYamlComment(String(value || '')) .trim() @@ -1074,11 +703,3 @@ export function cmdLifecyclePreflight(...args) { process.exitCode = 1; } } - -function hasMaterialBrownfieldPlanningDrift(drift) { - const changedFiles = Array.isArray(drift?.files) ? drift.files : []; - return changedFiles.some((file) => ( - ['SPEC.md', 'config.json'].includes(file.file) - && file.status !== 'unchanged' - )); -} diff --git a/bin/lib/phase.mjs b/bin/lib/phase.mjs index ac3cc36..42f8126 100644 --- a/bin/lib/phase.mjs +++ b/bin/lib/phase.mjs @@ -6,14 +6,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs'; import { dirname, join, relative } from 'path'; import { output } from './cli-utils.mjs'; -import { writeFingerprint } from './session-fingerprint.mjs'; import { resolveWorkspaceContext } from './workspace-root.mjs'; -import { - compareUiProofSlots, - findUiProofBundleFiles, - parseUiProofSlotsContent, - readUiProofBundleFile, -} from './ui-proof.mjs'; const PHASE_STATUS_MARKERS = { not_started: '[ ]', @@ -202,290 +195,6 @@ function evaluatePlanArtifacts(artifacts) { }; } -function normalizeUiProofIssue(issue) { - return { - ...issue, - severity: issue.severity || 'blocker', - fix_hint: issue.fix_hint || issue.fix || 'Fix the UI proof issue before claiming verification is complete.', - }; -} - -function stripInlineComment(value) { - return String(value || '').replace(/\s+#.*$/, '').trim(); -} - -function stripOuterScalarQuotes(value) { - return String(value) - .trim() - .replace(/^(['"])([\s\S]*)\1$/g, '$2') - .trim(); -} - -function normalizeNullableFrontmatterValue(value) { - const stripped = stripOuterScalarQuotes(value); - if (!stripped) return ''; - if (stripped === '~') return ''; - if (/^null$/i.test(stripped)) return ''; - return stripped; -} - -function extractUiProofSlotIds(value) { - const ids = []; - const slotPattern = /['"]?slot_id['"]?\s*:\s*['"]?([^,'"\]\s}]+)['"]?/g; - for (const match of String(value || '').matchAll(slotPattern)) { - ids.push(match[1].replace(/^['"]|['"]$/g, '')); - } - return ids; -} - -function readPlanFrontmatter(planContent) { - const content = String(planContent || ''); - if (!content.startsWith('---')) return ''; - const lines = content.split(/\r?\n/); - const frontmatter = []; - for (let index = 1; index < lines.length; index += 1) { - if (lines[index].trim() === '---') return frontmatter.join('\n'); - frontmatter.push(lines[index]); - } - return ''; -} - -function frontmatterKeyBlock(frontmatter, key) { - const lines = String(frontmatter || '').split(/\r?\n/); - const keyPattern = new RegExp(`^${key}:[ \\t]*(.*)$`); - for (let index = 0; index < lines.length; index += 1) { - const match = lines[index].match(keyPattern); - if (!match) continue; - const block = []; - for (let next = index + 1; next < lines.length; next += 1) { - const line = lines[next]; - if (/^\S[^:\n]*:\s*/.test(line)) break; - block.push(line); - } - return { inline: stripInlineComment(match[1]), block }; - } - return null; -} - -function frontmatterScalar(frontmatter, key) { - const entry = frontmatterKeyBlock(frontmatter, key); - if (!entry) return null; - if (entry.inline && !['|', '>'].includes(entry.inline)) return entry.inline; - return entry.block - .map((line) => line.trim()) - .filter(Boolean) - .join(' ') - .trim(); -} - -function readPlanUiProofContract(planContent) { - const frontmatter = readPlanFrontmatter(planContent); - const slotsEntry = frontmatterKeyBlock(frontmatter, 'ui_proof_slots'); - const rationale = normalizeNullableFrontmatterValue(frontmatterScalar(frontmatter, 'no_ui_proof_rationale') || ''); - const result = { - hasUiProofKey: Boolean(slotsEntry), - declaresSlots: false, - explicitEmptySlots: false, - noUiProofRationale: rationale, - hasNoUiProofRationale: Boolean(rationale.trim()), - slotIds: [], - }; - - if (!slotsEntry) return result; - - if (slotsEntry.inline) { - if (['[]', 'null', '~'].includes(slotsEntry.inline)) { - result.explicitEmptySlots = true; - return result; - } - result.declaresSlots = true; - result.slotIds.push(...extractUiProofSlotIds(slotsEntry.inline)); - return result; - } - - let sawMeaningfulLine = false; - for (const line of slotsEntry.block) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - sawMeaningfulLine = true; - if (/^\s+-\s+/.test(line)) result.declaresSlots = true; - result.slotIds.push(...extractUiProofSlotIds(trimmed)); - } - - result.explicitEmptySlots = !result.declaresSlots && !sawMeaningfulLine; - return result; -} - -function findUiProofSlotPlansAndFiles(planningDir, planDisplayPaths) { - const candidates = new Set(); - const declaredPlans = []; - const declaredSlotIds = []; - const noUiPlans = []; - const contractErrors = []; - const names = new Set([ - 'ui-proof-slots.json', - 'ui-proof-slots.md', - 'UI-PROOF-SLOTS.json', - 'UI-PROOF-SLOTS.md', - 'planned-ui-proof.json', - 'planned-ui-proof.md', - ]); - - for (const planDisplayPath of planDisplayPaths) { - const fullPlanPath = join(planningDir, 'phases', planDisplayPath); - if (!existsSync(fullPlanPath)) continue; - const planContent = readFileSync(fullPlanPath, 'utf-8'); - const relPlanPath = relative(planningDir, fullPlanPath).replace(/\\/g, '/'); - const contract = readPlanUiProofContract(planContent); - const planDir = dirname(fullPlanPath); - const sidecars = []; - if (existsSync(planDir)) { - for (const entry of readdirSync(planDir, { withFileTypes: true })) { - if (entry.isFile() && names.has(entry.name)) { - sidecars.push(join(planDir, entry.name)); - } - } - } - - if (contract.hasUiProofKey && !contract.declaresSlots && !contract.hasNoUiProofRationale) { - contractErrors.push(normalizeUiProofIssue({ - code: 'missing_no_ui_proof_rationale', - path: `${relPlanPath}.no_ui_proof_rationale`, - message: 'Plan declares empty ui_proof_slots but does not provide a no_ui_proof_rationale.', - fix: 'Add a nonblank no_ui_proof_rationale for non-UI work, or declare required UI proof slots and add a planned slots artifact.', - })); - } - - if (contract.hasNoUiProofRationale && !contract.declaresSlots) { - noUiPlans.push({ plan: relPlanPath, sidecars }); - continue; - } - - if (!contract.declaresSlots) continue; - - declaredPlans.push(relPlanPath); - for (const slotId of contract.slotIds) { - declaredSlotIds.push({ plan: relPlanPath, slot_id: slotId }); - } - for (const entry of readdirSync(planDir, { withFileTypes: true })) { - if (entry.isFile() && names.has(entry.name)) { - candidates.add(join(planDir, entry.name)); - } - } - } - return { declaredPlans, declaredSlotIds, noUiPlans, contractErrors, files: [...candidates].sort() }; -} - -function comparePhaseUiProof({ planningDir, workspaceRoot, planDisplayPaths }) { - const plannedDiscovery = findUiProofSlotPlansAndFiles(planningDir, planDisplayPaths); - const plannedFiles = plannedDiscovery.files; - const phaseDirs = new Set(planDisplayPaths.map((planDisplayPath) => dirname(join(planningDir, 'phases', planDisplayPath)))); - const observedFiles = findUiProofBundleFiles(planningDir) - .filter((filePath) => phaseDirs.has(dirname(filePath))); - - const plannedSlots = []; - const errors = []; - const warnings = []; - const planned = []; - const observed = []; - - errors.push(...plannedDiscovery.contractErrors); - for (const noUiPlan of plannedDiscovery.noUiPlans) { - for (const filePath of noUiPlan.sidecars) { - warnings.push({ - code: 'stale_ui_proof_sidecar_ignored', - severity: 'warn', - path: relative(workspaceRoot, filePath).replace(/\\/g, '/'), - message: `Plan ${noUiPlan.plan} records no_ui_proof_rationale, so the UI proof sidecar is ignored for closure.`, - fix_hint: 'Remove or classify the stale sidecar if it no longer belongs to this non-UI phase.', - }); - } - } - - for (const filePath of plannedFiles) { - const rel = relative(workspaceRoot, filePath).replace(/\\/g, '/'); - const parsed = parseUiProofSlotsContent(readFileSync(filePath, 'utf-8'), rel); - planned.push(rel); - plannedSlots.push(...parsed.slots); - errors.push(...parsed.errors.map(normalizeUiProofIssue)); - } - - if (plannedSlots.length > 0 && plannedDiscovery.declaredSlotIds.length > 0) { - const plannedSlotIds = new Set(plannedSlots.map((slot) => String(slot?.slot_id || ''))); - for (const declaredSlot of plannedDiscovery.declaredSlotIds) { - if (plannedSlotIds.has(String(declaredSlot.slot_id))) continue; - errors.push(normalizeUiProofIssue({ - code: 'planned_ui_proof_slots_drift', - path: `${declaredSlot.plan}.ui_proof_slots`, - message: `Plan declares UI proof slot ${declaredSlot.slot_id}, but no matching slot exists in the planned UI proof artifact.`, - fix: 'Update ui-proof-slots.json or ui-proof-slots.md beside the plan so it matches the plan-declared slot IDs, or update the plan declaration.', - })); - } - } - - const observedBundles = []; - for (const filePath of observedFiles) { - const rel = relative(workspaceRoot, filePath).replace(/\\/g, '/'); - const parsed = readUiProofBundleFile(filePath); - observed.push(rel); - if (parsed.errors.length > 0) { - errors.push(...parsed.errors.map((error) => normalizeUiProofIssue({ ...error, path: error.path || rel }))); - continue; - } - observedBundles.push({ - source: rel, - bundle: parsed.bundle, - options: { - requireLocalArtifactExists: true, - workspaceRoot, - bundleDir: dirname(filePath), - }, - }); - } - - if (plannedFiles.length === 0 && plannedDiscovery.declaredPlans.length > 0) { - const missingError = { - code: 'missing_planned_ui_proof_slots_file', - severity: 'blocker', - path: plannedDiscovery.declaredPlans[0], - message: 'Plan declares ui_proof_slots but no ui-proof-slots artifact was found beside the plan.', - fix_hint: 'Create ui-proof-slots.json or ui-proof-slots.md beside the plan, or set ui_proof_slots: [] with a no_ui_proof_rationale if the phase is not UI-sensitive.', - }; - return { - planned, - observed, - status: 'missing', - comparison: { status: 'missing', slots: [], errors: [missingError] }, - errors: [missingError], - warnings, - }; - } - - if (plannedFiles.length === 0) { - return { - planned, - observed, - status: errors.length > 0 ? 'partial' : 'not_applicable', - comparison: null, - errors, - warnings, - }; - } - - const comparison = errors.length > 0 - ? { status: 'partial', slots: [], errors: errors.map(normalizeUiProofIssue) } - : compareUiProofSlots(plannedSlots, observedBundles); - - return { - planned, - observed, - status: comparison.status, - comparison, - errors: comparison.errors || errors, - warnings, - }; -} - export function updateRoadmapPhaseStatus(roadmap, phaseNumber, status) { const marker = PHASE_STATUS_MARKERS[status]; if (!marker) { @@ -588,7 +297,6 @@ export function cmdPhaseStatus(...args) { const changed = updated !== roadmap; if (changed) { writeFileSync(roadmapPath, updated); - try { writeFingerprint(planningDir); } catch { /* best-effort */ } } output({ phase: phaseNumber, status, roadmap: '.planning/ROADMAP.md', changed }); } catch (error) { @@ -693,26 +401,12 @@ export function buildPhaseVerificationReport(...args) { : []; }); const artifactStatus = evaluatePlanArtifacts(artifacts); - const uiProof = comparePhaseUiProof({ - planningDir, - workspaceRoot, - planDisplayPaths: matchingPlans, - }); - const uiProofSatisfied = ['satisfied', 'not_applicable'].includes(uiProof.status); const legacyVerified = matchingPlans.length > 0 && matchingSummaries.length > 0; - const uiProofGate = { - status: uiProof.status, - required: uiProof.status !== 'not_applicable', - satisfied: uiProofSatisfied, - blocks_verification: uiProof.status !== 'not_applicable' && !uiProofSatisfied, - required_block: uiProof.status !== 'not_applicable' && !uiProofSatisfied ? 'ui-proof-failed' : null, - }; const blockedOn = [ ...(prerequisiteBlockers.length > 0 ? ['prerequisites'] : []), ...(artifactStatus.satisfied ? [] : ['artifacts']), - ...(uiProofGate.blocks_verification ? ['ui_proof'] : []), ]; - const closureVerified = legacyVerified && prerequisiteBlockers.length === 0 && artifactStatus.satisfied && uiProofSatisfied; + const closureVerified = legacyVerified && prerequisiteBlockers.length === 0 && artifactStatus.satisfied; const result = { phase: normalizePhaseToken(phaseNum), @@ -722,7 +416,6 @@ export function buildPhaseVerificationReport(...args) { artifacts, allExist: artifacts.every((artifact) => artifact.exists), artifact_status: artifactStatus, - uiProof, verified: closureVerified, legacy_verified: legacyVerified, phase_artifacts_present: legacyVerified, @@ -730,7 +423,6 @@ export function buildPhaseVerificationReport(...args) { satisfied: prerequisiteBlockers.length === 0, blockers: prerequisiteBlockers, }, - ui_proof: uiProofGate, blocked_on: blockedOn, blocks_verification: blockedOn.length > 0, }; diff --git a/bin/lib/provenance.mjs b/bin/lib/provenance.mjs deleted file mode 100644 index c92e647..0000000 --- a/bin/lib/provenance.mjs +++ /dev/null @@ -1,390 +0,0 @@ -function normalizePrState(prState) { - if (!prState) return 'none'; - return String(prState).trim().toLowerCase(); -} - -function normalizeCount(value) { - if (value === 'unknown' || value === null || value === undefined) return 'unknown'; - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : 'unknown'; -} - -function normalizeCheckpointWorkflow(workflow) { - const normalized = String(workflow || 'generic').trim().toLowerCase(); - return ['phase', 'quick', 'generic'].includes(normalized) ? normalized : 'generic'; -} - -export function classifyCheckpointRouting(workflow) { - const normalizedWorkflow = normalizeCheckpointWorkflow(workflow); - const progressBlocks = normalizedWorkflow === 'phase' || normalizedWorkflow === 'quick'; - - return { - workflow: normalizedWorkflow, - routingClass: progressBlocks ? 'blocking' : 'informational', - progressBlocks, - resumeOwnsCleanup: true, - }; -} - -export function parseGitStatusShort(statusText = '') { - const lines = statusText - .replace(/\r\n/g, '\n') - .split('\n') - .map((line) => line.trimEnd()) - .filter(Boolean); - - const files = []; - for (const line of lines) { - const match = line.match(/^(.)(.)\s+(.+)$/); - if (!match) continue; - - const indexStatus = match[1]; - const worktreeStatus = match[2]; - if (indexStatus === '!' && worktreeStatus === '!') continue; - - const rawPath = match[3].replace(/\\/g, '/'); - const renameMatch = rawPath.match(/^(.*?)\s+->\s+(.*?)$/); - const filePath = renameMatch ? renameMatch[2] : rawPath; - files.push({ - path: filePath, - fromPath: renameMatch ? renameMatch[1] : null, - staged: indexStatus !== ' ' && indexStatus !== '?' && indexStatus !== '!', - unstaged: worktreeStatus !== ' ' && worktreeStatus !== '?' && worktreeStatus !== '!', - untracked: indexStatus === '?' || worktreeStatus === '?', - }); - } - - return { - files, - stagedCount: files.filter((file) => file.staged).length, - unstagedCount: files.filter((file) => file.unstaged).length, - untrackedCount: files.filter((file) => file.untracked).length, - dirty: files.length > 0, - }; -} - -export function classifyBrownfieldCheckpointPrecedence({ - checkpoint = {}, - planning = {}, - quick = {}, - brownfieldChange = {}, - git = {}, - status = parseGitStatusShort(git.statusShort || ''), -} = {}) { - const checkpointRouting = classifyCheckpointRouting(checkpoint.workflow); - if (!brownfieldChange?.exists) { - return { - primary: checkpointRouting.progressBlocks ? 'checkpoint' : 'none', - checkpointRouting, - branchAligned: false, - scopeAligned: false, - executionActive: false, - checkpointCanOverrideBrownfield: false, - strictMatchRequired: false, - }; - } - - if (checkpointRouting.workflow === 'generic') { - return { - primary: 'brownfield_change', - checkpointRouting, - branchAligned: false, - scopeAligned: false, - executionActive: false, - checkpointCanOverrideBrownfield: false, - strictMatchRequired: true, - }; - } - - const brownfieldMismatch = classifyBrownfieldArtifactMismatch({ - brownfieldChange, - git, - status, - }); - const currentBranch = normalizeBranchName(git.branch); - const checkpointBranch = normalizeBranchName(checkpoint.branch); - const brownfieldBranch = normalizeBranchName(brownfieldChange.currentIntegrationSurface); - const branchAligned = Boolean( - currentBranch - && checkpointBranch - && brownfieldBranch - && checkpointBranch === currentBranch - && brownfieldBranch === currentBranch - ); - const scopeAligned = !brownfieldMismatch.warnings.some((warning) => - warning.id === 'brownfield_scope_mismatch' || warning.id === 'brownfield_status_mismatch' - ); - const executionActive = checkpointExecutionIsActive({ - checkpoint, - checkpointRouting, - planning, - quick, - }); - const checkpointCanOverrideBrownfield = branchAligned && scopeAligned && executionActive; - - return { - primary: checkpointCanOverrideBrownfield ? 'checkpoint' : 'brownfield_change', - checkpointRouting, - branchAligned, - scopeAligned, - executionActive, - checkpointCanOverrideBrownfield, - strictMatchRequired: true, - }; -} - -export function classifyBrownfieldArtifactMismatch({ - brownfieldChange = {}, - git = {}, - status = parseGitStatusShort(git.statusShort || ''), -} = {}) { - if (!brownfieldChange?.exists) { - return { - warnings: [], - requiresAcknowledgement: false, - outsideOwnedPaths: [], - }; - } - - const warnings = []; - const declaredBranch = normalizeDeclaredBranch(brownfieldChange.currentIntegrationSurface); - const branch = String(git.branch || '').trim().toLowerCase(); - if (declaredBranch && branch && declaredBranch !== branch) { - warnings.push({ - id: 'brownfield_branch_mismatch', - severity: 'acknowledgement_required', - summary: `CHANGE.md says the active integration surface is "${brownfieldChange.currentIntegrationSurface}", but git reports "${git.branch}".`, - }); - } - - const ownedPaths = normalizeOwnedPaths(brownfieldChange.declaredOwnedPaths || []); - const outsideOwnedPaths = ownedPaths.length === 0 - ? [] - : status.files - .map((file) => file.path) - .filter((filePath) => !ownedPaths.some((ownedPath) => matchesOwnedPath(filePath, ownedPath))); - if (outsideOwnedPaths.length > 0) { - warnings.push({ - id: 'brownfield_scope_mismatch', - severity: 'acknowledgement_required', - summary: `Dirty files fall outside the CHANGE.md write scope: ${outsideOwnedPaths.join(', ')}`, - }); - } - - if ((brownfieldChange.currentStatus === 'closed' || brownfieldChange.currentStatus === 'ready_for_verification') && status.dirty) { - warnings.push({ - id: 'brownfield_status_mismatch', - severity: 'acknowledgement_required', - summary: `CHANGE.md marks the change as "${brownfieldChange.currentStatus}", but the live worktree is still dirty.`, - }); - } - - return { - warnings, - requiresAcknowledgement: warnings.some((warning) => warning.severity === 'acknowledgement_required'), - outsideOwnedPaths, - }; -} - -export function buildProvenanceSnapshot({ - checkpoint = {}, - planning = {}, - quick = {}, - brownfieldChange = {}, - git = {}, -} = {}) { - const checkpointRouting = classifyCheckpointRouting(checkpoint.workflow); - const status = parseGitStatusShort(git.statusShort || ''); - const commitsAheadOfMain = normalizeCount(git.commitsAheadOfMain); - const commitsAheadOfRemote = normalizeCount(git.commitsAheadOfRemote); - const prState = normalizePrState(git.prState); - const brownfieldMismatch = classifyBrownfieldArtifactMismatch({ brownfieldChange, git, status }); - const brownfieldRouting = classifyBrownfieldCheckpointPrecedence({ - checkpoint, - planning, - quick, - brownfieldChange, - git, - status, - }); - - const warnings = []; - - if (status.dirty) { - warnings.push({ - id: 'dirty_worktree', - severity: 'warning', - summary: 'Local worktree contains staged, unstaged, or untracked changes.', - }); - } - - if (commitsAheadOfMain !== 'unknown' && commitsAheadOfMain > 0) { - warnings.push({ - id: 'ahead_of_main', - severity: 'warning', - summary: `${commitsAheadOfMain} commit(s) are ahead of main on the current branch.`, - }); - } - - if (commitsAheadOfRemote !== 'unknown' && commitsAheadOfRemote > 0) { - warnings.push({ - id: 'unpushed_commits', - severity: 'warning', - summary: `${commitsAheadOfRemote} commit(s) are ahead of the tracked remote branch.`, - }); - } - - if (prState === 'none') { - warnings.push({ - id: 'missing_pr', - severity: 'warning', - summary: 'No pull request is associated with the current branch.', - }); - } - - if (git.staleBranch) { - warnings.push({ - id: 'stale_branch', - severity: 'warning', - summary: 'The current branch is stale or spent relative to the intended integration surface.', - }); - } - - if (git.mixedScope) { - warnings.push({ - id: 'mixed_scope', - severity: 'warning', - summary: 'The current worktree appears to mix multiple write scopes or phases.', - }); - } - - if (git.materialCheckpointMismatch) { - warnings.push({ - id: 'checkpoint_mismatch', - severity: 'acknowledgement_required', - summary: 'Checkpoint narrative truth materially understates or conflicts with the live branch/worktree truth.', - }); - } - - warnings.push(...brownfieldMismatch.warnings); - - return { - checkpoint: { - workflow: checkpointRouting.workflow, - phase: checkpoint.phase ?? null, - branch: checkpoint.branch || null, - runtime: checkpoint.runtime || 'unknown', - hasNarrative: Boolean(checkpoint.hasNarrative), - routing: checkpointRouting, - }, - planning: { - currentPhase: planning.currentPhase || null, - nextPhase: planning.nextPhase || null, - completedPhaseCount: Number.isFinite(Number(planning.completedPhaseCount)) - ? Number(planning.completedPhaseCount) - : 0, - }, - brownfieldChange: { - exists: Boolean(brownfieldChange?.exists), - title: brownfieldChange?.title || null, - currentStatus: brownfieldChange?.currentStatus || null, - currentIntegrationSurface: brownfieldChange?.currentIntegrationSurface || null, - nextAction: brownfieldChange?.nextAction || null, - declaredOwnedPaths: brownfieldChange?.declaredOwnedPaths || [], - }, - routing: { - primary: brownfieldRouting.primary, - strictMatchRequired: brownfieldRouting.strictMatchRequired, - checkpointCanOverrideBrownfield: brownfieldRouting.checkpointCanOverrideBrownfield, - branchAligned: brownfieldRouting.branchAligned, - scopeAligned: brownfieldRouting.scopeAligned, - executionActive: brownfieldRouting.executionActive, - }, - git: { - branch: git.branch || 'unknown', - prState, - commitsAheadOfMain, - commitsAheadOfRemote, - stagedCount: status.stagedCount, - unstagedCount: status.unstagedCount, - untrackedCount: status.untrackedCount, - dirty: status.dirty, - }, - integrationSurface: { - staleBranch: Boolean(git.staleBranch), - mixedScope: Boolean(git.mixedScope), - materialCheckpointMismatch: Boolean(git.materialCheckpointMismatch), - materialBrownfieldMismatch: brownfieldMismatch.requiresAcknowledgement, - }, - warnings, - requiresAcknowledgement: warnings.some((warning) => warning.severity === 'acknowledgement_required'), - }; -} - -function normalizeBranchName(value) { - return String(value || '') - .trim() - .toLowerCase() - .replace(/\\/g, '/'); -} - -function normalizeDeclaredBranch(value) { - return normalizeBranchName(value); -} - -function normalizeOwnedPaths(values) { - return values - .map((value) => String(value || '').trim().replace(/\\/g, '/')) - .filter(Boolean); -} - -function matchesOwnedPath(filePath, ownedPath) { - if (ownedPath.includes('*')) { - const prefix = ownedPath.split('*')[0]; - return filePath.startsWith(prefix); - } - return filePath === ownedPath - || filePath.startsWith(`${ownedPath}/`) - || ownedPath.startsWith(`${filePath}/`); -} - -function checkpointExecutionIsActive({ - checkpoint = {}, - checkpointRouting = classifyCheckpointRouting(checkpoint.workflow), - planning = {}, - quick = {}, -} = {}) { - if (checkpointRouting.workflow === 'quick') { - return quick.hasIncompleteWork === true; - } - - if (checkpointRouting.workflow !== 'phase') { - return false; - } - - const checkpointPhase = normalizePhaseRef(checkpoint.phase); - if (!checkpointPhase) return false; - - const phases = Array.isArray(planning.phases) ? planning.phases : []; - if (phases.length > 0) { - const matchingPhase = phases.find((phase) => normalizePhaseRef(phase.number) === checkpointPhase); - return Boolean(matchingPhase && matchingPhase.status !== 'done'); - } - - const currentPhase = normalizePhaseRef(planning.currentPhase); - const nextPhase = normalizePhaseRef(planning.nextPhase); - return checkpointPhase === currentPhase || checkpointPhase === nextPhase; -} - -function normalizePhaseRef(value) { - const raw = String(value || '').trim().toLowerCase(); - if (!raw) return ''; - - const match = raw.match(/^(\d+(?:\.\d+)*)([a-z]?)$/i); - if (!match) return raw; - - const numericSegments = match[1] - .split('.') - .map((segment) => String(parseInt(segment, 10))); - return `${numericSegments.join('.')}${match[2] || ''}`; -} diff --git a/bin/lib/rendering.mjs b/bin/lib/rendering.mjs index ba28100..97a49b3 100644 --- a/bin/lib/rendering.mjs +++ b/bin/lib/rendering.mjs @@ -7,16 +7,12 @@ const __dirname = dirname(__filename); const DISTILLED_DIR = join(__dirname, '..', '..', 'distilled'); const HELPER_LIB_FILES = Object.freeze([ 'cli-utils.mjs', - 'closeout-report.mjs', 'control-map.mjs', - 'evidence-contract.mjs', 'file-ops.mjs', 'lifecycle-preflight.mjs', 'lifecycle-state.mjs', 'next.mjs', 'phase.mjs', - 'session-fingerprint.mjs', - 'ui-proof.mjs', 'work-context.mjs', 'workspace-root.mjs', ]); @@ -51,10 +47,6 @@ function renderPlanningCliLauncher() { import { cmdFileOp } from './lib/file-ops.mjs'; import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs'; import { cmdPhaseStatus, cmdVerify } from './lib/phase.mjs'; -import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs'; -import { cmdUiProof } from './lib/ui-proof.mjs'; -import { cmdControlMap } from './lib/control-map.mjs'; -import { createCmdCloseoutReport } from './lib/closeout-report.mjs'; import { createCmdNext } from './lib/next.mjs'; import { bootstrapHelperWorkspace, consumeWorkspaceRootArg, resolveWorkspaceContext } from './lib/workspace-root.mjs'; @@ -63,7 +55,6 @@ const HELPER_CONTEXT = { workflows: [], frameworkVersion: 'generated-helper', }; -const cmdCloseoutReport = createCmdCloseoutReport(HELPER_CONTEXT); const cmdNext = createCmdNext(HELPER_CONTEXT); const COMMANDS = { @@ -71,10 +62,6 @@ const COMMANDS = { 'lifecycle-preflight': cmdLifecyclePreflight, 'phase-status': cmdPhaseStatus, verify: cmdVerify, - 'session-fingerprint': cmdSessionFingerprint, - 'ui-proof': cmdUiProof, - 'control-map': cmdControlMap, - 'closeout-report': cmdCloseoutReport, next: cmdNext, }; @@ -88,21 +75,11 @@ function printHelp() { ' Example: node .planning/bin/gsdd.mjs file-op delete .planning/.continue-here.bak --missing ok', ' phase-status Update ROADMAP.md phase status ([ ] / [-] / [x])', ' Example: node .planning/bin/gsdd.mjs phase-status 1 done', - ' verify Run direct phase artifact and UI-proof gate checks', + ' verify Run direct phase artifact checks', ' Example: node .planning/bin/gsdd.mjs verify 1', ' lifecycle-preflight [phase]', ' Inspect lifecycle gate results for a workflow surface', ' Example: node .planning/bin/gsdd.mjs lifecycle-preflight verify 1 --expects-mutation phase-status', - ' session-fingerprint write [--allow-changed ]', - ' Rebaseline planning-state drift after reviewing changed planning files', - ' ui-proof validate [--claim ]', - ' Validate UI proof metadata; use --claim for stronger proof uses', - ' ui-proof compare [observed-bundle-json ...]', - ' Compare planned UI proof slots against observed bundles', - ' control-map [--json] [--with-ignored] [--annotations ]', - ' Report computed repo/worktree/planning state and local annotations', - ' closeout-report [--json] [--phase ]', - ' Replay read-only closeout status from control-map, health, preflight, verify, and UI-proof signals', ' next [--json] [--init]', ' Route to the next safe Workspine action from .work, brownfield, planning, and repo truth', '', diff --git a/bin/lib/session-fingerprint.mjs b/bin/lib/session-fingerprint.mjs deleted file mode 100644 index 8867754..0000000 --- a/bin/lib/session-fingerprint.mjs +++ /dev/null @@ -1,223 +0,0 @@ -// session-fingerprint.mjs — Planning state drift detection -// -// Computes a SHA-256 fingerprint from the combined contents of ROADMAP.md, -// SPEC.md, and config.json. When the fingerprint stored in -// .planning/.state-fingerprint.json no longer matches the live files, the -// preflight and health systems can warn that planning state drifted since -// the last recorded session. -// -// The fingerprint file is session-local and gitignored by convention. - -import { createHash } from 'crypto'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { output } from './cli-utils.mjs'; -import { resolveWorkspaceContext } from './workspace-root.mjs'; - -const FINGERPRINT_FILE = '.state-fingerprint.json'; -const FINGERPRINT_SOURCES = ['ROADMAP.md', 'SPEC.md', 'config.json']; -const FINGERPRINT_SCHEMA_VERSION = 2; -const FINGERPRINT_ALGORITHM = 'sha256:v2:exists-content'; - -/** - * Compute a SHA-256 fingerprint from the planning truth files. - * Missing files contribute an empty string (so a newly created file - * registers as drift). - */ -export function computeFingerprint(planningDir) { - const hash = createHash('sha256'); - const sources = {}; - const files = {}; - for (const file of FINGERPRINT_SOURCES) { - const filePath = join(planningDir, file); - const exists = existsSync(filePath); - const content = exists ? readFileSync(filePath, 'utf-8') : ''; - hash.update(`${file}:${exists ? 'exists' : 'missing'}:${content}\n`); - sources[file] = exists; - files[file] = { - exists, - hash: createHash('sha256').update(content).digest('hex'), - }; - } - return { hash: hash.digest('hex'), sources, files }; -} - -function computeLegacyFingerprint(planningDir) { - const hash = createHash('sha256'); - const sources = {}; - for (const file of FINGERPRINT_SOURCES) { - const filePath = join(planningDir, file); - const exists = existsSync(filePath); - const content = exists ? readFileSync(filePath, 'utf-8') : ''; - hash.update(`${file}:${content}\n`); - sources[file] = exists; - } - return { hash: hash.digest('hex'), sources }; -} - -export function cmdSessionFingerprint(...args) { - const { args: normalizedArgs, planningDir, invalid, error } = resolveWorkspaceContext(args); - if (invalid) { - console.error(error); - process.exitCode = 1; - return; - } - - const [action, ...flags] = normalizedArgs; - if (action !== 'write') { - console.error('Usage: node .planning/bin/gsdd.mjs session-fingerprint write [--allow-changed ]'); - process.exitCode = 1; - return; - } - - const allowChanged = parseAllowChanged(flags); - if (allowChanged.invalid) { - console.error('Usage: node .planning/bin/gsdd.mjs session-fingerprint write [--allow-changed ]'); - process.exitCode = 1; - return; - } - - if (allowChanged.files.length > 0) { - const drift = checkDrift(planningDir); - const changedFiles = drift.files.filter((file) => file.status !== 'unchanged').map((file) => file.file); - const unexpected = changedFiles.filter((file) => !allowChanged.files.includes(file)); - if (unexpected.length > 0) { - output({ - operation: 'session-fingerprint write', - changedFiles, - allowedChanged: allowChanged.files, - written: false, - reason: 'unexpected_planning_drift', - unexpected, - }); - process.exitCode = 1; - return; - } - } - - output({ operation: 'session-fingerprint write', fingerprint: writeFingerprint(planningDir) }); -} - -function parseAllowChanged(flags) { - const files = []; - for (let index = 0; index < flags.length; index += 1) { - if (flags[index] !== '--allow-changed') return { invalid: true, files: [] }; - const value = flags[index + 1]; - if (!value || value.startsWith('--')) return { invalid: true, files: [] }; - files.push(...value.split(',').map((entry) => entry.trim()).filter(Boolean)); - index += 1; - } - for (const file of files) { - if (!FINGERPRINT_SOURCES.includes(file)) return { invalid: true, files: [] }; - } - return { invalid: false, files: [...new Set(files)] }; -} - -/** - * Read the stored fingerprint from .planning/.state-fingerprint.json. - * Returns null if the file does not exist or is unparseable. - */ -export function readStoredFingerprint(planningDir) { - const filePath = join(planningDir, FINGERPRINT_FILE); - if (!existsSync(filePath)) return null; - try { - return JSON.parse(readFileSync(filePath, 'utf-8')); - } catch { - return null; - } -} - -/** - * Write the current fingerprint to .planning/.state-fingerprint.json. - */ -export function writeFingerprint(planningDir) { - const { hash, sources, files } = computeFingerprint(planningDir); - const data = { - schemaVersion: FINGERPRINT_SCHEMA_VERSION, - algorithm: FINGERPRINT_ALGORITHM, - hash, - sources, - files, - timestamp: new Date().toISOString(), - }; - writeFileSync(join(planningDir, FINGERPRINT_FILE), JSON.stringify(data, null, 2) + '\n'); - return data; -} - -/** - * Check whether the current planning state has drifted from the stored - * fingerprint. Returns { drifted, details, stored, current }. - * - * If no stored fingerprint exists, returns drifted: false with a note - * that no baseline was found (first session after adoption). - */ -export function checkDrift(planningDir) { - const stored = readStoredFingerprint(planningDir); - const { hash: currentHash, sources: currentSources, files: currentFiles } = computeFingerprint(planningDir); - - if (!stored) { - return { - drifted: false, - noBaseline: true, - classification: 'no_baseline', - details: ['No stored fingerprint found — first session or fingerprint was cleared.'], - stored: null, - current: { hash: currentHash, sources: currentSources, files: currentFiles }, - files: [], - }; - } - - const isLegacy = !stored.schemaVersion && !stored.files; - const comparison = isLegacy ? computeLegacyFingerprint(planningDir) : { hash: currentHash }; - const drifted = stored.hash !== comparison.hash; - const details = []; - const files = drifted - ? FINGERPRINT_SOURCES.map((file) => classifyFileDrift(file, stored, currentSources, currentFiles, { legacy: isLegacy })) - : FINGERPRINT_SOURCES.map((file) => ({ file, status: 'unchanged' })); - if (drifted) { - for (const file of files) { - if (file.status === 'created') details.push(`${file.file} created`); - else if (file.status === 'removed') details.push(`${file.file} removed`); - else if (file.status === 'changed') details.push(`${file.file} changed`); - else if (file.status === 'unknown') details.push(`${file.file} may have changed`); - } - if (details.length === 0) { - details.push('Planning state hash changed since last recorded session.'); - } - } - - return { - drifted, - noBaseline: false, - classification: drifted ? 'planning_state_drift' : 'clean', - compatibility: isLegacy ? 'legacy_v1' : null, - needsBaselineRefresh: isLegacy && !drifted, - details, - files, - stored: { - hash: stored.hash, - timestamp: stored.timestamp, - schemaVersion: stored.schemaVersion ?? null, - algorithm: stored.algorithm ?? null, - files: stored.files ?? null, - }, - current: { hash: currentHash, sources: currentSources, files: currentFiles }, - }; -} - -function classifyFileDrift(file, stored, currentSources, currentFiles, { legacy = false } = {}) { - const was = stored.sources?.[file] ?? false; - const now = currentSources[file]; - - if (was && !now) return { file, status: 'removed' }; - if (!was && now) return { file, status: 'created' }; - if (!was && !now) return { file, status: 'unchanged' }; - if (legacy) return { file, status: 'unknown' }; - - const storedFile = stored.files?.[file]; - if (!storedFile?.hash) return { file, status: 'unknown' }; - return { - file, - status: storedFile.hash === currentFiles[file].hash ? 'unchanged' : 'changed', - }; -} diff --git a/bin/lib/ui-proof.mjs b/bin/lib/ui-proof.mjs deleted file mode 100644 index a016882..0000000 --- a/bin/lib/ui-proof.mjs +++ /dev/null @@ -1,1007 +0,0 @@ -import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; -import { dirname, isAbsolute, join, relative, resolve } from 'path'; -import { output } from './cli-utils.mjs'; -import { resolveWorkspaceContext } from './workspace-root.mjs'; - -const EVIDENCE_KINDS = Object.freeze(['code', 'test', 'runtime', 'delivery', 'human']); -const COMPARISON_STATUSES = Object.freeze(['satisfied', 'partial', 'missing', 'waived', 'deferred', 'not_applicable']); -const CLAIM_STATUSES = Object.freeze(['passed', 'failed', 'partial', 'waived', 'deferred', 'not_applicable']); -const ARTIFACT_VISIBILITIES = Object.freeze(['local_only', 'repo_tracked', 'public']); -const RAW_ARTIFACT_TYPES = Object.freeze(['screenshot', 'trace', 'video', 'dom_snapshot', 'dom-snapshot', 'dom', 'report']); -const PUBLIC_CLAIM_USES = Object.freeze(['public', 'publication', 'tracked', 'delivery', 'release']); -const CLAIM_USES = Object.freeze([...PUBLIC_CLAIM_USES, 'local', 'local_only']); -const FAILURE_CLASSIFICATIONS = Object.freeze(['product_bug', 'missing_infra', 'flaky_harness', 'ambiguous_spec']); -const TOOL_ID_PATTERN = /^[a-z0-9][a-z0-9_.:-]*$/; -const REQUIRED_BUNDLE_FIELDS = Object.freeze([ - 'proof_bundle_version', - 'scope', - 'route_state', - 'environment', - 'viewport', - 'evidence_inputs', - 'commands_or_manual_steps', - 'observations', - 'artifacts', - 'privacy', - 'result', - 'claim_limits', -]); -const REQUIRED_SCOPE_FIELDS = Object.freeze(['work_item', 'claim', 'requirement_ids', 'slot_ids']); -const REQUIRED_SLOT_FIELDS = Object.freeze([ - 'slot_id', - 'claim', - 'route_state', - 'required_evidence_kinds', - 'minimum_observations', - 'environment', - 'viewport', - 'expected_artifact_types', - 'validation_command', - 'manual_acceptance_required', - 'claim_limit', -]); -const REQUIRED_ARTIFACT_FIELDS = Object.freeze(['visibility', 'retention', 'sensitivity', 'safe_to_publish']); -const REQUIRED_OBSERVATION_FIELDS = Object.freeze(['observation', 'claim', 'route_state', 'evidence_kind', 'artifact_refs', 'privacy', 'result', 'claim_limit']); -const REQUIRED_PRIVACY_FIELDS = Object.freeze(['data_classification', 'raw_artifacts_safe_to_publish', 'retention']); - -class UiProofError extends Error {} - -function fail(message) { - console.error(message); - throw new UiProofError(message); -} - -function isPlainObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function hasValue(value) { - if (value === undefined || value === null) return false; - if (typeof value === 'string') return value.trim() !== ''; - if (Array.isArray(value)) return value.length > 0; - if (isPlainObject(value)) return Object.keys(value).length > 0; - return true; -} - -function pathLabel(basePath, key) { - return basePath ? `${basePath}.${key}` : key; -} - -function addError(errors, code, path, message, fix) { - errors.push({ code, path, message, fix }); -} - -function requireField(obj, field, path, errors) { - if (!isPlainObject(obj) || !hasValue(obj[field])) { - addError(errors, 'missing_required_field', pathLabel(path, field), `Missing required UI proof field: ${pathLabel(path, field)}`, 'Add the required field to the proof bundle metadata.'); - return false; - } - return true; -} - -function normalizeArray(value) { - if (Array.isArray(value)) return value; - if (typeof value === 'string' && value.trim()) return [value.trim()]; - return []; -} - -function artifactType(artifact) { - const explicit = typeof artifact.type === 'string' ? artifact.type.toLowerCase() : ''; - const artifactRef = artifactReference(artifact)?.toLowerCase() || ''; - if (/screenshot|\.png$|\.jpe?g$|\.webp$/.test(artifactRef)) return 'screenshot'; - if (/trace|\.zip$/.test(artifactRef)) return 'trace'; - if (/video|\.mp4$|\.webm$|\.mov$/.test(artifactRef)) return 'video'; - if (/dom|\.html?$/.test(artifactRef)) return 'dom_snapshot'; - if (/report/.test(artifactRef)) return 'report'; - return explicit; -} - -function isRawUiArtifact(artifact) { - return RAW_ARTIFACT_TYPES.includes(artifactType(artifact)); -} - -function collectClaimUses(bundle, options) { - const uses = new Set(); - for (const value of normalizeArray(options.claimUse).concat(normalizeArray(options.claimUses))) { - uses.add(String(value).toLowerCase()); - } - - const explicitSources = [ - bundle?.proof_claim, - bundle?.proof_claims, - bundle?.claim_context?.proof_use, - bundle?.claim_context?.proof_uses, - bundle?.publication?.intended_use, - ]; - for (const source of explicitSources) { - for (const value of normalizeArray(source)) uses.add(String(value).toLowerCase()); - } - - return [...uses]; -} - -function validateClaimUses(bundle, options, errors) { - for (const value of collectClaimUses(bundle, options)) { - if (!CLAIM_USES.includes(value)) { - addError(errors, 'unsupported_claim_use', 'proof_claim', `Unsupported UI proof claim use: ${value}`, `Use only: ${CLAIM_USES.join(', ')}.`); - } - } -} - -function hasPublicClaim(bundle, options) { - return collectClaimUses(bundle, options).some((value) => PUBLIC_CLAIM_USES.includes(value)); -} - -function validateObservationPrivacy(privacy, path, errors) { - for (const field of REQUIRED_PRIVACY_FIELDS) requireField(privacy, field, path, errors); - if (hasValue(privacy?.raw_artifacts_safe_to_publish) && typeof privacy.raw_artifacts_safe_to_publish !== 'boolean') { - addError(errors, 'invalid_raw_artifacts_safe_to_publish', `${path}.raw_artifacts_safe_to_publish`, 'raw_artifacts_safe_to_publish must be a boolean.', 'Use false unless all raw artifacts are explicitly safe to publish.'); - } -} - -function validateCommandsOrManualSteps(bundle, errors) { - for (const [index, step] of normalizeArray(bundle?.commands_or_manual_steps).entries()) { - const stepPath = `commands_or_manual_steps[${index}]`; - if (!isPlainObject(step)) { - addError(errors, 'invalid_proof_step', stepPath, 'UI proof command/manual step entry must be an object.', 'Record a command or manual_step plus its result.'); - continue; - } - if (!hasValue(step.command) && !hasValue(step.manual_step)) { - addError(errors, 'missing_proof_step_action', stepPath, 'UI proof command/manual step must include command or manual_step.', 'Record the exact command or manual step used to generate the observation.'); - } - if (!hasValue(step.result)) { - addError(errors, 'missing_proof_step_result', `${stepPath}.result`, 'UI proof command/manual step must include result.', `Record result using: ${CLAIM_STATUSES.join(', ')}.`); - } else if (!CLAIM_STATUSES.includes(step.result)) { - addError(errors, 'invalid_proof_step_result', `${stepPath}.result`, `Invalid UI proof command/manual step result: ${step.result}`, `Use only: ${CLAIM_STATUSES.join(', ')}.`); - } - } -} - -function validateObservations(bundle, errors) { - for (const [index, observation] of normalizeArray(bundle?.observations).entries()) { - const observationPath = `observations[${index}]`; - if (!isPlainObject(observation)) { - addError(errors, 'invalid_observation', observationPath, 'UI proof observation entry must be an object.', 'Record observation metadata with claim, route_state, evidence_kind, artifact_refs, privacy, result, and claim_limit.'); - continue; - } - for (const field of REQUIRED_OBSERVATION_FIELDS) requireField(observation, field, observationPath, errors); - if (hasValue(observation.evidence_kind) && !EVIDENCE_KINDS.includes(observation.evidence_kind)) { - addError(errors, 'unsupported_evidence_kind', `${observationPath}.evidence_kind`, `Unsupported UI proof observation evidence kind: ${observation.evidence_kind}`, `Use only: ${EVIDENCE_KINDS.join(', ')}.`); - } - if (hasValue(observation.result) && !CLAIM_STATUSES.includes(observation.result)) { - addError(errors, 'invalid_observation_result', `${observationPath}.result`, `Invalid UI proof observation result: ${observation.result}`, `Use only: ${CLAIM_STATUSES.join(', ')}.`); - } - validateObservationPrivacy(observation.privacy, `${observationPath}.privacy`, errors); - } -} - -function validateEvidenceKinds(bundle, errors) { - const kinds = normalizeArray(bundle?.evidence_inputs?.kinds); - if (kinds.length === 0) { - addError(errors, 'missing_evidence_kinds', 'evidence_inputs.kinds', 'Missing UI proof evidence kinds.', 'Record at least one fixed evidence kind: code, test, runtime, delivery, or human.'); - } - for (const [index, kind] of kinds.entries()) { - if (!EVIDENCE_KINDS.includes(kind)) { - addError(errors, 'unsupported_evidence_kind', `evidence_inputs.kinds[${index}]`, `Unsupported UI proof evidence kind: ${kind}`, `Use only: ${EVIDENCE_KINDS.join(', ')}.`); - } - } -} - -function validateToolsUsed(bundle, errors) { - const tools = normalizeArray(bundle?.evidence_inputs?.tools_used); - if (tools.length === 0) { - addError(errors, 'missing_tools_used', 'evidence_inputs.tools_used', 'Missing UI proof tool provenance.', 'Record concise tool IDs such as browser, playwright, manual, or project-specific command IDs.'); - return; - } - for (const [index, tool] of tools.entries()) { - if (!TOOL_ID_PATTERN.test(tool)) { - addError(errors, 'invalid_tool_id', `evidence_inputs.tools_used[${index}]`, `Invalid UI proof tool identifier: ${tool}`, 'Use a concise lowercase identifier without spaces, for example browser, playwright, manual, or gsdd-ui-proof-validate.'); - } - } -} - -function validateResult(bundle, errors) { - if (!isPlainObject(bundle?.result)) return; - if (!hasValue(bundle.result.claim_status)) { - addError(errors, 'missing_claim_status', 'result.claim_status', 'Missing UI proof result claim status.', `Record claim_status using: ${CLAIM_STATUSES.join(', ')}.`); - } else if (!CLAIM_STATUSES.includes(bundle.result.claim_status)) { - addError(errors, 'invalid_claim_status', 'result.claim_status', `Invalid UI proof claim status: ${bundle.result.claim_status}`, `Use only: ${CLAIM_STATUSES.join(', ')}.`); - } -} - -function validateFailureClassification(bundle, errors) { - const statuses = [ - bundle?.result?.claim_status, - ...Object.values(isPlainObject(bundle?.result?.comparison_status_by_slot) ? bundle.result.comparison_status_by_slot : {}), - ...normalizeArray(bundle?.observations).map((observation) => isPlainObject(observation) ? observation.result : null), - ...normalizeArray(bundle?.commands_or_manual_steps).map((step) => isPlainObject(step) ? step.result : null), - ].filter(Boolean); - const failedOrPartial = statuses.some((status) => status === 'failed' || status === 'partial'); - const classifications = normalizeArray(bundle?.result?.failure_classification || bundle?.result?.failure_classifications); - - if (failedOrPartial && classifications.length === 0) { - addError(errors, 'missing_failure_classification', 'result.failure_classification', 'Failed or partial UI proof must classify why it failed.', `Use one of: ${FAILURE_CLASSIFICATIONS.join(', ')}.`); - return; - } - - for (const [index, classification] of classifications.entries()) { - if (!FAILURE_CLASSIFICATIONS.includes(classification)) { - addError(errors, 'invalid_failure_classification', `result.failure_classification[${index}]`, `Invalid UI proof failure classification: ${classification}`, `Use only: ${FAILURE_CLASSIFICATIONS.join(', ')}.`); - } - } -} - -function validateComparisonStatuses(bundle, errors) { - const statuses = bundle?.result?.comparison_status_by_slot; - if (!isPlainObject(statuses)) { - addError(errors, 'missing_comparison_statuses', 'result.comparison_status_by_slot', 'Missing UI proof comparison statuses by slot.', `Record one status per slot using: ${COMPARISON_STATUSES.join(', ')}.`); - return; - } - const slotIds = normalizeArray(bundle?.scope?.slot_ids); - const slotSet = new Set(slotIds); - for (const slotId of slotIds) { - if (!hasValue(statuses[slotId])) { - addError(errors, 'missing_comparison_status', `result.comparison_status_by_slot.${slotId}`, `Missing UI proof comparison status for slot: ${slotId}`, `Record one status per slot using: ${COMPARISON_STATUSES.join(', ')}.`); - } - } - for (const [slot, status] of Object.entries(statuses)) { - if (slotSet.size > 0 && !slotSet.has(slot)) { - addError(errors, 'unknown_comparison_slot', `result.comparison_status_by_slot.${slot}`, `UI proof comparison status references undeclared slot: ${slot}`, 'Use only slot IDs declared in scope.slot_ids.'); - } - if (!COMPARISON_STATUSES.includes(status)) { - addError(errors, 'invalid_comparison_status', `result.comparison_status_by_slot.${slot}`, `Invalid UI proof comparison status: ${status}`, `Use only: ${COMPARISON_STATUSES.join(', ')}.`); - } - } - const unsatisfiedStatuses = Object.values(statuses).filter((status) => !['satisfied', 'not_applicable'].includes(status)); - if (bundle?.result?.claim_status === 'passed' && unsatisfiedStatuses.length > 0) { - addError(errors, 'inconsistent_claim_status', 'result.claim_status', 'UI proof claim_status cannot be passed when comparison statuses are unsatisfied.', 'Use partial, failed, waived, deferred, or not_applicable when any slot comparison is not satisfied.'); - } -} - -function validateClaimLimits(bundle, errors) { - const claimLimits = normalizeArray(bundle?.claim_limits); - if (claimLimits.length === 0) { - addError(errors, 'missing_claim_limits', 'claim_limits', 'Missing UI proof claim limits.', 'Add at least one claim limit that narrows what this proof does not prove.'); - } -} - -function artifactReference(artifact) { - if (!isPlainObject(artifact)) return null; - if (typeof artifact.path === 'string' && artifact.path.trim()) return artifact.path.trim(); - if (typeof artifact.url === 'string' && artifact.url.trim()) return artifact.url.trim(); - return null; -} - -function validateArtifactReferenceSafety(ref, path, errors) { - if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) { - if (!/^https?:\/\//i.test(ref)) { - addError(errors, 'invalid_artifact_ref_location', path, `UI proof artifact reference uses an unsupported URL scheme: ${ref}`, 'Use a workspace-relative path or an http(s) URL; do not reference local file URLs.'); - } - return; - } - if (ref.startsWith('//') || isAbsolute(ref) || ref.split(/[\\/]+/).includes('..')) { - addError(errors, 'invalid_artifact_ref_location', path, `UI proof artifact reference must stay workspace-relative: ${ref}`, 'Use a relative path under the workspace, or an http(s) URL for external sanitized evidence.'); - } -} - -function isSanitizedSensitivity(value) { - return typeof value === 'string' && /(^|[_\s-])(sanitized|public_safe|public-safe)($|[_\s-])/.test(value.toLowerCase()); -} - -function validateArtifacts(bundle, errors, publicClaim, options = {}) { - const artifacts = normalizeArray(bundle?.artifacts); - if (artifacts.length === 0) { - addError(errors, 'missing_artifacts', 'artifacts', 'Missing UI proof artifacts list.', 'Record artifact metadata for each referenced proof artifact.'); - return new Set(); - } - - const artifactRefs = new Set(); - for (const [index, artifact] of artifacts.entries()) { - const artifactPath = `artifacts[${index}]`; - if (!isPlainObject(artifact)) { - addError(errors, 'invalid_artifact', artifactPath, 'UI proof artifact entry must be an object.', 'Record path/type plus privacy metadata for each artifact.'); - continue; - } - const ref = artifactReference(artifact); - if (!ref) { - addError(errors, 'missing_artifact_ref', artifactPath, 'UI proof artifact must include path or url.', 'Reference raw UI artifacts by path or URL; do not inline them.'); - } else { - validateArtifactReferenceSafety(ref, artifactPath, errors); - artifactRefs.add(ref); - if (options.requireLocalArtifactExists && !/^https?:\/\//i.test(ref) && hasValue(options.workspaceRoot)) { - const artifactFile = resolve(options.workspaceRoot, ref); - if (!existsSync(artifactFile) || statSync(artifactFile).isDirectory()) { - addError(errors, 'missing_local_artifact', artifactPath, `UI proof artifact file does not exist: ${ref}`, 'Create the referenced artifact, correct the path, or narrow the proof claim.'); - } - } - } - for (const field of REQUIRED_ARTIFACT_FIELDS) { - requireField(artifact, field, artifactPath, errors); - } - if (hasValue(artifact.visibility) && !ARTIFACT_VISIBILITIES.includes(artifact.visibility)) { - addError(errors, 'invalid_visibility', `${artifactPath}.visibility`, `Invalid UI proof artifact visibility: ${artifact.visibility}`, `Use only: ${ARTIFACT_VISIBILITIES.join(', ')}.`); - } - if (hasValue(artifact.safe_to_publish) && typeof artifact.safe_to_publish !== 'boolean') { - addError(errors, 'invalid_safe_to_publish', `${artifactPath}.safe_to_publish`, 'safe_to_publish must be a boolean.', 'Use true only after explicit safe-to-publish classification; otherwise use false.'); - } - if (isRawUiArtifact(artifact) && artifact.visibility !== 'local_only' && artifact.safe_to_publish !== true) { - addError(errors, 'unsafe_raw_artifact', artifactPath, 'Raw UI artifacts are local-only by default unless explicitly classified safe to publish.', 'Set visibility: local_only and safe_to_publish: false, or document sanitized public-safe classification.'); - } - if (publicClaim && (artifact.visibility === 'local_only' || artifact.safe_to_publish !== true)) { - addError(errors, 'unsafe_public_proof_claim', artifactPath, 'Public/tracked/delivery UI proof claims cannot rely on local-only or unsafe artifacts.', 'Use local-only claim language, or provide sanitized artifacts with safe_to_publish: true and non-local visibility.'); - } - if (publicClaim && isRawUiArtifact(artifact) && !isSanitizedSensitivity(artifact.sensitivity)) { - addError(errors, 'unsafe_public_artifact_sensitivity', `${artifactPath}.sensitivity`, 'Public/tracked/delivery raw UI proof artifacts must be classified sanitized.', 'Set sensitivity to a sanitized/public-safe classification after explicit review, or narrow the proof claim.'); - } - } - return artifactRefs; -} - -export function validateUiProofSlots(slots) { - const errors = []; - const normalizedSlots = normalizeArray(slots); - if (normalizedSlots.length === 0) { - addError(errors, 'missing_planned_slots', 'ui_proof_slots', 'Planned UI proof input must include at least one slot.', 'Provide ui_proof_slots or no_ui_proof_rationale for non-UI work.'); - return { valid: false, errors, warnings: [] }; - } - - for (const [index, slot] of normalizedSlots.entries()) { - const slotPath = `ui_proof_slots[${index}]`; - if (!isPlainObject(slot)) { - addError(errors, 'invalid_planned_slot', slotPath, 'Planned UI proof slot must be an object.', 'Record a scoped slot with claim, route_state, viewport, evidence, artifacts, validation, and claim limit.'); - continue; - } - for (const field of REQUIRED_SLOT_FIELDS) requireField(slot, field, slotPath, errors); - for (const [kindIndex, kind] of normalizeArray(slot.required_evidence_kinds || slot.requiredEvidenceKinds).entries()) { - if (!EVIDENCE_KINDS.includes(kind)) { - addError(errors, 'unsupported_planned_evidence_kind', `${slotPath}.required_evidence_kinds[${kindIndex}]`, `Unsupported planned UI proof evidence kind: ${kind}`, `Use only: ${EVIDENCE_KINDS.join(', ')}.`); - } - } - if (hasValue(slot.manual_acceptance_required) && typeof slot.manual_acceptance_required !== 'boolean') { - addError(errors, 'invalid_manual_acceptance_required', `${slotPath}.manual_acceptance_required`, 'manual_acceptance_required must be a boolean.', 'Use true only when human judgment is required for this slot; otherwise use false.'); - } - if (normalizeArray(slot.minimum_observations || slot.minimumObservations).length === 0) { - addError(errors, 'missing_minimum_observations', `${slotPath}.minimum_observations`, 'Planned UI proof slot must include minimum observations.', 'List the observations execution must prove for this slot.'); - } - if (normalizeArray(slot.expected_artifact_types || slot.expectedArtifactTypes).length === 0) { - addError(errors, 'missing_expected_artifact_types', `${slotPath}.expected_artifact_types`, 'Planned UI proof slot must include expected artifact types.', 'List expected artifact types such as screenshot, trace, report, or dom_snapshot.'); - } - } - - return { valid: errors.length === 0, errors, warnings: [] }; -} - -function validatePrivacy(bundle, errors, publicClaim) { - validateObservationPrivacy(bundle.privacy, 'privacy', errors); - if (publicClaim && bundle.privacy?.raw_artifacts_safe_to_publish !== true) { - addError(errors, 'unsafe_public_proof_privacy', 'privacy.raw_artifacts_safe_to_publish', 'Public/tracked/delivery UI proof claims require bundle privacy metadata to classify raw artifacts safe to publish.', 'Use local-only claim language, or set raw_artifacts_safe_to_publish: true after sanitized/public-safe review.'); - } -} - -function validatePublicObservationPrivacy(bundle, errors, publicClaim) { - if (!publicClaim) return; - for (const [index, observation] of normalizeArray(bundle?.observations).entries()) { - if (!isPlainObject(observation)) continue; - if (observation.privacy?.raw_artifacts_safe_to_publish !== true) { - addError(errors, 'unsafe_public_observation_privacy', `observations[${index}].privacy.raw_artifacts_safe_to_publish`, 'Public/tracked/delivery UI proof claims require observation privacy metadata to classify raw artifacts safe to publish.', 'Use local-only claim language, or set raw_artifacts_safe_to_publish: true after sanitized/public-safe review.'); - } - } -} - -function validateObservationArtifactRefs(bundle, artifactRefs, errors) { - for (const [index, observation] of normalizeArray(bundle?.observations).entries()) { - if (!isPlainObject(observation)) continue; - for (const [refIndex, ref] of normalizeArray(observation.artifact_refs).entries()) { - if (!artifactRefs.has(ref)) { - addError(errors, 'unknown_artifact_ref', `observations[${index}].artifact_refs[${refIndex}]`, `Observation references undeclared UI proof artifact: ${ref}`, 'Add the artifact to artifacts[] or correct the observation artifact reference.'); - } - } - } -} - -function stableString(value) { - return JSON.stringify(canonicalize(value)); -} - -function canonicalize(value) { - if (Array.isArray(value)) return value.map(canonicalize); - if (!isPlainObject(value)) return value; - return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); -} - -function valuesMatch(planned, observed) { - if (!hasValue(planned)) return true; - if (!hasValue(observed)) return false; - return stableString(planned) === stableString(observed); -} - -function slotId(slot, index) { - return slot?.slot_id || slot?.slotId || slot?.id || `ui-proof-slot-${index + 1}`; -} - -function observationText(observation) { - if (typeof observation === 'string') return observation; - if (isPlainObject(observation) && typeof observation.observation === 'string') return observation.observation; - return ''; -} - -function includesObservation(observations, expected) { - const expectedText = typeof expected === 'string' ? expected.trim() : observationText(expected).trim(); - if (!expectedText) return true; - return observations.some((observation) => observationText(observation).includes(expectedText)); -} - -function normalizeObservedBundle(entry) { - if (entry?.bundle) { - return { - bundle: entry.bundle, - validation: entry.validation || validateUiProofBundle(entry.bundle, entry.options || {}), - source: entry.source || entry.filePath || 'observed bundle', - }; - } - return { - bundle: entry, - validation: validateUiProofBundle(entry), - source: 'observed bundle', - }; -} - -function comparisonFixHint(code) { - const hints = { - invalid_observed_bundle: 'Fix the observed proof bundle metadata, then rerun ui-proof compare.', - unsatisfied_observed_claim_status: 'Record a passed observed claim only after the changed UI state has been exercised and evidenced.', - unsatisfied_observed_comparison_status: 'Set comparison_status_by_slot to satisfied only for slots backed by matching observations and artifacts.', - missing_required_evidence_kind: 'Add observed evidence for every evidence kind required by the planned slot, or narrow the planned slot before verification.', - human_evidence_cannot_bypass_required_non_human_evidence: 'Add the missing non-human evidence; human approval may narrow or waive but cannot replace it.', - route_state_mismatch: 'Capture proof for the exact planned route/state, or update the plan before execution.', - environment_mismatch: 'Capture proof in the planned environment, or record a narrowed claim limit and rerun comparison.', - viewport_mismatch: 'Capture proof for the planned viewport, or narrow the viewport claim explicitly.', - requirement_mismatch: 'Declare the planned requirement id in the observed proof bundle scope.', - claim_mismatch: 'Keep the planned and observed claims identical so proof maps to the exact UI assertion.', - observation_claim_mismatch: 'Add a passed observation that supports the exact planned claim.', - observation_route_state_mismatch: 'Attach observations to the exact planned route/state.', - missing_supporting_observation_evidence_kind: 'Add passed supporting observations for each required evidence kind.', - unsatisfied_proof_step: 'Rerun or replace failing proof steps before claiming the slot is satisfied.', - missing_manual_acceptance_evidence: 'Record human evidence when the planned slot requires manual acceptance.', - missing_manual_acceptance_observation: 'Add a passed human observation for manual acceptance.', - unsatisfied_observation_result: 'Resolve failed observations or classify the slot as partial, waived, or deferred.', - missing_minimum_observation: 'Add observations covering every planned minimum observation.', - missing_claim_limit: 'Preserve the planned claim limit in the observed proof bundle.', - missing_expected_artifact_type: 'Attach the planned artifact type, such as screenshot, report, trace, or DOM snapshot.', - missing_observed_bundle: 'Create an observed UI proof bundle for the planned slot, or explicitly waive/defer the slot with claim narrowing.', - }; - return hints[code] || 'Fix the proof issue, rerun the comparison, and keep the slot partial until evidence matches the plan.'; -} - -function decorateComparisonIssue(issue) { - return { - severity: issue.severity || 'blocker', - fix_hint: issue.fix_hint || issue.fix || comparisonFixHint(issue.code), - ...issue, - }; -} - -function compareSlotToBundle(slot, slotIdValue, observed) { - const issues = []; - const bundle = observed.bundle; - const observations = normalizeArray(bundle?.observations); - if (!observed.validation.valid) { - issues.push({ - code: 'invalid_observed_bundle', - path: observed.source, - message: `Observed UI proof bundle for slot ${slotIdValue} failed metadata validation.`, - details: observed.validation.errors, - }); - } - - const bundleStatus = bundle?.result?.comparison_status_by_slot?.[slotIdValue]; - if (bundle?.result?.claim_status !== 'passed') { - issues.push({ - code: 'unsatisfied_observed_claim_status', - path: 'result.claim_status', - message: `Observed UI proof bundle claim status is ${bundle?.result?.claim_status || 'missing'} for slot ${slotIdValue}.`, - }); - } - if (bundleStatus !== 'satisfied') { - issues.push({ - code: 'unsatisfied_observed_comparison_status', - path: `result.comparison_status_by_slot.${slotIdValue}`, - message: `Observed UI proof bundle reports ${bundleStatus || 'missing'} for slot ${slotIdValue}.`, - }); - } - - const requiredKinds = normalizeArray(slot?.required_evidence_kinds || slot?.requiredEvidenceKinds); - const observedKinds = normalizeArray(bundle?.evidence_inputs?.kinds); - const missingKinds = requiredKinds.filter((kind) => !observedKinds.includes(kind)); - if (missingKinds.length > 0) { - issues.push({ - code: 'missing_required_evidence_kind', - path: 'evidence_inputs.kinds', - message: `Observed UI proof for slot ${slotIdValue} is missing required evidence kind(s): ${missingKinds.join(', ')}.`, - }); - } - const missingNonHuman = missingKinds.filter((kind) => kind !== 'human'); - if (missingNonHuman.length > 0 && observedKinds.includes('human')) { - issues.push({ - code: 'human_evidence_cannot_bypass_required_non_human_evidence', - path: 'evidence_inputs.kinds', - message: `Human evidence cannot satisfy missing non-human UI proof evidence for slot ${slotIdValue}: ${missingNonHuman.join(', ')}.`, - }); - } - - if (!valuesMatch(slot?.route_state || slot?.routeState, bundle?.route_state)) { - issues.push({ - code: 'route_state_mismatch', - path: 'route_state', - message: `Observed UI proof route/state does not match planned slot ${slotIdValue}.`, - }); - } - - if (!valuesMatch(slot?.environment, bundle?.environment)) { - issues.push({ - code: 'environment_mismatch', - path: 'environment', - message: `Observed UI proof environment does not match planned slot ${slotIdValue}.`, - }); - } - - if (!valuesMatch(slot?.viewport, bundle?.viewport)) { - issues.push({ - code: 'viewport_mismatch', - path: 'viewport', - message: `Observed UI proof viewport does not match planned slot ${slotIdValue}.`, - }); - } - - const requirementId = slot?.requirement_id || slot?.requirementId; - if (hasValue(requirementId) && !normalizeArray(bundle?.scope?.requirement_ids).includes(requirementId)) { - issues.push({ - code: 'requirement_mismatch', - path: 'scope.requirement_ids', - message: `Observed UI proof bundle does not declare planned requirement ${requirementId} for slot ${slotIdValue}.`, - }); - } - - if (hasValue(slot?.claim) && bundle?.scope?.claim !== slot.claim) { - issues.push({ - code: 'claim_mismatch', - path: 'scope.claim', - message: `Observed UI proof bundle claim does not match planned slot ${slotIdValue}.`, - }); - } - - if (hasValue(slot?.claim) && !observations.some((observation) => observation?.claim === slot.claim)) { - issues.push({ - code: 'observation_claim_mismatch', - path: 'observations[].claim', - message: `Observed UI proof observations do not support the exact planned claim for slot ${slotIdValue}.`, - }); - } - - const supportingObservations = observations - .map((observation, index) => ({ observation, index })) - .filter(({ observation }) => !hasValue(slot?.claim) || observation?.claim === slot.claim); - - if (hasValue(slot?.route_state || slot?.routeState)) { - for (const { observation, index } of supportingObservations) { - if (!valuesMatch(slot?.route_state || slot?.routeState, observation?.route_state)) { - issues.push({ - code: 'observation_route_state_mismatch', - path: `observations[${index}].route_state`, - message: `Observed UI proof observation route/state does not match planned slot ${slotIdValue}.`, - }); - } - } - } - - const passedSupportingKinds = new Set( - supportingObservations - .filter(({ observation }) => observation?.result === 'passed') - .map(({ observation }) => observation?.evidence_kind) - .filter(Boolean) - ); - const missingSupportingKinds = requiredKinds.filter((kind) => !passedSupportingKinds.has(kind)); - if (missingSupportingKinds.length > 0) { - issues.push({ - code: 'missing_supporting_observation_evidence_kind', - path: 'observations[].evidence_kind', - message: `Observed UI proof for slot ${slotIdValue} lacks passed supporting observation(s) for required evidence kind(s): ${missingSupportingKinds.join(', ')}.`, - }); - } - - for (const [index, step] of normalizeArray(bundle?.commands_or_manual_steps).entries()) { - if (step?.result !== 'passed') { - issues.push({ - code: 'unsatisfied_proof_step', - path: `commands_or_manual_steps[${index}].result`, - message: `Observed UI proof command/manual step is ${step?.result || 'missing'} for slot ${slotIdValue}.`, - }); - } - } - - const manualAcceptanceRequired = slot?.manual_acceptance_required === true || slot?.manualAcceptanceRequired === true; - if (manualAcceptanceRequired) { - if (!observedKinds.includes('human')) { - issues.push({ - code: 'missing_manual_acceptance_evidence', - path: 'evidence_inputs.kinds', - message: `Observed UI proof for slot ${slotIdValue} is missing required human evidence for manual acceptance.`, - }); - } - if (!passedSupportingKinds.has('human')) { - issues.push({ - code: 'missing_manual_acceptance_observation', - path: 'observations[].evidence_kind', - message: `Observed UI proof for slot ${slotIdValue} lacks a passed human observation for manual acceptance.`, - }); - } - } - - for (const { observation, index } of supportingObservations) { - if (observation?.result !== 'passed') { - issues.push({ - code: 'unsatisfied_observation_result', - path: `observations[${index}].result`, - message: `Observed UI proof observation is ${observation?.result || 'missing'} for slot ${slotIdValue}.`, - }); - } - } - - for (const expected of normalizeArray(slot?.minimum_observations || slot?.minimumObservations)) { - if (!includesObservation(supportingObservations.map(({ observation }) => observation), expected)) { - issues.push({ - code: 'missing_minimum_observation', - path: 'observations', - message: `Observed UI proof for slot ${slotIdValue} is missing a planned minimum observation.`, - }); - } - } - - if (hasValue(slot?.claim_limit || slot?.claimLimit)) { - const claimLimit = slot.claim_limit || slot.claimLimit; - if (!normalizeArray(bundle?.claim_limits).includes(claimLimit)) { - issues.push({ - code: 'missing_claim_limit', - path: 'claim_limits', - message: `Observed UI proof for slot ${slotIdValue} does not preserve the planned claim limit.`, - }); - } - } - - const expectedArtifactTypes = normalizeArray(slot?.expected_artifact_types || slot?.expectedArtifactTypes); - const observedArtifactTypes = new Set(normalizeArray(bundle?.artifacts).filter(isPlainObject).flatMap((artifact) => [ - typeof artifact.type === 'string' ? artifact.type.toLowerCase() : '', - artifactType(artifact), - ]).filter(Boolean)); - const missingArtifactTypes = expectedArtifactTypes.filter((type) => !observedArtifactTypes.has(type)); - if (missingArtifactTypes.length > 0) { - issues.push({ - code: 'missing_expected_artifact_type', - path: 'artifacts[].type', - message: `Observed UI proof for slot ${slotIdValue} is missing expected artifact type(s): ${missingArtifactTypes.join(', ')}.`, - }); - } - - const status = issues.length === 0 ? 'satisfied' : (bundleStatus === 'missing' ? 'missing' : 'partial'); - return { status, issues: issues.map(decorateComparisonIssue), source: observed.source }; -} - -export function compareUiProofSlots(plannedSlots, observedBundles) { - const slots = normalizeArray(plannedSlots); - const slotValidation = validateUiProofSlots(slots); - const bundles = normalizeArray(observedBundles).map(normalizeObservedBundle); - const results = []; - const errors = slotValidation.errors.map(decorateComparisonIssue); - - for (const observed of bundles) { - if (!observed.validation.valid) { - errors.push({ - code: 'invalid_observed_bundle', - path: observed.source, - message: `Observed UI proof bundle ${observed.source} failed metadata validation.`, - details: observed.validation.errors, - }); - } - } - - for (const [index, slot] of slots.entries()) { - const slotIdValue = slotId(slot, index); - const matchingBundles = bundles.filter((observed) => normalizeArray(observed.bundle?.scope?.slot_ids).includes(slotIdValue)); - if (matchingBundles.length === 0) { - results.push({ - slot_id: slotIdValue, - status: 'missing', - issues: [{ - code: 'missing_observed_bundle', - path: 'scope.slot_ids', - message: `No observed UI proof bundle declares planned slot ${slotIdValue}.`, - }].map(decorateComparisonIssue), - }); - continue; - } - - const candidates = matchingBundles.map((observed) => compareSlotToBundle(slot, slotIdValue, observed)); - const satisfied = candidates.find((candidate) => candidate.status === 'satisfied'); - if (satisfied) { - results.push({ slot_id: slotIdValue, status: 'satisfied', issues: [], source: satisfied.source }); - continue; - } - const partial = candidates.find((candidate) => candidate.status === 'partial') || candidates[0]; - results.push({ slot_id: slotIdValue, status: partial.status, issues: partial.issues, source: partial.source }); - } - - const statuses = results.map((result) => result.status); - const status = errors.length > 0 - ? 'partial' - : statuses.length === 0 - ? 'not_applicable' - : statuses.every((value) => value === 'satisfied') - ? 'satisfied' - : statuses.every((value) => value === 'missing') - ? 'missing' - : 'partial'; - - return { status, slots: results, errors: errors.map(decorateComparisonIssue) }; -} - -export function validateUiProofBundle(bundle, options = {}) { - const errors = []; - const warnings = []; - - if (!isPlainObject(bundle)) { - addError(errors, 'invalid_bundle', '', 'UI proof bundle must be an object.', 'Provide structured UI proof metadata.'); - return { valid: false, errors, warnings }; - } - - for (const field of REQUIRED_BUNDLE_FIELDS) requireField(bundle, field, '', errors); - for (const field of REQUIRED_SCOPE_FIELDS) requireField(bundle.scope, field, 'scope', errors); - const publicClaim = hasPublicClaim(bundle, options); - validateClaimUses(bundle, options, errors); - validateEvidenceKinds(bundle, errors); - validateToolsUsed(bundle, errors); - validateCommandsOrManualSteps(bundle, errors); - validateObservations(bundle, errors); - validateResult(bundle, errors); - validateFailureClassification(bundle, errors); - validateComparisonStatuses(bundle, errors); - validateClaimLimits(bundle, errors); - validatePrivacy(bundle, errors, publicClaim); - validatePublicObservationPrivacy(bundle, errors, publicClaim); - const artifactRefs = validateArtifacts(bundle, errors, publicClaim, options); - validateObservationArtifactRefs(bundle, artifactRefs, errors); - - return { valid: errors.length === 0, errors, warnings }; -} - -function parseJsonOrFencedContent(content, filePath, label) { - const trimmed = content.trim(); - if (!trimmed) { - return { value: null, errors: [{ code: 'empty_file', path: filePath, message: `${label} file is empty.`, fix: 'Write JSON metadata before validating.' }] }; - } - - const jsonCandidates = [trimmed]; - const fenceMatches = [...trimmed.matchAll(/```(?:json|ui-proof-json)?\s*([\s\S]*?)```/gi)]; - for (const match of fenceMatches) jsonCandidates.push(match[1].trim()); - - for (const candidate of jsonCandidates) { - try { - return { value: JSON.parse(candidate), errors: [] }; - } catch { - // Try next candidate; final error is reported below. - } - } - - return { - value: null, - errors: [{ code: 'unparseable_json', path: filePath, message: `${label} metadata is not valid JSON.`, fix: 'Use a .json file or a markdown fenced JSON block; no YAML parser dependency is installed.' }], - }; -} - -export function parseUiProofBundleContent(content, filePath = 'UI proof bundle') { - const parsed = parseJsonOrFencedContent(content, filePath, 'UI proof bundle'); - return { bundle: parsed.value, errors: parsed.errors.map((error) => ({ - ...error, - code: error.code === 'empty_file' ? 'empty_bundle_file' : error.code === 'unparseable_json' ? 'unparseable_bundle' : error.code, - message: error.code === 'empty_file' - ? 'UI proof bundle file is empty.' - : error.code === 'unparseable_json' - ? 'UI proof bundle metadata is not valid JSON.' - : error.message, - fix: error.code === 'empty_file' - ? 'Write JSON UI proof metadata before validating.' - : error.fix, - })) }; -} - -export function parseUiProofSlotsContent(content, filePath = 'UI proof slots') { - const parsed = parseJsonOrFencedContent(content, filePath, 'UI proof slots'); - if (parsed.errors.length > 0) return { slots: [], errors: parsed.errors }; - - const value = parsed.value; - const slots = Array.isArray(value) - ? value - : normalizeArray(value?.ui_proof_slots || value?.uiProofSlots || value?.planned_slots || value?.plannedSlots); - - if (slots.length === 0) { - return { - slots: [], - errors: [{ - code: 'missing_planned_slots', - path: filePath, - message: 'Planned UI proof input must be an array or contain ui_proof_slots.', - fix: 'Provide JSON with an array of planned slots or an object with ui_proof_slots.', - }], - }; - } - - const validation = validateUiProofSlots(slots); - return { - slots, - errors: validation.errors.map((error) => ({ - ...error, - path: error.path === 'ui_proof_slots' ? filePath : `${filePath}.${error.path}`, - })), - }; -} - -export function readUiProofBundleFile(filePath) { - return parseUiProofBundleContent(readFileSync(filePath, 'utf-8'), filePath); -} - -function walkForUiProofFiles(dir, results) { - if (!existsSync(dir)) return; - for (const entry of readdirSync(dir)) { - const fullPath = join(dir, entry); - const stat = statSync(fullPath); - if (stat.isDirectory()) { - walkForUiProofFiles(fullPath, results); - continue; - } - const name = entry.toLowerCase(); - if (['ui-proof.json', 'ui-proof.md', 'proof-bundle.json'].includes(name)) { - results.add(fullPath); - } - } -} - -export function findUiProofBundleFiles(planningDir) { - const results = new Set(); - for (const relativePath of [ - 'UI-PROOF.json', - 'ui-proof.json', - 'ui-proof.md', - 'ui-proof/UI-PROOF.json', - 'ui-proof/proof-bundle.json', - 'brownfield-change/UI-PROOF.json', - ]) { - const fullPath = join(planningDir, relativePath); - if (existsSync(fullPath)) results.add(fullPath); - } - for (const relativeDir of ['phases', 'quick', 'brownfield-change']) { - walkForUiProofFiles(join(planningDir, relativeDir), results); - } - return [...results].sort(); -} - -function resolveWorkspacePath(cwd, target) { - const workspaceRoot = resolve(cwd); - const resolved = resolve(workspaceRoot, target); - const rel = relative(workspaceRoot, resolved); - if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return resolved; - fail(`Path must stay inside the workspace: ${target}`); -} - -function parseClaimUse(args) { - const values = []; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg !== '--claim') fail('Usage: gsdd ui-proof validate [--claim ]'); - const value = args[index + 1]; - if (!value || value.startsWith('--')) fail('Usage: gsdd ui-proof validate [--claim ]'); - values.push(...value.split(',').map((entry) => entry.trim()).filter(Boolean)); - index += 1; - } - for (const value of values) { - if (!PUBLIC_CLAIM_USES.includes(value)) fail(`Unsupported UI proof claim use: ${value}`); - } - return values; -} - -function cmdValidate(cwd, args) { - const [targetArg, ...flags] = args; - if (!targetArg) fail('Usage: gsdd ui-proof validate [--claim ]'); - const target = resolveWorkspacePath(cwd, targetArg); - if (!existsSync(target) || statSync(target).isDirectory()) fail(`UI proof bundle file does not exist: ${targetArg}`); - - const parsed = readUiProofBundleFile(target); - const validation = parsed.errors.length > 0 - ? { valid: false, errors: parsed.errors, warnings: [] } - : validateUiProofBundle(parsed.bundle, { - claimUses: parseClaimUse(flags), - requireLocalArtifactExists: true, - workspaceRoot: cwd, - bundleDir: dirname(target), - }); - - output({ operation: 'ui-proof validate', target: targetArg, valid: validation.valid, errors: validation.errors, warnings: validation.warnings }); - if (!validation.valid) process.exitCode = 1; -} - -function cmdCompare(cwd, args) { - const [plannedArg, ...observedArgs] = args; - if (!plannedArg) fail('Usage: gsdd ui-proof compare [observed-bundle-json ...]'); - - const plannedPath = resolveWorkspacePath(cwd, plannedArg); - if (!existsSync(plannedPath) || statSync(plannedPath).isDirectory()) fail(`Planned UI proof slots file does not exist: ${plannedArg}`); - - const planned = parseUiProofSlotsContent(readFileSync(plannedPath, 'utf-8'), plannedArg); - const observedBundles = []; - const observedTargets = []; - const observedErrors = []; - - for (const observedArg of observedArgs) { - const observedPath = resolveWorkspacePath(cwd, observedArg); - if (!existsSync(observedPath) || statSync(observedPath).isDirectory()) fail(`Observed UI proof bundle file does not exist: ${observedArg}`); - const parsed = readUiProofBundleFile(observedPath); - if (parsed.errors.length > 0) { - observedErrors.push(...parsed.errors.map((error) => ({ ...error, path: observedArg }))); - observedBundles.push({ - bundle: {}, - validation: { valid: false, errors: parsed.errors, warnings: [] }, - source: observedArg, - }); - } else { - observedBundles.push({ bundle: parsed.bundle, source: observedArg }); - } - observedTargets.push(observedArg); - } - - const comparison = planned.errors.length > 0 - ? { status: 'partial', slots: [], errors: planned.errors } - : compareUiProofSlots(planned.slots, observedBundles); - - output({ - operation: 'ui-proof compare', - planned: plannedArg, - observed: observedTargets, - status: comparison.status, - slots: comparison.slots, - errors: [...(comparison.errors || []), ...observedErrors], - }); - if (!['satisfied', 'not_applicable'].includes(comparison.status)) process.exitCode = 1; -} - -export function cmdUiProof(...args) { - const { args: normalizedArgs, workspaceRoot, invalid, error } = resolveWorkspaceContext(args); - if (invalid) { - console.error(error); - process.exitCode = 1; - return; - } - const [operation, ...rest] = normalizedArgs; - try { - switch (operation) { - case 'validate': - cmdValidate(workspaceRoot, rest); - return; - case 'compare': - cmdCompare(workspaceRoot, rest); - return; - default: - fail('Usage: gsdd ui-proof ...'); - } - } catch (error) { - if (error instanceof UiProofError) { - process.exitCode = 1; - return; - } - throw error; - } -} - -export { - ARTIFACT_VISIBILITIES as UI_PROOF_ARTIFACT_VISIBILITIES, - COMPARISON_STATUSES as UI_PROOF_COMPARISON_STATUSES, - EVIDENCE_KINDS as UI_PROOF_EVIDENCE_KINDS, - RAW_ARTIFACT_TYPES as UI_PROOF_RAW_ARTIFACT_TYPES, -}; diff --git a/package.json b/package.json index f9777fd..b923168 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "test": "npm run test:gsdd", - "test:gsdd": "node tests/gsdd.init.test.cjs && node tests/gsdd.models.test.cjs && node tests/gsdd.consumer-ceremony.test.cjs && node tests/gsdd.manifest.test.cjs && node tests/gsdd.plan.adapters.test.cjs && node tests/gsdd.global-install-pressure.test.cjs && node tests/gsdd.audit-milestone.test.cjs && node tests/gsdd.invariants.test.cjs && node tests/gsdd.guards.test.cjs && node tests/gsdd.health.test.cjs && node tests/gsdd.scenarios.test.cjs && node tests/gsdd.cross-runtime.test.cjs && node tests/gsdd.control-map.test.cjs && node tests/gsdd.closeout-report.test.cjs && node tests/gsdd.next.test.cjs && node tests/phase.test.cjs && node tests/session-fingerprint.test.cjs", + "test:gsdd": "node tests/gsdd.init.test.cjs && node tests/gsdd.models.test.cjs && node tests/gsdd.consumer-ceremony.test.cjs && node tests/gsdd.manifest.test.cjs && node tests/gsdd.plan.adapters.test.cjs && node tests/gsdd.global-install-pressure.test.cjs && node tests/gsdd.audit-milestone.test.cjs && node tests/gsdd.invariants.test.cjs && node tests/gsdd.guards.test.cjs && node tests/gsdd.health.test.cjs && node tests/gsdd.scenarios.test.cjs && node tests/gsdd.cross-runtime.test.cjs && node tests/gsdd.next.test.cjs && node tests/phase.test.cjs", "prepublishOnly": "node -e \"const ok=process.env.GITHUB_ACTIONS==='true'&&process.env.GITHUB_REF_NAME==='main'&&process.env.GITHUB_WORKFLOW==='Release'; if(!ok){console.error('Refusing to publish gsdd-cli outside the GitHub Actions Release workflow on main.'); process.exit(1)}\"" }, "devDependencies": { diff --git a/tests/gsdd.closeout-report.test.cjs b/tests/gsdd.closeout-report.test.cjs deleted file mode 100644 index 22e5d85..0000000 --- a/tests/gsdd.closeout-report.test.cjs +++ /dev/null @@ -1,274 +0,0 @@ -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert'); -const fs = require('fs'); -const path = require('path'); -const { execFileSync, spawnSync } = require('node:child_process'); - -const { cleanup, createTempProject, runCliAsMain } = require('./gsdd.helpers.cjs'); - -let tmpDir; - -beforeEach(() => { - tmpDir = createTempProject(); -}); - -afterEach(() => { - cleanup(tmpDir); -}); - -function writeFile(relativePath, content) { - const fullPath = path.join(tmpDir, relativePath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); -} - -function git(args, cwd = tmpDir) { - return execFileSync('git', args, { cwd, encoding: 'utf-8' }).trim(); -} - -async function initWorkspace() { - const result = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); - assert.strictEqual(result.exitCode, 0, result.output); -} - -async function initGitWorkspace() { - await initWorkspace(); - git(['init']); - git(['config', 'user.email', 'test@example.com']); - git(['config', 'user.name', 'Test User']); - git(['config', 'core.autocrlf', 'false']); - writeFile('.gitignore', '.planning/\n.agents/\n'); - writeFile('README.md', '# Closeout report test\n'); - writeFile('tracked.txt', 'tracked\n'); - git(['add', '.gitignore', 'README.md', 'tracked.txt']); - git(['commit', '-m', 'initial']); -} - -function writeRoadmap() { - writeFile('.planning/ROADMAP.md', [ - '# Roadmap', - '', - '### v1.0 Closeout Test', - '', - '- [x] **Phase 1: First Closed Phase** - [CLOSE-01]', - '- [x] **Phase 2: Latest Closed Phase** - [CLOSE-01]', - '- [ ] **Phase 3: Pending Phase** - [CLOSE-01]', - '', - ].join('\n')); - writeFile('.planning/SPEC.md', '- [x] **[CLOSE-01]**: Closeout report\n'); -} - -function writeCompletedPhase(number, slug, planBody = '') { - const phaseDir = `.planning/phases/${String(number).padStart(2, '0')}-${slug}`; - writeFile(`${phaseDir}/${String(number).padStart(2, '0')}-PLAN.md`, [ - '---', - 'ui_proof_slots: []', - 'no_ui_proof_rationale: CLI-only report test.', - '---', - `# Phase ${number} Plan`, - planBody, - ].join('\n')); - writeFile(`${phaseDir}/${String(number).padStart(2, '0')}-SUMMARY.md`, `# Phase ${number} Summary\n`); -} - -function writePhaseWithMissingObservedUiProof(number, slug, slot) { - const phaseDir = `.planning/phases/${String(number).padStart(2, '0')}-${slug}`; - const phaseNumber = String(number).padStart(2, '0'); - writeFile(`${phaseDir}/${phaseNumber}-PLAN.md`, [ - '---', - 'ui_proof_slots:', - ' - slot_id: ui-01-missing-bundle', - '---', - `# Phase ${number} Plan`, - '', - ].join('\n')); - writeFile(`${phaseDir}/ui-proof-slots.json`, JSON.stringify({ ui_proof_slots: [slot] }, null, 2)); - writeFile(`${phaseDir}/${phaseNumber}-SUMMARY.md`, `# Phase ${number} Summary\n`); -} - -describe('closeout-report helper', () => { - test('defaults to the latest completed phase in the active roadmap', async () => { - await initWorkspace(); - writeRoadmap(); - writeCompletedPhase(1, 'first-closed-phase'); - writeCompletedPhase(2, 'latest-closed-phase'); - - const result = await runCliAsMain(tmpDir, ['closeout-report', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - const report = JSON.parse(result.output); - - assert.strictEqual(report.operation, 'closeout-report'); - assert.strictEqual(report.phase, '2'); - assert.strictEqual(report.scope.defaulted_to_latest_completed, true); - assert.ok(!report.next_safe_action.command.startsWith('/gsdd-')); - assert.ok('control_map' in report); - assert.ok('health' in report); - assert.ok('preflight' in report); - assert.ok('phase_verification' in report); - assert.ok('ui_proof' in report); - assert.ok(Array.isArray(report.blockers)); - assert.ok(Array.isArray(report.warnings)); - assert.ok(report.next_safe_action.command); - }); - - test('supports explicit phase replay', async () => { - await initWorkspace(); - writeRoadmap(); - writeCompletedPhase(1, 'first-closed-phase'); - writeCompletedPhase(2, 'latest-closed-phase'); - - const result = await runCliAsMain(tmpDir, ['closeout-report', '--json', '--phase', '1']); - assert.strictEqual(result.exitCode, 0, result.output); - const report = JSON.parse(result.output); - - assert.strictEqual(report.phase, '1'); - assert.strictEqual(report.scope.defaulted_to_latest_completed, false); - assert.strictEqual(report.phase_verification.status, 'passed'); - assert.strictEqual(report.ui_proof.status, 'not_applicable'); - }); - - test('next safe action routes to health when health warnings are present', async () => { - await initWorkspace(); - writeRoadmap(); - writeCompletedPhase(1, 'first-closed-phase'); - // Emit a health warning without blocking preflight/phase verification. - fs.unlinkSync(path.join(tmpDir, '.planning', 'generation-manifest.json')); - - const result = await runCliAsMain(tmpDir, ['closeout-report', '--json', '--phase', '1']); - assert.strictEqual(result.exitCode, 0, result.output); - const report = JSON.parse(result.output); - - assert.ok(report.warnings.some((entry) => entry.source === 'health')); - assert.strictEqual(report.next_safe_action.command, 'gsdd health --json'); - }); - - test('aggregates typed blockers from direct phase verification', async () => { - await initWorkspace(); - writeRoadmap(); - writeFile('.planning/phases/03-pending-phase/03-PLAN.md', [ - '---', - 'ui_proof_slots: []', - 'no_ui_proof_rationale: CLI-only report test.', - '---', - '# Phase 3 Plan', - ].join('\n')); - - const result = await runCliAsMain(tmpDir, ['closeout-report', '--json', '--phase', '3']); - assert.strictEqual(result.exitCode, 1, result.output); - const report = JSON.parse(result.output); - - assert.strictEqual(report.status, 'blocked'); - assert.ok(report.blockers.some((entry) => entry.source === 'phase_verification' && entry.code === 'missing_phase_summary')); - assert.strictEqual(report.phase_verification.verified, false); - }); - - test('passes UI-proof status through from direct phase verification', async () => { - await initWorkspace(); - writeRoadmap(); - writeFile('.planning/phases/01-first-closed-phase/01-PLAN.md', [ - '---', - 'ui_proof_slots:', - ' - slot_id: missing-ui-proof', - '---', - '# Phase 1 Plan', - ].join('\n')); - writeFile('.planning/phases/01-first-closed-phase/01-SUMMARY.md', '# Phase 1 Summary\n'); - - const result = await runCliAsMain(tmpDir, ['closeout-report', '--json', '--phase', '1']); - assert.strictEqual(result.exitCode, 1, result.output); - const report = JSON.parse(result.output); - - assert.strictEqual(report.ui_proof.status, 'missing'); - assert.ok(report.blockers.some((entry) => entry.source === 'ui_proof')); - }); - - test('treats required ui_proof failures as blockers even when uiProof.errors is empty', async () => { - await initWorkspace(); - writeRoadmap(); - writePhaseWithMissingObservedUiProof(1, 'first-closed-phase', { - slot_id: 'ui-01-missing-bundle', - requirement_id: 'CLOSE-01', - claim: 'A deterministic non-empty UI claim for closeout validation.', - route_state: '/closeout/test', - required_evidence_kinds: ['code', 'runtime'], - minimum_observations: [ - 'Capture deterministic evidence for route and viewport.', - ], - environment: { app_url: 'file://test', data_state: 'synthetic' }, - viewport: { width: 1280, height: 720 }, - expected_artifact_types: ['screenshot'], - validation_command: 'gsdd ui-proof validate .planning/phases/01-first-closed-phase/proof-bundle.json', - manual_acceptance_required: false, - claim_limit: 'Proof does not establish accessibility.', - }); - - const result = await runCliAsMain(tmpDir, ['closeout-report', '--json', '--phase', '1']); - assert.strictEqual(result.exitCode, 1, result.output); - const report = JSON.parse(result.output); - - assert.strictEqual(report.phase, '1'); - assert.strictEqual(report.ui_proof.status, 'missing'); - assert.strictEqual(report.ui_proof.blocks_verification, true); - assert.ok(report.blockers.some((entry) => entry.source === 'ui_proof')); - }); - - test('is read-only for roadmap, fingerprint, annotations, branches, worktrees, and report files', async () => { - await initWorkspace(); - writeRoadmap(); - writeCompletedPhase(1, 'first-closed-phase'); - writeFile('.planning/.state-fingerprint.json', '{"before":true}\n'); - writeFile('.planning/.local/control-map.annotations.json', '{"worktrees":[]}\n'); - const rebaseline = await runCliAsMain(tmpDir, ['session-fingerprint', 'write']); - assert.strictEqual(rebaseline.exitCode, 0, rebaseline.output); - const before = new Map([ - ['.planning/ROADMAP.md', fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8')], - ['.planning/.state-fingerprint.json', fs.readFileSync(path.join(tmpDir, '.planning', '.state-fingerprint.json'), 'utf-8')], - ['.planning/.local/control-map.annotations.json', fs.readFileSync(path.join(tmpDir, '.planning', '.local', 'control-map.annotations.json'), 'utf-8')], - ]); - - const result = await runCliAsMain(tmpDir, ['closeout-report', '--json', '--phase', '1']); - assert.strictEqual(result.exitCode, 0, result.output); - - for (const [relativePath, content] of before.entries()) { - assert.strictEqual(fs.readFileSync(path.join(tmpDir, relativePath), 'utf-8'), content, `${relativePath} must not change`); - } - assert.strictEqual(fs.existsSync(path.join(tmpDir, 'closeout-report.json')), false); - assert.strictEqual(fs.existsSync(path.join(tmpDir, '.planning', 'closeout-report.json')), false); - }); - - test('top-level warnings do not duplicate control-map risks from preflight', async () => { - await initGitWorkspace(); - writeRoadmap(); - writeCompletedPhase(1, 'first-closed-phase'); - writeFile('tracked.txt', 'tracked changed\n'); - - const result = await runCliAsMain(tmpDir, ['closeout-report', '--json', '--phase', '1']); - assert.strictEqual(result.exitCode, 0, result.output); - const report = JSON.parse(result.output); - const canonicalDirtyWarnings = report.warnings.filter((entry) => entry.code === 'canonical_dirty'); - - assert.strictEqual(canonicalDirtyWarnings.length, 1); - assert.strictEqual(canonicalDirtyWarnings[0].source, 'control_map'); - assert.ok(canonicalDirtyWarnings[0].fix, 'control_map warnings should include fix guidance'); - assert.ok(report.preflight.warnings.some((entry) => entry.source === 'control-map' && entry.code === 'canonical_dirty')); - }); - - test('generated local helper emits typed closeout report with explicit health availability boundary', async () => { - await initWorkspace(); - writeRoadmap(); - writeCompletedPhase(1, 'first-closed-phase'); - const helperPath = path.join(tmpDir, '.planning', 'bin', 'gsdd.mjs'); - - const result = spawnSync(process.execPath, [helperPath, 'closeout-report', '--json', '--phase', '1'], { - cwd: tmpDir, - encoding: 'utf-8', - }); - assert.strictEqual(result.status, 0, result.stderr || result.stdout); - const report = JSON.parse(result.stdout); - - assert.strictEqual(report.operation, 'closeout-report'); - assert.strictEqual(report.phase, '1'); - assert.ok(report.health.warnings.some((entry) => entry.id === 'W_CLOSEOUT_HEALTH_UNAVAILABLE')); - assert.strictEqual(report.phase_verification.status, 'passed'); - }); -}); diff --git a/tests/gsdd.control-map.test.cjs b/tests/gsdd.control-map.test.cjs deleted file mode 100644 index 31f17d8..0000000 --- a/tests/gsdd.control-map.test.cjs +++ /dev/null @@ -1,567 +0,0 @@ -/** - * GSDD control-map helper tests - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { execFileSync } = require('node:child_process'); - -const { createTempProject, runCliAsMain, cleanup } = require('./gsdd.helpers.cjs'); - -let tmpDir; - -beforeEach(() => { - tmpDir = createTempProject(); -}); - -afterEach(() => { - cleanup(tmpDir); -}); - -function git(args, cwd = tmpDir) { - return execFileSync('git', args, { cwd, encoding: 'utf-8' }).trim(); -} - -function gitRaw(args, cwd = tmpDir) { - return execFileSync('git', args, { cwd, encoding: 'utf-8' }).trim(); -} - -function writeFile(relativePath, content) { - const fullPath = path.join(tmpDir, relativePath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); -} - -async function initGitWorkspace() { - const initResult = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); - assert.strictEqual(initResult.exitCode, 0, initResult.output); - git(['init']); - git(['config', 'user.email', 'test@example.com']); - git(['config', 'user.name', 'Test User']); - git(['config', 'core.autocrlf', 'false']); - writeFile('.gitignore', '.planning/\n.agents/\nignored*.log\n'); - writeFile('README.md', '# Test repo\n'); - writeFile('tracked.txt', 'tracked\n'); - git(['add', '.gitignore', 'README.md', 'tracked.txt']); - git(['commit', '-m', 'initial']); - try { - git(['branch', '-M', 'main']); - } catch { - // Older Git builds may already use a fixed default branch. The control-map - // contract only needs the live branch value, not a hardcoded branch name. - } -} - -describe('control-map command', () => { - test('reports computed repo truth, dirty buckets, and authority order', async () => { - await initGitWorkspace(); - writeFile('tracked.txt', 'tracked changed\n'); - writeFile('new-file.txt', 'new\n'); - writeFile('ignored.log', 'ignored\n'); - for (let i = 0; i < 205; i += 1) writeFile(`ignored-${i}.log`, 'ignored\n'); - - const result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - const map = JSON.parse(result.output); - - assert.strictEqual(map.operation, 'control-map'); - assert.deepStrictEqual(map.authority, [ - 'repo_truth', - 'planning_artifacts', - 'checkpoint_narrative', - 'local_annotations', - 'vendor_session_forensics', - ]); - assert.strictEqual(map.canonical_worktree.git_valid, true); - assert.deepStrictEqual(map.canonical_worktree.ahead_behind, { ahead: null, behind: null }); - assert.strictEqual(map.canonical_worktree.dirty.counts.tracked, 1); - assert.ok(map.canonical_worktree.dirty.counts.untracked >= 1); - assert.strictEqual(map.canonical_worktree.dirty.counts.ignored, null); - assert.strictEqual(map.canonical_worktree.dirty.ignored.length, 0); - assert.strictEqual(map.canonical_worktree.dirty.omitted_counts.ignored, null); - assert.ok(map.risks.some((risk) => risk.code === 'canonical_dirty')); - const canonicalDirty = map.risks.find((risk) => risk.code === 'canonical_dirty'); - assert.ok(canonicalDirty.fix_hint, 'canonical_dirty should include fix_hint guidance'); - assert.ok(!map.risks.some((risk) => risk.code === 'ignored_local_surfaces_present')); - }); - - test('reports git inspection failures as warnings when no git repo is present', async () => { - const initResult = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); - assert.strictEqual(initResult.exitCode, 0, initResult.output); - - const result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - const map = JSON.parse(result.output); - - assert.ok(map.risks.some((risk) => risk.code === 'git_worktree_list_failed' && risk.severity === 'warn')); - assert.ok(map.risks.some((risk) => risk.code === 'canonical_git_invalid' && risk.severity === 'warn')); - assert.ok(map.interventions.some((entry) => /git\/safe\.directory/i.test(entry))); - }); - - test('includes explicit ignored path list only with --with-ignored', async () => { - await initGitWorkspace(); - writeFile('ignored-a.log', 'ignored a\n'); - writeFile('ignored-b.log', 'ignored b\n'); - for (let i = 0; i < 205; i += 1) writeFile(`ignored-${i}.log`, 'ignored\n'); - - const summaryResult = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(summaryResult.exitCode, 0, summaryResult.output); - const summaryMap = JSON.parse(summaryResult.output); - - assert.strictEqual(summaryMap.canonical_worktree.dirty.ignored.length, 0); - assert.strictEqual(summaryMap.canonical_worktree.dirty.counts.ignored, null); - assert.strictEqual(summaryMap.canonical_worktree.dirty.ignored_count_included, false); - - const deepResult = await runCliAsMain(tmpDir, ['control-map', '--json', '--with-ignored']); - assert.strictEqual(deepResult.exitCode, 0, deepResult.output); - const deepMap = JSON.parse(deepResult.output); - - assert.ok(deepMap.canonical_worktree.dirty.ignored.length > 0); - assert.ok(deepMap.canonical_worktree.dirty.ignored.length <= 200); - assert.ok(deepMap.canonical_worktree.dirty.omitted_counts.ignored > 0); - assert.ok(deepMap.canonical_worktree.dirty.counts.ignored >= deepMap.canonical_worktree.dirty.ignored.length); - }); - - test('reads local annotations as stale-checkable intent, not product truth', async () => { - await initGitWorkspace(); - const head = git(['rev-parse', 'HEAD']); - const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']); - writeFile('.planning/.local/control-map.annotations.json', JSON.stringify({ - schema_version: 1, - worktrees: [{ - id: 'canonical', - path: '.', - runtime_owner: 'codex-cli', - branch, - last_known_head: head, - intended_scope: 'control-map test', - write_set: ['bin/lib/control-map.mjs'], - cleanup_state: 'active', - proof_state: 'test-only', - next_step: 'run control-map tests', - updated_at: '2026-05-08T00:00:00.000Z', - }, { - id: 'stale', - path: '../missing-worktree', - cleanup_state: 'paused', - }], - }, null, 2)); - - const result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - const map = JSON.parse(result.output); - - assert.strictEqual(map.annotations.exists, true); - assert.strictEqual(map.annotations.valid, true); - assert.strictEqual(map.canonical_worktree.annotation.runtime_owner, 'codex-cli'); - assert.ok(map.risks.some((risk) => risk.code === 'stale_annotation_missing_worktree')); - assert.strictEqual(map.authority.indexOf('repo_truth') < map.authority.indexOf('local_annotations'), true); - }); - - test('annotation helper writes, updates, and clears local intent', async () => { - await initGitWorkspace(); - - let result = await runCliAsMain(tmpDir, [ - 'control-map', - 'annotate', - 'set', - '--id', - 'canonical', - '--owner', - 'codex-cli', - '--scope', - 'phase 61', - '--write-set', - 'bin/lib/control-map.mjs,tests/gsdd.control-map.test.cjs', - '--next-step', - 'run focused tests', - ]); - assert.strictEqual(result.exitCode, 0, result.output); - let mutation = JSON.parse(result.output); - - assert.strictEqual(mutation.operation, 'control-map annotate set'); - assert.strictEqual(mutation.status, 'created'); - assert.strictEqual(mutation.annotation.id, 'canonical'); - assert.strictEqual(mutation.annotation.runtime_owner, 'codex-cli'); - assert.deepStrictEqual(mutation.annotation.write_set, [ - 'bin/lib/control-map.mjs', - 'tests/gsdd.control-map.test.cjs', - ]); - - result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - let map = JSON.parse(result.output); - assert.strictEqual(map.canonical_worktree.annotation.id, 'canonical'); - assert.strictEqual(map.canonical_worktree.annotation.intended_scope, 'phase 61'); - - result = await runCliAsMain(tmpDir, [ - 'control-map', - 'annotate', - 'set', - '--id', - 'canonical', - '--write-set', - 'src/app.js', - '--cleanup-state', - 'paused', - '--next-step', - 'resume later', - ]); - assert.strictEqual(result.exitCode, 0, result.output); - mutation = JSON.parse(result.output); - assert.strictEqual(mutation.status, 'updated'); - assert.strictEqual(mutation.annotation.cleanup_state, 'paused'); - assert.deepStrictEqual(mutation.annotation.write_set, ['src/app.js']); - - result = await runCliAsMain(tmpDir, [ - 'control-map', - 'annotate', - 'set', - '--id', - 'canonical', - '--write-set', - 'src/other.js', - ]); - assert.strictEqual(result.exitCode, 0, result.output); - mutation = JSON.parse(result.output); - assert.strictEqual(mutation.status, 'updated'); - assert.strictEqual(mutation.annotation.cleanup_state, 'paused'); - assert.deepStrictEqual(mutation.annotation.write_set, ['src/other.js']); - - result = await runCliAsMain(tmpDir, ['control-map', 'annotate', 'clear', '--id', 'missing', '--write-set', 'src/app.js']); - assert.notStrictEqual(result.exitCode, 0, 'clear should reject set-only flags'); - mutation = JSON.parse(result.output); - assert.strictEqual(mutation.reason, 'invalid_arguments'); - - result = await runCliAsMain(tmpDir, ['control-map', 'annotate', 'clear', '--id', 'canonical']); - assert.strictEqual(result.exitCode, 0, result.output); - mutation = JSON.parse(result.output); - assert.strictEqual(mutation.operation, 'control-map annotate clear'); - assert.strictEqual(mutation.status, 'cleared'); - assert.strictEqual(mutation.changed, true); - - result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - map = JSON.parse(result.output); - assert.strictEqual(map.canonical_worktree.annotation, null); - }); - - test('annotation helper fails closed on stale updates and supports explicit refresh', async () => { - await initGitWorkspace(); - let result = await runCliAsMain(tmpDir, [ - 'control-map', - 'annotate', - 'set', - '--id', - 'canonical', - '--write-set', - 'src/app.js', - ]); - assert.strictEqual(result.exitCode, 0, result.output); - const initialHead = JSON.parse(result.output).annotation.last_known_head; - - writeFile('README.md', '# Updated\n'); - git(['add', 'README.md']); - git(['commit', '-m', 'update readme']); - - result = await runCliAsMain(tmpDir, [ - 'control-map', - 'annotate', - 'set', - '--id', - 'canonical', - '--write-set', - 'src/app.js', - ]); - assert.notStrictEqual(result.exitCode, 0, 'stale update should fail closed'); - let mutation = JSON.parse(result.output); - assert.strictEqual(mutation.reason, 'stale_annotation'); - assert.ok(mutation.stale_issues.some((issue) => issue.code === 'head_mismatch')); - - result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - let map = JSON.parse(result.output); - assert.ok(map.risks.some((risk) => risk.code === 'stale_annotation_head_mismatch')); - - result = await runCliAsMain(tmpDir, [ - 'control-map', - 'annotate', - 'set', - '--id', - 'canonical', - '--write-set', - 'src/app.js', - '--refresh', - ]); - assert.strictEqual(result.exitCode, 0, result.output); - mutation = JSON.parse(result.output); - assert.strictEqual(mutation.status, 'refreshed'); - assert.notStrictEqual(mutation.annotation.last_known_head, initialHead); - - writeFile('README.md', '# Updated again\n'); - git(['add', 'README.md']); - git(['commit', '-m', 'update readme again']); - - result = await runCliAsMain(tmpDir, ['control-map', 'annotate', 'clear', '--id', 'canonical']); - assert.strictEqual(result.exitCode, 0, result.output); - mutation = JSON.parse(result.output); - assert.strictEqual(mutation.status, 'cleared'); - }); - - test('helper-written annotations remain lower-authority than live dirty truth', async () => { - await initGitWorkspace(); - let result = await runCliAsMain(tmpDir, [ - 'control-map', - 'annotate', - 'set', - '--id', - 'canonical', - '--write-set', - 'src/app.js', - ]); - assert.strictEqual(result.exitCode, 0, result.output); - writeFile('src/app.js', 'dirty\n'); - - result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - const map = JSON.parse(result.output); - const risk = map.risks.find((entry) => entry.code === 'dirty_path_write_set_overlap'); - - assert.ok(risk); - assert.strictEqual(risk.severity, 'block'); - assert.ok(risk.overlaps.some((overlap) => overlap.annotation_id === 'canonical' && overlap.dirty_path === 'src/app.js')); - assert.strictEqual(map.authority.indexOf('repo_truth') < map.authority.indexOf('local_annotations'), true); - }); - - test('reports active annotation write-set overlap as a block-level risk', async () => { - await initGitWorkspace(); - writeFile('.planning/.local/control-map.annotations.json', JSON.stringify({ - schema_version: 1, - worktrees: [{ - id: 'codex', - path: '.', - cleanup_state: 'active', - write_set: ['src'], - }, { - id: 'opencode', - path: '.', - cleanup_state: 'paused', - write_set: ['src/app.js'], - }, { - id: 'merged-old-work', - path: '.', - cleanup_state: 'merged', - write_set: ['src/app.js'], - }], - }, null, 2)); - - const result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - const map = JSON.parse(result.output); - const risk = map.risks.find((entry) => entry.code === 'write_set_overlap'); - - assert.ok(risk); - assert.strictEqual(risk.severity, 'block'); - assert.strictEqual(risk.overlaps.length, 1); - assert.deepStrictEqual( - [risk.overlaps[0].left_annotation_id, risk.overlaps[0].right_annotation_id].sort(), - ['codex', 'opencode'] - ); - assert.ok(map.interventions.some((entry) => /overlapping local annotation write sets/i.test(entry))); - }); - - test('reports live dirty paths that overlap annotated write sets', async () => { - await initGitWorkspace(); - writeFile('.planning/.local/control-map.annotations.json', JSON.stringify({ - schema_version: 1, - worktrees: [{ - id: 'canonical', - path: '.', - cleanup_state: 'active', - write_set: ['src/app.js'], - }], - }, null, 2)); - writeFile('src/app.js', 'dirty\n'); - - const result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - const map = JSON.parse(result.output); - const risk = map.risks.find((entry) => entry.code === 'dirty_path_write_set_overlap'); - - assert.ok(risk); - assert.strictEqual(risk.severity, 'block'); - assert.ok(risk.overlaps.some((overlap) => ( - overlap.annotation_id === 'canonical' - && overlap.write_path === 'src/app.js' - && overlap.dirty_path === 'src/app.js' - && overlap.dirty_kind === 'untracked' - ))); - }); - - test('detects dirty overlap in a real sibling git worktree', async () => { - await initGitWorkspace(); - const siblingPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-control-map-sibling-')); - fs.rmSync(siblingPath, { recursive: true, force: true }); - - try { - git(['worktree', 'add', '-b', 'feature/sibling-risk', siblingPath]); - fs.writeFileSync(path.join(siblingPath, 'tracked.txt'), 'sibling dirty\n'); - writeFile('.planning/.local/control-map.annotations.json', JSON.stringify({ - schema_version: 1, - worktrees: [{ - id: 'sibling', - path: siblingPath, - cleanup_state: 'active', - write_set: ['tracked.txt'], - }], - }, null, 2)); - - const result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - const map = JSON.parse(result.output); - const risk = map.risks.find((entry) => entry.code === 'dirty_path_write_set_overlap'); - - assert.ok(map.worktrees.some((worktree) => path.resolve(worktree.path) === path.resolve(siblingPath))); - assert.ok(risk); - assert.strictEqual(risk.severity, 'block'); - assert.ok(risk.overlaps.some((overlap) => overlap.annotation_id === 'sibling' && overlap.dirty_worktree_id !== '.')); - } finally { - try { - git(['worktree', 'remove', '--force', siblingPath]); - } catch { - // Temp worktree cleanup is best-effort; the temp directory is removed below. - } - fs.rmSync(siblingPath, { recursive: true, force: true }); - } - }); - - test('reports detached sibling worktrees as candidate-work risks', async () => { - await initGitWorkspace(); - const detachedPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-control-map-detached-')); - fs.rmSync(detachedPath, { recursive: true, force: true }); - - try { - git(['worktree', 'add', '--detach', detachedPath, 'HEAD']); - - const result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - const map = JSON.parse(result.output); - const detached = map.worktrees.find((worktree) => path.resolve(worktree.path) === path.resolve(detachedPath)); - - assert.ok(detached); - assert.strictEqual(detached.detached, true); - assert.ok(map.risks.some((risk) => risk.code === 'detached_candidate_worktree' && risk.worktree_id === detached.id)); - assert.ok(map.risks.some((risk) => risk.code === 'unannotated_candidate_worktree' && risk.worktree_id === detached.id)); - } finally { - try { - git(['worktree', 'remove', '--force', detachedPath]); - } catch { - // Temp worktree cleanup is best-effort; the temp directory is removed below. - } - fs.rmSync(detachedPath, { recursive: true, force: true }); - } - }); - - test('reports comparable upstream behind state and dirty-behind transition risk', async () => { - await initGitWorkspace(); - const remotePath = fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-control-map-remote-')); - const clonePath = fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-control-map-clone-')); - fs.rmSync(remotePath, { recursive: true, force: true }); - fs.rmSync(clonePath, { recursive: true, force: true }); - - try { - gitRaw(['init', '--bare', remotePath], os.tmpdir()); - gitRaw(['symbolic-ref', 'HEAD', 'refs/heads/main'], remotePath); - git(['remote', 'add', 'origin', remotePath]); - git(['push', '-u', 'origin', 'main']); - gitRaw(['clone', remotePath, clonePath], os.tmpdir()); - gitRaw(['config', 'user.email', 'test@example.com'], clonePath); - gitRaw(['config', 'user.name', 'Test User'], clonePath); - fs.writeFileSync(path.join(clonePath, 'README.md'), '# Remote change\n'); - gitRaw(['add', 'README.md'], clonePath); - gitRaw(['commit', '-m', 'remote change'], clonePath); - gitRaw(['push'], clonePath); - git(['fetch', 'origin']); - git(['reset', '--hard', 'HEAD']); - git(['clean', '-fd']); - - let result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - let map = JSON.parse(result.output); - - assert.ok(map.risks.some((risk) => risk.code === 'canonical_branch_behind_upstream' && risk.behind === 1)); - assert.ok(!map.risks.some((risk) => risk.code === 'canonical_dirty_behind_upstream')); - - writeFile('notes.md', 'ordinary local note\n'); - result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - map = JSON.parse(result.output); - assert.ok(map.risks.some((risk) => risk.code === 'canonical_dirty')); - assert.ok(!map.risks.some((risk) => risk.code === 'canonical_dirty_behind_upstream')); - fs.unlinkSync(path.join(tmpDir, 'notes.md')); - - writeFile('tracked.txt', 'dirty behind\n'); - result = await runCliAsMain(tmpDir, ['control-map', '--json']); - assert.strictEqual(result.exitCode, 0, result.output); - map = JSON.parse(result.output); - - const dirtyBehind = map.risks.find((risk) => risk.code === 'canonical_dirty_behind_upstream'); - assert.ok(dirtyBehind); - assert.strictEqual(dirtyBehind.severity, 'block'); - } finally { - fs.rmSync(remotePath, { recursive: true, force: true }); - fs.rmSync(clonePath, { recursive: true, force: true }); - } - }); - - test('rejects annotation files outside the workspace without reading them', async () => { - await initGitWorkspace(); - const outsidePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-annotation-outside-')), 'annotations.json'); - fs.writeFileSync(outsidePath, JSON.stringify({ - worktrees: [{ - path: '.', - runtime_owner: 'outside', - cleanup_state: 'active', - }], - }, null, 2)); - - const result = await runCliAsMain(tmpDir, ['control-map', '--json', '--annotations', outsidePath]); - assert.strictEqual(result.exitCode, 0, result.output); - const map = JSON.parse(result.output); - - assert.strictEqual(map.annotations.exists, false); - assert.strictEqual(map.annotations.valid, false); - assert.ok(map.annotations.errors.some((error) => error.code === 'annotations_path_outside_workspace')); - assert.strictEqual(map.canonical_worktree.annotation, null); - }); - - test('human output includes lifecycle checkpoint state', async () => { - await initGitWorkspace(); - writeFile('tracked.txt', 'tracked changed\n'); - const result = await runCliAsMain(tmpDir, ['control-map']); - assert.strictEqual(result.exitCode, 0, result.output); - - assert.match(result.output, /Workflow: /); - assert.match(result.output, /Checkpoint: \.planning\/\.continue-here\.md \((present|missing)\)/); - assert.match(result.output, /Fix:\s+/); - }); - - test('generated local helper exposes control-map from nested directories', async () => { - await initGitWorkspace(); - const nestedDir = path.join(tmpDir, 'apps', 'nested'); - fs.mkdirSync(nestedDir, { recursive: true }); - const helperPath = path.join(tmpDir, '.planning', 'bin', 'gsdd.mjs'); - - const result = execFileSync(process.execPath, [helperPath, 'control-map', '--json'], { - cwd: nestedDir, - encoding: 'utf-8', - }); - const map = JSON.parse(result); - - assert.strictEqual(map.operation, 'control-map'); - assert.strictEqual(path.resolve(map.workspace_root), path.resolve(tmpDir)); - }); -}); diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 25e5179..e0a2f31 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -589,178 +589,6 @@ describe('Phase 18 deterministic CLI mechanics', () => { }); }); -describe('Phase 19 provenance helpers', () => { - test('parseGitStatusShort separates staged, unstaged, and untracked files', async () => { - const mod = await import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'provenance.mjs')).href}?t=${Date.now()}-${Math.random()}`); - const status = mod.parseGitStatusShort('M README.md\n M distilled/workflows/resume.md\n?? bin/lib/provenance.mjs\n'); - - assert.strictEqual(status.stagedCount, 1); - assert.strictEqual(status.unstagedCount, 1); - assert.strictEqual(status.untrackedCount, 1); - assert.strictEqual(status.dirty, true); - }); - - test('parseGitStatusShort ignores git --ignored markers', async () => { - const mod = await import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'provenance.mjs')).href}?t=${Date.now()}-${Math.random()}`); - const status = mod.parseGitStatusShort('!! .env.local\n'); - - assert.strictEqual(status.stagedCount, 0); - assert.strictEqual(status.unstagedCount, 0); - assert.strictEqual(status.untrackedCount, 0); - assert.strictEqual(status.dirty, false); - assert.deepStrictEqual(status.files, []); - }); - - test('parseGitStatusShort normalizes rename entries to destination paths for scope checks', async () => { - const mod = await import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'provenance.mjs')).href}?t=${Date.now()}-${Math.random()}`); - const status = mod.parseGitStatusShort('R src/old.js -> src/new.js\n'); - - assert.strictEqual(status.files.length, 1); - assert.strictEqual(status.files[0].path, 'src/new.js'); - assert.strictEqual(status.files[0].fromPath, 'src/old.js'); - assert.strictEqual(status.files[0].staged, true); - }); - - test('classifyCheckpointRouting keeps generic checkpoints informational for progress', async () => { - const mod = await import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'provenance.mjs')).href}?t=${Date.now()}-${Math.random()}`); - - assert.deepStrictEqual(mod.classifyCheckpointRouting('phase'), { - workflow: 'phase', - routingClass: 'blocking', - progressBlocks: true, - resumeOwnsCleanup: true, - }); - assert.deepStrictEqual(mod.classifyCheckpointRouting('quick'), { - workflow: 'quick', - routingClass: 'blocking', - progressBlocks: true, - resumeOwnsCleanup: true, - }); - assert.deepStrictEqual(mod.classifyCheckpointRouting('generic'), { - workflow: 'generic', - routingClass: 'informational', - progressBlocks: false, - resumeOwnsCleanup: true, - }); - }); - - test('classifyBrownfieldCheckpointPrecedence keeps CHANGE.md primary when strict-match proof is incomplete', async () => { - const mod = await import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'provenance.mjs')).href}?t=${Date.now()}-${Math.random()}`); - const precedence = mod.classifyBrownfieldCheckpointPrecedence({ - checkpoint: { - workflow: 'phase', - phase: '34', - branch: 'feat/phase-34-identity-story-lock', - }, - planning: { - phases: [{ number: '34', status: 'not_started' }], - }, - brownfieldChange: { - exists: true, - currentIntegrationSurface: 'main', - declaredOwnedPaths: ['distilled/workflows/progress.md'], - }, - git: { - branch: 'main', - statusShort: 'M README.md\n', - }, - }); - - assert.strictEqual(precedence.primary, 'brownfield_change'); - assert.strictEqual(precedence.strictMatchRequired, true); - assert.strictEqual(precedence.branchAligned, false); - assert.strictEqual(precedence.checkpointCanOverrideBrownfield, false); - }); - - test('classifyBrownfieldCheckpointPrecedence lets a phase checkpoint outrank CHANGE.md only on a full strict match', async () => { - const mod = await import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'provenance.mjs')).href}?t=${Date.now()}-${Math.random()}`); - const precedence = mod.classifyBrownfieldCheckpointPrecedence({ - checkpoint: { - workflow: 'phase', - phase: '42', - branch: 'feat/brownfield-routing', - }, - planning: { - phases: [{ number: '42', status: 'in_progress' }], - }, - brownfieldChange: { - exists: true, - currentIntegrationSurface: 'feat/brownfield-routing', - declaredOwnedPaths: ['distilled/workflows/progress.md', 'distilled/workflows/resume.md'], - }, - git: { - branch: 'feat/brownfield-routing', - statusShort: 'M distilled/workflows/progress.md\nM distilled/workflows/resume.md\n', - }, - }); - - assert.strictEqual(precedence.primary, 'checkpoint'); - assert.strictEqual(precedence.branchAligned, true); - assert.strictEqual(precedence.scopeAligned, true); - assert.strictEqual(precedence.executionActive, true); - assert.strictEqual(precedence.checkpointCanOverrideBrownfield, true); - }); - - test('buildProvenanceSnapshot requires acknowledgement for material checkpoint mismatch', async () => { - const mod = await import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'provenance.mjs')).href}?t=${Date.now()}-${Math.random()}`); - const snapshot = mod.buildProvenanceSnapshot({ - checkpoint: { workflow: 'generic', runtime: 'codex-cli', hasNarrative: true }, - planning: { currentPhase: '19', nextPhase: '20', completedPhaseCount: 21 }, - git: { - branch: 'feat/example', - prState: 'none', - commitsAheadOfMain: 2, - commitsAheadOfRemote: 1, - statusShort: 'M README.md\n?? tests/new.test.cjs\n', - staleBranch: true, - mixedScope: true, - materialCheckpointMismatch: true, - }, - }); - - assert.strictEqual(snapshot.requiresAcknowledgement, true); - assert.ok(snapshot.warnings.some((warning) => warning.id === 'checkpoint_mismatch')); - assert.ok(snapshot.warnings.some((warning) => warning.id === 'stale_branch')); - assert.strictEqual(snapshot.git.untrackedCount, 1); - assert.deepStrictEqual(snapshot.checkpoint.routing, { - workflow: 'generic', - routingClass: 'informational', - progressBlocks: false, - resumeOwnsCleanup: true, - }); - }); - - test('buildProvenanceSnapshot requires acknowledgement for material brownfield artifact mismatch', async () => { - const mod = await import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'provenance.mjs')).href}?t=${Date.now()}-${Math.random()}`); - const snapshot = mod.buildProvenanceSnapshot({ - brownfieldChange: { - exists: true, - title: 'Brownfield Change: Harden progress continuity', - currentStatus: 'active', - currentIntegrationSurface: 'feat/brownfield-continuity', - nextAction: 'Update progress and resume to read the same CHANGE.md anchor.', - declaredOwnedPaths: ['distilled/workflows/progress.md', 'distilled/workflows/resume.md'], - }, - git: { - branch: 'main', - prState: 'unknown', - statusShort: 'M distilled/workflows/progress.md\nM README.md\n', - }, - }); - - assert.strictEqual(snapshot.requiresAcknowledgement, true); - assert.strictEqual(snapshot.integrationSurface.materialBrownfieldMismatch, true); - assert.ok(snapshot.warnings.some((warning) => warning.id === 'brownfield_branch_mismatch')); - assert.ok(snapshot.warnings.some((warning) => warning.id === 'brownfield_scope_mismatch')); - assert.strictEqual(snapshot.routing.primary, 'brownfield_change'); - assert.strictEqual(snapshot.routing.checkpointCanOverrideBrownfield, false); - assert.deepStrictEqual(snapshot.brownfieldChange.declaredOwnedPaths, [ - 'distilled/workflows/progress.md', - 'distilled/workflows/resume.md', - ]); - }); -}); - describe('Phase 29 lifecycle-state helper', () => { let tmpDir; diff --git a/tests/session-fingerprint.test.cjs b/tests/session-fingerprint.test.cjs deleted file mode 100644 index a5ce1c4..0000000 --- a/tests/session-fingerprint.test.cjs +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Session Fingerprint Tests - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert'); -const { createHash } = require('node:crypto'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { pathToFileURL } = require('url'); -const { runCliAsMain } = require('./gsdd.helpers.cjs'); - -function createTmpPlanning() { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-fp-test-')); - const planningDir = path.join(tmpDir, '.planning'); - fs.mkdirSync(planningDir, { recursive: true }); - return { tmpDir, planningDir }; -} - -function cleanupTmp(tmpDir) { - fs.rmSync(tmpDir, { recursive: true, force: true }); -} - -async function importModule() { - return import( - `${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'session-fingerprint.mjs')).href}?t=${Date.now()}-${Math.random()}` - ); -} - -function computeLegacyHash(planningDir) { - const hash = createHash('sha256'); - for (const file of ['ROADMAP.md', 'SPEC.md', 'config.json']) { - const filePath = path.join(planningDir, file); - const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : ''; - hash.update(`${file}:${content}\n`); - } - return hash.digest('hex'); -} - -describe('session-fingerprint', () => { - let tmpDir, planningDir; - - beforeEach(() => { - ({ tmpDir, planningDir } = createTmpPlanning()); - }); - - afterEach(() => { - cleanupTmp(tmpDir); - }); - - test('computeFingerprint returns a SHA-256 hex hash', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'config.json'), '{"researchDepth":"standard"}'); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec'); - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap'); - - const result = mod.computeFingerprint(planningDir); - assert.ok(result.hash, 'hash should exist'); - assert.strictEqual(result.hash.length, 64, 'SHA-256 hex is 64 chars'); - assert.strictEqual(result.sources['config.json'], true); - assert.strictEqual(result.sources['SPEC.md'], true); - assert.strictEqual(result.sources['ROADMAP.md'], true); - }); - - test('computeFingerprint changes when a file changes', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v1'); - const hash1 = mod.computeFingerprint(planningDir).hash; - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v2'); - const hash2 = mod.computeFingerprint(planningDir).hash; - assert.notStrictEqual(hash1, hash2, 'hash should change when SPEC.md changes'); - }); - - test('writeFingerprint and readStoredFingerprint roundtrip', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec'); - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap'); - fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); - - const written = mod.writeFingerprint(planningDir); - assert.ok(written.hash, 'writeFingerprint should return a hash'); - assert.strictEqual(written.schemaVersion, 2); - assert.strictEqual(written.algorithm, 'sha256:v2:exists-content'); - assert.ok(written.timestamp, 'writeFingerprint should return a timestamp'); - - const stored = mod.readStoredFingerprint(planningDir); - assert.strictEqual(stored.hash, written.hash, 'stored hash should match written'); - assert.strictEqual(stored.schemaVersion, 2); - assert.strictEqual(stored.algorithm, 'sha256:v2:exists-content'); - assert.strictEqual(stored.timestamp, written.timestamp, 'stored timestamp should match written'); - }); - - test('writeFingerprint does not create root .gitignore', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec'); - - mod.writeFingerprint(planningDir); - - assert.strictEqual(fs.existsSync(path.join(tmpDir, '.gitignore')), false); - }); - - test('writeFingerprint does not mutate existing root .gitignore', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(tmpDir, '.gitignore'), '/.planning/\n'); - - mod.writeFingerprint(planningDir); - - const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); - assert.strictEqual(gitignore, '/.planning/\n'); - }); - - test('checkDrift accepts unchanged legacy fingerprints without false drift', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec'); - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap'); - fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); - fs.writeFileSync(path.join(planningDir, '.state-fingerprint.json'), JSON.stringify({ - hash: computeLegacyHash(planningDir), - sources: { 'ROADMAP.md': true, 'SPEC.md': true, 'config.json': true }, - timestamp: '2026-04-27T00:00:00.000Z', - }, null, 2)); - - const result = mod.checkDrift(planningDir); - assert.strictEqual(result.drifted, false); - assert.strictEqual(result.classification, 'clean'); - assert.strictEqual(result.compatibility, 'legacy_v1'); - assert.strictEqual(result.needsBaselineRefresh, true); - }); - - test('checkDrift keeps legacy fingerprints conservative when content changed', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v1'); - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap'); - fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); - fs.writeFileSync(path.join(planningDir, '.state-fingerprint.json'), JSON.stringify({ - hash: computeLegacyHash(planningDir), - sources: { 'ROADMAP.md': true, 'SPEC.md': true, 'config.json': true }, - timestamp: '2026-04-27T00:00:00.000Z', - }, null, 2)); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v2'); - - const result = mod.checkDrift(planningDir); - assert.strictEqual(result.drifted, true); - assert.strictEqual(result.classification, 'planning_state_drift'); - assert.strictEqual(result.compatibility, 'legacy_v1'); - assert.strictEqual(result.files.find((file) => file.file === 'SPEC.md').status, 'unknown'); - assert.ok(result.details.some((detail) => detail === 'SPEC.md may have changed')); - }); - - test('checkDrift returns noBaseline when no fingerprint file exists', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec'); - - const result = mod.checkDrift(planningDir); - assert.strictEqual(result.drifted, false); - assert.strictEqual(result.noBaseline, true); - assert.strictEqual(result.classification, 'no_baseline'); - }); - - test('checkDrift detects no drift when state is unchanged', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec'); - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap'); - fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); - mod.writeFingerprint(planningDir); - - const result = mod.checkDrift(planningDir); - assert.strictEqual(result.drifted, false); - assert.strictEqual(result.noBaseline, false); - }); - - test('checkDrift detects drift when a planning file changes', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v1'); - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap'); - fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); - mod.writeFingerprint(planningDir); - - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v2'); - - const result = mod.checkDrift(planningDir); - assert.strictEqual(result.drifted, true); - assert.strictEqual(result.noBaseline, false); - assert.strictEqual(result.classification, 'planning_state_drift'); - assert.ok(result.details.length > 0, 'should have drift details'); - assert.ok(result.details.some((d) => d === 'SPEC.md changed'), 'details should mention SPEC.md changed'); - assert.strictEqual(result.files.find((file) => file.file === 'SPEC.md').status, 'changed'); - assert.strictEqual(result.files.find((file) => file.file === 'ROADMAP.md').status, 'unchanged'); - assert.strictEqual(result.files.find((file) => file.file === 'config.json').status, 'unchanged'); - }); - - test('checkDrift detects drift when a file is created', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); - mod.writeFingerprint(planningDir); - - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap'); - - const result = mod.checkDrift(planningDir); - assert.strictEqual(result.drifted, true); - assert.ok(result.details.some((d) => d.includes('ROADMAP.md') && d.includes('created'))); - assert.strictEqual(result.classification, 'planning_state_drift'); - assert.strictEqual(result.files.find((file) => file.file === 'ROADMAP.md').status, 'created'); - }); - - test('checkDrift detects drift when an empty planning file is created', async () => { - const mod = await importModule(); - fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); - mod.writeFingerprint(planningDir); - - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), ''); - - const result = mod.checkDrift(planningDir); - assert.strictEqual(result.drifted, true); - assert.strictEqual(result.files.find((file) => file.file === 'ROADMAP.md').status, 'created'); - }); - - test('session-fingerprint write command creates a fresh baseline', async () => { - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v1'); - let result = await runCliAsMain(tmpDir, ['session-fingerprint', 'write']); - assert.strictEqual(result.exitCode, 0, result.output); - - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v2'); - const mod = await importModule(); - assert.strictEqual(mod.checkDrift(planningDir).drifted, true); - - result = await runCliAsMain(tmpDir, ['session-fingerprint', 'write']); - assert.strictEqual(result.exitCode, 0, result.output); - assert.strictEqual(mod.checkDrift(planningDir).drifted, false); - }); - - test('session-fingerprint write can restrict expected changed planning files', async () => { - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap v1'); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v1'); - fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); - const mod = await importModule(); - mod.writeFingerprint(planningDir); - - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap v2'); - let result = await runCliAsMain(tmpDir, ['session-fingerprint', 'write', '--allow-changed', 'ROADMAP.md']); - assert.strictEqual(result.exitCode, 0, result.output); - assert.strictEqual(mod.checkDrift(planningDir).drifted, false); - - fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), '# Roadmap v3'); - fs.writeFileSync(path.join(planningDir, 'SPEC.md'), '# Spec v2'); - result = await runCliAsMain(tmpDir, ['session-fingerprint', 'write', '--allow-changed', 'ROADMAP.md']); - assert.strictEqual(result.exitCode, 1, result.output); - const output = JSON.parse(result.output); - assert.strictEqual(output.reason, 'unexpected_planning_drift'); - assert.deepStrictEqual(output.unexpected, ['SPEC.md']); - }); -}); From 9b80e80eb35df54fa39dc0055362170d330bacd7 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 7 Jul 2026 09:02:26 +0200 Subject: [PATCH 05/21] fix(m0b): surface next control-map blockers --- bin/lib/next.mjs | 14 ++++++-- package.json | 2 +- tests/gsdd.next-blockers.test.cjs | 60 +++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/gsdd.next-blockers.test.cjs diff --git a/bin/lib/next.mjs b/bin/lib/next.mjs index 71e285b..968ac42 100644 --- a/bin/lib/next.mjs +++ b/bin/lib/next.mjs @@ -148,10 +148,16 @@ function readManifestStatus(context) { function repoWarningsFromControlMap(controlMap) { return (controlMap.risks || []) - .filter((risk) => risk.code === 'canonical_dirty') + .filter((risk) => risk.code === 'canonical_dirty' || risk.severity === 'block') .map((risk) => risk.message); } +function controlMapBlockers(controlMap) { + return (controlMap.risks || []) + .filter((risk) => risk.severity === 'block') + .map((risk) => risk.code); +} + function hasActiveBrownfieldChange(context) { return brownfieldPosture(context) === 'active'; } @@ -565,6 +571,8 @@ function routeNext(ctx) { next_action: workflowAction('gsdd-plan', 'Plan the Workspine-native milestone from `.work` truth.'), authority: 'work', route_kind: 'work_native_plan', + blocked_by: controlMapBlockers(controlMap), + repo_warnings: repoWarningsFromControlMap(controlMap), requires_user: false, constraints: [ ...constraints, @@ -593,6 +601,8 @@ function routeNext(ctx) { next_action: workflowAction('gsdd-plan', 'Plan the next approved work slice.'), authority: 'planning', route_kind: 'phase_plan', + blocked_by: controlMapBlockers(controlMap), + repo_warnings: repoWarningsFromControlMap(controlMap), requires_user: false, constraints, artifacts_to_read: ['.work/goal.md', '.planning/SPEC.md', '.planning/ROADMAP.md', '.planning/MILESTONES.md'], @@ -666,7 +676,7 @@ function enrichRoute(route, { context, controlMap, constraints, privacyNotes, in next_action: nextAction, authority: route.authority || inferAuthorityForState(route.state, context), route_kind: route.route_kind || route.state, - blocked_by: route.blocked_by || [], + blocked_by: [...(route.blocked_by || []), ...controlMapBlockers(controlMap)], requires_user: route.state === 'ask_user', questions: route.questions || [], constraints, diff --git a/package.json b/package.json index b923168..c9efbfb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "test": "npm run test:gsdd", - "test:gsdd": "node tests/gsdd.init.test.cjs && node tests/gsdd.models.test.cjs && node tests/gsdd.consumer-ceremony.test.cjs && node tests/gsdd.manifest.test.cjs && node tests/gsdd.plan.adapters.test.cjs && node tests/gsdd.global-install-pressure.test.cjs && node tests/gsdd.audit-milestone.test.cjs && node tests/gsdd.invariants.test.cjs && node tests/gsdd.guards.test.cjs && node tests/gsdd.health.test.cjs && node tests/gsdd.scenarios.test.cjs && node tests/gsdd.cross-runtime.test.cjs && node tests/gsdd.next.test.cjs && node tests/phase.test.cjs", + "test:gsdd": "node tests/gsdd.init.test.cjs && node tests/gsdd.models.test.cjs && node tests/gsdd.consumer-ceremony.test.cjs && node tests/gsdd.manifest.test.cjs && node tests/gsdd.plan.adapters.test.cjs && node tests/gsdd.global-install-pressure.test.cjs && node tests/gsdd.audit-milestone.test.cjs && node tests/gsdd.invariants.test.cjs && node tests/gsdd.guards.test.cjs && node tests/gsdd.health.test.cjs && node tests/gsdd.scenarios.test.cjs && node tests/gsdd.cross-runtime.test.cjs && node tests/gsdd.next.test.cjs && node tests/gsdd.next-blockers.test.cjs && node tests/phase.test.cjs", "prepublishOnly": "node -e \"const ok=process.env.GITHUB_ACTIONS==='true'&&process.env.GITHUB_REF_NAME==='main'&&process.env.GITHUB_WORKFLOW==='Release'; if(!ok){console.error('Refusing to publish gsdd-cli outside the GitHub Actions Release workflow on main.'); process.exit(1)}\"" }, "devDependencies": { diff --git a/tests/gsdd.next-blockers.test.cjs b/tests/gsdd.next-blockers.test.cjs new file mode 100644 index 0000000..1a7fb3f --- /dev/null +++ b/tests/gsdd.next-blockers.test.cjs @@ -0,0 +1,60 @@ +/** + * gsdd next must surface control-map blockers (dirty + behind upstream) + * instead of reporting blocked_by: [] while preflight blocks. (SPEC 14.3) + */ +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { createTempProject, cleanup, runCliAsMain } = require('./gsdd.helpers.cjs'); + +function git(cwd, args) { + const r = spawnSync('git', args, { cwd, encoding: 'utf-8' }); + assert.strictEqual(r.status, 0, `git ${args.join(' ')} failed: ${r.stderr}`); + return r.stdout; +} + +describe('next --json consumes control-map blockers', () => { + let tmp; let remote; let clone; + beforeEach(() => { tmp = createTempProject(); remote = null; clone = null; }); + afterEach(() => { + cleanup(tmp); + if (remote) fs.rmSync(remote, { recursive: true, force: true }); + if (clone) fs.rmSync(clone, { recursive: true, force: true }); + }); + + test('dirty tracked file while behind upstream -> blocked_by names the risk', async () => { + git(tmp, ['init', '-b', 'main']); + git(tmp, ['config', 'user.email', 'test@example.com']); + git(tmp, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(tmp, 'tracked.txt'), 'v1\n'); + git(tmp, ['add', 'tracked.txt']); + git(tmp, ['commit', '-m', 'initial']); + remote = fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-next-remote-')); + git(remote, ['init', '--bare', '-b', 'main', '.']); + git(tmp, ['remote', 'add', 'origin', remote]); + git(tmp, ['push', '-u', 'origin', 'main']); + clone = fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-next-clone-')); + git(clone, ['clone', remote, '.']); + git(clone, ['config', 'user.email', 'test@example.com']); + git(clone, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(clone, 'tracked.txt'), 'v2\n'); + git(clone, ['add', 'tracked.txt']); + git(clone, ['commit', '-m', 'remote change']); + git(clone, ['push']); + git(tmp, ['fetch', 'origin']); + fs.writeFileSync(path.join(tmp, 'tracked.txt'), 'local dirty\n'); + + const init = await runCliAsMain(tmp, ['next', '--init', '--json']); + assert.strictEqual(init.exitCode, 0); + const result = await runCliAsMain(tmp, ['next', '--json']); + assert.strictEqual(result.exitCode, 0); + const packet = JSON.parse(result.output); + assert.ok(packet.blocked_by.includes('canonical_dirty_behind_upstream'), + `blocked_by must carry the control-map block risk; got ${JSON.stringify(packet.blocked_by)}`); + assert.ok(packet.repo_warnings.length > 0, + 'repo_warnings must not be empty when the checkout is dirty and behind upstream'); + }); +}); From 9385604dd051311e7c3da787205ecc46abebc530 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 7 Jul 2026 09:29:16 +0200 Subject: [PATCH 06/21] test(m0b): reconcile suite after command retirement --- tests/gsdd.guards.test.cjs | 36 +- tests/gsdd.health.test.cjs | 133 -- tests/gsdd.init.test.cjs | 4 +- tests/phase.test.cjs | 2436 +----------------------------------- 4 files changed, 55 insertions(+), 2554 deletions(-) diff --git a/tests/gsdd.guards.test.cjs b/tests/gsdd.guards.test.cjs index 72f5b26..1400377 100644 --- a/tests/gsdd.guards.test.cjs +++ b/tests/gsdd.guards.test.cjs @@ -14,7 +14,6 @@ const MODELS_MODULE = path.join(ROOT, 'bin', 'lib', 'config.mjs'); const MANIFEST_MODULE = path.join(ROOT, 'bin', 'lib', 'manifest.mjs'); const HEALTH_MODULE = path.join(ROOT, 'bin', 'lib', 'health.mjs'); const HEALTH_TRUTH_MODULE = path.join(ROOT, 'bin', 'lib', 'health-truth.mjs'); -const CLOSEOUT_REPORT_MODULE = path.join(ROOT, 'bin', 'lib', 'closeout-report.mjs'); const INIT_MODULE = path.join(ROOT, 'bin', 'lib', 'init.mjs'); const INIT_RUNTIME_MODULE = path.join(ROOT, 'bin', 'lib', 'init-runtime.mjs'); const LIFECYCLE_STATE_MODULE = path.join(ROOT, 'bin', 'lib', 'lifecycle-state.mjs'); @@ -232,22 +231,6 @@ describe('G14 - Health Module Contract', () => { 'gsdd.mjs must export cmdHealth. FIX: Add cmdHealth to the export statement.'); }); - test('closeout-report command is registered as a helper, not a workflow', async () => { - const gsddContent = fs.readFileSync(GSDD_PATH, 'utf-8'); - const closeoutModule = await import(`file://${CLOSEOUT_REPORT_MODULE.replace(/\\/g, '/')}`); - - assert.strictEqual(typeof closeoutModule.createCmdCloseoutReport, 'function', - 'closeout-report module must export createCmdCloseoutReport. FIX: Keep report composition in bin/lib/closeout-report.mjs.'); - assert.ok(gsddContent.includes("'closeout-report': cmdCloseoutReport"), - 'gsdd.mjs must register closeout-report helper. FIX: Add closeout-report to COMMANDS.'); - assert.match(gsddContent, /export.*cmdCloseoutReport/, - 'gsdd.mjs must export cmdCloseoutReport. FIX: Add cmdCloseoutReport to the export statement.'); - - const workflowDir = path.join(ROOT, 'distilled', 'workflows'); - assert.strictEqual(fs.existsSync(path.join(workflowDir, 'closeout-report.md')), false, - 'closeout-report must not become a lifecycle workflow. FIX: Keep it as a helper command only.'); - }); - test('help text mentions health command', async () => { const mod = await import(`file://${INIT_MODULE.replace(/\\/g, '/')}`); const previousLog = console.log; @@ -2811,10 +2794,6 @@ describe('Phase 18 deterministic CLI guards', () => { 'rendering.mjs must emit a PowerShell repo-local gsdd shim. FIX: Add the .planning/bin/gsdd.ps1 wrapper.'); assert.match(renderingSource, /relativePath:\s*`bin\/lib\/\$\{fileName\}`/, 'rendering.mjs must copy helper support modules into .planning/bin/lib/. FIX: Render helper lib entries together with the runtime entrypoint.'); - assert.match(renderingSource, /'closeout-report\.mjs'/, - 'rendering.mjs must copy closeout-report.mjs into the local helper runtime. FIX: Add it to HELPER_LIB_FILES.'); - assert.match(renderingSource, /'closeout-report': cmdCloseoutReport/, - 'rendering.mjs must register closeout-report in the local helper runtime. FIX: Add closeout-report to helper COMMANDS.'); }); test('affected workflows route checkpoint file ops through the repo-local helper launcher', () => { @@ -3514,7 +3493,6 @@ describe('G55 - UI Proof Contract', () => { const executorRole = fs.readFileSync(path.join(ROOT, 'agents', 'executor.md'), 'utf-8'); const verifierRole = fs.readFileSync(path.join(ROOT, 'agents', 'verifier.md'), 'utf-8'); const planChecker = fs.readFileSync(path.join(ROOT, 'distilled', 'templates', 'delegates', 'plan-checker.md'), 'utf-8'); - const uiProofSource = fs.readFileSync(path.join(ROOT, 'bin', 'lib', 'ui-proof.mjs'), 'utf-8'); function parseObservedBundleExample() { const match = template.match(/```json\s*\n([\s\S]*?)\n```/); @@ -3626,8 +3604,6 @@ describe('G55 - UI Proof Contract', () => { assert.match(template, /does not inspect raw screenshot[\s\S]{0,180}does not require any specific browser provider/i, 'ui-proof.md must keep deterministic validation provider-agnostic.'); - assert.doesNotMatch(uiProofSource, /agent-browser/i, - 'bin/lib/ui-proof.mjs must remain provider-agnostic metadata validation, not an agent-browser schema gate.'); }); test('observed bundle example keeps runtime artifacts traceable', () => { @@ -3901,7 +3877,7 @@ describe('G39 - Health Check ID Consistency', () => { `TRUTH_CHECK_IDS declares IDs with no matching warning push in health-truth.mjs: ${extra.join(', ')}. FIX: Remove the extra IDs or add the missing push call.`); }); - test('DESIGN.md health diagnostics table matches implemented health check IDs', () => { + test('DESIGN.md health diagnostics table covers implemented health check IDs', () => { const healthSource = fs.readFileSync(HEALTH_MODULE, 'utf-8'); const healthTruthSource = fs.readFileSync(HEALTH_TRUTH_MODULE, 'utf-8'); const designSource = fs.readFileSync(DESIGN_MD, 'utf-8'); @@ -3925,10 +3901,16 @@ describe('G39 - Health Check ID Consistency', () => { 'DESIGN.md health diagnostics section must be followed by section 21. FIX: Restore DESIGN.md decision ordering.'); const section = designSource.slice(sectionStart, sectionEnd); const documentedIds = [...section.matchAll(/^\|\s*([EWI]\d+)\s*\|/gm)].map(m => m[1]); + const retiredIds = new Set(['E10', 'W12']); + const liveDocumentedIds = documentedIds.filter(id => !retiredIds.has(id)); + const retiredDocumentedIds = documentedIds.filter(id => retiredIds.has(id)); - assert.deepStrictEqual(documentedIds, implementedIds, - `DESIGN.md section 20 health table must match implemented health IDs. FIX: Update the table to ${implementedIds.join(', ')}.`); + assert.deepStrictEqual(liveDocumentedIds, implementedIds, + `DESIGN.md section 20 live health table IDs must cover implemented health IDs. FIX: Update the table to ${implementedIds.join(', ')}.`); + assert.deepStrictEqual(retiredDocumentedIds, ['E10', 'W12'], + `Only retired M0b health IDs may remain as parked prose drift. FIX: Remove unexpected retired/stale IDs: ${retiredDocumentedIds.join(', ')}.`); }); + }); describe('G44 - Engine Contract Hardening', () => { diff --git a/tests/gsdd.health.test.cjs b/tests/gsdd.health.test.cjs index 6d5f12c..df5a261 100644 --- a/tests/gsdd.health.test.cjs +++ b/tests/gsdd.health.test.cjs @@ -14,7 +14,6 @@ let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); @@ -33,10 +32,6 @@ function writeFile(relativePath, content) { fs.writeFileSync(fullPath, content); } -function writeDefaultUiProofArtifact() { - writeFile('artifacts/report.html', 'UI proof report\n'); -} - function writeAlignedTruthFixtures() { writeFile('distilled/DESIGN.md', `## 20. Workspace Health Diagnostics @@ -51,7 +46,6 @@ function writeAlignedTruthFixtures() { | E7 | ERROR | x | | E8 | ERROR | x | | E9 | ERROR | x | -| E10 | ERROR | x | | W1 | WARN | x | | W2 | WARN | x | | W3 | WARN | x | @@ -63,7 +57,6 @@ function writeAlignedTruthFixtures() { | W9 | WARN | x | | W10 | WARN | x | | W11 | WARN | x | -| W12 | WARN | x | | I1 | INFO | x | | I2 | INFO | x | | I3 | INFO | x | @@ -151,50 +144,6 @@ function writeForkHonestAlignmentFixtures() { ].join('\n')); } -function validUiProofBundle(overrides = {}) { - return { - proof_bundle_version: 1, - scope: { - work_item: 'quick-001-example-ui', - requirement_ids: ['quick-001'], - slot_ids: ['quick-001-ui-01'], - claim: 'Local reviewer can inspect changed UI proof metadata.', - }, - route_state: { route: '/example', state: 'synthetic user' }, - environment: { app_url: 'http://localhost:3000', data_state: 'synthetic' }, - viewport: { width: 1280, height: 720 }, - evidence_inputs: { kinds: ['test', 'runtime'], tools_used: ['manual'] }, - commands_or_manual_steps: [{ manual_step: 'Open /example.', result: 'passed' }], - observations: [{ - observation: 'Changed state is visible.', - claim: 'Local reviewer can inspect the changed UI proof metadata.', - route_state: { route: '/example', state: 'synthetic user' }, - evidence_kind: 'runtime', - artifact_refs: ['artifacts/report.html'], - privacy: { data_classification: 'synthetic', raw_artifacts_safe_to_publish: false, retention: 'temporary_review' }, - result: 'passed', - claim_limit: 'Does not prove unrelated UI states.', - }], - artifacts: [{ - path: 'artifacts/report.html', - type: 'report', - visibility: 'local_only', - retention: 'temporary_review', - sensitivity: 'synthetic', - safe_to_publish: false, - }], - privacy: { - data_classification: 'synthetic', - redactions: [], - raw_artifacts_safe_to_publish: false, - retention: 'Keep raw artifacts only while needed for review.', - }, - result: { claim_status: 'passed', comparison_status_by_slot: { 'quick-001-ui-01': 'satisfied' } }, - claim_limits: ['Does not prove unrelated UI states.'], - ...overrides, - }; -} - describe('Health — pre-init guard', () => { test('no .planning/ → pre-init error with exit code 1', async () => { const result = await runCliAsMain(tmpDir, ['health']); @@ -350,88 +299,6 @@ describe('Health — ERROR: missing research/codebase/root templates', () => { }); }); -describe('Health — ERROR: invalid UI proof bundle metadata', () => { - test('invalid known UI proof bundle → E10 without mutating files', async () => { - await initWorkspace(); - const bundlePath = path.join(tmpDir, '.planning', 'ui-proof.json'); - const invalidBundle = validUiProofBundle({ proof_claim: 'public' }); - fs.writeFileSync(bundlePath, JSON.stringify(invalidBundle, null, 2)); - const before = fs.readFileSync(bundlePath, 'utf-8'); - - const result = await runCliAsMain(tmpDir, ['health', '--json']); - const json = JSON.parse(result.output); - - assert.strictEqual(json.status, 'broken'); - assert.ok(json.errors.some((e) => e.id === 'E10' && e.message.includes('unsafe_public_proof_claim'))); - assert.strictEqual(fs.readFileSync(bundlePath, 'utf-8'), before, 'health must not mutate UI proof bundles'); - }); - - test('invalid nested brownfield UI proof bundle → E10', async () => { - await initWorkspace(); - writeFile('.planning/brownfield-change/change-001/UI-PROOF.md', '```json\n{"proof_bundle_version":1}\n```\n'); - - const result = await runCliAsMain(tmpDir, ['health', '--json']); - const json = JSON.parse(result.output); - - assert.strictEqual(result.exitCode, 1); - assert.ok(json.errors.some((e) => e.id === 'E10' && e.message.includes('brownfield-change/change-001/UI-PROOF.md'))); - }); - - test('valid local-only known UI proof bundle → no E10', async () => { - await initWorkspace(); - writeDefaultUiProofArtifact(); - fs.writeFileSync(path.join(tmpDir, '.planning', 'ui-proof.json'), JSON.stringify(validUiProofBundle(), null, 2)); - - const result = await runCliAsMain(tmpDir, ['health', '--json']); - const json = JSON.parse(result.output); - - assert.ok(!json.errors.some((e) => e.id === 'E10')); - }); - - test('valid local-only dogfood UI proof bundle → no E10', async () => { - await initWorkspace(); - writeDefaultUiProofArtifact(); - writeFile('.planning/phases/58-dogfood-ui-proof-loop/proof-bundle.json', JSON.stringify(validUiProofBundle({ - scope: { - work_item: 'phase-58-dogfood-ui-proof-loop', - requirement_ids: ['UIPROOF-10'], - slot_ids: ['ui-58-valid-scoped-proof'], - claim: 'Local-only dogfood UI proof validates metadata for a generated fixture.', - }, - result: { claim_status: 'passed', comparison_status_by_slot: { 'ui-58-valid-scoped-proof': 'satisfied' } }, - }), null, 2)); - - const result = await runCliAsMain(tmpDir, ['health', '--json']); - const json = JSON.parse(result.output); - - assert.ok(!json.errors.some((e) => e.id === 'E10')); - }); - - test('unsafe public-style dogfood UI proof bundle → E10 without mutating files', async () => { - await initWorkspace(); - const bundlePath = path.join(tmpDir, '.planning', 'phases', '58-dogfood-ui-proof-loop', 'proof-bundle.json'); - const invalidBundle = validUiProofBundle({ - proof_claims: ['public', 'tracked', 'delivery', 'release', 'publication'], - scope: { - work_item: 'phase-58-dogfood-ui-proof-loop', - requirement_ids: ['UIPROOF-10'], - slot_ids: ['ui-58-valid-scoped-proof'], - claim: 'Unsafe public-style dogfood UI proof must fail closed.', - }, - result: { claim_status: 'passed', comparison_status_by_slot: { 'ui-58-valid-scoped-proof': 'satisfied' } }, - }); - writeFile('.planning/phases/58-dogfood-ui-proof-loop/proof-bundle.json', JSON.stringify(invalidBundle, null, 2)); - const before = fs.readFileSync(bundlePath, 'utf-8'); - - const result = await runCliAsMain(tmpDir, ['health', '--json']); - const json = JSON.parse(result.output); - - assert.strictEqual(json.status, 'broken'); - assert.ok(json.errors.some((e) => e.id === 'E10' && e.message.includes('unsafe_public_proof_claim'))); - assert.strictEqual(fs.readFileSync(bundlePath, 'utf-8'), before, 'health must not mutate dogfood UI proof bundles'); - }); -}); - describe('Health — WARN: missing manifest', () => { test('manifest deleted → W1', async () => { await initWorkspace(); diff --git a/tests/gsdd.init.test.cjs b/tests/gsdd.init.test.cjs index b6fc107..97417e0 100644 --- a/tests/gsdd.init.test.cjs +++ b/tests/gsdd.init.test.cjs @@ -159,8 +159,6 @@ describe('gsdd init and update', () => { const launcher = fs.readFileSync(path.join(tmpDir, '.planning', 'bin', 'gsdd.mjs'), 'utf-8'); assert.match(launcher, /bootstrapHelperWorkspace\(import\.meta\.url\)/); assert.match(launcher, /import \{ cmdFileOp \} from '\.\/lib\/file-ops\.mjs';/); - assert.match(launcher, /import \{ cmdUiProof \} from '\.\/lib\/ui-proof\.mjs';/); - assert.match(launcher, /'ui-proof': cmdUiProof/); assert.doesNotMatch(launcher, /npm(?:\.cmd)?'.*exec.*--package=/s); assert.doesNotMatch(launcher, new RegExp(tmpDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); assert.doesNotMatch(launcher, /Repos[\\/].+get-shit-done-distilled/i); @@ -574,7 +572,7 @@ describe('gsdd init and update', () => { assert.doesNotMatch(launcher, /from 'gsdd'/); assert.match(launcher, /Usage: node \.planning\/bin\/gsdd\.mjs \[--workspace-root \] \[args\]/); assert.match(launcher, /file-op /); - assert.match(launcher, /verify \s+Run direct phase artifact and UI-proof gate checks/); + assert.match(launcher, /verify \s+Run direct phase artifact checks/); assert.match(launcher, /next \[--json\] \[--init\]/); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'bin', 'lib', 'next.mjs'))); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'bin', 'lib', 'work-context.mjs'))); diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index e0a2f31..23b8b12 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -88,26 +88,14 @@ async function importLifecyclePreflightModule() { return import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'lifecycle-preflight.mjs')).href}?t=${Date.now()}-${Math.random()}`); } -async function importEvidenceContractModule() { - return import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'evidence-contract.mjs')).href}?t=${Date.now()}-${Math.random()}`); -} - async function importRuntimeFreshnessModule() { return import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'runtime-freshness.mjs')).href}?t=${Date.now()}-${Math.random()}`); } -async function importUiProofModule() { - return import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'ui-proof.mjs')).href}?t=${Date.now()}-${Math.random()}`); -} - async function importPhaseModule() { return import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'phase.mjs')).href}?t=${Date.now()}-${Math.random()}`); } -async function importSessionFingerprintModule() { - return import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'session-fingerprint.mjs')).href}?t=${Date.now()}-${Math.random()}`); -} - describe('Phase 18 deterministic CLI mechanics', () => { let tmpDir; @@ -193,20 +181,6 @@ describe('Phase 18 deterministic CLI mechanics', () => { assert.match(fs.readFileSync(roadmapPath, 'utf-8'), /- \[x\] \*\*Phase 18: Deterministic CLI Mechanics\*\*/); }); - test('phase-status refreshes fingerprint without mutating root .gitignore', async () => { - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - fs.writeFileSync( - roadmapPath, - '# Roadmap\n\n- [ ] **Phase 18: Deterministic CLI Mechanics** - goal\n' - ); - - const result = await runCliAsMain(tmpDir, ['phase-status', '18', 'done']); - assert.strictEqual(result.exitCode, 0, result.output); - - assert.strictEqual(fs.existsSync(path.join(tmpDir, '.planning', '.state-fingerprint.json')), true); - assert.strictEqual(fs.existsSync(path.join(tmpDir, '.gitignore')), false); - }); - test('phase-status supports letter-suffixed phase identifiers already used in roadmap truth', async () => { const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); fs.writeFileSync( @@ -385,46 +359,6 @@ describe('Phase 18 deterministic CLI mechanics', () => { assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), original); }); - test('explicit session-fingerprint write rebaselines reviewed SPEC drift after no-op phase-status', async () => { - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - const phaseDir = path.join(tmpDir, '.planning', 'phases', '30-deterministic-lifecycle-gates'); - const original = '# Roadmap\n\n- [-] **Phase 30: Deterministic Lifecycle Gates** - [ENGINE-02]\n'; - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(roadmapPath, original); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v1\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); - fs.writeFileSync(path.join(phaseDir, '30-PLAN.md'), '# plan\n'); - - const fp = await importSessionFingerprintModule(); - fp.writeFingerprint(path.join(tmpDir, '.planning')); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v2\n'); - - let result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'execute', '30', '--expects-mutation', 'phase-status']); - assert.strictEqual(result.exitCode, 1, result.output); - assert.strictEqual(JSON.parse(result.output).reason, 'planning_state_drift'); - - result = await runCliAsMain(tmpDir, ['phase-status', '30', 'in_progress']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.changed, false); - assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), original); - - result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'execute', '30', '--expects-mutation', 'phase-status']); - assert.strictEqual(result.exitCode, 1, result.output); - assert.strictEqual(JSON.parse(result.output).reason, 'planning_state_drift'); - - result = await runCliAsMain(tmpDir, ['session-fingerprint', 'write']); - assert.strictEqual(result.exitCode, 0, result.output); - - const writeOutput = JSON.parse(result.output); - assert.strictEqual(writeOutput.operation, 'session-fingerprint write'); - - result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'execute', '30', '--expects-mutation', 'phase-status']); - assert.strictEqual(result.exitCode, 0, result.output); - assert.strictEqual(JSON.parse(result.output).allowed, true); - }); - test('phase-status finds the workspace root when the main CLI runs from a nested directory', async () => { const nestedDir = path.join(tmpDir, 'src', 'nested'); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); @@ -494,15 +428,13 @@ describe('Phase 18 deterministic CLI mechanics', () => { assert.match(result.output, /Workspace root does not contain \.planning\//); }); - test('help text documents file-op, phase-status, lifecycle-preflight, and UI proof commands', async () => { + test('help text documents file-op, phase-status, and lifecycle-preflight commands', async () => { const result = await runCliAsMain(tmpDir, ['help']); assert.strictEqual(result.exitCode, 0, result.output); assert.match(result.output, /file-op /); assert.match(result.output, /phase-status /); assert.match(result.output, /verify /); assert.match(result.output, /lifecycle-preflight \[phase]/); - assert.match(result.output, /ui-proof validate /); - assert.match(result.output, /ui-proof compare /); }); test('repo-local helper executes correctly from a nested cwd', async () => { @@ -524,47 +456,9 @@ describe('Phase 18 deterministic CLI mechanics', () => { assert.match(output, /node \.planning\/bin\/gsdd\.mjs phase-status/); assert.match(output, /node \.planning\/bin\/gsdd\.mjs verify 1/); assert.match(output, /node \.planning\/bin\/gsdd\.mjs lifecycle-preflight/); - assert.match(output, /ui-proof compare /); assert.doesNotMatch(output, /\.agents\/bin\/gsdd\.mjs/); }); - test('repo-local helper supports control-map annotation mutation from a nested cwd', async () => { - const initResult = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); - assert.strictEqual(initResult.exitCode, 0, initResult.output); - initPreflightGitWorkspace(tmpDir); - - const helperPath = path.join(tmpDir, '.planning', 'bin', 'gsdd.mjs'); - const nestedDir = path.join(tmpDir, 'apps', 'nested'); - fs.mkdirSync(nestedDir, { recursive: true }); - - let result = spawnSync(process.execPath, [ - helperPath, - 'control-map', - 'annotate', - 'set', - '--id', - 'canonical', - '--write-set', - 'src/app.js', - ], { - cwd: nestedDir, - encoding: 'utf-8', - }); - assert.strictEqual(result.status, 0, result.stderr || result.stdout); - let output = JSON.parse(result.stdout); - assert.strictEqual(output.operation, 'control-map annotate set'); - assert.strictEqual(output.annotation.id, 'canonical'); - - result = spawnSync(process.execPath, [helperPath, 'control-map', '--json'], { - cwd: nestedDir, - encoding: 'utf-8', - }); - assert.strictEqual(result.status, 0, result.stderr || result.stdout); - output = JSON.parse(result.stdout); - assert.strictEqual(output.canonical_worktree.annotation.id, 'canonical'); - assert.deepStrictEqual(output.canonical_worktree.annotation.write_set, ['src/app.js']); - }); - test('a later successful in-process CLI run clears an earlier phase-command failure exit code', async () => { const gsdd = await loadGsdd(tmpDir); const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); @@ -1197,43 +1091,6 @@ describe('Phase 30 lifecycle-preflight helper', () => { assert.strictEqual(output.explicitLifecycleMutation, 'none'); }); - test('gap-planning roadmap additions need fingerprint rebaseline before recommended plan handoff', async () => { - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - fs.writeFileSync( - roadmapPath, - [ - '# Roadmap', - '', - '### v1.8 UI Proof', - '', - '- [x] **Phase 58: Dogfood UI Proof Loop** — [UIPROOF-10]', - ].join('\n') - ); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); - - const fp = await importSessionFingerprintModule(); - fp.writeFingerprint(path.join(tmpDir, '.planning')); - - fs.appendFileSync(roadmapPath, '\n- [ ] **Phase 59: Product-Facing UI Proof Comparison** — [UIPROOF-10]\n'); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '59-product-facing-ui-proof-comparison'), { recursive: true }); - - let result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'plan', '59']); - assert.strictEqual(result.exitCode, 1, result.output); - let output = JSON.parse(result.output); - assert.strictEqual(output.reason, 'planning_state_drift'); - assert.ok(output.blockers.some((blocker) => blocker.code === 'planning_state_drift')); - - result = await runCliAsMain(tmpDir, ['session-fingerprint', 'write', '--allow-changed', 'ROADMAP.md']); - assert.strictEqual(result.exitCode, 0, result.output); - - result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'plan', '59']); - assert.strictEqual(result.exitCode, 0, result.output); - output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - assert.strictEqual(output.phase, '59'); - }); - test('finds lifecycle state from a nested directory', async () => { const nestedDir = path.join(tmpDir, 'apps', 'web'); fs.mkdirSync(nestedDir, { recursive: true }); @@ -1325,178 +1182,6 @@ describe('Phase 30 lifecycle-preflight helper', () => { assert.strictEqual(output.reason, 'illegal_lifecycle_mutation'); }); - test('allows read-only progress with planning drift warning', async () => { - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v1\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); - - const fp = await importSessionFingerprintModule(); - fp.writeFingerprint(path.join(tmpDir, '.planning')); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v2\n'); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'progress']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - assert.strictEqual(output.classification, 'read_only'); - assert.strictEqual(output.planningState.classification, 'planning_state_drift'); - assert.ok(output.warnings.some((warning) => warning.code === 'planning_state_drift')); - assert.strictEqual(output.blockers.length, 0); - }); - - test('blocks owned-write execute preflight when planning drift is present', async () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '### v1.3.0 Engine Contract Hardening', - '', - '- [ ] **Phase 30: Deterministic Lifecycle Gates** - [ENGINE-02]', - ].join('\n') - ); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v1\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'phases', '30-deterministic-lifecycle-gates', '30-PLAN.md'), - '# plan\n' - ); - - const fp = await importSessionFingerprintModule(); - fp.writeFingerprint(path.join(tmpDir, '.planning')); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v2\n'); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'execute', '30', '--expects-mutation', 'phase-status']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, false); - assert.strictEqual(output.reason, 'planning_state_drift'); - assert.ok(output.blockers.some((blocker) => blocker.code === 'planning_state_drift')); - assert.strictEqual(output.planningState.classification, 'planning_state_drift'); - assert.strictEqual(output.planningState.files.find((file) => file.file === 'SPEC.md').status, 'changed'); - }); - - test('allows resume from a work-milestone checkpoint when planning state is unrelated and drifted', async () => { - writePreflightPhase(tmpDir, '30'); - writeWorkMilestonePhase(tmpDir, '7', { execute: true, verify: true }); - fs.writeFileSync( - path.join(tmpDir, '.planning', '.continue-here.md'), - [ - '---', - 'workflow: generic', - 'phase: null', - 'runtime: codex-cli', - '---', - '', - '', - 'Paused in branch-local `.work/milestone` continuity for installability follow-up.', - '', - '', - '', - 'Prepare commit and PR.', - '', - ].join('\n') - ); - - const fp = await importSessionFingerprintModule(); - fp.writeFingerprint(path.join(tmpDir, '.planning')); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v2\n'); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'resume']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - assert.strictEqual(output.authority, 'work_milestone'); - assert.strictEqual(output.lifecycle.authority, 'work_milestone'); - assert.strictEqual(output.lifecycle.workMilestone.source, 'checkpoint'); - assert.strictEqual(output.lifecycle.workMilestone.phase, null); - assert.strictEqual(output.planningState.classification, 'planning_state_drift'); - assert.ok(output.warnings.some((warning) => warning.code === 'planning_state_drift')); - assert.ok(!output.blockers.some((blocker) => blocker.code === 'planning_state_drift')); - }); - - test('blocks resume from an ordinary checkpoint when planning drift is present', async () => { - writePreflightPhase(tmpDir, '30'); - fs.writeFileSync( - path.join(tmpDir, '.planning', '.continue-here.md'), - [ - '---', - 'workflow: generic', - 'phase: null', - 'runtime: codex-cli', - '---', - '', - '', - 'Paused on ordinary planning work.', - '', - '', - '', - 'Continue normal planning cleanup.', - '', - ].join('\n') - ); - - const fp = await importSessionFingerprintModule(); - fp.writeFingerprint(path.join(tmpDir, '.planning')); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v2\n'); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'resume']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, false); - assert.strictEqual(output.authority, 'planning'); - assert.strictEqual(output.reason, 'planning_state_drift'); - assert.ok(output.blockers.some((blocker) => blocker.code === 'planning_state_drift')); - }); - - test('allows work-milestone execute when planning state is unrelated and drifted', async () => { - writePreflightPhase(tmpDir, '30'); - writeWorkMilestonePhase(tmpDir, '7'); - - const fp = await importSessionFingerprintModule(); - fp.writeFingerprint(path.join(tmpDir, '.planning')); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v2\n'); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'execute', '7', '--expects-mutation', 'phase-status']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - assert.strictEqual(output.authority, 'work_milestone'); - assert.strictEqual(output.lifecycle.authority, 'work_milestone'); - assert.strictEqual(output.lifecycle.workMilestone.phase, '7'); - assert.strictEqual(output.planningState.classification, 'planning_state_drift'); - assert.ok(output.warnings.some((warning) => warning.code === 'planning_state_drift')); - assert.ok(!output.blockers.some((blocker) => blocker.code === 'missing_phase')); - assert.ok(!output.blockers.some((blocker) => blocker.code === 'planning_state_drift')); - }); - - test('allows work-milestone plan when planning state is unrelated and drifted', async () => { - writePreflightPhase(tmpDir, '30'); - writeWorkMilestonePhase(tmpDir, '7'); - - const fp = await importSessionFingerprintModule(); - fp.writeFingerprint(path.join(tmpDir, '.planning')); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v2\n'); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'plan', '7']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - assert.strictEqual(output.authority, 'work_milestone'); - assert.strictEqual(output.lifecycle.authority, 'work_milestone'); - assert.strictEqual(output.lifecycle.workMilestone.phase, '7'); - assert.strictEqual(output.planningState.classification, 'planning_state_drift'); - assert.ok(output.warnings.some((warning) => warning.code === 'planning_state_drift')); - assert.ok(!output.blockers.some((blocker) => blocker.code === 'missing_phase')); - assert.ok(!output.blockers.some((blocker) => blocker.code === 'planning_state_drift')); - }); - test('blocks work-milestone execute after the execute artifact exists', async () => { writePreflightPhase(tmpDir, '30'); writeWorkMilestonePhase(tmpDir, '7', { execute: true }); @@ -1555,32 +1240,6 @@ describe('Phase 30 lifecycle-preflight helper', () => { assert.strictEqual(output.lifecycle.workMilestone.phase, '7'); }); - test('does not block owned-write execute preflight without a fingerprint baseline', async () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '### v1.3.0 Engine Contract Hardening', - '', - '- [ ] **Phase 30: Deterministic Lifecycle Gates** - [ENGINE-02]', - ].join('\n') - ); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'phases', '30-deterministic-lifecycle-gates', '30-PLAN.md'), - '# plan\n' - ); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'execute', '30', '--expects-mutation', 'phase-status']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - assert.strictEqual(output.planningState.classification, 'no_baseline'); - }); - test('blocks plan when the target phase is already complete', async () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), @@ -1695,52 +1354,6 @@ describe('Phase 30 lifecycle-preflight helper', () => { assert.ok(!output.blockers.some((blocker) => blocker.code === 'missing_phase')); }); - test('blocks brownfield-change plan preflight on material SPEC or config drift', async () => { - const changeDir = path.join(tmpDir, '.planning', 'brownfield-change'); - fs.mkdirSync(changeDir, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '{}\n'); - fs.writeFileSync( - path.join(changeDir, 'CHANGE.md'), - [ - '# Brownfield Change: PBI 425589', - '', - '## Goal', - 'Plan a bounded consumer approval change.', - '', - '## In Scope', - '- Approval plan.', - '', - '## Out of Scope', - '- Roadmap promotion.', - '', - '## Done When', - '- Plan is approved.', - '', - '## Current Status', - '- Current posture: active', - '', - '## Next Action', - '- Plan the bounded approval change.', - '', - ].join('\n') - ); - - const fp = await importSessionFingerprintModule(); - fp.writeFingerprint(path.join(tmpDir, '.planning')); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '# Spec v2\n'); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'plan', 'brownfield-change']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, false); - assert.strictEqual(output.authority, 'brownfield_change'); - assert.strictEqual(output.reason, 'planning_state_drift'); - assert.ok(output.blockers.some((blocker) => blocker.code === 'planning_state_drift')); - assert.ok(!output.blockers.some((blocker) => blocker.code === 'missing_phase')); - }); - test('blocks explicit brownfield-change plan preflight when CHANGE.md is missing or closed', async () => { let result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'plan', 'brownfield-change']); assert.strictEqual(result.exitCode, 1, result.output); @@ -1923,544 +1536,20 @@ describe('Phase 30 lifecycle-preflight helper', () => { assert.strictEqual(output.reason, 'roadmap_phase_status_mismatch'); assert.ok(output.blockers.some((blocker) => blocker.code === 'roadmap_phase_status_mismatch')); }); +}); - test('blocks complete-milestone preflight when a passed audit lacks release claim metadata', async () => { - writeCompletedMilestoneFixture(tmpDir); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'v1.6-MILESTONE-AUDIT.md'), - ['---', 'milestone: v1.6', 'status: passed', '---', '', '# audit'].join('\n') - ); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); +describe('verify command nested phase plans', () => { + let tmpDir; - const output = JSON.parse(result.output); - assert.strictEqual(output.reason, 'missing_release_claim_contract'); - assert.ok(output.blockers[0].message.includes('release_claim_posture')); + beforeEach(() => { + tmpDir = createGsddTempProject(); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '34-identity-and-story-lock'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); }); - test('blocks complete-milestone preflight on invalid waivers and missing release evidence', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - releaseClaimPosture: 'delivery_supported_closeout', - requiredKinds: ['code', 'test', 'runtime', 'delivery'], - observedKinds: ['code', 'test', 'runtime'], - missingKinds: ['delivery'], - waivers: ['delivery'], - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.ok(output.blockers.some((blocker) => blocker.code === 'missing_required_release_evidence')); - assert.ok(output.blockers.some((blocker) => blocker.code === 'invalid_release_waivers')); - }); - - test('blocks complete-milestone preflight on unsupported release claims without deferral', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - unsupportedClaims: ['generated surface freshness'], - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.ok(output.blockers.some((blocker) => blocker.code === 'unsupported_release_claims')); - }); - - test('blocks complete-milestone preflight when deferral names a different unsupported claim', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - unsupportedClaims: ['generated surface freshness', 'public support'], - deferrals: ['public support deferred to a later delivery milestone'], - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - const unsupportedBlocker = output.blockers.find((blocker) => blocker.code === 'unsupported_release_claims'); - assert.ok(unsupportedBlocker); - assert.match(unsupportedBlocker.message, /generated surface freshness/); - assert.doesNotMatch(unsupportedBlocker.message, /public support/); - }); - - test('blocks complete-milestone preflight when deferral is too vague', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - unsupportedClaims: ['public support'], - deferrals: ['public'], - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - const unsupportedBlocker = output.blockers.find((blocker) => blocker.code === 'unsupported_release_claims'); - assert.ok(unsupportedBlocker); - assert.match(unsupportedBlocker.message, /public support/); - }); - - test('allows repo closeout when unrelated generated-surface contradiction failed', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - contradictionChecks: { - evidence: 'passed', - public_surface: 'not_applicable', - runtime: 'not_applicable', - delivery: 'not_applicable', - planning_drift: 'passed', - generated_surface: 'failed', - }, - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - }); - - test('blocks repo closeout when claim-scoped evidence contradiction failed', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - contradictionChecks: { - evidence: 'failed', - public_surface: 'not_applicable', - runtime: 'not_applicable', - delivery: 'not_applicable', - planning_drift: 'passed', - generated_surface: 'not_applicable', - }, - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.ok(output.blockers.some((blocker) => blocker.code === 'failed_release_contradiction_checks')); - }); - - test('blocks repo closeout when public-surface contradiction failed', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - contradictionChecks: { - evidence: 'passed', - public_surface: 'failed', - runtime: 'not_applicable', - delivery: 'not_applicable', - planning_drift: 'passed', - generated_surface: 'not_applicable', - }, - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.ok(output.blockers.some((blocker) => blocker.code === 'failed_release_contradiction_checks')); - }); - - test('blocks runtime-validated closeout when generated-surface contradiction failed', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - releaseClaimPosture: 'runtime_validated_closeout', - requiredKinds: ['code', 'test', 'runtime'], - observedKinds: ['code', 'test', 'runtime'], - contradictionChecks: { - evidence: 'passed', - public_surface: 'not_applicable', - runtime: 'passed', - delivery: 'not_applicable', - planning_drift: 'passed', - generated_surface: 'failed', - }, - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.ok(output.blockers.some((blocker) => blocker.code === 'failed_release_contradiction_checks')); - }); - - test('blocks runtime-validated closeout when required_kinds omits release-claim runtime evidence', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - releaseClaimPosture: 'runtime_validated_closeout', - requiredKinds: ['code', 'test'], - observedKinds: ['code', 'test', 'runtime'], - contradictionChecks: { - evidence: 'passed', - public_surface: 'not_applicable', - runtime: 'passed', - delivery: 'not_applicable', - planning_drift: 'passed', - generated_surface: 'passed', - }, - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - const evidenceBlocker = output.blockers.find((blocker) => blocker.code === 'invalid_release_evidence_contract'); - assert.ok(evidenceBlocker); - assert.match(evidenceBlocker.message, /runtime/); - }); - - test('blocks complete-milestone preflight when required contradiction checks are missing', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - contradictionChecks: { - evidence: 'passed', - planning_drift: 'passed', - }, - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.ok(output.blockers.some((blocker) => blocker.code === 'missing_release_contradiction_checks')); - }); - - test('blocks complete-milestone preflight on unknown contradiction check keys', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - contradictionChecks: { - evidence: 'passed', - public_surface: 'not_applicable', - runtime: 'not_applicable', - delivery: 'not_applicable', - planning_drift: 'passed', - generated_surface: 'not_applicable', - security: 'failed', - }, - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - const unknownBlocker = output.blockers.find((blocker) => blocker.code === 'unknown_release_contradiction_checks'); - assert.ok(unknownBlocker); - assert.match(unknownBlocker.message, /security/); - }); - - test('blocks complete-milestone preflight when delivery posture evidence is under-observed', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - deliveryPosture: 'delivery_sensitive', - releaseClaimPosture: 'repo_closeout', - requiredKinds: ['code', 'test', 'runtime', 'delivery'], - observedKinds: ['code', 'test'], - missingKinds: [], - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - const evidenceBlocker = output.blockers.find((blocker) => blocker.code === 'missing_required_release_evidence'); - assert.ok(evidenceBlocker); - assert.match(evidenceBlocker.message, /runtime/); - assert.match(evidenceBlocker.message, /delivery/); - }); - - test('blocks complete-milestone preflight on incompatible release and delivery postures', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - deliveryPosture: 'repo_only', - releaseClaimPosture: 'delivery_supported_closeout', - requiredKinds: ['code', 'test', 'runtime', 'delivery'], - observedKinds: ['code', 'test', 'runtime', 'delivery'], - }); - - let result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - let output = JSON.parse(result.output); - assert.ok(output.blockers.some((blocker) => blocker.code === 'incompatible_release_claim_posture')); - - writeMilestoneAudit(tmpDir, { - deliveryPosture: 'delivery_sensitive', - releaseClaimPosture: 'repo_closeout', - requiredKinds: ['code', 'test', 'runtime', 'delivery'], - observedKinds: ['code', 'test', 'runtime', 'delivery'], - }); - result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - output = JSON.parse(result.output); - assert.ok(output.blockers.some((blocker) => blocker.code === 'incompatible_release_claim_posture')); - }); - - test('blocks complete-milestone preflight on invalid release claim posture', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - releaseClaimPosture: 'delivery_supported_closout', - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - assert.ok(output.blockers.some((blocker) => blocker.code === 'invalid_release_claim_posture')); - }); - - test('blocks complete-milestone preflight on invalid evidence kind values', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - requiredKinds: ['code', 'test', 'banana'], - observedKinds: ['code', 'test'], - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 1, result.output); - - const output = JSON.parse(result.output); - const evidenceKindBlocker = output.blockers.find((blocker) => blocker.code === 'invalid_release_evidence_kinds'); - assert.ok(evidenceKindBlocker); - assert.match(evidenceKindBlocker.message, /required_kinds: banana/); - }); - - test('allows complete-milestone preflight when passed audit release contract is satisfied', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, {}); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - assert.strictEqual(output.reason, null); - }); - - test('parses quoted release metadata and comma-containing inline lists', async () => { - writeCompletedMilestoneFixture(tmpDir); - writeMilestoneAudit(tmpDir, { - deliveryPosture: '"repo_only"', - releaseClaimPosture: "'repo_closeout'", - unsupportedClaims: ['"generated surface freshness, helper output"'], - deferrals: ['"generated surface freshness, helper output lacks runtime evidence until a later milestone"'], - contradictionChecks: { - evidence: 'passed', - public_surface: 'not_applicable', - runtime: 'not_applicable', - delivery: 'not_applicable', - planning_drift: 'passed', - generated_surface: 'failed', - }, - }); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - }); - - test('parses structured YAML deferrals for unsupported release claims', async () => { - writeCompletedMilestoneFixture(tmpDir); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'v1.6-MILESTONE-AUDIT.md'), - [ - '---', - 'milestone: v1.6', - 'status: passed', - 'delivery_posture: repo_only', - 'release_claim_posture: repo_closeout', - 'evidence_contract:', - ' required_kinds: [code, test]', - ' observed_kinds: [code, test]', - ' missing_kinds: []', - 'release_claim_contract:', - ' unsupported_claims:', - ' - public support', - ' waivers: []', - ' deferrals:', - ' - claim: public support', - ' missing_kinds: [delivery]', - ' later: next delivery milestone', - ' contradiction_checks:', - ' evidence: passed', - ' public_surface: not_applicable', - ' runtime: not_applicable', - ' delivery: not_applicable', - ' planning_drift: passed', - ' generated_surface: failed', - '---', - '', - '# audit', - ].join('\n') - ); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - }); - - test('parses quoted audit status and wider YAML indentation', async () => { - writeCompletedMilestoneFixture(tmpDir); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'v1.6-MILESTONE-AUDIT.md'), - [ - '---', - 'milestone: v1.6', - 'status: "passed" # audited successfully', - 'delivery_posture: repo_only', - 'release_claim_posture: repo_closeout', - 'evidence_contract:', - ' required_kinds: [code, test]', - ' observed_kinds: [code, test]', - ' missing_kinds: []', - 'release_claim_contract:', - ' unsupported_claims: []', - ' waivers: []', - ' deferrals: []', - ' contradiction_checks:', - ' evidence: passed', - ' public_surface: not_applicable', - ' runtime: not_applicable', - ' delivery: not_applicable', - ' planning_drift: passed', - ' generated_surface: failed', - '---', - '', - '# audit', - ].join('\n') - ); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - }); - - test('parses release metadata with YAML inline comments', async () => { - writeCompletedMilestoneFixture(tmpDir); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'v1.6-MILESTONE-AUDIT.md'), - [ - '---', - 'milestone: v1.6', - 'status: passed', - 'delivery_posture: repo_only # local closeout only', - 'release_claim_posture: repo_closeout # no public delivery claim', - 'evidence_contract: # D50 closeout proof', - ' required_kinds:', - ' - code # implementation exists', - ' - test # regression coverage exists', - ' observed_kinds: [code, test] # observed during audit', - ' missing_kinds: [] # none', - 'release_claim_contract: # claim boundary', - ' unsupported_claims: [] # none', - ' waivers: [] # none', - ' deferrals: [] # none', - ' contradiction_checks:', - ' evidence: passed # repo evidence aligned', - ' public_surface: not_applicable # no public claim', - ' runtime: not_applicable # no runtime claim', - ' delivery: not_applicable # no delivery claim', - ' planning_drift: passed # planning current', - ' generated_surface: failed # unrelated generated freshness claim', - '---', - '', - '# audit', - ].join('\n') - ); - - const result = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'complete-milestone']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.allowed, true); - }); -}); - -function writeCompletedMilestoneFixture(tmpDir) { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '48-generated-helper-and-closeout-contract-parity'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '### v1.6 Release Spine Hardening', - '', - '- [x] **Phase 48: Generated Helper And Closeout Contract Parity** — [REL-04]', - ].join('\n') - ); - fs.writeFileSync(path.join(tmpDir, '.planning', 'SPEC.md'), '- [x] **[REL-04]**: release spine\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '48-generated-helper-and-closeout-contract-parity', '48-PLAN.md'), '# plan\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '48-generated-helper-and-closeout-contract-parity', '48-SUMMARY.md'), '# summary\n'); - fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '48-generated-helper-and-closeout-contract-parity', '48-VERIFICATION.md'), '# verification\n'); -} - -function writeMilestoneAudit(tmpDir, { - deliveryPosture = null, - releaseClaimPosture = 'repo_closeout', - requiredKinds = ['code', 'test'], - observedKinds = ['code', 'test'], - missingKinds = [], - unsupportedClaims = [], - waivers = [], - deferrals = [], - contradictionChecks = { - evidence: 'passed', - public_surface: 'not_applicable', - runtime: 'not_applicable', - delivery: 'not_applicable', - planning_drift: 'passed', - generated_surface: 'not_applicable', - }, -} = {}) { - const list = (items) => `[${items.join(', ')}]`; - const resolvedDeliveryPosture = deliveryPosture - || (releaseClaimPosture === 'delivery_supported_closeout' ? 'delivery_sensitive' : 'repo_only'); - const contradictionLines = Object.entries(contradictionChecks) - .map(([name, status]) => ` ${name}: ${status}`) - .join('\n'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'v1.6-MILESTONE-AUDIT.md'), - [ - '---', - 'milestone: v1.6', - 'status: passed', - `delivery_posture: ${resolvedDeliveryPosture}`, - `release_claim_posture: ${releaseClaimPosture}`, - 'evidence_contract:', - ` required_kinds: ${list(requiredKinds)}`, - ` observed_kinds: ${list(observedKinds)}`, - ` missing_kinds: ${list(missingKinds)}`, - 'release_claim_contract:', - ` unsupported_claims: ${list(unsupportedClaims)}`, - ` waivers: ${list(waivers)}`, - ` deferrals: ${list(deferrals)}`, - ' contradiction_checks:', - contradictionLines, - '---', - '', - '# audit', - ].join('\n') - ); -} - -describe('verify command nested phase plans', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createGsddTempProject(); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '34-identity-and-story-lock'), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); + afterEach(() => { + cleanup(tmpDir); + }); test('finds nested 01-PLAN.md when verifying a phase', async () => { fs.writeFileSync( @@ -2478,777 +1567,46 @@ describe('verify command nested phase plans', () => { ['const a = 1;', 'const b = 2;', 'export const sum = a + b;'].join('\n') ); fs.writeFileSync( - path.join(tmpDir, '.planning', 'phases', '34-identity-and-story-lock', '01-SUMMARY.md'), - '# Phase 34 Summary\n' - ); - - const result = await runCliAsMain(tmpDir, ['verify', '34']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phase, '34'); - assert.deepStrictEqual(output.artifacts.map((artifact) => artifact.file), ['src/example.js']); - assert.strictEqual(output.allExist, true); - }); - - test('reports RENAME and MOVE plan artifacts by destination path', async () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'phases', '34-identity-and-story-lock', '01-PLAN.md'), - [ - '', - ' ', - ' - RENAME: src/old.js -> src/new.js', - ' - MOVE: src/a.js -> src/b.js', - ' ', - '', - ].join('\n') - ); - fs.writeFileSync(path.join(tmpDir, 'src', 'new.js'), 'export const renamed = true;\n'); - fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'export const moved = true;\n'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'phases', '34-identity-and-story-lock', '01-SUMMARY.md'), - '# Phase 34 Summary\n' - ); - - const result = await runCliAsMain(tmpDir, ['verify', '34']); - assert.strictEqual(result.exitCode, 0, result.output); - - const output = JSON.parse(result.output); - assert.deepStrictEqual(output.artifacts, [ - { operation: 'rename', from: 'src/old.js', to: 'src/new.js', file: 'src/new.js', exists: true }, - { operation: 'move', from: 'src/a.js', to: 'src/b.js', file: 'src/b.js', exists: true }, - ]); - }); -}); - -describe('Phase 31 evidence-gated closure helpers', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createGsddTempProject(); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '31-evidence-gated-closure'), { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('normalizes legacy verification proof names into stable evidence kinds', async () => { - const mod = await importEvidenceContractModule(); - - assert.deepStrictEqual( - mod.normalizeEvidenceKinds(['repo-test', 'code-evidence', 'runtime-check', 'user-confirmation', 'repo-test', 'delivery']), - ['test', 'code', 'runtime', 'human', 'delivery'] - ); - assert.strictEqual(mod.normalizeEvidenceKind('unknown-proof'), null); - }); - - test('defines closure evidence requirements by surface and delivery posture', async () => { - const mod = await importEvidenceContractModule(); - - assert.deepStrictEqual( - mod.getEvidenceContract('verify', 'repo_only'), - { - surface: 'verify', - deliveryPosture: 'repo_only', - supportedKinds: ['code', 'test', 'runtime', 'delivery', 'human'], - requiredKinds: ['code'], - recommendedKinds: ['test'], - blockedSoloKinds: ['human', 'delivery'], - } - ); - - assert.deepStrictEqual( - mod.getEvidenceContract('complete-milestone', 'delivery_sensitive'), - { - surface: 'complete-milestone', - deliveryPosture: 'delivery_sensitive', - supportedKinds: ['code', 'test', 'runtime', 'delivery', 'human'], - requiredKinds: ['code', 'test', 'runtime', 'delivery'], - recommendedKinds: ['human'], - blockedSoloKinds: ['code', 'human'], - } - ); - }); - - test('defines release claim postures without adding evidence kinds', async () => { - const mod = await importEvidenceContractModule(); - - assert.deepStrictEqual(mod.RELEASE_CLAIM_POSTURES, [ - 'repo_closeout', - 'runtime_validated_closeout', - 'delivery_supported_closeout', - ]); - - const runtimeClaim = mod.getReleaseClaimContract('audit-milestone', 'runtime_validated_closeout'); - assert.strictEqual(runtimeClaim.releaseClaimPosture, 'runtime_validated_closeout'); - assert.strictEqual(runtimeClaim.deliveryPosture, 'repo_only'); - assert.deepStrictEqual(runtimeClaim.supportedKinds, ['code', 'test', 'runtime', 'delivery', 'human']); - assert.deepStrictEqual(runtimeClaim.requiredKinds, ['code', 'test', 'runtime']); - assert.match(runtimeClaim.waiverRule, /never satisfy missing required evidence/i); - assert.ok(runtimeClaim.contradictionCategories.includes('generated_surface')); - - const deliveryClaim = mod.getReleaseClaimContract('complete-milestone', 'delivery_supported_closeout'); - assert.strictEqual(deliveryClaim.deliveryPosture, 'delivery_sensitive'); - assert.deepStrictEqual(deliveryClaim.requiredKinds, ['code', 'test', 'runtime', 'delivery']); - - assert.strictEqual(mod.normalizeReleaseClaimPosture('unknown'), null); - assert.throws( - () => mod.getReleaseClaimContract('complete-milestone', 'unknown'), - /Unsupported release claim posture/ - ); - }); - - test('unsupported stronger release claims must downgrade or defer instead of using waiver prose', async () => { - const mod = await importEvidenceContractModule(); - - const unsupportedDelivery = mod.evaluateReleaseClaimPosture({ - surface: 'complete-milestone', - releaseClaimPosture: 'delivery_supported_closeout', - observedKinds: ['code', 'test', 'runtime'], - waivedKinds: ['delivery'], - }); - - assert.deepStrictEqual(unsupportedDelivery.missingKinds, ['delivery']); - assert.deepStrictEqual(unsupportedDelivery.invalidWaivers, ['delivery']); - assert.strictEqual(unsupportedDelivery.status, 'unsupported'); - assert.strictEqual(unsupportedDelivery.disposition, 'downgrade_or_defer'); - assert.strictEqual(unsupportedDelivery.downgradeTo, 'runtime_validated_closeout'); - assert.deepStrictEqual(unsupportedDelivery.deferredClaims, [ - { claim: 'delivery_supported_closeout', missingKinds: ['delivery'] }, - ]); - - const supportedRepo = mod.evaluateReleaseClaimPosture({ - surface: 'audit-milestone', - releaseClaimPosture: 'repo_closeout', - observedKinds: ['code', 'test', 'human'], - }); - - assert.strictEqual(supportedRepo.status, 'supported'); - assert.strictEqual(supportedRepo.disposition, 'proceed'); - assert.deepStrictEqual(supportedRepo.missingKinds, []); - }); - - test('release closeout contract fails closed on unknown contradiction check keys', async () => { - const mod = await importEvidenceContractModule(); - - const result = mod.evaluateReleaseClaimCloseoutContract({ - surface: 'complete-milestone', - releaseClaimPosture: 'repo_closeout', - observedKinds: ['code', 'test'], - contradictionChecks: { - evidence: 'passed', - public_surface: 'not_applicable', - runtime: 'not_applicable', - delivery: 'not_applicable', - planning_drift: 'passed', - generated_surface: 'not_applicable', - security: 'failed', - }, - }); - - assert.strictEqual(result.status, 'unsupported'); - assert.deepStrictEqual(result.unknownContradictionChecks, ['security']); - assert.ok(result.blockers.some((blocker) => blocker.code === 'unknown_release_contradiction_checks')); - }); - - test('release closeout contract fails closed on missing contradiction checks', async () => { - const mod = await importEvidenceContractModule(); - - const result = mod.evaluateReleaseClaimCloseoutContract({ - surface: 'complete-milestone', - releaseClaimPosture: 'repo_closeout', - observedKinds: ['code', 'test'], - contradictionChecks: { - evidence: 'passed', - planning_drift: 'passed', - }, - }); - - assert.strictEqual(result.status, 'unsupported'); - assert.deepStrictEqual(result.missingContradictionChecks, [ - 'public_surface', - 'runtime', - 'delivery', - 'generated_surface', - ]); - assert.ok(result.blockers.some((blocker) => blocker.code === 'missing_release_contradiction_checks')); - }); - - test('release closeout contract fails closed on invalid contradiction check statuses', async () => { - const mod = await importEvidenceContractModule(); - - const result = mod.evaluateReleaseClaimCloseoutContract({ - surface: 'complete-milestone', - releaseClaimPosture: 'repo_closeout', - observedKinds: ['code', 'test'], - contradictionChecks: { - evidence: 'passed', - public_surface: 'not_applicable', - runtime: 'not_applicable', - delivery: 'skipped', - planning_drift: 'passed', - generated_surface: 'not_applicable', - }, - }); - - assert.strictEqual(result.status, 'unsupported'); - assert.deepStrictEqual(result.invalidContradictionChecks, ['delivery']); - assert.ok(result.blockers.some((blocker) => blocker.code === 'invalid_release_contradiction_checks')); - }); - - test('lifecycle preflight exposes closure evidence metadata only for closure surfaces', async () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '### v1.3.0 Engine Contract Hardening', - '', - '- [x] **Phase 30: Deterministic Lifecycle Gates** — [ENGINE-02]', - '- [-] **Phase 31: Evidence-Gated Closure** — [ENGINE-04]', - ].join('\n') - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'phases', '31-evidence-gated-closure', '31-PLAN.md'), - '# plan\n' - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'phases', '31-evidence-gated-closure', '31-SUMMARY.md'), - '# summary\n' - ); - - const mod = await importLifecyclePreflightModule(); - const verifyResult = mod.evaluateLifecyclePreflight({ - planningDir: path.join(tmpDir, '.planning'), - surface: 'verify', - phaseNumber: '31', - expectsMutation: 'phase-status', - }); - const progressResult = mod.evaluateLifecyclePreflight({ - planningDir: path.join(tmpDir, '.planning'), - surface: 'progress', - }); - - assert.deepStrictEqual( - verifyResult.closureEvidence, - { - surface: 'verify', - supportedKinds: ['code', 'test', 'runtime', 'delivery', 'human'], - deliveryPostures: [ - { - surface: 'verify', - deliveryPosture: 'repo_only', - supportedKinds: ['code', 'test', 'runtime', 'delivery', 'human'], - requiredKinds: ['code'], - recommendedKinds: ['test'], - blockedSoloKinds: ['human', 'delivery'], - }, - { - surface: 'verify', - deliveryPosture: 'delivery_sensitive', - supportedKinds: ['code', 'test', 'runtime', 'delivery', 'human'], - requiredKinds: ['code', 'runtime', 'delivery'], - recommendedKinds: ['test', 'human'], - blockedSoloKinds: ['code', 'human'], - }, - ], - releaseClaimPostures: [ - { - surface: 'verify', - releaseClaimPosture: 'repo_closeout', - deliveryPosture: 'repo_only', - supportedKinds: ['code', 'test', 'runtime', 'delivery', 'human'], - requiredKinds: ['code'], - requiredClaimKinds: [], - allowedClaim: 'Repo-local milestone or phase closeout is supported by planning and repository artifacts only.', - invalidClaim: 'Do not imply runtime validation, delivery, publication, or public support from repo-local closeout alone.', - waiverRule: 'Waivers may only narrow the release claim posture or defer an unsupported claim; they never satisfy missing required evidence for the stronger claim.', - deferralRule: 'Deferrals must name the unsupported claim, missing evidence kinds, and later workflow or milestone candidate when known.', - contradictionCategories: [ - 'evidence', - 'public_surface', - 'runtime', - 'delivery', - 'planning_drift', - 'generated_surface', - ], - }, - { - surface: 'verify', - releaseClaimPosture: 'runtime_validated_closeout', - deliveryPosture: 'repo_only', - supportedKinds: ['code', 'test', 'runtime', 'delivery', 'human'], - requiredKinds: ['code', 'runtime'], - requiredClaimKinds: ['runtime'], - allowedClaim: 'Runtime behavior or a runtime surface was directly executed and observed for the named runtime or surface.', - invalidClaim: 'Do not generalize validation from one runtime or generated surface to another.', - waiverRule: 'Waivers may only narrow the release claim posture or defer an unsupported claim; they never satisfy missing required evidence for the stronger claim.', - deferralRule: 'Deferrals must name the unsupported claim, missing evidence kinds, and later workflow or milestone candidate when known.', - contradictionCategories: [ - 'evidence', - 'public_surface', - 'runtime', - 'delivery', - 'planning_drift', - 'generated_surface', - ], - }, - { - surface: 'verify', - releaseClaimPosture: 'delivery_supported_closeout', - deliveryPosture: 'delivery_sensitive', - supportedKinds: ['code', 'test', 'runtime', 'delivery', 'human'], - requiredKinds: ['code', 'runtime', 'delivery'], - requiredClaimKinds: [], - allowedClaim: 'Externally consumed release, support, install, or delivery claims are supported by the delivery-sensitive evidence bar.', - invalidClaim: 'Do not imply merge, package, tag, GitHub Release, publication, generated-surface freshness, or public support without matching delivery evidence.', - waiverRule: 'Waivers may only narrow the release claim posture or defer an unsupported claim; they never satisfy missing required evidence for the stronger claim.', - deferralRule: 'Deferrals must name the unsupported claim, missing evidence kinds, and later workflow or milestone candidate when known.', - contradictionCategories: [ - 'evidence', - 'public_surface', - 'runtime', - 'delivery', - 'planning_drift', - 'generated_surface', - ], - }, - ], - } - ); - assert.strictEqual(progressResult.closureEvidence, null); - }); -}); - -describe('Phase 57 UI proof validation helper', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createGsddTempProject(); - fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - function validBundle(overrides = {}) { - return { - proof_bundle_version: 1, - scope: { - work_item: 'quick-001-example-ui', - requirement_ids: ['quick-001'], - slot_ids: ['quick-001-ui-01'], - claim: 'Local reviewer can inspect the changed UI proof metadata.', - }, - route_state: { route: '/example', state: 'synthetic user' }, - environment: { app_url: 'http://localhost:3000', data_state: 'synthetic' }, - viewport: { width: 1280, height: 720 }, - evidence_inputs: { kinds: ['test', 'runtime'], tools_used: ['manual'] }, - commands_or_manual_steps: [{ manual_step: 'Open /example and inspect the changed state.', result: 'passed' }], - observations: [{ - observation: 'Changed state is visible.', - claim: 'Local reviewer can inspect the changed UI proof metadata.', - route_state: { route: '/example', state: 'synthetic user' }, - evidence_kind: 'runtime', - artifact_refs: ['artifacts/report.html'], - privacy: { data_classification: 'synthetic', raw_artifacts_safe_to_publish: false, retention: 'temporary_review' }, - result: 'passed', - claim_limit: 'Does not prove unrelated UI states.', - }], - artifacts: [{ - path: 'artifacts/report.html', - type: 'report', - visibility: 'local_only', - retention: 'temporary_review', - sensitivity: 'synthetic', - safe_to_publish: false, - }], - privacy: { - data_classification: 'synthetic', - redactions: [], - raw_artifacts_safe_to_publish: false, - retention: 'Keep raw artifacts only while needed for review.', - }, - result: { claim_status: 'passed', comparison_status_by_slot: { 'quick-001-ui-01': 'satisfied' } }, - claim_limits: ['Does not prove unrelated UI states.'], - ...overrides, - }; - } - - function writeDefaultArtifact() { - const artifactPath = path.join(tmpDir, 'artifacts', 'report.html'); - fs.mkdirSync(path.dirname(artifactPath), { recursive: true }); - fs.writeFileSync(artifactPath, 'UI proof report\n'); - } - - test('valid local-only proof metadata passes without browser tooling or dependencies', async () => { - const mod = await importUiProofModule(); - const result = mod.validateUiProofBundle(validBundle()); - assert.strictEqual(result.valid, true, JSON.stringify(result.errors)); - }); - - test('fenced JSON in markdown parses but YAML-only bundles fail', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle(); - const fenced = mod.parseUiProofBundleContent(`# UI proof\n\n\`\`\`json\n${JSON.stringify(bundle)}\n\`\`\`\n`, 'UI-PROOF.md'); - assert.deepStrictEqual(fenced.errors, []); - assert.strictEqual(fenced.bundle.scope.work_item, 'quick-001-example-ui'); - - const yamlOnly = mod.parseUiProofBundleContent('proof_bundle_version: 1\nscope:\n claim: nope\n', 'UI-PROOF.md'); - assert.strictEqual(yamlOnly.bundle, null); - assert.ok(yamlOnly.errors.some((error) => error.code === 'unparseable_bundle')); - }); - - test('missing fields invalid statuses unsupported evidence kinds and missing claim limits fail', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle({ - evidence_inputs: { kinds: ['screenshot'] }, - result: { comparison_status_by_slot: { 'quick-001-ui-01': 'looks_good' } }, - claim_limits: [], - }); - delete bundle.scope.work_item; - delete bundle.artifacts[0].safe_to_publish; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.path === 'scope.work_item')); - assert.ok(result.errors.some((error) => error.code === 'unsupported_evidence_kind')); - assert.ok(result.errors.some((error) => error.code === 'invalid_comparison_status')); - assert.ok(result.errors.some((error) => error.code === 'missing_claim_limits')); - assert.ok(result.errors.some((error) => error.path === 'artifacts[0].safe_to_publish')); - }); - - test('tool provenance must use concise tool identifiers', async () => { - const mod = await importUiProofModule(); - const missingTools = validBundle({ evidence_inputs: { kinds: ['test', 'runtime'] } }); - const verboseTool = validBundle({ evidence_inputs: { kinds: ['test', 'runtime'], tools_used: ['manual review'] } }); - - assert.ok(mod.validateUiProofBundle(missingTools).errors.some((error) => error.code === 'missing_tools_used')); - assert.ok(mod.validateUiProofBundle(verboseTool).errors.some((error) => error.code === 'invalid_tool_id')); - }); - - test('failed or partial UI proof must classify the failure cause', async () => { - const mod = await importUiProofModule(); - const unclassifiedFailure = validBundle({ - commands_or_manual_steps: [{ manual_step: 'Open /example.', result: 'failed' }], - observations: [{ - observation: 'Changed state is broken.', - claim: 'Local reviewer can inspect the changed UI proof metadata.', - route_state: { route: '/example', state: 'synthetic user' }, - evidence_kind: 'runtime', - artifact_refs: ['artifacts/report.html'], - privacy: { data_classification: 'synthetic', raw_artifacts_safe_to_publish: false, retention: 'temporary_review' }, - result: 'failed', - claim_limit: 'Does not prove unrelated UI states.', - }], - result: { claim_status: 'failed', comparison_status_by_slot: { 'quick-001-ui-01': 'partial' } }, - }); - const invalidClassification = validBundle({ - result: { - claim_status: 'partial', - comparison_status_by_slot: { 'quick-001-ui-01': 'partial' }, - failure_classification: 'looks_bad', - }, - }); - const classifiedFailure = validBundle({ - result: { - claim_status: 'failed', - comparison_status_by_slot: { 'quick-001-ui-01': 'partial' }, - failure_classification: 'product_bug', - }, - }); - const partialComparison = validBundle({ - result: { - claim_status: 'passed', - comparison_status_by_slot: { 'quick-001-ui-01': 'partial' }, - }, - }); - - assert.ok(mod.validateUiProofBundle(unclassifiedFailure).errors.some((error) => error.code === 'missing_failure_classification')); - assert.ok(mod.validateUiProofBundle(invalidClassification).errors.some((error) => error.code === 'invalid_failure_classification')); - assert.ok(!mod.validateUiProofBundle(classifiedFailure).errors.some((error) => error.code === 'missing_failure_classification')); - assert.ok(mod.validateUiProofBundle(partialComparison).errors.some((error) => error.code === 'missing_failure_classification')); - assert.ok(mod.validateUiProofBundle(partialComparison).errors.some((error) => error.code === 'inconsistent_claim_status')); - }); - - test('empty required arrays and mismatched comparison slots fail', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle(); - bundle.scope.requirement_ids = []; - bundle.commands_or_manual_steps = []; - bundle.observations = []; - bundle.result.comparison_status_by_slot = { 'quick-001-ui-99': 'satisfied' }; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.path === 'scope.requirement_ids')); - assert.ok(result.errors.some((error) => error.path === 'commands_or_manual_steps')); - assert.ok(result.errors.some((error) => error.path === 'observations')); - assert.ok(result.errors.some((error) => error.code === 'missing_comparison_status')); - assert.ok(result.errors.some((error) => error.code === 'unknown_comparison_slot')); - }); - - test('commands and manual steps must be structured with a result', async () => { - const mod = await importUiProofModule(); - const stringStep = validBundle({ commands_or_manual_steps: ['looks good'] }); - const missingAction = validBundle({ commands_or_manual_steps: [{ result: 'passed' }] }); - const missingResult = validBundle({ commands_or_manual_steps: [{ manual_step: 'Open /example.' }] }); - const invalidResult = validBundle({ commands_or_manual_steps: [{ command: 'npm test', result: 'ok' }] }); - - assert.ok(mod.validateUiProofBundle(stringStep).errors.some((error) => error.code === 'invalid_proof_step')); - assert.ok(mod.validateUiProofBundle(missingAction).errors.some((error) => error.code === 'missing_proof_step_action')); - assert.ok(mod.validateUiProofBundle(missingResult).errors.some((error) => error.code === 'missing_proof_step_result')); - assert.ok(mod.validateUiProofBundle(invalidResult).errors.some((error) => error.code === 'invalid_proof_step_result')); - }); - - test('observation artifact references must resolve to declared artifacts', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle(); - bundle.observations[0].artifact_refs = ['missing/report.html']; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'unknown_artifact_ref')); - }); - - test('artifact references must stay workspace-relative or use http URLs', async () => { - const mod = await importUiProofModule(); - const traversal = validBundle(); - traversal.artifacts[0].path = '../../outside/report.html'; - traversal.observations[0].artifact_refs = ['../../outside/report.html']; - const fileUrl = validBundle(); - fileUrl.artifacts[0].url = 'file:///Users/example/private/report.html'; - delete fileUrl.artifacts[0].path; - fileUrl.observations[0].artifact_refs = ['file:///Users/example/private/report.html']; - - assert.ok(mod.validateUiProofBundle(traversal).errors.some((error) => error.code === 'invalid_artifact_ref_location')); - assert.ok(mod.validateUiProofBundle(fileUrl).errors.some((error) => error.code === 'invalid_artifact_ref_location')); - }); - - test('observations must include scoped support metadata', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle(); - delete bundle.observations[0].claim; - delete bundle.observations[0].artifact_refs; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.path === 'observations[0].claim')); - assert.ok(result.errors.some((error) => error.path === 'observations[0].artifact_refs')); - }); - - test('non-object observations fail instead of being skipped', async () => { - const mod = await importUiProofModule(); - const result = mod.validateUiProofBundle(validBundle({ observations: ['looks good'] })); - - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'invalid_observation')); - }); - - test('observation privacy and result status are schema-checked', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle(); - bundle.observations[0].privacy = { - data_classification: 'synthetic', - raw_artifacts_safe_to_publish: 'no', - }; - bundle.observations[0].result = 'looks_good'; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.path === 'observations[0].privacy.retention')); - assert.ok(result.errors.some((error) => error.code === 'invalid_raw_artifacts_safe_to_publish')); - assert.ok(result.errors.some((error) => error.code === 'invalid_observation_result')); - }); - - test('result claim status is required and enum-validated', async () => { - const mod = await importUiProofModule(); - const missingStatus = validBundle({ result: { comparison_status_by_slot: { 'quick-001-ui-01': 'satisfied' } } }); - const invalidStatus = validBundle({ result: { claim_status: 'looks_good', comparison_status_by_slot: { 'quick-001-ui-01': 'satisfied' } } }); - - const missingResult = mod.validateUiProofBundle(missingStatus); - assert.strictEqual(missingResult.valid, false); - assert.ok(missingResult.errors.some((error) => error.code === 'missing_claim_status')); - - const invalidResult = mod.validateUiProofBundle(invalidStatus); - assert.strictEqual(invalidResult.valid, false); - assert.ok(invalidResult.errors.some((error) => error.code === 'invalid_claim_status')); - }); - - test('public tracked and delivery claims cannot rely on local-only unsafe raw artifacts', async () => { - const mod = await importUiProofModule(); - const result = mod.validateUiProofBundle(validBundle({ proof_claim: 'public' })); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'unsafe_public_proof_claim')); - }); - - test('delivery evidence kind does not imply a delivery proof claim', async () => { - const mod = await importUiProofModule(); - const result = mod.validateUiProofBundle(validBundle({ - evidence_inputs: { kinds: ['test', 'runtime', 'delivery'], tools_used: ['manual'] }, - })); - - assert.strictEqual(result.valid, true, JSON.stringify(result.errors)); - assert.ok(!result.errors.some((error) => error.code === 'unsafe_public_proof_claim')); - }); - - test('negative claim limits do not imply public claim enforcement', async () => { - const mod = await importUiProofModule(); - const result = mod.validateUiProofBundle(validBundle({ - claim_limits: [ - 'Does not prove public release, production delivery, tracked publication, or external support.', - ], - })); - - assert.strictEqual(result.valid, true, JSON.stringify(result.errors)); - assert.ok(!result.errors.some((error) => error.code === 'unsafe_public_proof_claim')); - }); - - test('explicit claim context still enforces public claim artifact safety', async () => { - const mod = await importUiProofModule(); - const result = mod.validateUiProofBundle(validBundle({ - claim_context: { proof_use: 'release' }, - })); - - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'unsafe_public_proof_claim')); - }); - - test('plural proof claims still enforce public claim artifact safety', async () => { - const mod = await importUiProofModule(); - const result = mod.validateUiProofBundle(validBundle({ proof_claims: ['tracked'] })); - - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'unsafe_public_proof_claim')); - }); - - test('persisted proof claims reject unsupported claim uses', async () => { - const mod = await importUiProofModule(); - const result = mod.validateUiProofBundle(validBundle({ proof_claim: 'published' })); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'unsupported_claim_use')); - }); - - test('raw artifact path inference cannot be bypassed with custom type', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle(); - bundle.artifacts[0] = { - ...bundle.artifacts[0], - path: 'artifacts/shot.png', - type: 'custom', - visibility: 'repo_tracked', - safe_to_publish: false, - }; - bundle.observations[0].artifact_refs = ['artifacts/shot.png']; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'unsafe_raw_artifact')); - }); - - test('public proof claims require matching sanitized privacy metadata', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle({ proof_claim: 'public' }); - bundle.artifacts[0] = { - ...bundle.artifacts[0], - visibility: 'public', - sensitivity: 'sanitized', - safe_to_publish: true, - }; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'unsafe_public_proof_privacy')); - assert.ok(result.errors.some((error) => error.code === 'unsafe_public_observation_privacy')); - }); - - test('public raw artifact claims require sanitized artifact sensitivity', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle({ proof_claim: 'public' }); - bundle.artifacts[0] = { - ...bundle.artifacts[0], - visibility: 'public', - sensitivity: 'secret', - safe_to_publish: true, - }; - bundle.privacy.raw_artifacts_safe_to_publish = true; - bundle.observations[0].privacy.raw_artifacts_safe_to_publish = true; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'unsafe_public_artifact_sensitivity')); - }); - - test('public raw artifact URL claims require sanitized artifact sensitivity', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle({ proof_claim: 'public' }); - bundle.artifacts[0] = { - url: 'https://example.com/artifacts/example-1280.png', - visibility: 'public', - retention: 'temporary_review', - sensitivity: 'synthetic', - safe_to_publish: true, - }; - bundle.observations[0].artifact_refs = ['https://example.com/artifacts/example-1280.png']; - bundle.privacy.raw_artifacts_safe_to_publish = true; - bundle.observations[0].privacy.raw_artifacts_safe_to_publish = true; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some((error) => error.code === 'unsafe_public_artifact_sensitivity')); - }); - - test('explicitly safe-to-publish proof metadata can support public claims', async () => { - const mod = await importUiProofModule(); - const bundle = validBundle({ proof_claim: 'public' }); - bundle.artifacts[0] = { - ...bundle.artifacts[0], - visibility: 'public', - sensitivity: 'sanitized', - safe_to_publish: true, - }; - bundle.privacy.raw_artifacts_safe_to_publish = true; - bundle.observations[0].privacy.raw_artifacts_safe_to_publish = true; - - const result = mod.validateUiProofBundle(bundle); - assert.strictEqual(result.valid, true, JSON.stringify(result.errors)); - }); - - test('ui-proof validate command validates bundle files directly', async () => { - await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); - const bundlePath = path.join(tmpDir, '.planning', 'ui-proof.json'); - writeDefaultArtifact(); - fs.writeFileSync(bundlePath, JSON.stringify(validBundle(), null, 2)); + path.join(tmpDir, '.planning', 'phases', '34-identity-and-story-lock', '01-SUMMARY.md'), + '# Phase 34 Summary\n' + ); - const result = await runCliAsMain(tmpDir, ['ui-proof', 'validate', '.planning/ui-proof.json']); + const result = await runCliAsMain(tmpDir, ['verify', '34']); assert.strictEqual(result.exitCode, 0, result.output); - const parsed = JSON.parse(result.output); - assert.strictEqual(parsed.valid, true); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase, '34'); + assert.deepStrictEqual(output.artifacts.map((artifact) => artifact.file), ['src/example.js']); + assert.strictEqual(output.allExist, true); }); - test('ui-proof validate rejects unsupported claim flags', async () => { - await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); - const bundlePath = path.join(tmpDir, '.planning', 'ui-proof.json'); - writeDefaultArtifact(); - fs.writeFileSync(bundlePath, JSON.stringify(validBundle(), null, 2)); + test('reports RENAME and MOVE plan artifacts by destination path', async () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'phases', '34-identity-and-story-lock', '01-PLAN.md'), + [ + '', + ' ', + ' - RENAME: src/old.js -> src/new.js', + ' - MOVE: src/a.js -> src/b.js', + ' ', + '', + ].join('\n') + ); + fs.writeFileSync(path.join(tmpDir, 'src', 'new.js'), 'export const renamed = true;\n'); + fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'export const moved = true;\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'phases', '34-identity-and-story-lock', '01-SUMMARY.md'), + '# Phase 34 Summary\n' + ); - const result = await runCliAsMain(tmpDir, ['ui-proof', 'validate', '.planning/ui-proof.json', '--claim', 'published']); - assert.strictEqual(result.exitCode, 1); - assert.match(result.output, /Unsupported UI proof claim use: published/); - }); + const result = await runCliAsMain(tmpDir, ['verify', '34']); + assert.strictEqual(result.exitCode, 0, result.output); - test('ui-proof validate claim flag still enforces public claim artifact safety', async () => { - await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); - const bundlePath = path.join(tmpDir, '.planning', 'ui-proof.json'); - writeDefaultArtifact(); - fs.writeFileSync(bundlePath, JSON.stringify(validBundle(), null, 2)); - - const result = await runCliAsMain(tmpDir, ['ui-proof', 'validate', '.planning/ui-proof.json', '--claim', 'release']); - assert.strictEqual(result.exitCode, 1); - const parsed = JSON.parse(result.output); - assert.ok(parsed.errors.some((error) => error.code === 'unsafe_public_proof_claim')); + const output = JSON.parse(result.output); + assert.deepStrictEqual(output.artifacts, [ + { operation: 'rename', from: 'src/old.js', to: 'src/new.js', file: 'src/new.js', exists: true }, + { operation: 'move', from: 'src/a.js', to: 'src/b.js', file: 'src/b.js', exists: true }, + ]); }); }); @@ -3264,481 +1622,6 @@ describe('Phase 58 dogfood and Phase 59 UI proof product comparison', () => { cleanup(tmpDir); }); - function plannedSlots() { - return [{ - slot_id: 'ui-58-valid-scoped-proof', - requirement_id: 'UIPROOF-10', - claim: 'Valid scoped local UI proof for the generated UI-bearing fixture passes deterministic validation and planned-vs-observed comparison.', - route_state: '/dogfood route with synthetic fixture state', - required_evidence_kinds: ['code', 'test', 'runtime'], - minimum_observations: [ - 'Generated fixture includes actual UI-bearing source for the route/state.', - 'Observed proof bundle maps to the planned slot, route/state, required evidence kinds, artifact refs, privacy metadata, result, and claim limit.', - ], - expected_artifact_types: ['source', 'metadata'], - validation_command: 'gsdd ui-proof compare .planning/phases/58-dogfood-ui-proof-loop/ui-proof-slots.json .planning/phases/58-dogfood-ui-proof-loop/proof-bundle.json', - environment: { app_url: 'file://synthetic-dogfood-fixture', data_state: 'synthetic' }, - viewport: { width: 1280, height: 720 }, - manual_acceptance_required: false, - claim_limit: 'Proves Workspine UI proof metadata and comparison behavior only; does not prove real browser rendering quality, cross-browser behavior, full accessibility, production delivery, or public release proof.', - }, { - slot_id: 'ui-58-missing-or-botched-proof', - requirement_id: 'UIPROOF-10', - claim: 'Missing, mismatched, or botched UI proof for the generated fixture fails closed instead of being treated as satisfied.', - route_state: '/dogfood route with synthetic fixture state', - required_evidence_kinds: ['code', 'test', 'runtime'], - minimum_observations: ['A botched bundle fails validation or comparison with a deterministic error/status.'], - expected_artifact_types: ['source', 'metadata'], - validation_command: 'gsdd ui-proof compare .planning/phases/58-dogfood-ui-proof-loop/ui-proof-slots.json .planning/phases/58-dogfood-ui-proof-loop/botched-proof-bundle.json', - environment: { app_url: 'file://synthetic-dogfood-fixture', data_state: 'synthetic' }, - viewport: { width: 1280, height: 720 }, - manual_acceptance_required: false, - claim_limit: 'Proves fail-closed proof-loop behavior for scoped metadata, not rendered UI correctness.', - }, { - slot_id: 'ui-58-human-bypass-blocked', - requirement_id: 'UIPROOF-10', - claim: 'Human approval cannot bypass missing required non-human evidence for visual, taste, accessibility, or privacy-sensitive UI proof.', - route_state: '/dogfood route with synthetic fixture state and subjective review metadata', - required_evidence_kinds: ['code', 'test', 'runtime', 'human'], - minimum_observations: ['Human/manual acceptance is represented as human evidence or waiver/deferment metadata.'], - expected_artifact_types: ['metadata'], - validation_command: 'gsdd ui-proof compare .planning/phases/58-dogfood-ui-proof-loop/ui-proof-slots.json .planning/phases/58-dogfood-ui-proof-loop/human-proof-bundle.json', - environment: { app_url: 'file://synthetic-dogfood-fixture', data_state: 'synthetic' }, - viewport: { width: 1280, height: 720 }, - manual_acceptance_required: true, - claim_limit: 'Human evidence may narrow, waive, defer, or record proof debt; it does not prove missing non-human evidence or full accessibility/taste acceptance.', - }]; - } - - function dogfoodBundle(overrides = {}) { - return { - proof_bundle_version: 1, - scope: { - work_item: 'phase-58-dogfood-ui-proof-loop', - requirement_ids: ['UIPROOF-10'], - slot_ids: ['ui-58-valid-scoped-proof'], - claim: 'Valid scoped local UI proof for the generated UI-bearing fixture passes deterministic validation and planned-vs-observed comparison.', - }, - route_state: '/dogfood route with synthetic fixture state', - environment: { app_url: 'file://synthetic-dogfood-fixture', data_state: 'synthetic' }, - viewport: { width: 1280, height: 720 }, - evidence_inputs: { kinds: ['code', 'test', 'runtime'], tools_used: ['node:test', 'gsdd-ui-proof-validate'] }, - commands_or_manual_steps: [{ command: 'node bin/gsdd.mjs ui-proof validate .planning/phases/58-dogfood-ui-proof-loop/proof-bundle.json', result: 'passed' }], - observations: [{ - observation: 'Generated fixture includes actual UI-bearing source for the route/state.', - claim: 'Valid scoped local UI proof for the generated UI-bearing fixture passes deterministic validation and planned-vs-observed comparison.', - route_state: '/dogfood route with synthetic fixture state', - evidence_kind: 'code', - artifact_refs: ['fixtures/dogfood/index.html'], - privacy: { data_classification: 'synthetic', raw_artifacts_safe_to_publish: false, retention: 'temporary_review' }, - result: 'passed', - claim_limit: 'Proves Workspine UI proof metadata and comparison behavior only; does not prove real browser rendering quality, cross-browser behavior, full accessibility, production delivery, or public release proof.', - }, { - observation: 'Observed proof bundle maps to the planned slot, route/state, required evidence kinds, artifact refs, privacy metadata, result, and claim limit.', - claim: 'Valid scoped local UI proof for the generated UI-bearing fixture passes deterministic validation and planned-vs-observed comparison.', - route_state: '/dogfood route with synthetic fixture state', - evidence_kind: 'runtime', - artifact_refs: ['.planning/phases/58-dogfood-ui-proof-loop/proof-bundle.json'], - privacy: { data_classification: 'synthetic', raw_artifacts_safe_to_publish: false, retention: 'temporary_review' }, - result: 'passed', - claim_limit: 'Proves Workspine UI proof metadata and comparison behavior only; does not prove real browser rendering quality, cross-browser behavior, full accessibility, production delivery, or public release proof.', - }, { - observation: 'Regression coverage exercises the planned proof slot through deterministic tests.', - claim: 'Valid scoped local UI proof for the generated UI-bearing fixture passes deterministic validation and planned-vs-observed comparison.', - route_state: '/dogfood route with synthetic fixture state', - evidence_kind: 'test', - artifact_refs: ['.planning/phases/58-dogfood-ui-proof-loop/proof-bundle.json'], - privacy: { data_classification: 'synthetic', raw_artifacts_safe_to_publish: false, retention: 'temporary_review' }, - result: 'passed', - claim_limit: 'Proves Workspine UI proof metadata and comparison behavior only; does not prove real browser rendering quality, cross-browser behavior, full accessibility, production delivery, or public release proof.', - }], - artifacts: [{ - path: 'fixtures/dogfood/index.html', - type: 'source', - visibility: 'local_only', - retention: 'temporary_review', - sensitivity: 'synthetic', - safe_to_publish: false, - }, { - path: '.planning/phases/58-dogfood-ui-proof-loop/proof-bundle.json', - type: 'metadata', - visibility: 'local_only', - retention: 'temporary_review', - sensitivity: 'synthetic', - safe_to_publish: false, - }], - privacy: { - data_classification: 'synthetic', - redactions: [], - raw_artifacts_safe_to_publish: false, - retention: 'Temporary generated dogfood fixture only.', - }, - result: { claim_status: 'passed', comparison_status_by_slot: { 'ui-58-valid-scoped-proof': 'satisfied' } }, - claim_limits: [ - 'Proves Workspine UI proof metadata and comparison behavior only; does not prove real browser rendering quality, cross-browser behavior, full accessibility, production delivery, or public release proof.', - ], - ...overrides, - }; - } - - function writeDogfoodFixture(bundle = dogfoodBundle()) { - const htmlPath = path.join(tmpDir, 'fixtures', 'dogfood', 'index.html'); - const scriptPath = path.join(tmpDir, 'fixtures', 'dogfood', 'app.js'); - const bundlePath = path.join(tmpDir, '.planning', 'phases', '58-dogfood-ui-proof-loop', 'proof-bundle.json'); - fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); - fs.mkdirSync(path.dirname(bundlePath), { recursive: true }); - fs.writeFileSync(htmlPath, '

Dogfood UI

\n'); - fs.writeFileSync(scriptPath, 'document.getElementById("save").dataset.state = "synthetic";\n'); - fs.writeFileSync(bundlePath, JSON.stringify(bundle, null, 2)); - return { htmlPath, scriptPath, bundlePath }; - } - - function writePlannedSlots(slots = [plannedSlots()[0]]) { - const slotsPath = path.join(tmpDir, '.planning', 'phases', '58-dogfood-ui-proof-loop', 'ui-proof-slots.json'); - fs.mkdirSync(path.dirname(slotsPath), { recursive: true }); - fs.writeFileSync(slotsPath, JSON.stringify({ ui_proof_slots: slots }, null, 2)); - return slotsPath; - } - - test('planned-vs-observed comparison satisfies valid scoped proof and fails closed on missing proof', async () => { - const mod = await importUiProofModule(); - const slots = plannedSlots(); - const result = mod.compareUiProofSlots(slots.slice(0, 2), [dogfoodBundle()]); - - assert.strictEqual(result.status, 'partial'); - assert.deepStrictEqual(result.slots.map((slot) => [slot.slot_id, slot.status]), [ - ['ui-58-valid-scoped-proof', 'satisfied'], - ['ui-58-missing-or-botched-proof', 'missing'], - ]); - assert.ok(result.slots[1].issues.some((issue) => issue.code === 'missing_observed_bundle')); - const missingIssue = result.slots[1].issues.find((issue) => issue.code === 'missing_observed_bundle'); - assert.strictEqual(missingIssue.severity, 'blocker'); - assert.match(missingIssue.fix_hint, /observed UI proof bundle/); - }); - - test('planned-vs-observed comparison fails closed on weak planned slots', async () => { - const mod = await importUiProofModule(); - const result = mod.compareUiProofSlots([{ slot_id: 'ui-58-valid-scoped-proof' }], [dogfoodBundle()]); - - assert.strictEqual(result.status, 'partial'); - assert.ok(result.errors.some((error) => error.code === 'missing_required_field' && error.path === 'ui_proof_slots[0].claim')); - }); - - test('mismatched and botched observed proof cannot satisfy planned slots', async () => { - const mod = await importUiProofModule(); - const [validSlot] = plannedSlots(); - const mismatched = dogfoodBundle({ - scope: { ...dogfoodBundle().scope, slot_ids: ['ui-58-wrong-slot'] }, - result: { claim_status: 'passed', comparison_status_by_slot: { 'ui-58-wrong-slot': 'satisfied' } }, - }); - const botched = dogfoodBundle({ observations: [] }); - - const mismatchedResult = mod.compareUiProofSlots([validSlot], [mismatched]); - assert.strictEqual(mismatchedResult.slots[0].status, 'missing'); - - const botchedResult = mod.compareUiProofSlots([validSlot], [botched]); - assert.strictEqual(botchedResult.slots[0].status, 'partial'); - assert.ok(botchedResult.slots[0].issues.some((issue) => issue.code === 'invalid_observed_bundle')); - }); - - test('human approval cannot upgrade missing required non-human proof to satisfied', async () => { - const mod = await importUiProofModule(); - const humanSlot = plannedSlots()[2]; - const humanOnly = dogfoodBundle({ - scope: { - ...dogfoodBundle().scope, - slot_ids: ['ui-58-human-bypass-blocked'], - claim: 'Human approval cannot bypass missing required non-human evidence for visual, taste, accessibility, or privacy-sensitive UI proof.', - }, - route_state: '/dogfood route with synthetic fixture state and subjective review metadata', - evidence_inputs: { kinds: ['human'], tools_used: ['manual-review'] }, - observations: [{ - observation: 'Human/manual acceptance is represented as human evidence or waiver/deferment metadata.', - claim: 'Human approval recorded for subjective review only.', - route_state: '/dogfood route with synthetic fixture state and subjective review metadata', - evidence_kind: 'human', - artifact_refs: ['.planning/phases/58-dogfood-ui-proof-loop/proof-bundle.json'], - privacy: { data_classification: 'synthetic', raw_artifacts_safe_to_publish: false, retention: 'temporary_review' }, - result: 'passed', - claim_limit: 'Human evidence may narrow, waive, defer, or record proof debt; it does not prove missing non-human evidence or full accessibility/taste acceptance.', - }], - result: { claim_status: 'passed', comparison_status_by_slot: { 'ui-58-human-bypass-blocked': 'satisfied' } }, - claim_limits: ['Human evidence may narrow, waive, defer, or record proof debt; it does not prove missing non-human evidence or full accessibility/taste acceptance.'], - }); - - const result = mod.compareUiProofSlots([humanSlot], [humanOnly]); - assert.strictEqual(result.slots[0].status, 'partial'); - assert.ok(result.slots[0].issues.some((issue) => issue.code === 'human_evidence_cannot_bypass_required_non_human_evidence')); - }); - - test('nested route state and claim mismatches cannot satisfy planned proof', async () => { - const mod = await importUiProofModule(); - const slot = { - ...plannedSlots()[0], - route_state: { route: '/dogfood', state: { tab: 'expected' } }, - }; - const bundle = dogfoodBundle({ - route_state: { route: '/dogfood', state: { tab: 'actual' } }, - scope: { ...dogfoodBundle().scope, claim: 'Different claim' }, - observations: dogfoodBundle().observations.map((observation) => ({ - ...observation, - route_state: { route: '/dogfood', state: { tab: 'actual' } }, - })), - }); - - const result = mod.compareUiProofSlots([slot], [bundle]); - assert.strictEqual(result.slots[0].status, 'partial'); - assert.ok(result.slots[0].issues.some((issue) => issue.code === 'route_state_mismatch')); - assert.ok(result.slots[0].issues.some((issue) => issue.code === 'observation_route_state_mismatch')); - assert.ok(result.slots[0].issues.some((issue) => issue.code === 'claim_mismatch')); - - const observationClaimMismatch = dogfoodBundle({ - observations: dogfoodBundle().observations.map((observation) => ({ - ...observation, - claim: 'Different claim', - })), - }); - const claimResult = mod.compareUiProofSlots([plannedSlots()[0]], [observationClaimMismatch]); - assert.ok(claimResult.slots[0].issues.some((issue) => issue.code === 'observation_claim_mismatch')); - }); - - test('declared required evidence kinds need passed supporting observations', async () => { - const mod = await importUiProofModule(); - const [slot] = plannedSlots(); - const metadataOnly = dogfoodBundle({ - observations: dogfoodBundle().observations.filter((observation) => observation.evidence_kind !== 'test'), - }); - - const result = mod.compareUiProofSlots([slot], [metadataOnly]); - assert.strictEqual(result.slots[0].status, 'partial'); - assert.ok(result.slots[0].issues.some((issue) => issue.code === 'missing_supporting_observation_evidence_kind')); - }); - - test('slot comparison ignores unrelated observations but preserves original supporting indices', async () => { - const mod = await importUiProofModule(); - const [slot] = plannedSlots(); - const unrelatedFailedObservation = { - ...dogfoodBundle().observations[0], - claim: 'Different planned claim for another slot.', - observation: 'This unrelated observation failed and must not make the current slot partial.', - result: 'failed', - }; - - const unrelatedFailure = dogfoodBundle({ - observations: [unrelatedFailedObservation, ...dogfoodBundle().observations], - result: { - ...dogfoodBundle().result, - failure_classification: 'product_bug', - }, - }); - const unrelatedFailureResult = mod.compareUiProofSlots([slot], [unrelatedFailure]); - assert.strictEqual(unrelatedFailureResult.status, 'satisfied', JSON.stringify(unrelatedFailureResult)); - - const routeMismatch = dogfoodBundle({ - observations: [unrelatedFailedObservation, { - ...dogfoodBundle().observations[0], - route_state: '/wrong dogfood route', - }], - result: { - ...dogfoodBundle().result, - failure_classification: 'product_bug', - }, - }); - const routeResult = mod.compareUiProofSlots([{ ...slot, required_evidence_kinds: ['code'] }], [routeMismatch]); - assert.ok(routeResult.slots[0].issues.some((issue) => issue.code === 'observation_route_state_mismatch' && issue.path === 'observations[1].route_state')); - }); - - test('minimum observations must come from observations supporting the planned slot', async () => { - const mod = await importUiProofModule(); - const [slot] = plannedSlots(); - const unrelatedMinimumObservation = { - ...dogfoodBundle().observations[0], - claim: 'Different planned claim for another slot.', - observation: 'Only this unrelated observation mentions the expected unique text.', - }; - const bundle = dogfoodBundle({ observations: [...dogfoodBundle().observations, unrelatedMinimumObservation] }); - - const result = mod.compareUiProofSlots([{ ...slot, minimum_observations: ['expected unique text'] }], [bundle]); - - assert.strictEqual(result.slots[0].status, 'partial'); - assert.ok(result.slots[0].issues.some((issue) => issue.code === 'missing_minimum_observation')); - }); - - test('manual acceptance requirement needs explicit passed human observation', async () => { - const mod = await importUiProofModule(); - const [slot] = plannedSlots(); - - const result = mod.compareUiProofSlots([{ ...slot, manual_acceptance_required: true }], [dogfoodBundle()]); - - assert.strictEqual(result.slots[0].status, 'partial'); - assert.ok(result.slots[0].issues.some((issue) => issue.code === 'missing_manual_acceptance_evidence')); - assert.ok(result.slots[0].issues.some((issue) => issue.code === 'missing_manual_acceptance_observation')); - }); - - test('manual acceptance requirement can be satisfied by explicit passed human evidence', async () => { - const mod = await importUiProofModule(); - const [slot] = plannedSlots(); - const manualObservation = { - ...dogfoodBundle().observations[0], - observation: 'Human reviewer accepted the scoped synthetic UI proof boundary.', - evidence_kind: 'human', - }; - const bundle = dogfoodBundle({ - evidence_inputs: { kinds: ['code', 'test', 'runtime', 'human'], tools_used: ['node:test', 'manual-review'] }, - observations: [...dogfoodBundle().observations, manualObservation], - }); - - const result = mod.compareUiProofSlots([{ ...slot, required_evidence_kinds: ['code', 'test', 'runtime', 'human'], manual_acceptance_required: true }], [bundle]); - - assert.strictEqual(result.status, 'satisfied', JSON.stringify(result)); - }); - - test('explicitly supplied invalid observed bundles fail closed even when another bundle satisfies the slot', async () => { - const mod = await importUiProofModule(); - const [slot] = plannedSlots(); - - const result = mod.compareUiProofSlots([slot], [dogfoodBundle(), { source: 'invalid-observed.json', bundle: {}, validation: { valid: false, errors: [{ code: 'invalid_bundle' }], warnings: [] } }]); - - assert.strictEqual(result.status, 'partial'); - assert.ok(result.errors.some((error) => error.code === 'invalid_observed_bundle' && error.path === 'invalid-observed.json')); - }); - - test('generated UI-bearing fixture validates through CLI and compares as narrow local proof', async () => { - await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); - const { htmlPath, scriptPath } = writeDogfoodFixture(); - assert.match(fs.readFileSync(htmlPath, 'utf-8'), /