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:
@@ -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.
|
||||
|
||||
471
scripts/gen-loop-host-contract.cjs
Normal file
471
scripts/gen-loop-host-contract.cjs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user