mirror of
https://github.com/kochetkov-ma/claude-brewcode.git
synced 2026-09-14 20:16:41 +08:00
474 lines
22 KiB
JavaScript
474 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* suite-creator-contract.mjs - pins the Claude Code 2.1.269 facts that the three
|
|
* creator agents (hook-creator, skill-creator, agent-creator) teach, so a future
|
|
* drift fails a test instead of shipping silently.
|
|
*
|
|
* The 2026-09-12 restructure split each agent into a short body + on-demand
|
|
* `references/*.md` files. Most facts this suite pins now live in a reference,
|
|
* not the agent body -- every such check is TWO asserts: (1) the reference still
|
|
* states the fact, (2) the agent body still cites that reference's path. A check
|
|
* that only verified "agent body contains X" before must never collapse back to
|
|
* one assert just because the text moved.
|
|
*
|
|
* Evidence of record: `.claude/reports/20260912-173000_agents-refresh/delta-{hooks,agents,skills}.md`
|
|
* (fetched 2026-09-12, upstream docs as of CC 2.1.269). Fixtures below are the
|
|
* transcription of it, hardcoded so the suite runs standalone (no network, no MCP).
|
|
*
|
|
* Assertion policy: unconditional exact-equality / exact-set checks with a
|
|
* description. No branching decides which asserts run. Every failure names the
|
|
* file, the expected value and the actual value.
|
|
*
|
|
* Usage: node brewcode/agents/tests/suite-creator-contract.mjs
|
|
*/
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = join(fileURLToPath(import.meta.url), '..'); // agents/tests/
|
|
const AGENTS = join(HERE, '..'); // brewcode/agents/
|
|
const PLUGIN_ROOT = join(AGENTS, '..'); // brewcode/
|
|
|
|
const FILES = ['hook-creator.md', 'skill-creator.md', 'agent-creator.md'];
|
|
const text = Object.fromEntries(
|
|
FILES.map((f) => [f, readFileSync(join(AGENTS, f), 'utf8')]),
|
|
);
|
|
|
|
// Every `${CLAUDE_PLUGIN_ROOT}/.../*.md` reference token the three bodies cite
|
|
// (their "Read on demand" tables) -- loaded once, keyed by basename. A missing
|
|
// file is NOT skipped: it is recorded and reported as a defect (refs.exist below).
|
|
const CITED = [...new Set(FILES.flatMap((f) =>
|
|
[...text[f].matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/[^\s`|]+\.md/g)].map((m) => m[0]),
|
|
))];
|
|
const REF_PATH = {};
|
|
for (const token of CITED) {
|
|
REF_PATH[token.split('/').pop()] = token.replace('${CLAUDE_PLUGIN_ROOT}', PLUGIN_ROOT);
|
|
}
|
|
const REFS = Object.keys(REF_PATH);
|
|
for (const base of REFS) {
|
|
text[base] = existsSync(REF_PATH[base]) ? readFileSync(REF_PATH[base], 'utf8') : '';
|
|
}
|
|
const ALL_KEYS = [...FILES, ...REFS];
|
|
|
|
// ---------------------------------------------------------------- fixtures
|
|
|
|
/** The 33 hook events, verbatim from `hooks-events.md` "## All 33 Hook Events". */
|
|
const EVENTS_33 = [
|
|
'ConfigChange', 'CwdChanged', 'DirectoryAdded', 'Elicitation', 'ElicitationResult',
|
|
'FileChanged', 'InstructionsLoaded', 'MessageDisplay', 'Notification', 'PermissionDenied',
|
|
'PermissionRequest', 'PostCompact', 'PostModelSwitch', 'PostToolBatch', 'PostToolUse',
|
|
'PostToolUseFailure', 'PreCompact', 'PreModelSwitch', 'PreToolUse', 'SessionEnd',
|
|
'SessionStart', 'Setup', 'Stop', 'StopFailure', 'SubagentStart', 'SubagentStop',
|
|
'TaskCompleted', 'TaskCreated', 'TeammateIdle', 'UserPromptExpansion', 'UserPromptSubmit',
|
|
'WorktreeCreate', 'WorktreeRemove',
|
|
];
|
|
|
|
/** hooks-events.md writes some events in the house DICT abbreviations. */
|
|
const ABBREV = {
|
|
SS: 'SessionStart', PTU: 'PreToolUse', POT: 'PostToolUse',
|
|
PR: 'PermissionRequest', PCD: 'PostCompact', MD: 'MessageDisplay',
|
|
};
|
|
|
|
const HANDLER_TYPES = ['agent', 'command', 'http', 'mcp_tool', 'prompt'];
|
|
|
|
const SESSIONSTART_SOURCES = ['clear', 'compact', 'fork', 'resume', 'startup'];
|
|
|
|
/** 12 values -- 2.1.267 added `account_on_hold` + `cloud_credential_error` (was 10). */
|
|
const STOPFAILURE_TYPES = [
|
|
'account_on_hold', 'authentication_failed', 'billing_error', 'cloud_credential_error',
|
|
'invalid_request', 'max_output_tokens', 'model_not_found', 'oauth_org_not_allowed',
|
|
'overloaded', 'rate_limit', 'server_error', 'unknown',
|
|
];
|
|
|
|
/** 12 values -- 2.1.234 added the 3 `quota_auto_resume_*` values (was 9). */
|
|
const NOTIFICATION_TYPES = [
|
|
'agent_completed', 'agent_needs_input', 'auth_success', 'elicitation_complete',
|
|
'elicitation_dialog', 'elicitation_response', 'elicitation_url_dialog', 'idle_prompt',
|
|
'permission_prompt', 'quota_auto_resume_disabled', 'quota_auto_resume_fired',
|
|
'quota_auto_resume_stale',
|
|
];
|
|
|
|
const REF_VER = '2.1.269';
|
|
|
|
/** Claims that were true once and are now wrong; a hit, in any casing, is a regression. */
|
|
const BANNED = [
|
|
['non-blockable', 'PostToolUse does carry decision:"block" + updatedToolOutput (hooks:1923)'],
|
|
['no decision field', 'PostToolUse does carry a decision field'],
|
|
['user_prompt', 'the UserPromptSubmit stdin field is `prompt`'],
|
|
['denial_reason', 'the PermissionDenied stdin field is `reason`'],
|
|
];
|
|
|
|
/** Reference each agent body must cite because the fact-checks below read it there. */
|
|
const REQUIRED_CITES = {
|
|
'hook-creator.md': [
|
|
'hooks-events.md', 'hooks-types-config.md', 'hooks-env.md',
|
|
'hooks-templates.md', 'hooks-io-contract.md',
|
|
],
|
|
'skill-creator.md': ['frontmatter-fields.md'],
|
|
'agent-creator.md': ['agent-frontmatter-fields.md', 'agent-scope-and-tools.md', 'agent-template.md'],
|
|
};
|
|
|
|
// ---------------------------------------------------------------- helpers
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const results = [];
|
|
|
|
function deepEqual(a, b) {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
}
|
|
|
|
function check(name, actual, expected, message) {
|
|
if (deepEqual(actual, expected)) {
|
|
passed++;
|
|
results.push(` PASS ${name} (${message})`);
|
|
} else {
|
|
failed++;
|
|
results.push(
|
|
` FAIL ${name} (${message} | actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Lines of `file` between the two anchors, end-exclusive. `endAnchor` may be absent (runs to EOF). */
|
|
function section(file, startAnchor, endAnchor) {
|
|
const lines = text[file].split('\n');
|
|
const from = lines.findIndex((l) => l.startsWith(startAnchor));
|
|
const to = lines.findIndex((l, i) => i > from && l.startsWith(endAnchor));
|
|
return lines.slice(from < 0 ? 0 : from, to < 0 ? lines.length : to);
|
|
}
|
|
|
|
/** Backticked tokens of a markdown cell, sorted+deduped. */
|
|
function tokens(cell) {
|
|
return [...new Set([...cell.matchAll(/`([^`]+)`/g)].map((m) => m[1]))].sort();
|
|
}
|
|
|
|
/** 1-based line numbers of `file` whose text matches `re`. */
|
|
function hits(file, re) {
|
|
return text[file].split('\n')
|
|
.map((l, i) => (re.test(l) ? i + 1 : -1))
|
|
.filter((n) => n > 0);
|
|
}
|
|
|
|
/** Literal -> regex source. */
|
|
function esc(literal) {
|
|
return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/** 1-based line number of the character offset `index` in `file`. */
|
|
function lineAt(file, index) {
|
|
return text[file].slice(0, index).split('\n').length;
|
|
}
|
|
|
|
/**
|
|
* Text windows in which `file` talks about PostToolUse: every `PostToolUse` mention
|
|
* (never the distinct PostToolUseFailure event) plus a `| POT |` row start. A claim
|
|
* about POT counts only if it lives inside one of these.
|
|
*/
|
|
function potWindows(file) {
|
|
const t = text[file];
|
|
return [...t.matchAll(/PostToolUse(?!Failure)|^\| POT \|/gm)]
|
|
.map((m) => t.slice(m.index, m.index + 260));
|
|
}
|
|
|
|
/** Whether `file`'s body cites `refBase` (its Read-on-demand table names the file). */
|
|
function cites(file, refBase) {
|
|
return new RegExp(esc(refBase)).test(text[file]);
|
|
}
|
|
|
|
/** Runs a hook template with `stdin` and returns its non-empty stdout lines. */
|
|
function runHookTemplate(source, stdin) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'creator-contract-'));
|
|
try {
|
|
const file = join(dir, 'hook.sh');
|
|
writeFileSync(file, source);
|
|
const stdout = execFileSync('bash', [file], { input: stdin, encoding: 'utf8' });
|
|
return stdout.trim().split('\n').filter(Boolean);
|
|
} catch (error) {
|
|
return [`<hook failed: ${error.message.split('\n')[0]}>`];
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------- 0. restructure integrity
|
|
|
|
{
|
|
const missing = REFS.filter((base) => !existsSync(REF_PATH[base]));
|
|
check('refs.exist', missing, [],
|
|
'every path cited in a "Read on demand" table must exist on disk');
|
|
|
|
const tooLong = REFS
|
|
.filter((base) => text[base] && text[base].split('\n').length > 200)
|
|
.map((base) => `${base}:${text[base].split('\n').length}`);
|
|
check('refs.maxLines200', tooLong, [],
|
|
'every cited reference file must stay <=200 lines (on-demand loading budget)');
|
|
|
|
function bodyWords(file) {
|
|
const parts = text[file].split(/^---$/m);
|
|
return parts.slice(2).join('---').trim().split(/\s+/).filter(Boolean).length;
|
|
}
|
|
const overWords = FILES
|
|
.filter((f) => bodyWords(f) > 1500)
|
|
.map((f) => `${f}:${bodyWords(f)}`);
|
|
check('body.maxWords1500', overWords, [],
|
|
'each agent body (measured after frontmatter) must stay <=1500 words');
|
|
|
|
function frontmatter(file) {
|
|
return text[file].split(/^---$/m)[1] || '';
|
|
}
|
|
const noName = FILES.filter((f) => !/^name:\s*\S+/m.test(frontmatter(f)));
|
|
check('fm.name', noName, [], 'every creator agent frontmatter must carry `name`');
|
|
|
|
const noModelInherit = FILES.filter((f) => !/^model:\s*inherit\s*$/m.test(frontmatter(f)));
|
|
check('fm.modelInherit', noModelInherit, [],
|
|
'every creator agent frontmatter must carry `model: inherit`');
|
|
|
|
const noBash = FILES.filter((f) => {
|
|
const toolsLine = frontmatter(f).split('\n').find((l) => l.startsWith('tools:')) || '';
|
|
return !/\bBash\b/.test(toolsLine);
|
|
});
|
|
check('fm.toolsBash', noBash, [], 'every creator agent frontmatter `tools:` must include `Bash`');
|
|
|
|
for (const [file, refs] of Object.entries(REQUIRED_CITES)) {
|
|
const missingCite = refs.filter((r) => !cites(file, r));
|
|
check(`cite.${file}`, missingCite, [],
|
|
`${file} must cite every reference that now teaches a fact this suite checks there`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 1. roster
|
|
|
|
{
|
|
const rows = section('hooks-events.md', '## All 33 Hook Events', '### Common stdin')
|
|
.filter((l) => /^\| \d+ \|/.test(l))
|
|
.map((l) => l.split('|')[2].trim());
|
|
const expanded = rows.map((e) => ABBREV[e] || e).sort();
|
|
check('roster.count', rows.length, 33,
|
|
'hooks-events.md must list every hook event exactly once');
|
|
check('roster.set', expanded, EVENTS_33,
|
|
'hooks-events.md event names must equal the 33 current events');
|
|
check('roster.heading', hits('hooks-events.md', /^## All 33 Hook Events$/).length, 1,
|
|
'hooks-events.md heading must state the same count it lists');
|
|
}
|
|
|
|
// ------------------------------------------------------------ 2. handler types
|
|
|
|
{
|
|
const types = section('hooks-types-config.md', '## Hook Types', '> `prompt`/`agent` = gates')
|
|
.filter((l) => /^\| `/.test(l))
|
|
.map((l) => l.split('|')[1].trim().replace(/`/g, ''))
|
|
.sort();
|
|
check('types.set', types, HANDLER_TYPES,
|
|
'hooks-types-config.md must document exactly the 5 handler types');
|
|
const header = hits('hook-creator.md',
|
|
/^> Ref ver: .* \| 5 hook types \(command, http, mcp_tool, prompt, agent\)/);
|
|
check('types.header', header.length, 1,
|
|
'hook-creator.md header line must name the same 5 handler types');
|
|
}
|
|
|
|
// ------------------------------------------------- 3. matcher rows (N1/N2/N3/N5)
|
|
|
|
{
|
|
const matcherSec = section('hooks-events.md', '## Matcher Patterns', '\u0000-never-matches');
|
|
const row = (re) => matcherSec.find((l) => re.test(l)) || '';
|
|
|
|
check('matcher.noMatcher.PostToolBatch',
|
|
/PostToolBatch/.test(row(/\| No matcher \|/)), true,
|
|
'hooks-events.md no-matcher row must list PostToolBatch (N5)');
|
|
check('matcher.noMatcher.PermissionDenied',
|
|
/PermissionDenied/.test(row(/\| No matcher \|/)), false,
|
|
'hooks-events.md PermissionDenied takes a tool-name matcher, not the no-matcher row (H02)');
|
|
check('matcher.toolName.PermissionDenied',
|
|
/PermissionDenied/.test(row(/\| tool name \|/)), true,
|
|
'hooks-events.md must put PermissionDenied on the tool-name matcher row (H02)');
|
|
|
|
check('matcher.SessionStart.sources',
|
|
tokens(row(/^\| SS \| source string \|/)), SESSIONSTART_SOURCES,
|
|
'hooks-events.md SessionStart sources must include `fork` (N1)');
|
|
check('matcher.StopFailure.types',
|
|
tokens(row(/^\| StopFailure \|/)), STOPFAILURE_TYPES,
|
|
'hooks-events.md StopFailure must carry all 12 error types (N2, 2.1.267)');
|
|
check('matcher.Notification.types',
|
|
tokens(row(/^\| Notification \|/)), NOTIFICATION_TYPES,
|
|
'hooks-events.md Notification must carry all 12 notification types (N3, 2.1.234)');
|
|
|
|
// N2/N1 second location: the events-table row for StopFailure/SessionStart, same file.
|
|
const eventsSec = section('hooks-events.md', '## All 33 Hook Events', '### Common stdin');
|
|
const sec2Row = eventsSec.find((l) => /^\| 22 \| StopFailure \|/.test(l)) || '';
|
|
check('matcher.StopFailure.types.sec2',
|
|
tokens(sec2Row.split('|')[4] || ''), STOPFAILURE_TYPES,
|
|
'hooks-events.md events-table StopFailure row must carry the same 12 error types as Matcher Patterns (N2)');
|
|
|
|
const ssRow = eventsSec.find((l) => /^\| 1 \| SS \|/.test(l)) || '';
|
|
check('matcher.SessionStart.sources.sec2',
|
|
tokens(ssRow.split('|')[4] || ''), SESSIONSTART_SOURCES,
|
|
'hooks-events.md events-table SessionStart row must carry the same 5 sources as Matcher Patterns (N1)');
|
|
}
|
|
|
|
// --------------------------------------------- 4. no live 200-subagent cap
|
|
|
|
{
|
|
// Any 200 near subagent talk, however phrased, across every agent body + cited reference.
|
|
const offenders = [];
|
|
for (const f of ALL_KEYS) {
|
|
for (const m of text[f].matchAll(/\b200\b/g)) {
|
|
const around = text[f].slice(Math.max(0, m.index - 160), m.index + 160);
|
|
const aboutSubagents = /subagents?\b|\bSAs?\b|spawn|MAX_SUBAGENTS/i.test(around);
|
|
const statesRemoval = /2\.1\.224/.test(around) && /remov(?:ed|al)/i.test(around);
|
|
if (aboutSubagents && !statesRemoval) {
|
|
offenders.push(`${f}:${lineAt(f, m.index)}`);
|
|
}
|
|
}
|
|
}
|
|
check('cap.no200', offenders, [],
|
|
'no creator file or reference may assert a live 200-subagent-per-session cap; it was removed in 2.1.224');
|
|
}
|
|
|
|
// -------------------------------------- 5. CLAUDE_PLUGIN_DATA is writable
|
|
|
|
{
|
|
const offenders = [];
|
|
for (const f of ALL_KEYS) {
|
|
for (const n of hits(f, /CLAUDE_PLUGIN_DATA/)) {
|
|
const line = text[f].split('\n')[n - 1];
|
|
if (/block|forbidden|protected[- ]path|never a Write target|read-only/i.test(line)) {
|
|
offenders.push(`${f}:${n}`);
|
|
}
|
|
}
|
|
}
|
|
check('pluginData.writable', offenders, [],
|
|
'no creator file or reference may claim ${CLAUDE_PLUGIN_DATA} writes are blocked; it is the official writable plugin data dir (D1 Q1/H16)');
|
|
|
|
const asked = hits('hooks-env.md', /Sensitive-path prompt \(2\.1\.233, verified in binary\)/);
|
|
check('pluginData.sensitivePathBlock', asked.length, 1,
|
|
'hooks-env.md must carry D1\'s sensitive-path ASK text exactly once (H16)');
|
|
}
|
|
|
|
// ---------------------------- 6. PostToolUse blockability, stated identically
|
|
|
|
{
|
|
const offenders = [];
|
|
for (const f of ALL_KEYS) {
|
|
for (const [needle, why] of BANNED) {
|
|
for (const n of hits(f, new RegExp(esc(needle), 'i'))) {
|
|
offenders.push(`${f}:${n} "${needle}" (${why})`);
|
|
}
|
|
}
|
|
}
|
|
check('pot.noBannedClaims', offenders, [],
|
|
'a banned stale claim reappeared in a creator file or reference (N6/H03/H02/H13)');
|
|
|
|
// Naming the field is not stating the fact: the meaning is asserted, not the token.
|
|
// \s+ tolerates hard-wrapped prose (frontmatter-fields.md wraps mid-sentence).
|
|
const UTO_RE = /`updatedToolOutput`\s+(?:replaces|rewrites)\s+what Claude sees/;
|
|
check('pot.updatedToolOutput.hooksIoContract', UTO_RE.test(text['hooks-io-contract.md']), true,
|
|
'hooks-io-contract.md (cited by hook-creator.md) must state `updatedToolOutput` replaces what Claude sees (N6)');
|
|
check('pot.updatedToolOutput.skillsFrontmatterFields', UTO_RE.test(text['frontmatter-fields.md']), true,
|
|
'frontmatter-fields.md (cited by skill-creator.md) must state `updatedToolOutput` replaces what Claude sees (N6)');
|
|
|
|
const BLOCKABLE_RE = /`decision: ?"block"`[^\n]*reason/;
|
|
check('pot.blockable.hooksIoContract',
|
|
potWindows('hooks-io-contract.md').some((w) => BLOCKABLE_RE.test(w)), true,
|
|
'hooks-io-contract.md must state PostToolUse blockability in the POT row itself: decision:"block" + reason (N6)');
|
|
check('pot.blockable.skillsFrontmatterFields', BLOCKABLE_RE.test(text['frontmatter-fields.md']), true,
|
|
'frontmatter-fields.md must state PostToolUse blockability the same way: decision:"block" + reason (N6)');
|
|
}
|
|
|
|
// ------------------------------------------------------ 7. reference version
|
|
|
|
{
|
|
// Convention change from the restructure: only hook-creator.md still carries a
|
|
// "Ref ver:" header; skill-creator.md/agent-creator.md cite the 2.1.269 delta in
|
|
// prose instead (their own intro sentence). Guard both forms so neither can drift.
|
|
const refVerFiles = FILES.filter((f) => /Ref ver:/.test(text[f])).sort();
|
|
check('refver.files', refVerFiles, ['hook-creator.md'],
|
|
'only hook-creator.md carries the "Ref ver:" header post-restructure');
|
|
|
|
const refVerValues = refVerFiles.map((f) => (text[f].match(/Ref ver:\s*([0-9]+\.[0-9]+\.[0-9]+)/) || [])[1]);
|
|
check('refver.values', refVerValues, [REF_VER], `hook-creator.md's "Ref ver:" must read ${REF_VER} (N4)`);
|
|
|
|
const mention = FILES.filter((f) => text[f].includes(REF_VER)).sort();
|
|
check('refver.allMention', mention, FILES.slice().sort(),
|
|
`every creator body must mention its ${REF_VER} baseline somewhere in prose`);
|
|
}
|
|
|
|
// ------------------------------- 8. AskUserQuestion is not promised to a SA
|
|
|
|
{
|
|
const SA_REMOVAL = /AskUserQuestion[^\n]*(removed|stripped|unavailable)|(?:removed|stripped) from (?:every|EVERY) SA/;
|
|
const declaring = FILES.filter((f) => {
|
|
const fm = text[f].split('\n').slice(0, 15).find((l) => l.startsWith('tools:')) || '';
|
|
return /AskUserQuestion/.test(fm);
|
|
});
|
|
// Ruling D1-Q3: the tool is stripped from every SA, so a declaration is inert - and
|
|
// documenting the removal does not license keeping the dead entry.
|
|
check('auq.declared', declaring, [],
|
|
'no creator may declare the inert AskUserQuestion in `tools:`; it is stripped from every SA (Q3)');
|
|
|
|
const carrying = FILES.filter((f) => SA_REMOVAL.test(text[f])).sort();
|
|
check('auq.removalFact.files', carrying, ['agent-creator.md', 'skill-creator.md'],
|
|
'both creators that describe a subagent tool pool must state the AskUserQuestion removal (Q3)');
|
|
|
|
check('auq.hookCreatorTools',
|
|
/AskUserQuestion/.test(text['hook-creator.md'].split('\n').slice(0, 15)
|
|
.find((l) => l.startsWith('tools:')) || ''), false,
|
|
'hook-creator.md must not declare the inert AskUserQuestion tool (Q3)');
|
|
}
|
|
|
|
// ------------------------------------- 9. BC-A01: templates are fail-closed
|
|
|
|
{
|
|
const tpl = section('hooks-templates.md', '## Templates', '## Best Practices').join('\n');
|
|
|
|
// Behavioural, not textual: the shipped bash template is run on the stop-hook path,
|
|
// the one that tempted an early `echo '{}'`. Two objects on stdout = discarded verdict.
|
|
const bashTpl = (tpl.match(/```bash\n([\s\S]*?)```/) || ['', ''])[1];
|
|
check('template.bash.singleStdoutWrite',
|
|
runHookTemplate(bashTpl, '{"stop_hook_active":true}'), ['{}'],
|
|
'hooks-templates.md bash template must print exactly one JSON object on every path (BC-A01)');
|
|
check('template.decideFn',
|
|
(tpl.match(/output\(decide\(await readStdin\(\)\)\)/g) || []).length, 1,
|
|
'hooks-templates.md JS template must emit one object from one decide() call (BC-A01)');
|
|
check('template.noCommentedDecisions',
|
|
/^\s*(#|\/\/) (?:Deny tool|Block stop|Inject context)/m.test(tpl), false,
|
|
'hooks-templates.md templates must not park the real decision outputs in comments (BC-A01)');
|
|
}
|
|
|
|
// ------------------------- 10. agent-creator 2.1.234-2.1.269 delta fixtures
|
|
// The facts moved to agent-{frontmatter-fields,scope-and-tools,template}.md;
|
|
// citation back to agent-creator.md is asserted once, up front, in cite.agent-creator.md.
|
|
|
|
{
|
|
check('experimentalCacheTtl.fieldTable',
|
|
/\|\s*`experimental\.cacheTtl`\s*\|/.test(text['agent-frontmatter-fields.md']), true,
|
|
'agent-frontmatter-fields.md OPT Fields table must carry the experimental.cacheTtl field (2.1.248)');
|
|
|
|
const precedence = section('agent-scope-and-tools.md', '## Model Precedence', '## Spawn From Main Conversation Only').join('\n');
|
|
check('modelPrecedence.forceVar',
|
|
/CLAUDE_CODE_SUBAGENT_MODEL_FORCE/.test(precedence), true,
|
|
'agent-scope-and-tools.md Model Precedence table must mention CLAUDE_CODE_SUBAGENT_MODEL_FORCE (2.1.257)');
|
|
|
|
const colorSec = section('agent-template.md', '## Color Semantics', '## Common AG Types')
|
|
.filter((l) => /^\| /.test(l) && !/^\| Color \|/.test(l) && !/^\|-{2,}/.test(l));
|
|
const colors = colorSec.flatMap((l) => l.split('|')[1].split(',').map((c) => c.trim().replace(/`/g, '')));
|
|
check('color.noMagenta', colors.includes('magenta'), false,
|
|
'agent-template.md Color Semantics table must not list magenta as a valid color value');
|
|
check('color.count8', new Set(colors).size, 8,
|
|
'agent-template.md Color Semantics table must enumerate exactly 8 valid color values');
|
|
|
|
const tpl = section('agent-template.md', '### 6. Guardrails', '## LLM Text Rules').join('\n');
|
|
check('template.returnContractHeading', /^## Return Contract$/m.test(tpl), true,
|
|
'agent-template.md generated-agent template (Guardrails) must carry a literal "## Return Contract" heading');
|
|
}
|
|
|
|
// ---------------------------------------------------------------- report
|
|
|
|
console.log('\nsuite-creator-contract (brewcode creator agents)');
|
|
console.log(` base: ${AGENTS}`);
|
|
for (const line of results) console.log(line);
|
|
console.log(`\n passed=${passed} failed=${failed} total=${passed + failed}\n`);
|
|
process.exit(failed === 0 ? 0 : 1);
|