From 48cc27bd84e2b73c195ef7b33954cbcf4269666b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 8 Jun 2026 22:16:58 -0400 Subject: [PATCH] feat(#903): generate Loop Host Contract from workflow markers (ADR-857 phase 3a-impl-2) (#906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline LOOP_HOST_CONTRACT constant in the Capability Registry generator with a generated-from-workflows contract (ADR-894 §3). The contract is now derived from inert `` marker blocks in the five step workflows, emitted as the committed gsd-core/bin/lib/loop-host-contract.cjs, and required by gen-capability-registry.cjs — one source of truth, no drift. Drift guards in gen-loop-host-contract.cjs: per-step point ownership (each step must declare exactly its canonical loop points), multiple-block + duplicate-key hard errors, and a word-boundary agent-role cross-check. Contract content is byte-identical to the former constant; registry-only, nothing wired into the live loop. Closes #903 Co-authored-by: Claude Opus 4.8 --- CONTEXT.md | 5 +- docs/ARCHITECTURE.md | 1 + docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- gsd-core/bin/lib/loop-host-contract.cjs | 105 ++++ gsd-core/workflows/discuss-phase.md | 7 + gsd-core/workflows/execute-phase.md | 7 + gsd-core/workflows/plan-phase.md | 7 + gsd-core/workflows/ship.md | 7 + gsd-core/workflows/verify-work.md | 7 + package.json | 3 +- scripts/gen-capability-registry.cjs | 59 +- scripts/gen-loop-host-contract.cjs | 471 ++++++++++++++++ tests/loop-host-contract.test.cjs | 702 ++++++++++++++++++++++++ 14 files changed, 1328 insertions(+), 59 deletions(-) create mode 100644 gsd-core/bin/lib/loop-host-contract.cjs create mode 100644 scripts/gen-loop-host-contract.cjs create mode 100644 tests/loop-host-contract.test.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 15efaabd2..607d884d1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -145,8 +145,11 @@ Projects a pure, typed install plan for a given runtime by composing artifact pl ### Capability [Planned] A bundle delivering one optional GSD feature, toggled as a unit at install or after install. Owns its skills, agents, hooks, federated config-key schema (keys + defaults + validation), and loop extension-point registrations, plus a `requires` list of other Capabilities. Declared co-located in the Capability's own folder and compiled into a generated central Capability Registry at build time. The five-step loop (Discuss → Plan → Execute → Verify → Ship) and shared-infrastructure skills (phase, config, help, update, surface, progress) are the privileged host, not Capabilities, in v1 — but host extension points are data so a loop step can become a Capability under a future uniform kernel. Supersedes the implicit feature-scattering across clusters, install-profiles, and config-schema. Generalizes the Skill Surface Budget Module and Runtime Install Policy Module. +### Loop Host Contract +Generated description of what the five-step loop (Discuss → Plan → Execute → Verify → Ship) exposes as extension points: per-step loop points, agent roles, and core artifacts. Sourced from structured `` HTML-comment markers embedded near the top of each of the five step workflow files (`discuss-phase.md`, `plan-phase.md`, `execute-phase.md`, `verify-work.md`, `ship.md`). Generated by `scripts/gen-loop-host-contract.cjs` → `gsd-core/bin/lib/loop-host-contract.cjs` (ADR-894 §3 phase 3a-impl-2). Covers exactly the 12 canonical points (discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post). The generator enforces a drift guard: every declared non-orchestrator agent role must correspond to an actual agent reference in the workflow file. Consumed by `gen-capability-registry.cjs` (replaces the former inline `LOOP_HOST_CONTRACT` constant). Run `node scripts/gen-loop-host-contract.cjs --write` after editing a workflow step marker. + ### Capability Registry -Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys`, `runtimes`, `requiresClosure(id)`. Validated against the inline Loop Host Contract (12 points; `gen-loop-host-contract.cjs` to replace the inline constant in phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities//capability.json`. +Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys`, `runtimes`, `requiresClosure(id)`. Validated against the Loop Host Contract (12 points; generated by `gen-loop-host-contract.cjs` from workflow markers, phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities//capability.json`. ### Loop Extension Point [Planned] A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; ~12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query (extending the `init.*` resolution seam) that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e5db74298..4d8f313be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -369,6 +369,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, etc.) | | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | +| `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts; emitted by `scripts/gen-loop-host-contract.cjs` from workflow markers (ADR-894 §3); consumed by `gen-capability-registry.cjs` | | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations; emitted by `scripts/gen-capability-registry.cjs` (ADR-894 §5) | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 31b557add..db56b51f7 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-08", + "generated": "2026-06-09", "families": { "agents": [ "gsd-advisor-researcher", @@ -307,6 +307,7 @@ "io.cjs", "learnings.cjs", "legacy-cleanup.cjs", + "loop-host-contract.cjs", "milestone.cjs", "model-catalog.cjs", "model-profiles.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index e4e1042e3..af2581eef 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (98 shipped) +## CLI Modules (99 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -418,6 +418,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `io.cjs` | CLI I/O primitives — `output`/`error` emission, JSON-error mode, and large-payload temp-file spillover (extracted from `core.cjs`, ADR-857) | | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) | +| `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts for the five-step pipeline (discuss/plan/execute/verify/ship); emitted by `scripts/gen-loop-host-contract.cjs --write` (ADR-894 §3); consumed by `gen-capability-registry.cjs` | | `milestone.cjs` | Milestone archival, requirements marking | | `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | | `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | diff --git a/gsd-core/bin/lib/loop-host-contract.cjs b/gsd-core/bin/lib/loop-host-contract.cjs new file mode 100644 index 000000000..6011558c4 --- /dev/null +++ b/gsd-core/bin/lib/loop-host-contract.cjs @@ -0,0 +1,105 @@ +'use strict'; + +/** + * loop-host-contract.cjs — generated by scripts/gen-loop-host-contract.cjs + * DO NOT EDIT BY HAND. Run: node scripts/gen-loop-host-contract.cjs --write + * ADR-894 §3 — Loop Host Contract, generated from workflow markers. + * 12 points: discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post, + * verify:pre/post, ship:pre/post. Per-step agentRoles and coreArtifacts. + */ + +const LOOP_HOST_CONTRACT = [ + { + "step": "discuss", + "points": [ + "discuss:pre", + "discuss:post" + ], + "agentRoles": [ + "orchestrator" + ], + "coreArtifacts": { + "produces": [ + "CONTEXT.md" + ], + "consumes": [] + } + }, + { + "step": "plan", + "points": [ + "plan:pre", + "plan:post" + ], + "agentRoles": [ + "researcher", + "planner", + "checker" + ], + "coreArtifacts": { + "produces": [ + "PLAN.md" + ], + "consumes": [ + "CONTEXT.md" + ] + } + }, + { + "step": "execute", + "points": [ + "execute:pre", + "execute:wave:pre", + "execute:wave:post", + "execute:post" + ], + "agentRoles": [ + "executor", + "verifier" + ], + "coreArtifacts": { + "produces": [ + "SUMMARY.md" + ], + "consumes": [ + "PLAN.md" + ] + } + }, + { + "step": "verify", + "points": [ + "verify:pre", + "verify:post" + ], + "agentRoles": [ + "orchestrator" + ], + "coreArtifacts": { + "produces": [ + "UAT.md" + ], + "consumes": [ + "SUMMARY.md" + ] + } + }, + { + "step": "ship", + "points": [ + "ship:pre", + "ship:post" + ], + "agentRoles": [ + "orchestrator" + ], + "coreArtifacts": { + "produces": [], + "consumes": [ + "UAT.md" + ] + } + } +]; + +module.exports = { LOOP_HOST_CONTRACT }; diff --git a/gsd-core/workflows/discuss-phase.md b/gsd-core/workflows/discuss-phase.md index bd457175f..d5d9b9fa1 100644 --- a/gsd-core/workflows/discuss-phase.md +++ b/gsd-core/workflows/discuss-phase.md @@ -1,3 +1,10 @@ + Extract implementation decisions that downstream agents need. Analyze the phase to identify gray areas, let the user choose what to discuss, then deep-dive each selected area until satisfied. diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index cbe371ff0..6bb73d375 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -1,3 +1,10 @@ + Execute all plans in a phase using wave-based parallel execution. Orchestrator stays lean — delegates plan execution to subagents. diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index 161d76b2b..3fa184086 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -1,3 +1,10 @@ + Create executable phase prompts (PLAN.md files) for a roadmap phase with integrated research and verification. Default flow: Research (if needed) -> Plan -> Verify -> Done. Orchestrates gsd-phase-researcher, gsd-planner, and gsd-plan-checker agents with a revision loop (max 3 iterations). diff --git a/gsd-core/workflows/ship.md b/gsd-core/workflows/ship.md index 8504a767b..24bfbe227 100644 --- a/gsd-core/workflows/ship.md +++ b/gsd-core/workflows/ship.md @@ -1,3 +1,10 @@ + Create a pull request from completed phase/milestone work, generate a rich PR body from planning artifacts, optionally run code review, and prepare for merge. Closes the plan → execute → verify → ship loop. diff --git a/gsd-core/workflows/verify-work.md b/gsd-core/workflows/verify-work.md index f4d7c6394..5f35fe7f9 100644 --- a/gsd-core/workflows/verify-work.md +++ b/gsd-core/workflows/verify-work.md @@ -1,3 +1,10 @@ + Validate built features through conversational testing with persistent state. Creates UAT.md that tracks test progress, survives /clear, and feeds gaps into /gsd:plan-phase --gaps. diff --git a/package.json b/package.json index d753bbcb8..050571a0e 100644 --- a/package.json +++ b/package.json @@ -77,10 +77,11 @@ "check:alias-drift": "node scripts/check-alias-drift.cjs", "check:identity-drift": "node scripts/lint-package-identity-drift.cjs", "check:integrity": "node scripts/check-npm-integrity.cjs", - "build": "npm run generate:identity && npm run build:lib && npm run gen:capability-registry && npm run build:hooks", + "build": "npm run generate:identity && npm run build:lib && npm run gen:loop-host-contract && npm run gen:capability-registry && npm run build:hooks", "build:hooks": "node scripts/build-hooks.js", "build:lib": "tsc -p tsconfig.build.json", "generate:identity": "node scripts/generate-package-identity.cjs", + "gen:loop-host-contract": "node scripts/gen-loop-host-contract.cjs --write", "gen:capability-registry": "node scripts/gen-capability-registry.cjs --write", "prepack": "npm run build:lib", "prepare": "npm run build:lib", diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs index 1a1bd57d9..36b2755ad 100644 --- a/scripts/gen-capability-registry.cjs +++ b/scripts/gen-capability-registry.cjs @@ -29,61 +29,10 @@ const SCHEMA_VERSION = '1'; // ─── Loop Host Contract ─────────────────────────────────────────────────────── // -// Inline constant — hardcoded from ADR-894 §3 (12 points + per-step agentRoles + -// coreArtifacts). This represents the host contract that will be generated from -// workflow markers once the workflow-marker infrastructure is in place. -// -// TODO 3a-impl-2: replace this constant with the generated-from-workflows host -// contract (ADR-894 §3). The workflow markers (, , -// ) must be authored in each of the five step workflows; the -// gen-loop-host-contract.cjs generator will parse them and produce this object. -const LOOP_HOST_CONTRACT = [ - { - step: 'discuss', - points: ['discuss:pre', 'discuss:post'], - agentRoles: ['orchestrator'], - coreArtifacts: { - produces: ['CONTEXT.md'], - consumes: [], - }, - }, - { - step: 'plan', - points: ['plan:pre', 'plan:post'], - agentRoles: ['researcher', 'planner', 'checker'], - coreArtifacts: { - produces: ['PLAN.md'], - consumes: ['CONTEXT.md'], - }, - }, - { - step: 'execute', - points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'], - agentRoles: ['executor', 'verifier'], - coreArtifacts: { - produces: ['SUMMARY.md'], - consumes: ['PLAN.md'], - }, - }, - { - step: 'verify', - points: ['verify:pre', 'verify:post'], - agentRoles: ['orchestrator'], - coreArtifacts: { - produces: ['UAT.md'], - consumes: ['SUMMARY.md'], - }, - }, - { - step: 'ship', - points: ['ship:pre', 'ship:post'], - agentRoles: ['orchestrator'], - coreArtifacts: { - produces: [], - consumes: ['UAT.md'], - }, - }, -]; +// Generated from workflow markers by scripts/gen-loop-host-contract.cjs (ADR-894 §3). +// Require the committed gsd-core/bin/lib/loop-host-contract.cjs artifact so the +// registry generator and the loop-host-contract generator share one source of truth. +const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs'); // Canonical point order — explicit constant (do NOT rely on Set insertion order). // Used for point-ordering semantics in consumes-satisfiability validation and topo-sort. diff --git a/scripts/gen-loop-host-contract.cjs b/scripts/gen-loop-host-contract.cjs new file mode 100644 index 000000000..5838e0d0d --- /dev/null +++ b/scripts/gen-loop-host-contract.cjs @@ -0,0 +1,471 @@ +#!/usr/bin/env node +'use strict'; + +/** + * gen-loop-host-contract.cjs — generates gsd-core/bin/lib/loop-host-contract.cjs + * from the blocks in the five step workflows. + * + * Usage: + * node scripts/gen-loop-host-contract.cjs # print to stdout + * node scripts/gen-loop-host-contract.cjs --write # write loop-host-contract.cjs + * node scripts/gen-loop-host-contract.cjs --check # exit 1 if committed file is stale + * + * ADR-894 phase 3a-impl-2. Parses structured markers from workflow files, + * cross-checks declared agent-roles against actual agent references in each + * workflow, asserts that the union of all points equals the 12 canonical points, + * and emits a committed CommonJS module exporting the contract array. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); +const CONTRACT_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'loop-host-contract.cjs'); + +// The five step workflows in pipeline order +const STEP_WORKFLOWS = [ + { file: 'discuss-phase.md', step: 'discuss' }, + { file: 'plan-phase.md', step: 'plan' }, + { file: 'execute-phase.md', step: 'execute' }, + { file: 'verify-work.md', step: 'verify' }, + { file: 'ship.md', step: 'ship' }, +]; + +// Canonical 12 loop points in pipeline order +const CANONICAL_POINTS = [ + 'discuss:pre', + 'discuss:post', + 'plan:pre', + 'plan:post', + 'execute:pre', + 'execute:wave:pre', + 'execute:wave:post', + 'execute:post', + 'verify:pre', + 'verify:post', + 'ship:pre', + 'ship:post', +]; + +// FIX 1: Per-step canonical point ownership. Each step must declare exactly these points. +const EXPECTED_POINTS_BY_STEP = { + discuss: ['discuss:pre', 'discuss:post'], + plan: ['plan:pre', 'plan:post'], + execute: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'], + verify: ['verify:pre', 'verify:post'], + ship: ['ship:pre', 'ship:post'], +}; + +// Role → agent-name mapping used for cross-check. +// Each non-orchestrator role must correspond to an actual agent reference in +// the workflow file (e.g. gsd-planner, gsd-executor, gsd-verifier, etc.). +const ROLE_TO_AGENT = { + researcher: 'gsd-phase-researcher', + planner: 'gsd-planner', + checker: 'gsd-plan-checker', + executor: 'gsd-executor', + verifier: 'gsd-verifier', +}; + +// ─── Parser ─────────────────────────────────────────────────────────────────── + +/** + * Parse a single block from file content. + * Returns a plain object with keys: step, points[], agentRoles[], produces[], consumes[]. + * Throws a descriptive error if the block is malformed or missing. + * + * Block format (one key: value per line, comma-separated list values): + * + * + * For empty list values (e.g. "consumes:") the field is an empty array. + * + * @param {string} content File content + * @param {string} fileName For error messages + * @returns {{ step: string, points: string[], agentRoles: string[], coreArtifacts: { produces: string[], consumes: string[] } }} + */ +function parseLoopHostBlock(content, fileName) { + // FIX 2: Detect ALL marker blocks — more than one is a hard error. + const blockRe = //g; + const allMatches = Array.from(content.matchAll(blockRe)); + if (allMatches.length === 0) { + throw new Error(fileName + ': missing block'); + } + if (allMatches.length > 1) { + throw new Error( + fileName + ': expected exactly one gsd:loop-host marker block, found ' + allMatches.length, + ); + } + + const blockBody = allMatches[0][1]; + + // FIX 2: Detect duplicate keys within the block. + const RECOGNIZED_KEYS = ['step', 'points', 'agent-roles', 'produces', 'consumes']; + const keyCounts = {}; + for (const line of blockBody.split('\n')) { + const trimmed = line.trim(); + for (const key of RECOGNIZED_KEYS) { + if (trimmed === key + ':' || trimmed.startsWith(key + ': ') || trimmed.startsWith(key + ':')) { + keyCounts[key] = (keyCounts[key] || 0) + 1; + break; + } + } + } + for (const key of RECOGNIZED_KEYS) { + if (keyCounts[key] > 1) { + throw new Error(fileName + ': duplicate key \'' + key + '\' in gsd:loop-host marker'); + } + } + + /** + * Parse a field line: "key: value1, value2" → [value1, value2] (trimmed, empty strings removed) + */ + function parseField(key) { + // Split on newlines and find the line starting with "key:" + const lines = blockBody.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === key + ':' || trimmed.startsWith(key + ': ') || trimmed.startsWith(key + ':')) { + const colonIdx = trimmed.indexOf(':'); + const raw = trimmed.slice(colonIdx + 1).trim(); + if (raw === '') return []; + return raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0); + } + } + throw new Error(fileName + ': gsd:loop-host block missing required field "' + key + '"'); + } + + function parseScalar(key) { + const lines = blockBody.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === key + ':' || trimmed.startsWith(key + ': ') || trimmed.startsWith(key + ':')) { + const colonIdx = trimmed.indexOf(':'); + const val = trimmed.slice(colonIdx + 1).trim(); + if (val === '') { + throw new Error(fileName + ': gsd:loop-host block field "' + key + '" must be a non-empty string'); + } + return val; + } + } + throw new Error(fileName + ': gsd:loop-host block missing required field "' + key + '"'); + } + + const step = parseScalar('step'); + const points = parseField('points'); + const agentRoles = parseField('agent-roles'); + const produces = parseField('produces'); + const consumes = parseField('consumes'); + + if (points.length === 0) { + throw new Error(fileName + ': gsd:loop-host block "points" must have at least one value'); + } + if (agentRoles.length === 0) { + throw new Error(fileName + ': gsd:loop-host block "agent-roles" must have at least one value'); + } + + return { + step, + points, + agentRoles, + coreArtifacts: { produces, consumes }, + }; +} + +// ─── Cross-check: declared roles vs. actual agent references ───────────────── + +/** + * For each non-orchestrator role in agentRoles, verify the workflow content + * contains a reference to the corresponding agent name. + * + * @param {string} content Full workflow file content + * @param {string[]} agentRoles Roles declared in the block + * @param {string} fileName For error messages + * @returns {string[]} Array of error strings; empty = OK + */ +/** + * Escape a string for literal use in a RegExp. + */ +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function crossCheckRoles(content, agentRoles, fileName) { + const errors = []; + for (const role of agentRoles) { + if (role === 'orchestrator') continue; // orchestrator = host itself; no agent file needed + const agentName = ROLE_TO_AGENT[role]; + if (!agentName) { + errors.push( + fileName + ': declared agent-role "' + role + '" has no entry in ROLE_TO_AGENT mapping', + ); + continue; + } + // FIX 3: Use word-boundary match so "gsd-plan-checker-v2" does NOT satisfy a required + // "gsd-plan-checker". Treat '-' as part of the token: boundary = start/end of string or + // a character that is neither \w nor '-'. + // Note: this is a presence check (any reference in the file), not a spawn-site check — + // a known limitation; spawn-site checks would require AST-level analysis. + const agentRe = new RegExp( + '(^|[^\\w-])' + escapeRegExp(agentName) + '($|[^\\w-])', + ); + if (!agentRe.test(content)) { + errors.push( + fileName + ': declared agent-role "' + role + '" maps to agent "' + agentName + + '" but "' + agentName + '" is not referenced anywhere in the workflow file', + ); + } + } + return errors; +} + +// ─── 12-points coverage assertion ──────────────────────────────────────────── + +/** + * Assert that the union of all points across all contract entries equals + * exactly the 12 canonical points (no more, no fewer), AND that each step + * declares exactly its own canonical points (FIX 1: per-step ownership). + * + * @param {{ step: string, points: string[] }[]} entries + * @returns {string[]} Error strings; empty = OK + */ +function assertPointsCoverage(entries) { + const errors = []; + + // FIX 1: Per-step ownership check — each step must declare exactly its own canonical points. + for (const entry of entries) { + const expected = EXPECTED_POINTS_BY_STEP[entry.step]; + if (!expected) continue; // unknown step — caught elsewhere + const expectedSet = new Set(expected); + const actualSet = new Set(entry.points); + let mismatch = false; + for (const p of expectedSet) { + if (!actualSet.has(p)) mismatch = true; + } + for (const p of actualSet) { + if (!expectedSet.has(p)) mismatch = true; + } + if (mismatch) { + errors.push( + 'step "' + entry.step + '" declares points [' + entry.points.join(', ') + + '] but expected [' + expected.join(', ') + ']', + ); + } + } + + // Global union + duplicate check (belt and suspenders alongside per-step check). + const allPoints = new Set(); + for (const entry of entries) { + for (const p of entry.points) { + if (allPoints.has(p)) { + errors.push('point "' + p + '" declared more than once across all step workflows'); + } + allPoints.add(p); + } + } + + const canonical = new Set(CANONICAL_POINTS); + for (const p of allPoints) { + if (!canonical.has(p)) { + errors.push('declared point "' + p + '" is not in the canonical 12-point set'); + } + } + for (const p of canonical) { + if (!allPoints.has(p)) { + errors.push('canonical point "' + p + '" is not declared in any step workflow'); + } + } + return errors; +} + +// ─── Contract builder ───────────────────────────────────────────────────────── + +/** + * Read and parse all five step workflows. Returns the contract array. + * Throws on any parse or cross-check error. + * + * @param {string} [workflowsDir] Override for testing + * @returns {{ step: string, points: string[], agentRoles: string[], coreArtifacts: { produces: string[], consumes: string[] } }[]} + */ +function buildContract(workflowsDir) { + const resolvedDir = workflowsDir !== undefined ? workflowsDir : WORKFLOWS_DIR; + const contract = []; + const allErrors = []; + + for (const { file, step } of STEP_WORKFLOWS) { + const filePath = path.join(resolvedDir, file); + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + allErrors.push('Could not read ' + file + ': ' + String(err.message)); + continue; + } + + let entry; + try { + entry = parseLoopHostBlock(content, file); + } catch (err) { + allErrors.push(String(err.message)); + continue; + } + + // Validate the declared step matches the expected step for this file + if (entry.step !== step) { + allErrors.push( + file + ': gsd:loop-host block declares step "' + entry.step + + '" but expected "' + step + '"', + ); + } + + // Cross-check roles + const roleErrors = crossCheckRoles(content, entry.agentRoles, file); + allErrors.push(...roleErrors); + + contract.push(entry); + } + + if (allErrors.length > 0) { + throw new Error('Loop host contract generation failed:\n' + allErrors.map((e) => ' ' + e).join('\n')); + } + + // Assert 12-points coverage + const pointErrors = assertPointsCoverage(contract); + if (pointErrors.length > 0) { + throw new Error('Loop host contract points coverage failed:\n' + pointErrors.map((e) => ' ' + e).join('\n')); + } + + return contract; +} + +// ─── Serialization ──────────────────────────────────────────────────────────── + +/** + * Serialize the contract array to a CommonJS module string. + * + * @param {object[]} contract + * @returns {string} + */ +function serializeContract(contract) { + const lines = []; + + lines.push("'use strict';"); + lines.push(''); + lines.push('/**'); + lines.push(' * loop-host-contract.cjs — generated by scripts/gen-loop-host-contract.cjs'); + lines.push(' * DO NOT EDIT BY HAND. Run: node scripts/gen-loop-host-contract.cjs --write'); + lines.push(' * ADR-894 §3 — Loop Host Contract, generated from workflow markers.'); + lines.push(' * 12 points: discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post,'); + lines.push(' * verify:pre/post, ship:pre/post. Per-step agentRoles and coreArtifacts.'); + lines.push(' */'); + lines.push(''); + lines.push('const LOOP_HOST_CONTRACT = ' + JSON.stringify(contract, null, 2) + ';'); + lines.push(''); + lines.push('module.exports = { LOOP_HOST_CONTRACT };'); + lines.push(''); + + return lines.join('\n'); +} + +// ─── --check diff helper ────────────────────────────────────────────────────── + +/** + * Normalize line endings to LF for CRLF-agnostic comparison. + * FIX 4: The serializer has no nondeterministic content (no timestamp), so + * the generated-by-line stripping that was here has been removed — full content + * comparison is now used so header drift is caught by --check. + * + * @param {string} content + * @returns {string} + */ +function normalizeLineEndings(content) { + return content.replace(/\r/g, ''); +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +function main() { + const flag = process.argv[2]; + + if (flag === '--check') { + let contract; + try { + contract = buildContract(); + } catch (err) { + process.stderr.write(String(err.message) + '\n'); + throw new ExitError(1, 'loop-host contract generation failed'); + } + const live = serializeContract(contract); + + if (!fs.existsSync(CONTRACT_PATH)) { + process.stderr.write( + 'gsd-core/bin/lib/loop-host-contract.cjs does not exist. Run:\n' + + ' node scripts/gen-loop-host-contract.cjs --write\n', + ); + throw new ExitError(1); + } + + const committed = fs.readFileSync(CONTRACT_PATH, 'utf8'); + // FIX 4: Compare full content (no generated-by stripping) so header drift is caught. + if (normalizeLineEndings(committed) !== normalizeLineEndings(live)) { + process.stderr.write( + 'gsd-core/bin/lib/loop-host-contract.cjs is stale. Run:\n' + + ' node scripts/gen-loop-host-contract.cjs --write\n', + ); + throw new ExitError(1); + } + + process.stdout.write('gsd-core/bin/lib/loop-host-contract.cjs is up to date.\n'); + } else if (flag === '--write') { + let contract; + try { + contract = buildContract(); + } catch (err) { + process.stderr.write(String(err.message) + '\n'); + throw new ExitError(1, 'loop-host contract generation failed — file not written'); + } + const content = serializeContract(contract); + fs.mkdirSync(path.dirname(CONTRACT_PATH), { recursive: true }); + fs.writeFileSync(CONTRACT_PATH, content, 'utf8'); + process.stdout.write('Wrote ' + CONTRACT_PATH + '\n'); + } else { + // Default: print to stdout + let contract; + try { + contract = buildContract(); + } catch (err) { + process.stderr.write(String(err.message) + '\n'); + throw new ExitError(1, 'loop-host contract generation failed'); + } + process.stdout.write(serializeContract(contract) + '\n'); + } +} + +// ─── Exports (for tests) ───────────────────────────────────────────────────── + +module.exports = { + parseLoopHostBlock, + crossCheckRoles, + assertPointsCoverage, + buildContract, + serializeContract, + normalizeLineEndings, + STEP_WORKFLOWS, + CANONICAL_POINTS, + EXPECTED_POINTS_BY_STEP, + ROLE_TO_AGENT, +}; + +// ─── CLI entry point ────────────────────────────────────────────────────────── + +if (require.main === module) { + runMain(main); +} diff --git a/tests/loop-host-contract.test.cjs b/tests/loop-host-contract.test.cjs new file mode 100644 index 000000000..945b47eb3 --- /dev/null +++ b/tests/loop-host-contract.test.cjs @@ -0,0 +1,702 @@ +'use strict'; + +/** + * loop-host-contract.test.cjs — behavioral tests for gen-loop-host-contract.cjs. + * + * ADR-894 phase 3a-impl-2. + * Uses node:test + node:assert/strict. + * NO source-grep: tests use in-memory fixtures and real workflow files. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { cleanup } = require('./helpers.cjs'); + +const { + parseLoopHostBlock, + crossCheckRoles, + assertPointsCoverage, + buildContract, + serializeContract, + normalizeLineEndings, + STEP_WORKFLOWS, + CANONICAL_POINTS, + EXPECTED_POINTS_BY_STEP, + ROLE_TO_AGENT, +} = require('../scripts/gen-loop-host-contract.cjs'); + +const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const CONTRACT_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'loop-host-contract.cjs'); + +// ─── Helper: write a temporary workflows directory ──────────────────────────── + +function makeTempWorkflowsDir(files) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lhc-test-')); + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(tmpDir, name), content, 'utf8'); + } + return tmpDir; +} + +// ─── Minimal valid workflow content templates ───────────────────────────────── + +function makeWorkflow(step, points, roles, produces, consumes, extraContent) { + const pointsList = points.join(', '); + const rolesList = roles.join(', '); + const producesList = produces.join(', '); + const consumesList = consumes.join(', '); + return ( + '\n' + + (extraContent || '') + ); +} + +// Minimal valid set of 5 workflows matching the canonical contract +function makeValidWorkflowFiles() { + return { + 'discuss-phase.md': makeWorkflow('discuss', ['discuss:pre', 'discuss:post'], ['orchestrator'], ['CONTEXT.md'], []), + 'plan-phase.md': makeWorkflow( + 'plan', ['plan:pre', 'plan:post'], + ['researcher', 'planner', 'checker'], + ['PLAN.md'], ['CONTEXT.md'], + // Cross-check content: must contain the agent names + 'gsd-phase-researcher gsd-planner gsd-plan-checker', + ), + 'execute-phase.md': makeWorkflow( + 'execute', ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'], + ['executor', 'verifier'], + ['SUMMARY.md'], ['PLAN.md'], + 'gsd-executor gsd-verifier', + ), + 'verify-work.md': makeWorkflow('verify', ['verify:pre', 'verify:post'], ['orchestrator'], ['UAT.md'], ['SUMMARY.md']), + 'ship.md': makeWorkflow('ship', ['ship:pre', 'ship:post'], ['orchestrator'], [], ['UAT.md']), + }; +} + +// ─── 1. parseLoopHostBlock ──────────────────────────────────────────────────── + +describe('parseLoopHostBlock', () => { + test('parses a valid block correctly', () => { + const content = makeWorkflow( + 'plan', ['plan:pre', 'plan:post'], + ['researcher', 'planner', 'checker'], + ['PLAN.md'], ['CONTEXT.md'], + ); + const result = parseLoopHostBlock(content, 'plan-phase.md'); + assert.strictEqual(result.step, 'plan'); + assert.deepEqual(result.points, ['plan:pre', 'plan:post']); + assert.deepEqual(result.agentRoles, ['researcher', 'planner', 'checker']); + assert.deepEqual(result.coreArtifacts.produces, ['PLAN.md']); + assert.deepEqual(result.coreArtifacts.consumes, ['CONTEXT.md']); + }); + + test('parses empty produces field as empty array', () => { + const content = makeWorkflow( + 'ship', ['ship:pre', 'ship:post'], ['orchestrator'], [], ['UAT.md'], + ); + const result = parseLoopHostBlock(content, 'ship.md'); + assert.deepEqual(result.coreArtifacts.produces, []); + assert.deepEqual(result.coreArtifacts.consumes, ['UAT.md']); + }); + + test('parses empty consumes field as empty array', () => { + const content = makeWorkflow( + 'discuss', ['discuss:pre', 'discuss:post'], ['orchestrator'], ['CONTEXT.md'], [], + ); + const result = parseLoopHostBlock(content, 'discuss-phase.md'); + assert.deepEqual(result.coreArtifacts.consumes, []); + assert.deepEqual(result.coreArtifacts.produces, ['CONTEXT.md']); + }); + + test('throws when block is missing', () => { + assert.throws( + () => parseLoopHostBlock('no block here\nhello', 'test.md'), + /missing.*gsd:loop-host/, + ); + }); + + test('throws when step field is missing from block', () => { + const content = + '\n'; + assert.throws( + () => parseLoopHostBlock(content, 'test.md'), + /missing required field "step"/, + ); + }); + + test('throws when points field is empty', () => { + const content = + '\n'; + assert.throws( + () => parseLoopHostBlock(content, 'test.md'), + /"points" must have at least one value/, + ); + }); + + test('parses multi-value fields with spaces correctly', () => { + const content = makeWorkflow( + 'execute', + ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'], + ['executor', 'verifier'], + ['SUMMARY.md'], ['PLAN.md'], + ); + const result = parseLoopHostBlock(content, 'execute-phase.md'); + assert.deepEqual(result.points, ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post']); + assert.deepEqual(result.agentRoles, ['executor', 'verifier']); + }); +}); + +// ─── 2. crossCheckRoles ─────────────────────────────────────────────────────── + +describe('crossCheckRoles', () => { + test('passes for orchestrator-only roles (no agent file needed)', () => { + const errors = crossCheckRoles('anything', ['orchestrator'], 'discuss-phase.md'); + assert.deepEqual(errors, []); + }); + + test('passes when agent name is present in content', () => { + const content = 'Agent(subagent_type="gsd-planner") Agent(subagent_type="gsd-phase-researcher") gsd-plan-checker'; + const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); + assert.deepEqual(errors, []); + }); + + test('fails when declared role has no agent reference in content', () => { + const content = 'gsd-phase-researcher gsd-plan-checker'; // planner missing + const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); + assert.strictEqual(errors.length, 1, 'expected exactly 1 error for missing planner'); + assert.match(errors[0], /planner.*gsd-planner/); + }); + + test('fails when declared role is unknown (not in ROLE_TO_AGENT)', () => { + const content = 'gsd-executor gsd-verifier'; + const errors = crossCheckRoles(content, ['executor', 'nonexistent-role'], 'execute-phase.md'); + assert.ok(errors.some((e) => e.includes('nonexistent-role') && e.includes('ROLE_TO_AGENT'))); + }); +}); + +// ─── 3. assertPointsCoverage ───────────────────────────────────────────────── + +describe('assertPointsCoverage', () => { + test('passes when all 12 canonical points are covered', () => { + const entries = [ + { step: 'discuss', points: ['discuss:pre', 'discuss:post'] }, + { step: 'plan', points: ['plan:pre', 'plan:post'] }, + { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, + { step: 'verify', points: ['verify:pre', 'verify:post'] }, + { step: 'ship', points: ['ship:pre', 'ship:post'] }, + ]; + const errors = assertPointsCoverage(entries); + assert.deepEqual(errors, []); + }); + + test('fails when a canonical point is missing', () => { + const entries = [ + { step: 'discuss', points: ['discuss:pre'] }, // missing discuss:post + { step: 'plan', points: ['plan:pre', 'plan:post'] }, + { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, + { step: 'verify', points: ['verify:pre', 'verify:post'] }, + { step: 'ship', points: ['ship:pre', 'ship:post'] }, + ]; + const errors = assertPointsCoverage(entries); + assert.ok(errors.some((e) => e.includes('discuss:post') && e.includes('not declared'))); + }); + + test('fails when an unknown point is declared', () => { + const entries = [ + { step: 'discuss', points: ['discuss:pre', 'discuss:post', 'discuss:extra'] }, + { step: 'plan', points: ['plan:pre', 'plan:post'] }, + { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, + { step: 'verify', points: ['verify:pre', 'verify:post'] }, + { step: 'ship', points: ['ship:pre', 'ship:post'] }, + ]; + const errors = assertPointsCoverage(entries); + assert.ok(errors.some((e) => e.includes('discuss:extra') && e.includes('not in the canonical'))); + }); + + test('fails when a point is declared twice', () => { + const entries = [ + { step: 'discuss', points: ['discuss:pre', 'discuss:post'] }, + { step: 'plan', points: ['plan:pre', 'plan:post', 'discuss:pre'] }, // duplicate + { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, + { step: 'verify', points: ['verify:pre', 'verify:post'] }, + { step: 'ship', points: ['ship:pre', 'ship:post'] }, + ]; + const errors = assertPointsCoverage(entries); + assert.ok(errors.some((e) => e.includes('discuss:pre') && e.includes('more than once'))); + }); +}); + +// ─── 4. buildContract — from real workflows ─────────────────────────────────── + +describe('buildContract from real workflows', () => { + test('produces a contract matching the inline LOOP_HOST_CONTRACT shape', () => { + const contract = buildContract(); // reads real gsd-core/workflows/ + + // Must be an array of 5 entries + assert.strictEqual(contract.length, 5, 'contract must have 5 step entries'); + + // Each entry must have step, points, agentRoles, coreArtifacts + for (const entry of contract) { + assert.ok(typeof entry.step === 'string', 'entry.step must be a string'); + assert.ok(Array.isArray(entry.points), 'entry.points must be an array'); + assert.ok(Array.isArray(entry.agentRoles), 'entry.agentRoles must be an array'); + assert.ok(typeof entry.coreArtifacts === 'object', 'entry.coreArtifacts must be an object'); + assert.ok(Array.isArray(entry.coreArtifacts.produces), 'entry.coreArtifacts.produces must be an array'); + assert.ok(Array.isArray(entry.coreArtifacts.consumes), 'entry.coreArtifacts.consumes must be an array'); + } + + // Verify exact match with the committed loop-host-contract.cjs + assert.deepEqual(contract, LOOP_HOST_CONTRACT, 'built contract must match committed LOOP_HOST_CONTRACT'); + }); + + test('covers exactly the 12 canonical points', () => { + const contract = buildContract(); + const allPoints = contract.flatMap((e) => e.points); + assert.strictEqual(allPoints.length, 12, 'must cover exactly 12 points'); + const pointSet = new Set(allPoints); + assert.strictEqual(pointSet.size, 12, 'all 12 points must be distinct'); + for (const p of CANONICAL_POINTS) { + assert.ok(pointSet.has(p), 'canonical point "' + p + '" must be declared'); + } + }); + + test('discuss step has orchestrator role and produces CONTEXT.md', () => { + const contract = buildContract(); + const discuss = contract.find((e) => e.step === 'discuss'); + assert.ok(discuss, 'discuss step must be present'); + assert.deepEqual(discuss.agentRoles, ['orchestrator']); + assert.deepEqual(discuss.coreArtifacts.produces, ['CONTEXT.md']); + assert.deepEqual(discuss.coreArtifacts.consumes, []); + }); + + test('plan step has researcher/planner/checker roles and produces PLAN.md', () => { + const contract = buildContract(); + const plan = contract.find((e) => e.step === 'plan'); + assert.ok(plan, 'plan step must be present'); + assert.deepEqual(plan.agentRoles, ['researcher', 'planner', 'checker']); + assert.deepEqual(plan.coreArtifacts.produces, ['PLAN.md']); + assert.deepEqual(plan.coreArtifacts.consumes, ['CONTEXT.md']); + }); + + test('execute step has executor/verifier roles and 4 points', () => { + const contract = buildContract(); + const execute = contract.find((e) => e.step === 'execute'); + assert.ok(execute, 'execute step must be present'); + assert.deepEqual(execute.agentRoles, ['executor', 'verifier']); + assert.deepEqual(execute.points, ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post']); + assert.deepEqual(execute.coreArtifacts.produces, ['SUMMARY.md']); + assert.deepEqual(execute.coreArtifacts.consumes, ['PLAN.md']); + }); + + test('verify step has orchestrator role and produces UAT.md', () => { + const contract = buildContract(); + const verify = contract.find((e) => e.step === 'verify'); + assert.ok(verify, 'verify step must be present'); + assert.deepEqual(verify.agentRoles, ['orchestrator']); + assert.deepEqual(verify.coreArtifacts.produces, ['UAT.md']); + assert.deepEqual(verify.coreArtifacts.consumes, ['SUMMARY.md']); + }); + + test('ship step has orchestrator role and empty produces', () => { + const contract = buildContract(); + const ship = contract.find((e) => e.step === 'ship'); + assert.ok(ship, 'ship step must be present'); + assert.deepEqual(ship.agentRoles, ['orchestrator']); + assert.deepEqual(ship.coreArtifacts.produces, []); + assert.deepEqual(ship.coreArtifacts.consumes, ['UAT.md']); + }); +}); + +// ─── 5. cross-check rejects nonexistent agent-role ─────────────────────────── + +describe('buildContract cross-check drift guard', () => { + test('rejects a block declaring a nonexistent agent-role', () => { + // Build a temporary workflows dir where plan-phase.md declares a role + // that has no corresponding agent reference in the file content. + const files = makeValidWorkflowFiles(); + // Override plan-phase.md to declare a "phantom" role with no agent reference + files['plan-phase.md'] = + '\n' + + // Include real agents but NOT the phantom role's agent (phantom is not in ROLE_TO_AGENT) + 'gsd-phase-researcher gsd-planner gsd-plan-checker\n'; + + const tmpDir = makeTempWorkflowsDir(files); + try { + assert.throws( + () => buildContract(tmpDir), + /ROLE_TO_AGENT|no entry/, + ); + } finally { + cleanup(tmpDir); + } + }); + + test('rejects a block declaring a role whose agent is absent from the workflow', () => { + // plan-phase.md declares 'researcher' but does NOT mention gsd-phase-researcher + const files = makeValidWorkflowFiles(); + files['plan-phase.md'] = + '\n' + + // Only planner and checker present, researcher's agent is absent + 'gsd-planner gsd-plan-checker\n'; + + const tmpDir = makeTempWorkflowsDir(files); + try { + assert.throws( + () => buildContract(tmpDir), + /gsd-phase-researcher.*not referenced|researcher.*gsd-phase-researcher/, + ); + } finally { + cleanup(tmpDir); + } + }); +}); + +// ─── 6. --check: CRLF-agnostic + committed-file staleness guard ────────────── + +describe('normalizeLineEndings and committed-file staleness', () => { + test('normalizeLineEndings strips CR characters', () => { + const crlf = 'line1\r\nline2\r\nline3'; + const lf = 'line1\nline2\nline3'; + assert.strictEqual(normalizeLineEndings(crlf), lf); + assert.strictEqual(normalizeLineEndings(lf), lf); + }); + + test('committed loop-host-contract.cjs is up to date (--check passes)', () => { + // Build the live contract from the real workflows + const contract = buildContract(); + const live = serializeContract(contract); + + // Read the committed file + const committed = fs.readFileSync(CONTRACT_PATH, 'utf8'); + + // FIX 4: Full-content comparison — no generated-by-line stripping needed + // because the serializer has no nondeterministic content (no timestamp). + assert.strictEqual( + normalizeLineEndings(committed), + normalizeLineEndings(live), + 'committed loop-host-contract.cjs is stale — run: node scripts/gen-loop-host-contract.cjs --write', + ); + }); +}); + +// ─── 7. STEP_WORKFLOWS and CANONICAL_POINTS exported constants ──────────────── + +describe('module exports', () => { + test('STEP_WORKFLOWS has 5 entries in pipeline order', () => { + assert.strictEqual(STEP_WORKFLOWS.length, 5); + assert.strictEqual(STEP_WORKFLOWS[0].step, 'discuss'); + assert.strictEqual(STEP_WORKFLOWS[1].step, 'plan'); + assert.strictEqual(STEP_WORKFLOWS[2].step, 'execute'); + assert.strictEqual(STEP_WORKFLOWS[3].step, 'verify'); + assert.strictEqual(STEP_WORKFLOWS[4].step, 'ship'); + }); + + test('CANONICAL_POINTS has exactly 12 entries', () => { + assert.strictEqual(CANONICAL_POINTS.length, 12); + }); + + test('ROLE_TO_AGENT covers all non-orchestrator roles', () => { + // All non-orchestrator roles from the real contract + const allRoles = new Set( + LOOP_HOST_CONTRACT.flatMap((e) => e.agentRoles).filter((r) => r !== 'orchestrator'), + ); + for (const role of allRoles) { + assert.ok( + ROLE_TO_AGENT[role] !== undefined, + 'ROLE_TO_AGENT must cover non-orchestrator role "' + role + '"', + ); + } + }); + + test('EXPECTED_POINTS_BY_STEP covers all 5 steps', () => { + assert.ok(EXPECTED_POINTS_BY_STEP, 'EXPECTED_POINTS_BY_STEP must be exported'); + assert.strictEqual(Object.keys(EXPECTED_POINTS_BY_STEP).length, 5); + assert.ok(Array.isArray(EXPECTED_POINTS_BY_STEP.execute)); + assert.strictEqual(EXPECTED_POINTS_BY_STEP.execute.length, 4); + }); +}); + +// ─── 8. Regression: FIX 1 — per-step point ownership ──────────────────────── + +describe('assertPointsCoverage per-step ownership (FIX 1)', () => { + test('fails when two steps swap a point (discuss declares plan:pre, plan declares discuss:pre)', () => { + const entries = [ + { step: 'discuss', points: ['discuss:post', 'plan:pre'] }, // wrong: has plan:pre instead of discuss:pre + { step: 'plan', points: ['discuss:pre', 'plan:post'] }, // wrong: has discuss:pre instead of plan:pre + { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, + { step: 'verify', points: ['verify:pre', 'verify:post'] }, + { step: 'ship', points: ['ship:pre', 'ship:post'] }, + ]; + const errors = assertPointsCoverage(entries); + assert.ok(errors.length > 0, 'expected per-step ownership errors'); + const combined = errors.join('\n'); + // Both steps should be named in the errors + assert.ok(combined.includes('discuss'), 'error must mention discuss step'); + assert.ok(combined.includes('plan'), 'error must mention plan step'); + }); + + test('fails when a step is missing one of its own points', () => { + const entries = [ + { step: 'discuss', points: ['discuss:pre'] }, // missing discuss:post + { step: 'plan', points: ['plan:pre', 'plan:post'] }, + { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, + { step: 'verify', points: ['verify:pre', 'verify:post'] }, + { step: 'ship', points: ['ship:pre', 'ship:post'] }, + ]; + const errors = assertPointsCoverage(entries); + assert.ok(errors.length > 0, 'expected ownership error for missing point'); + assert.ok( + errors.some((e) => e.includes('discuss') && e.includes('expected')), + 'error must name the step and expected points', + ); + }); + + test('fails when a step has an extra point beyond its own', () => { + const entries = [ + { step: 'discuss', points: ['discuss:pre', 'discuss:post', 'plan:pre'] }, // extra: plan:pre + { step: 'plan', points: ['plan:post'] }, // missing: plan:pre + { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, + { step: 'verify', points: ['verify:pre', 'verify:post'] }, + { step: 'ship', points: ['ship:pre', 'ship:post'] }, + ]; + const errors = assertPointsCoverage(entries); + assert.ok(errors.length > 0, 'expected ownership errors for extra and missing points'); + }); + + test('buildContract with swapped points across steps throws a per-step ownership error', () => { + const files = makeValidWorkflowFiles(); + // discuss declares plan:pre instead of discuss:pre, plan declares discuss:pre instead of plan:pre + files['discuss-phase.md'] = makeWorkflow('discuss', ['discuss:post', 'plan:pre'], ['orchestrator'], ['CONTEXT.md'], []); + files['plan-phase.md'] = makeWorkflow( + 'plan', ['discuss:pre', 'plan:post'], + ['researcher', 'planner', 'checker'], + ['PLAN.md'], ['CONTEXT.md'], + 'gsd-phase-researcher gsd-planner gsd-plan-checker', + ); + const tmpDir = makeTempWorkflowsDir(files); + let cleaned = false; + try { + assert.throws( + () => buildContract(tmpDir), + /step.*discuss.*expected|step.*plan.*expected/, + ); + } finally { + if (!cleaned) { + cleanup(tmpDir); + cleaned = true; + } + } + }); +}); + +// ─── 9. Regression: FIX 2 — multiple blocks + duplicate keys ───────────────── + +describe('parseLoopHostBlock multiple-block and duplicate-key detection (FIX 2)', () => { + test('throws when a file has two gsd:loop-host marker blocks', () => { + const block = + '\n'; + const content = block + '\nSome prose.\n\n' + block; + assert.throws( + () => parseLoopHostBlock(content, 'discuss-phase.md'), + /expected exactly one gsd:loop-host marker block, found 2/, + ); + }); + + test('throws when a block has a duplicate "points" key', () => { + const content = + '\n'; + assert.throws( + () => parseLoopHostBlock(content, 'discuss-phase.md'), + /duplicate key 'points' in gsd:loop-host marker/, + ); + }); + + test('throws when a block has a duplicate "step" key', () => { + const content = + '\n'; + assert.throws( + () => parseLoopHostBlock(content, 'discuss-phase.md'), + /duplicate key 'step' in gsd:loop-host marker/, + ); + }); + + test('buildContract with a two-block file throws with "found 2" error', () => { + const files = makeValidWorkflowFiles(); + const singleBlock = + '\n'; + files['discuss-phase.md'] = singleBlock + '\nDoc example:\n\n' + singleBlock; + const tmpDir = makeTempWorkflowsDir(files); + try { + assert.throws( + () => buildContract(tmpDir), + /found 2/, + ); + } finally { + cleanup(tmpDir); + } + }); +}); + +// ─── 10. Regression: FIX 3 — word-boundary agent cross-check ───────────────── + +describe('crossCheckRoles word-boundary match (FIX 3)', () => { + test('gsd-plan-checker-v2 does NOT satisfy required gsd-plan-checker reference', () => { + // Content has gsd-plan-checker-v2 but NOT bare gsd-plan-checker + const content = 'Agent("gsd-phase-researcher") Agent("gsd-planner") gsd-plan-checker-v2'; + const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); + assert.strictEqual(errors.length, 1, 'expected exactly 1 error for checker missing bare reference'); + assert.match(errors[0], /gsd-plan-checker/); + }); + + test('gsd-plan-checker (bare) still satisfies the checker role', () => { + const content = 'Agent("gsd-phase-researcher") Agent("gsd-planner") gsd-plan-checker something-else'; + const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); + assert.deepEqual(errors, []); + }); + + test('gsd-plan-checker immediately followed by newline satisfies the checker role', () => { + const content = 'gsd-phase-researcher\ngsd-planner\ngsd-plan-checker\n'; + const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); + assert.deepEqual(errors, []); + }); + + test('buildContract rejects workflow referencing only -v2 agent variant', () => { + const files = makeValidWorkflowFiles(); + // plan-phase.md refers to gsd-plan-checker-v2 but not gsd-plan-checker + files['plan-phase.md'] = + '\n' + + 'gsd-phase-researcher gsd-planner gsd-plan-checker-v2\n'; + const tmpDir = makeTempWorkflowsDir(files); + try { + assert.throws( + () => buildContract(tmpDir), + /gsd-plan-checker.*not referenced|checker.*gsd-plan-checker/, + ); + } finally { + cleanup(tmpDir); + } + }); +}); + +// ─── 11. Regression: FIX 4 — --check detects header/body tampering ─────────── + +describe('--check full-content comparison (FIX 4)', () => { + test('committed file with tampered header is detected as stale', () => { + const contract = buildContract(); + const live = serializeContract(contract); + // Tamper: replace the DO-NOT-EDIT line with something else + const tampered = live.replace( + ' * DO NOT EDIT BY HAND. Run: node scripts/gen-loop-host-contract.cjs --write', + ' * TAMPERED HEADER LINE', + ); + assert.notStrictEqual( + normalizeLineEndings(tampered), + normalizeLineEndings(live), + 'tampered content must differ from live content (staleness detected)', + ); + }); + + test('committed file with tampered body JSON is detected as stale', () => { + const contract = buildContract(); + const live = serializeContract(contract); + // Tamper: add a phantom step name + const tampered = live.replace('"step": "discuss"', '"step": "discuss-tampered"'); + assert.notStrictEqual( + normalizeLineEndings(tampered), + normalizeLineEndings(live), + 'tampered body must differ from live content (staleness detected)', + ); + }); + + test('un-tampered committed file passes full-content comparison', () => { + const contract = buildContract(); + const live = serializeContract(contract); + const committed = fs.readFileSync(CONTRACT_PATH, 'utf8'); + assert.strictEqual( + normalizeLineEndings(committed), + normalizeLineEndings(live), + 'committed file must match live serialization exactly (full-content comparison)', + ); + }); +}); + +// ─── 12. Regression: FIX 5 — temp-dir cleanup in existing cross-check tests ── +// (cleanup is handled via try/finally in each test above that creates temp dirs; +// this suite documents and verifies the makeTempWorkflowsDir helper itself) + +describe('temp-dir lifecycle', () => { + test('makeTempWorkflowsDir creates a directory that can be cleaned up', () => { + const files = { 'dummy.md': '' }; + const tmpDir = makeTempWorkflowsDir(files); + assert.ok(fs.existsSync(tmpDir), 'temp dir must exist after creation'); + cleanup(tmpDir); + assert.ok(!fs.existsSync(tmpDir), 'temp dir must not exist after cleanup'); + }); +});