maestro/scripts/lint-pieces.mjs
clade 7049a874f3 feat: initial public release (MAESTRO v0.1.0)
Open-source release of MAESTRO, an agent orchestration platform that runs
LLM-driven tasks through sandboxed tools, with a web UI. Apache-2.0.
See README.md and docs/ (getting-started, configuration, architecture).
2026-06-03 04:01:14 +00:00

91 lines
3.1 KiB
JavaScript

#!/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();