feat(#903): generate Loop Host Contract from workflow markers (ADR-857 phase 3a-impl-2) (#906)

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 `<!-- gsd:loop-host ... -->` 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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-08 22:16:58 -04:00
committed by GitHub
parent ad754ca6cd
commit 48cc27bd84
14 changed files with 1328 additions and 59 deletions

View File

@@ -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 `<!-- gsd:loop-host ... -->` 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/<id>/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/<id>/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.

View File

@@ -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) |

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,10 @@
<!-- gsd:loop-host
step: discuss
points: discuss:pre, discuss:post
agent-roles: orchestrator
produces: CONTEXT.md
consumes:
-->
<purpose>
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.

View File

@@ -1,3 +1,10 @@
<!-- gsd:loop-host
step: execute
points: execute:pre, execute:wave:pre, execute:wave:post, execute:post
agent-roles: executor, verifier
produces: SUMMARY.md
consumes: PLAN.md
-->
<purpose>
Execute all plans in a phase using wave-based parallel execution. Orchestrator stays lean — delegates plan execution to subagents.
</purpose>

View File

@@ -1,3 +1,10 @@
<!-- gsd:loop-host
step: plan
points: plan:pre, plan:post
agent-roles: researcher, planner, checker
produces: PLAN.md
consumes: CONTEXT.md
-->
<purpose>
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).
</purpose>

View File

@@ -1,3 +1,10 @@
<!-- gsd:loop-host
step: ship
points: ship:pre, ship:post
agent-roles: orchestrator
produces:
consumes: UAT.md
-->
<purpose>
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.
</purpose>

View File

@@ -1,3 +1,10 @@
<!-- gsd:loop-host
step: verify
points: verify:pre, verify:post
agent-roles: orchestrator
produces: UAT.md
consumes: SUMMARY.md
-->
<purpose>
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.

View File

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

View File

@@ -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 (<loop-point>, <agent-role>,
// <loop-artifact>) 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.

View File

@@ -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 <!-- gsd:loop-host ... --> 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 <!-- gsd:loop-host ... --> 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):
* <!-- gsd:loop-host
* step: plan
* points: plan:pre, plan:post
* agent-roles: researcher, planner, checker
* produces: PLAN.md
* consumes: CONTEXT.md
* -->
*
* 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 = /<!--\s*gsd:loop-host\s*([\s\S]*?)-->/g;
const allMatches = Array.from(content.matchAll(blockRe));
if (allMatches.length === 0) {
throw new Error(fileName + ': missing <!-- gsd:loop-host ... --> 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);
}

View File

@@ -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 (
'<!-- gsd:loop-host\n' +
'step: ' + step + '\n' +
'points: ' + pointsList + '\n' +
'agent-roles: ' + rolesList + '\n' +
'produces: ' + producesList + '\n' +
'consumes: ' + consumesList + '\n' +
'-->\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\n<purpose>hello</purpose>', 'test.md'),
/missing.*gsd:loop-host/,
);
});
test('throws when step field is missing from block', () => {
const content =
'<!-- gsd:loop-host\n' +
'points: plan:pre, plan:post\n' +
'agent-roles: orchestrator\n' +
'produces:\n' +
'consumes:\n' +
'-->\n';
assert.throws(
() => parseLoopHostBlock(content, 'test.md'),
/missing required field "step"/,
);
});
test('throws when points field is empty', () => {
const content =
'<!-- gsd:loop-host\n' +
'step: plan\n' +
'points:\n' +
'agent-roles: orchestrator\n' +
'produces:\n' +
'consumes:\n' +
'-->\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'] =
'<!-- gsd:loop-host\n' +
'step: plan\n' +
'points: plan:pre, plan:post\n' +
'agent-roles: researcher, planner, checker, phantom\n' +
'produces: PLAN.md\n' +
'consumes: CONTEXT.md\n' +
'-->\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'] =
'<!-- gsd:loop-host\n' +
'step: plan\n' +
'points: plan:pre, plan:post\n' +
'agent-roles: researcher, planner, checker\n' +
'produces: PLAN.md\n' +
'consumes: CONTEXT.md\n' +
'-->\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 =
'<!-- gsd:loop-host\n' +
'step: discuss\n' +
'points: discuss:pre, discuss:post\n' +
'agent-roles: orchestrator\n' +
'produces: CONTEXT.md\n' +
'consumes:\n' +
'-->\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 =
'<!-- gsd:loop-host\n' +
'step: discuss\n' +
'points: discuss:pre, discuss:post\n' +
'points: discuss:pre\n' + // duplicate
'agent-roles: orchestrator\n' +
'produces: CONTEXT.md\n' +
'consumes:\n' +
'-->\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 =
'<!-- gsd:loop-host\n' +
'step: discuss\n' +
'step: plan\n' + // duplicate
'points: discuss:pre, discuss:post\n' +
'agent-roles: orchestrator\n' +
'produces: CONTEXT.md\n' +
'consumes:\n' +
'-->\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 =
'<!-- gsd:loop-host\n' +
'step: discuss\n' +
'points: discuss:pre, discuss:post\n' +
'agent-roles: orchestrator\n' +
'produces: CONTEXT.md\n' +
'consumes:\n' +
'-->\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'] =
'<!-- gsd:loop-host\n' +
'step: plan\n' +
'points: plan:pre, plan:post\n' +
'agent-roles: researcher, planner, checker\n' +
'produces: PLAN.md\n' +
'consumes: CONTEXT.md\n' +
'-->\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': '<!-- gsd:loop-host\nstep: discuss\n-->' };
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');
});
});