Skip to content
Merged
13 changes: 13 additions & 0 deletions docs/project-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
All notable changes to CafeKit are documented here, following
[Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

## [0.13.0] - 2026-06-18

### Changed
- **Specs context optimization (P0 + P1b)**: The `spec-state` UserPromptSubmit hook now emits the full Tollgate block only when spec state (phase + done/total tasks) actually changes; unchanged turns get a one-line reminder, cutting repeated per-turn context (~460 tokens/turn). State fingerprint cached in `.claude/hooks/.logs/tollgate-last.txt` (gitignored, fail-open).
- **Deterministic timestamp check**: `validate-spec-output.cjs` now fails a spec when `timestamps.requirements_done` / `design_done` / `tasks_done` reuse `timestamps.init`, moving a previously prompt-only rule into the hard validator backstop.
- **Per-criterion coverage check (P1d)**: `validate-spec-output.cjs` now verifies acceptance-criterion coverage at sub-level (e.g. `R3.4`), not just the requirement group (`R3`). Enforced only when `requirements.md` declares explicit `R{N}.{M}` literals and tasks use the numeric `_Requirements: x.y_` mapping; specs using the legacy numbered-list format are skipped (no false failures). Closes the "phantom traceability" gap where a task referencing `R3` counted as covering every `R3.x` criterion.
- **SKILL.md de-duplication (#2)**: `specs/SKILL.md` Step 9.5 Finalization Audit and the Pre-Finalization Checklist now split into a "validator-enforced" group (collapsed to one line pointing at `validate-spec-output.cjs`) and a "judgment-only" group the validator cannot see. Removes hand-restated rules that the deterministic validator already hard-fails on; all semantic rules (deletion policy, provider drift, decision propagation, EARS measurability, diagrams) are preserved verbatim. ~290 tokens off the always-loaded skill body with no loss of enforcement.
- **Removed Task Hydration step**: dropped Step 8 (Task Hydration) from the `specs` pipeline and deleted `references/task-hydration.md`. It created session-scoped Claude Tasks that died with the session and were re-derived from task files anyway; `develop` reads task files directly. Pipeline renumbered to 1→7 + Validation (8) + Finalization Audit (8.5) + Completion (9). Removes ~1 step and ~1KB of always-referenced guidance with no loss of source-of-truth (task files + `task_registry` unchanged).
- **Cross-layer contract drift check (#3)**: `validate-spec-output.cjs` can now detect BE/FE/DB contract drift. When `design.md` defines a named contract via `<!-- contract:NAME -->` + a fenced block, every task that declares `Contracts: NAME` must copy that block verbatim; the validator fails the spec on a divergent copy (e.g. `orderId` vs `order_id`) or an unknown contract name. Fully opt-in — specs without contract markers are unaffected. `templates/design.md` documents the syntax. Verified on a synthetic BE+FE fixture (match → PASS, field rename → FAIL, unknown name → FAIL) plus regression on the two real specs (no effect).
- **Merged tasks-parallel-analysis into tasks-generation (A1)**: deleted `rules/tasks-parallel-analysis.md` (~90% a restatement of `tasks-generation.md`'s Parallel Analysis section). Its 3 unique points (env/setup precondition, `(P)` outside checkbox brackets, skip container-only majors) were merged into `tasks-generation.md`. Step 7 loads one fewer rule file; no behavior change.

## [0.12.0] - 2026-06-16

### Added
Expand Down
16 changes: 16 additions & 0 deletions packages/spec/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.13.0] - 2026-06-18

### Changed
- **`hapo:specs` pipeline streamlined to 9 steps.** Removed the Task Hydration step (old Step 8): it created session-scoped Claude Tasks that died with the session and were re-derived from task files anyway, while `hapo:develop` reads task files directly. Task files + `spec.json.task_registry` remain the single source of truth.
- **Lower per-turn context cost.** The `spec-state` tollgate hook now emits its full block only when spec state changes (phase or done/total tasks); unchanged turns get a one-line reminder.
- **Leaner skill body.** De-duplicated `SKILL.md` finalization/checklist rules already enforced by the validator, and merged `rules/tasks-parallel-analysis.md` into `rules/tasks-generation.md` (one fewer rule file). No behavior change.

### Added
- **Deterministic validator hardening** (`validate-spec-output.cjs`):
- Fails when `timestamps.requirements_done` / `design_done` / `tasks_done` reuse `timestamps.init`.
- Verifies acceptance-criterion coverage at sub-level (e.g. `R3.4`), not just the requirement group — opt-in when `requirements.md` uses explicit `R{N}.{M}` literals.
- Detects cross-layer contract drift: a `<!-- contract:NAME -->` block in `design.md` must be copied verbatim by every task declaring `Contracts: NAME`; divergent copies or unknown names fail. Opt-in via markers; documented in `templates/design.md`.

### Notes
- All new validator checks are opt-in / progressive — specs in the legacy format are unaffected and continue to pass.

## [0.11.11] - 2026-06-08

### Changed
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@haposoft/cafekit",
"version": "0.12.0",
"version": "0.13.0",
"description": "Claude Code-first spec-driven workflow for AI coding assistants. Bundles CafeKit hapo: skills, runtime hooks, agents, and installer scaffolding.",
"author": "Haposoft <nghialt@haposoft.com>",
"license": "MIT",
Expand Down
21 changes: 21 additions & 0 deletions packages/spec/src/claude/hooks/spec-state.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ try {
return status === 'pending' && deps.every((dep) => taskStatusByPath.get(dep) === 'done');
});

// ── State-change gate: only emit the full tollgate when spec state changed ──
// This hook runs on EVERY UserPromptSubmit. Re-printing the whole block when
// phase + done/total are unchanged is wasted context (~460 tok/turn). We keep
// a tiny state fingerprint in a temp file: same -> one-line reminder; changed
// -> full block (and refresh the fingerprint). Fail-open at every step.
const stateKey = `${phase}|${taskCounts.done || 0}/${taskEntries.length}`;
const cacheFile = path.join(__dirname, '.logs', 'tollgate-last.txt');

let lastKey = '';
try { lastKey = fs.readFileSync(cacheFile, 'utf8').trim(); } catch { /* first run */ }

if (lastKey === stateKey) {
console.log(`\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${taskCounts.done || 0}/${taskEntries.length} tasks done). Tollgate active — sync \`spec.json\` when state changes.\n`);
process.exit(0);
}

try {
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
fs.writeFileSync(cacheFile, stateKey);
} catch { /* fail-open: if the write fails we still print the full block */ }

// Format the output
const lines = [];
lines.push('');
Expand Down
139 changes: 136 additions & 3 deletions packages/spec/src/claude/scripts/validate-spec-output.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ function extractRequirementIds(requirementsText) {
return [...ids].filter((id) => id !== 'R0').sort();
}

/**
* Extract sub-criteria IDs (e.g. R3.4) ONLY when requirements.md declares them
* as explicit literals — bold `**R3.4**` or a line-leading `R3.4`. Specs that
* write acceptance criteria as a plain numbered list under a heading declare no
* such literals, so this returns empty and per-criterion coverage is skipped
* for them (no behaviour change). This is a progressive check: it rewards an
* unambiguous format, it never penalises the legacy one.
*/
function extractSubCriteriaIds(requirementsText) {
const ids = new Set();
const re = /(?:^|\s)\**(R\d+\.\d+)\**/gim;
let match;
while ((match = re.exec(requirementsText)) !== null) {
const id = match[1].toUpperCase();
if (!id.startsWith('R0.')) ids.add(id);
}
return [...ids].sort();
}

function validateTaskSections(taskPath, content, errors) {
const hasContext = hasHeading(content, 'Context');
const hasConstraints = hasHeading(content, 'Constraints');
Expand Down Expand Up @@ -113,6 +132,75 @@ function validateTaskSections(taskPath, content, errors) {
}
}

/**
* Each phase completion must carry its own timestamp. Reusing `timestamps.init`
* for a later phase is forbidden (SKILL.md spec.json Update Rules). This used to
* be a prompt-only rule the model had to remember; here it is a hard backstop.
*/
function validateTimestamps(spec, errors) {
const ts = spec.timestamps;
if (!ts || typeof ts !== 'object') return;
const init = ts.init;
if (!init) return;

for (const phase of ['requirements_done', 'design_done', 'tasks_done']) {
if (ts[phase] && ts[phase] === init) {
errors.push(
`spec.json.timestamps.${phase}: reuses init timestamp (${init}); ` +
'each phase must stamp its own completion time',
);
}
}
}

/** Normalize a fenced code block body for byte-comparison: trim + collapse
* trailing whitespace per line + drop blank edges. Keeps inner structure so a
* real field rename (user_name vs userName) still differs. */
function normalizeBlock(body) {
return body
.split('\n')
.map((line) => line.replace(/\s+$/, ''))
.join('\n')
.replace(/^\n+|\n+$/g, '');
}

/**
* Parse canonical contract definitions from design.md. A definition is an HTML
* marker `<!-- contract:NAME -->` immediately followed by a fenced code block.
* Returns a Map<name, normalizedBody>. Empty when the spec uses no markers —
* which makes the whole cross-layer check opt-in (no effect on legacy specs).
*/
function extractContractDefs(designText) {
const defs = new Map();
const re = /<!--\s*contract:([A-Za-z0-9_.-]+)\s*-->\s*\n```[^\n]*\n([\s\S]*?)\n```/g;
let match;
while ((match = re.exec(designText)) !== null) {
defs.set(match[1], normalizeBlock(match[2]));
}
return defs;
}

/**
* From a task body, return the contracts it claims plus the first fenced block
* it carries (the task's local copy of the contract). Shape:
* { names: string[], block: string|null }
* `names` come from a `Contracts: A, B` line; `block` is the first fenced code
* block in the task (normalized) used to compare against the canonical defs.
*/
function extractTaskContracts(taskText) {
const names = [];
const nameLine = taskText.match(/^\s*Contracts:\s*([^\n]+)$/im);
if (nameLine) {
for (const token of nameLine[1].split(',')) {
const name = token.trim();
if (name) names.push(name);
}
}
const blockMatch = taskText.match(/```[^\n]*\n([\s\S]*?)\n```/);
const block = blockMatch ? normalizeBlock(blockMatch[1]) : null;
return { names, block };
}

function validateSpec(specDir) {
const errors = [];
const warnings = [];
Expand Down Expand Up @@ -141,6 +229,8 @@ function validateSpec(specDir) {
errors.push('spec.json.scope_lock: must be an object, not a boolean or array');
}

validateTimestamps(spec, errors);

const taskFiles = listTaskFiles(specDir);
const taskFileSet = new Set(taskFiles);

Expand Down Expand Up @@ -236,11 +326,20 @@ function validateSpec(specDir) {
}

let requirementIds = [];
let subCriteriaIds = [];
if (fs.existsSync(requirementsPath)) {
requirementIds = extractRequirementIds(fs.readFileSync(requirementsPath, 'utf8'));
const requirementsText = fs.readFileSync(requirementsPath, 'utf8');
requirementIds = extractRequirementIds(requirementsText);
subCriteriaIds = extractSubCriteriaIds(requirementsText);
}

const coveredRequirementIds = new Set();
const coveredSubCriteriaIds = new Set();
// Cross-layer contract defs (opt-in): empty unless design.md uses
// <!-- contract:NAME --> markers, so legacy specs are unaffected.
const contractDefs = fs.existsSync(designPath)
? extractContractDefs(fs.readFileSync(designPath, 'utf8'))
: new Map();
for (const taskFile of taskFiles) {
const fullPath = path.join(specDir, taskFile);
const content = fs.readFileSync(fullPath, 'utf8');
Expand All @@ -256,8 +355,30 @@ function validateSpec(specDir) {
const numericMappingRe = /_Requirements:\s*([^_\n]+)_/gi;
while ((match = numericMappingRe.exec(content)) !== null) {
for (const token of match[1].split(',')) {
const number = token.trim().match(/^(\d+)(?:\.\d+)?$/);
if (number) coveredRequirementIds.add(`R${number[1]}`);
const trimmed = token.trim();
const major = trimmed.match(/^(\d+)(?:\.\d+)?$/);
if (major) coveredRequirementIds.add(`R${major[1]}`);
// Record the full sub-criterion (e.g. 3.4 -> R3.4) for per-criterion coverage.
const sub = trimmed.match(/^(\d+\.\d+)$/);
if (sub) coveredSubCriteriaIds.add(`R${sub[1]}`);
}
}

// Cross-layer contract check (opt-in via design.md markers). When a task
// claims `Contracts: NAME`, that name must exist in design.md and the task's
// local copy of the block must match the canonical definition byte-for-byte
// (after whitespace normalization). This catches BE/FE drift like
// user_name vs userName before integration.
if (contractDefs.size > 0) {
const { names, block } = extractTaskContracts(content);
for (const name of names) {
if (!contractDefs.has(name)) {
errors.push(`${taskFile}: declares unknown contract "${name}" (not defined in design.md)`);
continue;
}
if (block !== null && block !== contractDefs.get(name)) {
errors.push(`${taskFile}: contract "${name}" body diverges from the canonical definition in design.md`);
}
}
}
}
Expand All @@ -268,6 +389,18 @@ function validateSpec(specDir) {
}
}

// Per-criterion coverage: only enforced when requirements.md declares explicit
// R{N}.{M} literals AND tasks use the numeric `_Requirements: x.y_` mapping.
// If a spec declares sub-criteria but no task maps any at sub-level, that is the
// legacy coarse format (major-only) — skip silently to avoid false failures.
if (subCriteriaIds.length > 0 && coveredSubCriteriaIds.size > 0) {
for (const subId of subCriteriaIds) {
if (!coveredSubCriteriaIds.has(subId)) {
errors.push(`requirements.md:${subId}: acceptance criterion not covered by any task`);
}
}
}

if (spec.ready_for_implementation === true && errors.length > 0) {
errors.push('spec.json.ready_for_implementation: cannot be true while validator errors exist');
}
Expand Down
Loading