Part 1 of 2 of the n/no-process-exit cleanup (umbrella #738): convert every process.exit() call in standalone scripts/** CLIs to the rule-compliant pattern. - New shared helper scripts/lib/cli-exit.cjs: ExitError(code,message) + runMain() which translates a thrown ExitError / returned number into process.exitCode (never process.exit()), flushing output and still firing process.on('exit'). - main()-based entrypoints: throw new ExitError(code) for errors, return <code> for verdicts; invoked via runMain(main). Child exit codes preserved via return. - top-level-only scripts: imperative body extracted into main() so mid-flow aborts (throw ExitError) actually halt; pure consts/helpers stay at module scope. - diff-touches-shipped-paths.cjs: stdin event handling restructured to an async read so the whole flow runs under runMain; uncaughtException/unhandledRejection nets replaced by an in-band catch that preserves EXIT_ERROR=2. Exit codes verified unchanged for every converted script (success/error/help and the 0/1/2 semantic codes in diff-touches). Rule stays warn here; flipped to error in part 2 (#738) once gsd-core/bin/** is also clean. Refs #739 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
9.8 KiB
JavaScript
277 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* gen-research-agents.cjs — profile-driven drift guard for the 7 researcher agents.
|
|
*
|
|
* Usage:
|
|
* node scripts/gen-research-agents.cjs # same as --check
|
|
* node scripts/gen-research-agents.cjs --check # assert every agent matches its profile
|
|
* node scripts/gen-research-agents.cjs --write # regenerate frontmatter from profiles
|
|
*
|
|
* --check assertions per agent:
|
|
* (a) frontmatter name/description/color/tools exactly match the profile
|
|
* (b) every requiredInclude string is present in the body
|
|
* (c) every requiredSeamCall string is present in the body
|
|
* (d) every outputContract marker string is present in the body
|
|
*
|
|
* --write regenerates ONLY the opening `---\n...\n---` frontmatter block from the
|
|
* profile, leaving the body byte-identical. After --write, --check must pass and
|
|
* `git diff` must be empty (profiles were derived from current state).
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { PROFILES } = require('./research-profiles.cjs');
|
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const AGENTS_DIR = path.join(ROOT, 'agents');
|
|
|
|
// ─── Frontmatter serialization ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build the frontmatter block for a profile.
|
|
*
|
|
* The agent files have two patterns for commented hooks:
|
|
* - Agents with Write in tools (file-writers): include the commented hooks block
|
|
* - The advisor-researcher (Read-only tools, no Write): no commented hooks
|
|
*
|
|
* We read the CURRENT commented-hooks block from the agent file and preserve it
|
|
* byte-for-byte; only name/description/tools/color are regenerated.
|
|
*/
|
|
function buildFrontmatter(profile, existingFrontmatter) {
|
|
// Extract the commented hooks section from the existing frontmatter, if any.
|
|
// The hooks block starts at `# hooks:` and runs to (but not including) the
|
|
// closing `---`. In the committed files there is NO blank line between
|
|
// `color:` and `# hooks:`, so we append it directly after the color line's `\n`.
|
|
const hooksMatch = existingFrontmatter.match(/(# hooks:[\s\S]*?)(?=\n---)/);
|
|
// hooksSuffix: if present, the block followed by a newline so `---` is on its own line;
|
|
// if absent, empty string (the closing `---` follows directly after color's `\n`).
|
|
const hooksSuffix = hooksMatch ? hooksMatch[1] + '\n' : '';
|
|
|
|
return (
|
|
'---\n' +
|
|
'name: ' + profile.name + '\n' +
|
|
'description: ' + profile.description + '\n' +
|
|
'tools: ' + profile.tools + '\n' +
|
|
'color: ' + profile.color + '\n' +
|
|
hooksSuffix +
|
|
'---'
|
|
);
|
|
}
|
|
|
|
// ─── Parse agent file ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse a .md file and return { frontmatterRaw, body, frontmatterFields }.
|
|
*
|
|
* frontmatterRaw: the raw text between the first and second `---` delimiters (exclusive)
|
|
* body: everything after the closing `---\n`
|
|
* frontmatterFields: { name, description, color, tools }
|
|
*/
|
|
function parseAgentFile(filePath) {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
|
|
// The frontmatter is between the first `---` line and the next `---` line.
|
|
const lines = raw.split('\n');
|
|
let start = -1;
|
|
let end = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].trim() === '---') {
|
|
if (start === -1) {
|
|
start = i;
|
|
} else {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (start === -1 || end === -1) {
|
|
throw new Error('No valid frontmatter delimiters found in ' + filePath);
|
|
}
|
|
|
|
const frontmatterLines = lines.slice(start + 1, end);
|
|
const frontmatterRaw = frontmatterLines.join('\n');
|
|
// body includes the closing `---` line and everything after
|
|
const fullFrontmatter = lines.slice(start, end + 1).join('\n');
|
|
const body = lines.slice(end + 1).join('\n');
|
|
|
|
const fields = {};
|
|
// Parse simple key: value pairs (not nested YAML, no multi-line values here)
|
|
for (const line of frontmatterLines) {
|
|
// Skip comment lines
|
|
if (line.trimStart().startsWith('#')) continue;
|
|
const m = line.match(/^(\w+):\s*(.*)/);
|
|
if (m) {
|
|
fields[m[1]] = m[2].trim();
|
|
}
|
|
}
|
|
|
|
return { raw, frontmatterRaw, fullFrontmatter, body, fields };
|
|
}
|
|
|
|
// ─── Check ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check one profile against its agent file.
|
|
* Returns an array of failure strings (empty = pass).
|
|
*/
|
|
function checkAgent(profile) {
|
|
// Validate required array fields — return a clear failure rather than throwing TypeError.
|
|
for (const field of ['requiredIncludes', 'requiredSeamCalls', 'outputContract']) {
|
|
if (!Array.isArray(profile[field])) {
|
|
return ['profile ' + profile.name + ': missing required array field ' + field];
|
|
}
|
|
}
|
|
|
|
const agentPath = path.join(AGENTS_DIR, profile.name + '.md');
|
|
const failures = [];
|
|
|
|
if (!fs.existsSync(agentPath)) {
|
|
return ['agent file not found: ' + agentPath];
|
|
}
|
|
|
|
const { fields } = parseAgentFile(agentPath);
|
|
const fullContent = fs.readFileSync(agentPath, 'utf8');
|
|
|
|
// (a) frontmatter fields
|
|
if (fields.name !== profile.name) {
|
|
failures.push(
|
|
'name mismatch: got "' + fields.name + '", want "' + profile.name + '"',
|
|
);
|
|
}
|
|
if (fields.description !== profile.description) {
|
|
failures.push(
|
|
'description mismatch:\n got: "' + fields.description + '"\n want: "' + profile.description + '"',
|
|
);
|
|
}
|
|
if (fields.color !== profile.color) {
|
|
failures.push(
|
|
'color mismatch: got "' + fields.color + '", want "' + profile.color + '"',
|
|
);
|
|
}
|
|
if (fields.tools !== profile.tools) {
|
|
failures.push(
|
|
'tools mismatch:\n got: "' + fields.tools + '"\n want: "' + profile.tools + '"',
|
|
);
|
|
}
|
|
|
|
// (b) requiredIncludes
|
|
for (const include of profile.requiredIncludes) {
|
|
if (!fullContent.includes(include)) {
|
|
failures.push('missing required include: ' + include);
|
|
}
|
|
}
|
|
|
|
// (c) requiredSeamCalls
|
|
for (const seam of profile.requiredSeamCalls) {
|
|
if (!fullContent.includes(seam)) {
|
|
failures.push('missing required seam call: ' + seam);
|
|
}
|
|
}
|
|
|
|
// (d) outputContract
|
|
for (const marker of profile.outputContract) {
|
|
if (!fullContent.includes(marker)) {
|
|
failures.push('missing output contract marker: ' + marker);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
/**
|
|
* Run --check for all profiles. Prints pass/fail per agent.
|
|
* Returns true if all pass, false otherwise.
|
|
*/
|
|
function runCheck() {
|
|
let allPassed = true;
|
|
|
|
for (const profile of PROFILES) {
|
|
const failures = checkAgent(profile);
|
|
if (failures.length === 0) {
|
|
process.stdout.write(' PASS ' + profile.name + '\n');
|
|
} else {
|
|
process.stdout.write(' FAIL ' + profile.name + '\n');
|
|
for (const f of failures) {
|
|
process.stdout.write(' ' + f.replace(/\n/g, '\n ') + '\n');
|
|
}
|
|
allPassed = false;
|
|
}
|
|
}
|
|
|
|
return allPassed;
|
|
}
|
|
|
|
// ─── Write ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Regenerate the frontmatter block of one agent file from its profile.
|
|
* The body (everything after the closing ---) is preserved byte-for-byte.
|
|
*/
|
|
function writeAgent(profile) {
|
|
const agentPath = path.join(AGENTS_DIR, profile.name + '.md');
|
|
const { fullFrontmatter, body } = parseAgentFile(agentPath);
|
|
|
|
const newFrontmatter = buildFrontmatter(profile, fullFrontmatter);
|
|
const newContent = newFrontmatter + '\n' + body;
|
|
|
|
fs.writeFileSync(agentPath, newContent, 'utf8');
|
|
}
|
|
|
|
function runWrite() {
|
|
for (const profile of PROFILES) {
|
|
const agentPath = path.join(AGENTS_DIR, profile.name + '.md');
|
|
if (!fs.existsSync(agentPath)) {
|
|
throw new ExitError(1, 'ERROR: agent file not found: ' + agentPath);
|
|
}
|
|
writeAgent(profile);
|
|
process.stdout.write(' wrote ' + profile.name + '.md\n');
|
|
}
|
|
process.stdout.write('\nRun --check to verify:\n');
|
|
process.stdout.write(' node scripts/gen-research-agents.cjs --check\n');
|
|
}
|
|
|
|
// ─── Exports (for tests) ──────────────────────────────────────────────────────
|
|
|
|
module.exports = { PROFILES, checkAgent, runCheck, parseAgentFile };
|
|
|
|
// ─── CLI entry point ──────────────────────────────────────────────────────────
|
|
|
|
function main() {
|
|
const flag = process.argv[2] || '--check';
|
|
|
|
if (flag === '--write') {
|
|
process.stdout.write('Writing frontmatter from profiles...\n');
|
|
runWrite();
|
|
process.stdout.write('\nVerifying...\n');
|
|
const ok = runCheck();
|
|
if (!ok) {
|
|
process.stderr.write('\nERROR: --check failed after --write. Fix serialization.\n');
|
|
throw new ExitError(1);
|
|
}
|
|
process.stdout.write('\nAll agents match their profiles.\n');
|
|
} else if (flag === '--check') {
|
|
process.stdout.write('Checking research agent profiles...\n');
|
|
const ok = runCheck();
|
|
if (!ok) {
|
|
process.stderr.write('\nSome agents do not match their profiles.\n');
|
|
process.stdout.write(
|
|
'\nTo regenerate frontmatter from profiles:\n' +
|
|
' node scripts/gen-research-agents.cjs --write\n',
|
|
);
|
|
throw new ExitError(1);
|
|
}
|
|
process.stdout.write('\nAll 7 agents match their profiles.\n');
|
|
} else {
|
|
throw new ExitError(1, 'Unknown flag: ' + flag + '\nUsage: node scripts/gen-research-agents.cjs [--check|--write]');
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
runMain(main);
|
|
}
|