feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* lint-pieces: enforces that no piece YAML uses `COMPLETE` / `ABORT` / `ASK`
|
||||
* as `transition.rules[].next` values. Terminal moves go through the
|
||||
* `complete` tool (Phase 6 design).
|
||||
*
|
||||
* `default_next: COMPLETE` is allowed — that's an engine-internal sentinel
|
||||
* used by context-overflow / ASK-limit / SpawnSubTask-unavailable fallback
|
||||
* paths and is never exposed to the LLM.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/lint-pieces.mjs # lint all pieces/*.yaml
|
||||
* node scripts/lint-pieces.mjs path/x.yaml # lint specific files
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — no violations
|
||||
* 1 — at least one piece uses a banned terminal in rules[].next
|
||||
*
|
||||
* History:
|
||||
* - Phase 6a-1: introduced ALLOWLIST mechanism for the 11 unmigrated pieces
|
||||
* - Phase 6a-2: ALLOWLIST drained to empty as pieces migrated
|
||||
* - Phase 6b: ALLOWLIST mechanism removed; violations are always hard fails
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const TERMINAL_NEXTS = new Set(['COMPLETE', 'ABORT', 'ASK']);
|
||||
|
||||
function collectPieceFiles(args) {
|
||||
if (args.length > 0) return args;
|
||||
const dir = 'pieces';
|
||||
if (!existsSync(dir)) {
|
||||
console.error(`[lint-pieces] directory not found: ${dir}`);
|
||||
process.exit(2);
|
||||
}
|
||||
return readdirSync(dir).filter((f) => f.endsWith('.yaml')).map((f) => join(dir, f));
|
||||
}
|
||||
|
||||
function findTerminalNexts(piece) {
|
||||
const offenders = [];
|
||||
if (!piece || typeof piece !== 'object') return offenders;
|
||||
const movements = Array.isArray(piece.movements) ? piece.movements : [];
|
||||
for (const movement of movements) {
|
||||
const rules = Array.isArray(movement?.rules) ? movement.rules : [];
|
||||
for (const rule of rules) {
|
||||
if (rule && typeof rule.next === 'string' && TERMINAL_NEXTS.has(rule.next)) {
|
||||
offenders.push({
|
||||
movement: String(movement?.name ?? '<unnamed>'),
|
||||
condition: String(rule.condition ?? ''),
|
||||
next: rule.next,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = collectPieceFiles(process.argv.slice(2));
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parse(readFileSync(filePath, 'utf-8'));
|
||||
} catch (e) {
|
||||
console.error(`[lint-pieces] failed to parse ${filePath}: ${e.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const offenders = findTerminalNexts(parsed);
|
||||
for (const offender of offenders) {
|
||||
violations.push({ file: filePath, ...offender });
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('\n[lint-pieces] ❌ pieces must not use COMPLETE/ABORT/ASK in rules[].next.');
|
||||
console.error('Use the `complete` tool for terminal moves (status: success | aborted | needs_user_input).');
|
||||
console.error('See docs/plans/2026-05-01-phase-6a-complete-tool.md.\n');
|
||||
for (const v of violations) {
|
||||
console.error(` ${v.file} → movement="${v.movement}" rule.next="${v.next}" condition="${v.condition}"`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[lint-pieces] ✓ ${files.length} piece(s) checked, no violations`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user