Files
msd-core/scripts/gen-loop-host-contract.cjs
Dennis Alexis Valin Dittrich b848b23861 feat(#3778): dispatch plan:pre planner contributions before quick planning (#3934)
* feat(#3778): dispatch plan:pre planner contributions in quick.md

- Add plan:pre capability gate to quick.md Step 5, mirroring plan-phase.md's
  existing render + generic contribution dispatch pattern
- Inject planner-targeted contribution fragments into the planner prompt,
  after AGENT_SKILLS_PLANNER, matching D-08 ordering
- Add tests/quick-plan-pre-capabilities.test.cjs proving the dispatch is
  generic (D-01) via real scanWiredKinds/coveredKindsInRegion functions
- Record quick.md's byte-growth rationale in this commit trailer for every
  gsd-core-verbatim runtime

Emitted-Drift-Ack-Growth: quick.md — #3778: Step 5 (Spawn planner, quick mode) gains a `plan:pre` capability gate, mirroring `plan-phase.md:420-424` and `:797`. This is shipped shell and prose read by an agent at runtime, not compiled, so the reasoning has to travel with the feature rather than being deferred to a reference doc: (1) the dispatch paragraph phrases role routing possessively ("the role each entry's `into` names") rather than as an `into ==` equality, because `coveredKindsInRegion` (scripts/gen-loop-host-contract.cjs) voids a segment's `kind == "contribution"` coverage credit when a role or capability equality shares that same segment — an equality phrasing here would silently fail the generic-dispatch proof required by D-01; (2) `activeHooks` is read directly in-context from `PLAN_PRE_HOOKS_JSON`/`HOOKS_JSON` and the unfiltered `rendered` digest is explicitly forbidden from being pasted, because `rendered` carries every kind and role — including non-planner-targeted contributions such as a `into: "checker"` twin — and pasting it would leak checker-scoped guidance into the planner's prompt (T-01-02 in the threat model); (3) the injection block sits inside `<planning_context>` AFTER `${AGENT_SKILLS_PLANNER}` and after the Project skills line, matching plan-phase's `:741` -> `:797` ordering (D-08), so agent-skills content is never shadowed by capability-contributed prose. No prose was moved into an eagerly `@`-imported reference to shrink the measured file — @gsd-core/references/loop-hook-dispatch.md already existed before this change and is deferred to for the generic contract only, exactly as plan-phase.md already does.

* test(#3778): expand quick.md plan:pre dispatch coverage to all nine locked conditions

Extend tests/quick-plan-pre-capabilities.test.cjs with D-02 (silent
omit-when-empty), D-03 (single shared planner spawn), D-06 (array-order
dispatch phrasing), D-07 (planner-only into filter), and D-08 (render call
< agent-skills placeholder < injection block < spawn ordering) assertions,
all extracted via a brace-bounded slice anchored on the literal injection
instruction rather than a naive first-brace scan (${AGENT_SKILLS_PLANNER}
and the surrounding prompt's ${VALIDATE_MODE ? ...} ternaries also contain
brace pairs).

Add a capability-registry.test.cjs describe block proving the registry-wide
D-07 exclusion is meaningful: at least one plan:pre contribution exists,
every plan:pre contribution has a non-empty into/fragment.inline, and the
registry as a whole carries at least one non-planner-into contribution.

Add a loop-host-contract.test.cjs regression pin for D-09: quick.md stays
absent from STEP_WORKFLOWS, parseLoopHostBlock still throws on quick.md's
real content, and buildContract() still yields exactly 5 entries.

Verified red-without-Task-1 by temporarily reverting quick.md to its
pre-f30de9cc content and re-running these three suites (D-08 failed as
expected), then restored via git checkout and re-confirmed green.

* docs(#3778): note quick planning also renders plan:pre in the tutorial

The tutorial's Step 6 named only /gsd-plan-phase as the trigger for the
plan:pre hook set. Since quick.md now dispatches the same hook set
(f30de9cc), the sentence understated the capability's real reach.

* feat(#3778): add changeset fragment

* chore(#3778): reference the upstream issue in the changeset fragment

The fragment was the only one of 81 in .changeset/ without a trailing
(#NNNN) reference or a bold lead-in. serializeChangelog auto-appends
only the pr: field, so the rendered CHANGELOG entry carried no link
back to issue #3778.

* test(#3778): scope the D-07 registry assertion to what it actually proves

The registry-wide non-planner check was named "D-07 exclusion is
meaningful", which overclaims: it proves only that `into` takes
non-planner values somewhere in the registry, not that anything is
excluded at plan:pre. Every plan:pre contribution is currently
into: "planner", so the filter is a forward-looking safeguard there.

Narrowing the assertion to plan:pre (as review suggested) would fail
today. Asserting plan:pre is all-planner would be brittle — it would
break the day a legitimate non-planner plan:pre contribution lands,
which is exactly when the safeguard starts doing work. So the
assertion is unchanged and only the name and comment are corrected.

* chore(#3778): point the changeset fragment at the upstream PR

The fragment carried pr: 3, the fork staging PR. changeset lint derives
the real PR number from GITHUB_EVENT_PATH, so on the upstream PR that
would read as pr-field drift. Point it at open-gsd/gsd-core#3934.

* test(#3778): require contributions in Quick revision prompts

* test(loop-host): require Quick auxiliary registration

* fix(#3778): preserve contributions in Quick plan revisions

* fix(#3778): validate Quick as a planner contribution host

* fix(#3778): tighten Quick contribution contract

* test(#3778): drop unnecessary source-contract exemption

* fix(#3778): require Quick planner target coverage

* docs(#3778): describe targeted auxiliary coverage

---------

Co-authored-by: davdittrich <davdittrich@gmail.com>
Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-01 21:16:57 -04:00

692 lines
26 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',
};
// ─── 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;
}
// ─── 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);
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,
assertPointsCoverage,
buildContract,
serializeContract,
normalizeLineEndings: normalizeEol,
STEP_WORKFLOWS,
HOST_LOOP_FILES,
CANONICAL_POINTS,
EXPECTED_POINTS_BY_STEP,
ROLE_TO_AGENT,
scanWiredPoints,
getWiredLoopPoints,
coveredKindsInRegion,
scanWiredKinds,
getWiredKinds,
HOOK_KINDS,
};
// ─── CLI entry point ──────────────────────────────────────────────────────────
if (require.main === module) {
runMain(main);
}