#!/usr/bin/env node /** * plan-steps-parse (C2, §5) — план markdown → [{op,object,ref}]. fail-CLOSE на любой брак. * Reject op:'Task' (VA-4). Канон object: файлы → repo-relative POSIX (SE-5). ref обязателен (SE-1). */ import { sessionProduces } from './plan-lock.mjs'; const BLOCK_RE = /```steps-json\s*\n([\s\S]*?)\n```/; const FILE_OPS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']); /** Достать содержимое блока ```steps-json``` или null. */ export function extractStepsBlock(md) { const m = String(md == null ? '' : md).match(BLOCK_RE); return m ? m[1] : null; } /** * Канон формы object для файловых op — repo-relative POSIX (SE-5): только backslash→slash, * БЕЗ абсолютизации. Стабильная, cwd-независимая форма для хранения в печати; реальная * нормализация для матча — pathNormalize в actionMatchesStep (plan-lock.mjs) на ОБЕ стороны * (шаг И событие) во время матча. Класть сюда pathNormalize нельзя: он cwd-зависим/абсолютен, * печать ставится в cwd хука-судьи, а матч — в cwd стены (рассинхрон). */ function canonObject(op, object) { const s = String(object ?? ''); return FILE_OPS.has(op) ? s.replace(/\\/g, '/') : s; } /** Распарсить + провалидировать шаги. Бросает на брак (fail-CLOSE). */ export function parsePlanSteps(md) { const raw = extractStepsBlock(md); if (raw == null) throw new Error('plan-steps-parse: нет блока ```steps-json``` (fail-CLOSE)'); let arr; try { arr = JSON.parse(raw); } catch { throw new Error('plan-steps-parse: блок не валидный JSON (fail-CLOSE)'); } if (!Array.isArray(arr) || arr.length === 0) throw new Error('plan-steps-parse: ожидался непустой массив'); return arr.map((s, i) => { if (!s || typeof s !== 'object') throw new Error(`plan-steps-parse: шаг ${i} не объект`); const op = String(s.op || ''); if (!op) throw new Error(`plan-steps-parse: шаг ${i} без op`); if (op === 'Task') throw new Error('plan-steps-parse: op:Task запрещён (субагенты, VA-4)'); // B+C ч.2 (точка 4): op:'Skill' не может быть шагом плана — навык объявляется в skills-json // и вызывается свободно (isPlanDeclaredSkill), указатель не двигая. Явное сообщение, не «без object». if (op === 'Skill') throw new Error(`plan-steps-parse: шаг ${i} op:'Skill' запрещён как шаг — объяви навык в skills-json (он вызывается свободно)`); // B+C ч.2 (точка 1): сеанс осмотра — особый тип шага (нет object/ref; интерактив по живым ref). // Якорь закрытия — produces (≥1 итоговый файл); goal — намерение для наставника/судьи; tools — // действующие инструменты сеанса (предохранитель §3.3 сработает в decide/sanitizeSessionTools). if (op === 'session') { const goal = String(s.goal ?? '').trim(); if (!goal) throw new Error(`plan-steps-parse: шаг ${i} (session) без goal — намерение осмотра обязательно`); const produces = sessionProduces(s); if (produces.length === 0) throw new Error(`plan-steps-parse: шаг ${i} (session) без produces — нужен ≥1 итоговый файл (он закрывает сеанс)`); const tools = Array.isArray(s.tools) ? s.tools.map((t) => String(t ?? '')).filter(Boolean) : []; return { op: 'session', goal, tools, produces }; } const object = String(s.object ?? ''); const ref = String(s.ref ?? ''); if (!object.trim()) throw new Error(`plan-steps-parse: шаг ${i} без object`); if (!ref.trim()) throw new Error(`plan-steps-parse: шаг ${i} без ref (closed-door, SE-1)`); return { op, object: canonObject(op, object), ref }; }); }