Files
msd-core/scripts/gen-loop-host-contract.cjs
Tom Boucher 06845717fe feat(#4740): make the Loop Host Contract role partition normative and enforced (#4742)
* test(#4740): pin the per-step role-family partition

Failing-first coverage for the Loop Host Contract role partition. At this
commit crossCheckRoleFamilies does not exist, so the rows throw
"crossCheckRoleFamilies is not a function" -- the RED proof they bind to
behavior rather than restating it.

ADR-894 section 3 assigns roles per step but parenthesises the assignment as
"(illustrative roles)", and nothing enforced it. The only thing standing in the
way was a single deepEqual in this same file, which is editable prose.

Rows cover: each step's own family accepted; a strict subset accepted; a
foreign role rejected at every step; an unknown role rejected; an unknown step
failing CLOSED; capitalization not silently matched; every offending role
reported rather than only the first; and purity, because buildContract puts the
same array into the generated contract.

Two rows exist because an earlier cut of this suite was vacuous. The purity
fixture is deliberately UNSORTED -- an alphabetically-sorted fixture cannot
fail an in-place sort(), and the mutant was being killed by three unrelated
rows instead. A parity row asserts ROLE_FAMILY and ROLE_TO_AGENT cover the
exact same role-name domain, both directions: they are parallel constants over
one domain, so divergence is the generative-fix class CLAUDE.md names.

Every negative row asserts the offending ROLE NAME and the STEP NAME appear in
the message. A count-only assertion survives a mutant that reports the wrong
role, which the 80% Stryker gate would surface only after a full CI round-trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(#4740): reject a cross-family agent-role declaration

Orchestration and execution are distinct functions of the loop and must not
drift into one another. That partition was real but unenforced: ADR-894
section 3 calls its own role assignment "illustrative", and the generator
accepted anything. Adding orchestrator to execute-phase.md's agent-roles line
compiled, --check passed once regenerated, and capability-validator.cjs then
began accepting into:"orchestrator" at every execute point.

ROLE_FAMILY maps every role to one of orchestration, planning or execution.
EXPECTED_FAMILY_BY_STEP gives each of the five steps exactly one family.
crossCheckRoleFamilies rejects a cross-family role, a role outside the
vocabulary, and an unknown step. It reports every offender, not the first.

It fails CLOSED on an unknown step, deliberately diverging from
assertPointsCoverage's "unknown step -- caught elsewhere". For points that is
true: the canonical-set and duplicate checks catch it. For roles there is no
second net, so failing open would leave an unknown step as the one input that
bypasses the gate.

crossCheckRoles' orchestrator exemption is untouched. ROLE_TO_AGENT maps roles
to agent FILES and the orchestrator is the host, owning none -- admissibility
and agent-file presence are separate concerns with separate checks.

Additive to section 3's existing rule that contribution.into must be a member
of the step's agentRoles, which is unchanged. That governs what a CAPABILITY
may target; this governs what a WORKFLOW may declare. No capability is
affected, and all five workflows already declare single-family sets, so the
gate is green on the commit that introduces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#4740): make the ADR-894 role assignment normative

Section 3 parenthesises its per-step role assignment as "(illustrative roles)".
That word was accurate about the list's PURPOSE -- it illustrated the shape of
a generated contract entry -- and wrong about its STATUS, because the
assignment was load-bearing from the moment the generator consumed it. Read
literally it makes the partition an example rather than a rule.

Appended as a dated in-place section per docs/contributor-standards.md, which
records that an accepted ADR is never rewritten and names this the default
pattern. Section 3's original body is untouched.

The amendment states the three disjoint families, the one family each step
admits, that a step may declare a strict subset but never outside it, and why
this is a clarification rather than a new decision: the contract is generated
from the workflow markers "so it cannot drift into a lie", and all five
workflows have always declared single-family sets. What was absent was any
statement that it is required, and any check that it holds.

It also pins the distinction that is easy to re-merge: contribution.into being
a member of agentRoles governs what a CAPABILITY may target and is unchanged;
the family rule governs what a WORKFLOW may declare. The CONTEXT.md glossary
entry for the Loop Host Contract records the same, beside the agent-reference
drift guard it already documented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4740): add changeset fragment

pr:0 placeholder is backfilled with the real number once the PR exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4740): backfill changeset pr number

Replaces the pr:0 placeholder with 4742 now that the PR exists. Verified with
GITHUB_BASE_REF=next, the way CI runs them: changeset lint and lint:docs both
go from invalid_pr(0) to ok. Without that env both report success without
evaluating the branch at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4740): stop injecting the orchestrator procedure into executors

claude-orchestration declared a contribution at execute:wave:pre with
into:"executor". loop-hook-dispatch.md defines a contribution as "inject
fragment.inline verbatim into the context for the role named in into", so its
267 lines were injected into EXECUTOR prompts whenever the capability was
enabled. Those lines are orchestration end to end -- construct a wave manifest,
resolve the dispatch backend, invoke the Workflow tool to spawn executors,
bridge per-agent results into the merge chain. An executor can act on none of
it.

Retargeting to into:"orchestrator" would not have been a fix. ROLE_TO_AGENT
carries no orchestrator entry by design: the orchestrator IS the host, and the
host's procedure lives in execute-phase.md. A step's agentRoles enumerates
agents a capability may inject context INTO, so adding orchestrator there would
model the host as an injectable agent -- the same category error pointed the
other way, and it would need an exception carved into the partition the same
issue just made normative.

So the defect is the mechanism, not the label. A contribution injects into an
agent's context; "replace step 3's inline dispatch loop" is a change to what
the HOST does. The contribution channel was serving as a host-behaviour
directive because it was the only channel available at an execute point.

The entry is removed. plan:post into:"planner" is correct and untouched. The
procedure is preserved verbatim at docs/workflow-backend-dispatch.md inside the
capability -- it is the only copy in the repo -- and is no longer injected
anywhere.

Consequence, not softened: the Workflow backend now has no loop wiring.
Detection, emission and config remain and the design is intact, but nothing
dispatches it. Under the separation ADR-1143 itself asserts it never had a
legitimate channel; ADR-1143's own audit already records the end-to-end path
has never been exercised. Wiring it properly needs a host-level mechanism that
does not exist today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4740): invert the stale execute:wave:pre registry assertions

Removing the contribution left four surfaces asserting or describing the old
state. Caught by an isolated review before a verification run was spent, which
is the point of reviewing first: the first of these was a guaranteed CI red.

execute-wave-post-gate-pipeline-e2e asserted against the REAL generated
registry that byLoopPoint['execute:wave:pre'] held exactly one contribution
with capId claude-orchestration. It now holds zero. Inverted to assert exactly
0 -- not a vague >= 0 -- and the #2285 comment above it now explains the
current state rather than the one it was written for.

CONTEXT.md's Claude Orchestration entry claimed two contributions at wired
points. It is now one, and the entry's execute:wave:post label was already
wrong before this change: the manifest said execute:wave:pre. Rewritten to one
plan:post contribution, why the execute-point one was removed, and where the
procedure now lives.

One assertion in claude-orchestration.test.cjs could not fail. It tested for
the prose "(into the executor)" while the doc says "(`into: executor`)", so no
plausible wording matched it and the paired plan:post assertion was carrying
the row. Replaced with a check on the structural claim, and proved RED by
restoring the two-contribution wording before reverting.

The moved procedure keeps section headings that speak as a live contribution --
"When this contribution is active", "Why execute:wave:pre". Preserving the body
verbatim was deliberate, so the headings stay and an editor's note under the
header explains why they read that way.

A sweep of all 17 files referencing byLoopPoint found no further siblings: the
remaining hits are a synthetic capability fixture and an empty-points test that
already expected no active hooks, both correct before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:30:47 -04:00

761 lines
29 KiB
JavaScript

#!/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 { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
const { normalizeEol } = require('../gsd-core/bin/lib/text-lines.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',
auxiliaryHosts: [
{ file: 'quick.md', point: 'plan:pre', kinds: ['contribution'], into: 'planner' },
],
},
{ 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',
};
// #4740 — Role → family mapping. Three families: orchestration, planning,
// execution. This constrains what a workflow may DECLARE in agent-roles for
// a given step (admissibility), a separate concern from ROLE_TO_AGENT's
// agent-file presence check above.
const ROLE_FAMILY = {
orchestrator: 'orchestration',
researcher: 'planning', planner: 'planning', checker: 'planning',
executor: 'execution', verifier: 'execution',
};
const EXPECTED_FAMILY_BY_STEP = {
discuss: 'orchestration',
plan: 'planning',
execute: 'execution',
verify: 'orchestration',
ship: 'orchestration',
};
// ─── 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
*/
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;
}
// ─── Cross-check: declared roles vs. their permitted family (#4740) ──────────
/**
* For a step, verify every role declared in agentRoles belongs to that step's
* expected family (orchestration / planning / execution).
*
* Unlike `assertPointsCoverage`'s `if (!expected) continue // caught
* elsewhere` guard, an unknown step here fails CLOSED: for points
* there is a second net (the canonical-set and duplicate checks), but nothing
* else in the repo validates role families, so failing open on an unknown
* step would make it the one input that silently bypasses this gate — the
* "unknown resolving to a safe known" failure mode this check exists to close.
*
* Pure: never sorts, de-dupes, or otherwise mutates `agentRoles` — the same
* array `buildContract` puts into the generated contract.
*
* @param {string} step The step named in the marker block.
* @param {string[]} agentRoles Roles declared in the block.
* @param {string} fileName For error messages.
* @returns {string[]} Array of error strings; empty = OK.
*/
function crossCheckRoleFamilies(step, agentRoles, fileName) {
const expectedFamily = EXPECTED_FAMILY_BY_STEP[step];
if (!expectedFamily) {
return [fileName + ': step "' + step + '" has no entry in EXPECTED_FAMILY_BY_STEP'];
}
const errors = [];
for (const role of agentRoles) {
const family = ROLE_FAMILY[role];
if (!family) {
errors.push(
fileName + ': declared agent-role "' + role + '" has no entry in ROLE_FAMILY mapping',
);
continue;
}
if (family !== expectedFamily) {
errors.push(
fileName + ': declared agent-role "' + role + '" (family "' + family +
'") is not permitted at step "' + step + '" (expected family "' + expectedFamily + '")',
);
}
}
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, auxiliaryHosts = [] } 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);
// Cross-check role families (#4740)
const roleFamilyErrors = crossCheckRoleFamilies(entry.step, entry.agentRoles, file);
allErrors.push(...roleFamilyErrors);
for (const auxiliary of auxiliaryHosts) {
let auxiliaryContent;
try {
auxiliaryContent = fs.readFileSync(path.join(resolvedDir, auxiliary.file), 'utf8');
} catch (err) {
allErrors.push('Could not read auxiliary host ' + auxiliary.file + ': ' + String(err.message));
continue;
}
const wiredPoints = scanWiredPoints(auxiliaryContent);
if (!wiredPoints.has(auxiliary.point)) {
allErrors.push(
auxiliary.file + ': auxiliary host missing expected point "' + auxiliary.point + '"',
);
continue;
}
const wiredKinds = scanWiredKinds(auxiliaryContent, auxiliary.into).get(auxiliary.point) || new Set();
for (const kind of auxiliary.kinds) {
if (!wiredKinds.has(kind)) {
allErrors.push(
auxiliary.file + ': auxiliary host point "' + auxiliary.point +
'" missing expected kind "' + kind + '"',
);
}
}
}
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');
}
// ─── 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 (normalizeEol(committed) !== normalizeEol(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');
}
}
// ─── Derived single-source-of-truth exports ───────────────────────────────────
/**
* Repo-relative paths to every host-loop workflow file, derived from STEP_WORKFLOWS.
* This is the ONLY canonical enumeration of host-loop files — all consumers (tests,
* registry generator, conformance gate) must derive from this rather than maintaining
* a separate hardcoded list.
*/
const HOST_LOOP_FILES = STEP_WORKFLOWS.flatMap(({ file, auxiliaryHosts = [] }) => [
'gsd-core/workflows/' + file,
...auxiliaryHosts.map((host) => 'gsd-core/workflows/' + host.file),
]);
/**
* Pure function: scan a text string for `loop render-hooks <point>` call sites.
* Returns a Set of matched point strings.
*
* @param {string} text Content of a workflow file (or any text).
* @returns {Set<string>}
*/
/** The call-site shape both scanners key on — one regex, two consumers (#3606). */
const CALL_SITE_RE = /loop render-hooks\s+([a-z:]+)/g;
function scanWiredPoints(text) {
const re = CALL_SITE_RE;
const result = new Set();
let m;
while ((m = re.exec(text)) !== null) {
result.add(m[1]);
}
return result;
}
// ─── Hook-kind coverage (#3606) ──────────────────────────────────────────────
const HOOK_KINDS = ['contribution', 'step', 'gate'];
/**
* The kinds one call site's dispatch text actually covers (#3606).
*
* A point having a `loop render-hooks <point>` call site proves the hooks are
* RENDERED, not that they are DISPATCHED — a consumer that iterates only
* `kind == "gate"` (or narrows `kind == "step"` to one `ref.skill`) silently
* drops every other registered kind. Coverage rules for the text following a
* call site, up to the next call site or the region cap, judged LINE by line:
*
* - A deferral line (one carrying an `@`-included path to the generic
* contract, e.g. `@gsd-core/references/loop-hook-dispatch.md`) that names a
* kind (`kind == "step"`) covers that kind; a deferral line with no kind
* discriminator ("apply each entry") covers every kind only when no role
* target is required. With `expectedInto`, the same segment must explicitly
* name both the kind and that exact `into` target. A bare §-citation of the
* reference (validation guidance only, no `@`) covers nothing — plan-phase
* cites the gate-validation section while dispatching only gates.
* - Otherwise a kind is covered when some LINE dispatches it unconditionally:
* a `kind == "<kind>"` discriminator with NO same-line narrowing to one
* hook (`ref.skill ==`, `ref.agent ==`, `ref.command ==`). A narrowed line
* special-cases ONE hook and proves nothing about the kind generally — the
* exact hand-rolled-consumer shape the reference warns about.
*
* Quote style and spacing vary across the corpus (`kind == "step"`,
* `kind === 'gate'`), so the matcher is tolerant of both quote characters and
* of `==`/`===`.
*
* Pure: same input, same output; CRLF-safe (line splitting tolerates \r).
*
* @param {string} region Dispatch text following one call site.
* @param {string} [expectedInto] Optional role target required in the same segment.
* @returns {Set<string>}
*/
function coveredKindsInRegion(region, expectedInto) {
const covered = new Set();
// Same-SEGMENT narrowing to ONE hook voids credit: `ref.skill ==`, `capId ==`,
// and `into ==` each special-case a subset, not the kind generally. An
// auxiliary host may name the one role it actually hosts via `expectedInto`;
// any other role target still voids credit.
// (plan-phase's `kind == "contribution" and capId == "security"` is the
// hand-rolled shape; `into == "planner"` covers only planner-targeted
// contributions). Segments, not lines: execute-phase legitimately writes
// "dispatch `kind == "step"` hooks per … . `ref.skill == "code-review"`:" —
// the deferral is one sentence, the specialization the next; narrowing in a
// DIFFERENT segment must not void the deferral's credit.
const identityNarrowingRe = /(?:ref\.(?:skill|agent|command)|capId)\s*={2,3}/;
const intoNarrowingRe = /into\s*={2,3}/;
const expectedIntoRe = expectedInto
? new RegExp(`into\\s*={2,3}\\s*["']${escapeRegExp(expectedInto)}["']`)
: null;
// Negated mentions describe an absence, not a dispatch ("Branch 1 — no active
// step hooks (`activeHooks` has no entry with `kind == "step"`)" — ship.md).
const negationRe = /\b(?:no|without|absent|lacks?|missing)\b[^.|]*kind\s*={2,3}/;
const deferralRe = /@\S*loop-hook-dispatch\.md/;
for (const line of region.split(/\r?\n/)) {
// Sentence segments: a `.`/`;` followed by whitespace ends a segment. A
// period NOT followed by whitespace (the `.md` inside a deferral path,
// `ref.skill`) is not a boundary.
for (const segment of line.split(/(?<=[.;])\s+/)) {
const kindDiscriminators = [];
for (const kind of HOOK_KINDS) {
if (new RegExp(`kind\\s*={2,3}\\s*["']${kind}["']`).test(segment)) kindDiscriminators.push(kind);
}
if (kindDiscriminators.length === 0) {
// A deferral with no kind discriminator ("apply each entry per …")
// still covers every kind for generic hosts. An auxiliary host with a
// required role target must state both its kind and target explicitly.
if (!expectedInto && deferralRe.test(segment)) for (const kind of HOOK_KINDS) covered.add(kind);
continue;
}
if (negationRe.test(segment)) continue;
const narrowed = identityNarrowingRe.test(segment) ||
(expectedInto
? !expectedIntoRe.test(segment)
: intoNarrowingRe.test(segment));
if (deferralRe.test(segment)) {
// Deferral naming kinds ("dispatch `kind == "step"` hooks per …").
if (!narrowed) for (const kind of kindDiscriminators) covered.add(kind);
continue;
}
if (!narrowed) for (const kind of kindDiscriminators) covered.add(kind);
}
}
return covered;
}
/**
* Scan every `loop render-hooks <point>` call site in `text` and accumulate,
* per point, the union of hook kinds its dispatch regions cover (#3606).
*
* @param {string} text Content of a workflow file (or any text).
* @param {string} [expectedInto] Optional role target an auxiliary host must dispatch.
* @returns {Map<string, Set<string>>} point → covered kinds.
*/
function scanWiredKinds(text, expectedInto) {
const result = new Map();
const siteRe = CALL_SITE_RE;
const sites = [];
let m;
while ((m = siteRe.exec(text)) !== null) sites.push({ point: m[1], start: m.index });
const REGION_CAP = 6000;
for (let i = 0; i < sites.length; i++) {
const regionEnd = i + 1 < sites.length ? sites[i + 1].start : Math.min(text.length, sites[i].start + REGION_CAP);
const region = text.slice(sites[i].start, regionEnd);
const covered = coveredKindsInRegion(region, expectedInto);
if (!result.has(sites[i].point)) result.set(sites[i].point, new Set());
for (const kind of covered) result.get(sites[i].point).add(kind);
}
return result;
}
/**
* Read every host-loop workflow file and return, per point, the union of hook
* kinds its call sites' dispatch text covers (#3606).
*
* @param {string} [repoRoot] Path to the repository root. Defaults to ROOT.
* @returns {Map<string, Set<string>>}
*/
function getWiredKinds(repoRoot) {
const resolvedRoot = repoRoot !== undefined ? repoRoot : ROOT;
const result = new Map();
for (const relPath of HOST_LOOP_FILES) {
const absPath = path.join(resolvedRoot, relPath);
let content;
try {
content = fs.readFileSync(absPath, 'utf8');
} catch (err) {
throw new Error('getWiredKinds: cannot read host-loop file ' + absPath + ': ' + err.message);
}
for (const [point, kinds] of scanWiredKinds(content)) {
if (!result.has(point)) result.set(point, new Set());
for (const kind of kinds) result.get(point).add(kind);
}
}
return result;
}
/**
* Read every host-loop workflow file and return the union of all wired loop points
* (i.e. points that have a `loop render-hooks <point>` call site).
*
* @param {string} [repoRoot] Path to the repository root. Defaults to ROOT.
* @returns {Set<string>}
*/
function getWiredLoopPoints(repoRoot) {
const resolvedRoot = repoRoot !== undefined ? repoRoot : ROOT;
const result = new Set();
for (const relPath of HOST_LOOP_FILES) {
const absPath = path.join(resolvedRoot, relPath);
let content;
try {
content = fs.readFileSync(absPath, 'utf8');
} catch (err) {
throw new Error('getWiredLoopPoints: cannot read host-loop file ' + absPath + ': ' + err.message);
}
for (const point of scanWiredPoints(content)) {
result.add(point);
}
}
return result;
}
// ─── Exports (for tests) ─────────────────────────────────────────────────────
module.exports = {
parseLoopHostBlock,
crossCheckRoles,
crossCheckRoleFamilies,
assertPointsCoverage,
buildContract,
serializeContract,
normalizeLineEndings: normalizeEol,
STEP_WORKFLOWS,
HOST_LOOP_FILES,
CANONICAL_POINTS,
EXPECTED_POINTS_BY_STEP,
ROLE_TO_AGENT,
ROLE_FAMILY,
scanWiredPoints,
getWiredLoopPoints,
coveredKindsInRegion,
scanWiredKinds,
getWiredKinds,
HOOK_KINDS,
};
// ─── CLI entry point ──────────────────────────────────────────────────────────
if (require.main === module) {
runMain(main);
}