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