From bc1d2a370a9ec8b03fa3c88efb8b0a25c97a0e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 18 Jun 2026 11:54:42 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20B+C=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?2=20=E2=80=94=20=D1=81=D0=B5=D0=B0=D0=BD=D1=81=20=D0=BE=D1=81?= =?UTF-8?q?=D0=BC=D0=BE=D1=82=D1=80=D0=B0=20op:"session"=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=20=D1=81=D1=82=D0=B5=D0=BD=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый тип шага плана op:"session" {goal, tools, produces} для интерактивного осмотра (логин/формы/чужой сайт) под планом: внутри сеанса смотреть/кликать по живым ref свободно, указатель не двигается; сеанс закрывает запись последнего produces (матч-якорь). Снят дедлок op:"Skill"-как-шаг. - plan-lock: sessionProduces, actionMatchesStep матчит последний produces, validatePlanTree валидирует session (produces>=1) и запрещает op:"Skill", sanitizeSessionTools (предохранитель §3.3: дроп Write/Edit/Bash/floor + warn). - enforce-supreme-gate decide: ветка указатель-на-сеансе — tools сеанса и промежуточные produces allow без сдвига, пол применяется (defense-in-depth). - plan-steps-parse: распознаёт op:"session" (goal/tools/produces, без object/ref), отвергает op:"Skill" с явным сообщением. - mentor-verdict: наставник понимает op:"session" — не заворачивает как непонятный шаг. - сеанс+tools/produces в хеше и подписи плана (подмена ломает печать). Спека: docs/superpowers/specs/2026-06-18-wall-interactive-session-design.md §3.2-3.3. +37 тестов, свод 4266 passed / 2 skipped. Co-Authored-By: Claude Opus 4.8 --- tools/enforce-supreme-gate.mjs | 28 +++++- tools/enforce-supreme-gate.test.mjs | 47 ++++++++++ tools/mentor-verdict.mjs | 3 + tools/mentor-verdict.test.mjs | 8 ++ tools/plan-lock.mjs | 76 +++++++++++++++-- tools/plan-lock.test.mjs | 127 ++++++++++++++++++++++++++++ tools/plan-steps-parse.mjs | 20 ++++- tools/plan-steps-parse.test.mjs | 28 ++++++ 8 files changed, 328 insertions(+), 9 deletions(-) diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index 44eeb36..6643eb7 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -6,7 +6,7 @@ * нельзя загрузить). fail-CLOSED (сбой → стоп; рубильник у владельца). */ import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; -import { verifyFrozenPlan, verifyFrozenArtifact, actionMatchesStep, nextStep, refResolves, treeLeafAt, validatePlanTree, treeLeaves } from './plan-lock.mjs'; +import { verifyFrozenPlan, verifyFrozenArtifact, actionMatchesStep, nextStep, refResolves, treeLeafAt, validatePlanTree, treeLeaves, sanitizeSessionTools, sessionProduces } from './plan-lock.mjs'; import { advanceOverTree, serializePointer, deserializePointer, normalizeToLeaf } from './step-pointer.mjs'; // W2 (C2, нах.F3): дисциплина чтения ДР-1 (модуль D) + нормализация путей для продюсера SE5. import { decideReadEvent } from './reading-discipline.mjs'; @@ -302,6 +302,32 @@ export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0 // R-08: текущий лист по указателю (целое=depth-1 / массив=дерево); спуск через контейнеры, лист только из sealed-дерева (I2) const step = treeLeafAt(frozenPlan.steps, stepPtr); if (!step) return { decision: 'block', reason: 'план исчерпан / указатель не резолвится в лист' }; + // B+C ч.2 (точка 3, спека §3.2): указатель на шаге-сеансе. Действующие инструменты сеанса + // (sanitized tools) и ПРОМЕЖУТОЧНЫЕ produces-файлы разрешены БЕЗ сдвига указателя — сколько + // угодно, по живым ref (стена клики с шагами НЕ сверяет). query-only уже пропущен выше (свободен + // под планом). Сеанс ЗАКРЫВАЕТ запись ПОСЛЕДНЕГО produces — она падает в actionMatchesStep ниже + // (матч сеанса = последний produces) → обычный сдвиг указателя. Пол применяется и здесь + // (defense-in-depth §3.3): промежуточный produces в runtime/секрет → вето. + if (step.op === 'session') { + const act = actionOf(toolUse); + const { allowed } = sanitizeSessionTools(step.tools); + const isSessionTool = allowed.includes(toolUse?.name); + const produces = sessionProduces(step); + const nz = (p) => { try { return (normalize ? normalize(String(p)) : String(p)); } catch { return null; } }; + const target = nz(act.object); + const closer = produces.length ? produces[produces.length - 1] : null; + const isProducesWrite = String(act.op) === 'Write' && target != null && produces.some((p) => nz(p) === target); + const isCloser = isProducesWrite && closer != null && nz(closer) === target; + if (isSessionTool || (isProducesWrite && !isCloser)) { + if (floorDecide({ toolUse, escapeGrants: [], escapeConsumed: [], now: 0 }).block) { + return { decision: 'block', reason: `сеанс осмотра ${step.n}: пол наложил бы вето на это действие — стена не двигает указатель, нужна дверь владельца (escape) (§3.3)` }; + } + const why = isSessionTool ? `инструмент «${toolUse.name}» из набора сеанса` : 'промежуточный итоговый файл (сеанс закроет запись последнего produces)'; + return { decision: 'allow', reason: `сеанс осмотра ${step.n}: ${why} — указатель не двигается`, advance: false }; + } + // Не инструмент сеанса и не промежуточный produces → падаем в actionMatchesStep: + // запись ПОСЛЕДНЕГО produces → матч (сдвиг, сеанс закрыт); иначе block «действие не в плане». + } if (!actionMatchesStep(step, actionOf(toolUse), { normalize })) return { decision: 'block', reason: `действие не в плане (ожидался шаг ${step.n}: ${step.op} ${step.object})` }; // Привязка к версии артефакта — только если план опирается на artifact_id: diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index b72e282..5089ce6 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -387,6 +387,53 @@ describe('decide() пропускает зелёный проход без ша }); }); +// B+C ч.2 (точка 3 handoff, спека §3.2): указатель на шаге-сеансе op:'session'. +describe('decide() — указатель на сеансе осмотра (op:session)', () => { + const SPLAN = { + plan_id: 's', frozen_at: 1, sig: 'ok', + steps: [{ n: 1, op: 'session', goal: 'осмотр логина', + tools: ['mcp__playwright__browser_click', 'browser_type'], + produces: ['docs/observer/notes.md', 'docs/observer/report.md'] }], + }; + const sctx = (over) => ctx({ frozenPlan: SPLAN, frozenArtifact: null, stepPtr: 0, ...over }); + + it('действующий инструмент сеанса (browser_click из набора) → allow без сдвига', () => { + const r = decide(sctx({ toolUse: { name: 'mcp__playwright__browser_click', input: {} } })); + expect(r.decision).toBe('allow'); + expect(r.advance).not.toBe(true); + expect(r.reason).toMatch(/сеанс/i); + }); + it('query-only под сеансом (WebFetch) → allow без сдвига', () => { + const r = decide(sctx({ toolUse: { name: 'WebFetch', input: {} } })); + expect(r.decision).toBe('allow'); + expect(r.advance).not.toBe(true); + }); + it('инструмент НЕ из набора и не query-only → block (default-deny держит)', () => { + const r = decide(sctx({ toolUse: { name: 'mcp__other__do_thing', input: {} } })); + expect(r.decision).toBe('block'); + }); + it('промежуточный produces (notes.md, не последний) → allow без сдвига', () => { + const r = decide(sctx({ toolUse: { name: 'Write', input: { file_path: 'docs/observer/notes.md' } } })); + expect(r.decision).toBe('allow'); + expect(r.advance).not.toBe(true); + }); + it('запись ПОСЛЕДНЕГО produces (report.md) → allow со сдвигом (сеанс закрыт)', () => { + const r = decide(sctx({ toolUse: { name: 'Write', input: { file_path: 'docs/observer/report.md' } } })); + expect(r.decision).toBe('allow'); + expect(r.advance).toBe(true); + }); + it('Write постороннего файла под сеансом → block (не produces, не в плане)', () => { + const r = decide(sctx({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } } })); + expect(r.decision).toBe('block'); + }); + it('пол применяется к сеансу: промежуточный produces в секрет (.env) → block (§3.3 defense-in-depth)', () => { + const planSecret = { ...SPLAN, steps: [{ ...SPLAN.steps[0], produces: ['app/.env', 'docs/observer/report.md'] }] }; + const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'app/.env' } }, frozenPlan: planSecret, frozenArtifact: null, stepPtr: 0 })); + expect(r.decision).toBe('block'); + expect(r.reason).toMatch(/пол|floor|escape/i); + }); +}); + const ARTID = 'aid-1'; const PLAN_REF = { plan_id: 'p', artifact_id: ARTID, frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', ref: '§1' }], sig: 'ok' }; diff --git a/tools/mentor-verdict.mjs b/tools/mentor-verdict.mjs index f89da00..31eacec 100644 --- a/tools/mentor-verdict.mjs +++ b/tools/mentor-verdict.mjs @@ -55,6 +55,9 @@ export function buildMentorVerdictPrompt({ plan = null, verifiedContext = [], ne const system = [ 'Ты — НАСТАВНИК. Разбери ПЛАН ПО ПУНКТАМ (не выбирай скил — это другой вызов).', DR1_LINE, + // B+C ч.2 (точка 5): шаг op:"session" — ЛЕГИТИМНЫЙ тип (сеанс осмотра). Не заворачивай его + // как «непонятный шаг»: интерактив (логин/формы/чужой сайт) нельзя расписать по кликам заранее. + 'Шаг op:"session" — это СЕАНС ОСМОТРА (легитимный тип шага): {goal — что осмотреть, tools — действующие инструменты сеанса (клик/ввод/MCP), produces — итоговый файл(ы), ≥1}. Внутри сеанса агент смотрит и кликает по живым ref сколько нужно; закрывает сеанс запись последнего produces. Суди ЦЕЛЬ и итоговый файл, не требуй расписать клики и не требуй для сеанса отдельный шаг-verify (его роль играет produces-отчёт).', 'Переоцени ТЕКУЩУЮ версию заново по памяти кругов ниже; что уже снято — НЕ повторяй.', // Smoke 2026-06-12: «статус+замечание» без типа элемента провоцировал массив объектов — // валидатор (F-C3/М1-М4: слот = строки) браковал содержательный вердикт. Тип — явно. diff --git a/tools/mentor-verdict.test.mjs b/tools/mentor-verdict.test.mjs index b290937..63233cb 100644 --- a/tools/mentor-verdict.test.mjs +++ b/tools/mentor-verdict.test.mjs @@ -158,6 +158,14 @@ describe('buildMentorVerdictPrompt (§6.1 verdict-слоты, НЕ router)', () expect(p.system).toMatch(/массив СТРОК/); expect(p.system).toMatch(/не объект/i); }); + // B+C ч.2 (точка 5): наставник обязан понимать тип шага op:'session' (сеанс осмотра) — + // иначе завернёт его как «непонятный шаг». system объясняет: смотреть/кликать по живым ref, + // якорь закрытия = produces. + it('system объясняет op:session (сеанс осмотра: goal/tools/produces) — не «непонятный шаг»', () => { + const p = buildMentorVerdictPrompt({ plan: { steps: [{ n: 1, op: 'session', goal: 'осмотр', tools: ['browser_click'], produces: 'r.md' }] } }); + expect(p.system).toMatch(/session|сеанс/i); + expect(p.system).toMatch(/produces/i); + }); it('roundMemory: пусто → нет блока; непусто → блок памяти (оба построителя)', () => { expect(buildMentorVerdictPrompt({ plan: { steps: [] } }).user).not.toContain('ПАМЯТЬ КРУГОВ'); expect(buildMentorVerdictPrompt({ plan: { steps: [] }, roundMemory: { objections: ['замечание M'] } }).user).toContain('замечание M'); diff --git a/tools/plan-lock.mjs b/tools/plan-lock.mjs index d321389..30d8807 100644 --- a/tools/plan-lock.mjs +++ b/tools/plan-lock.mjs @@ -81,6 +81,49 @@ export function verifyFrozenPlan(plan, key) { /** Нормализация команды Bash: схлопнуть пробелы, trim. */ function normCommand(c) { return String(c || '').split(/\s+/).filter(Boolean).join(' '); } +/** + * B+C ч.2 (сеанс осмотра, спека 2026-06-18-wall-interactive-session-design §3.2): нормализовать + * produces сеанса в непустой массив путей (строка → [строка]; массив → массив без пустых/пробельных). + * produces — итоговый файл(ы), которые сеанс ОБЯЗАН произвести (≥1). Якорь закрытия — ПОСЛЕДНИЙ + * из списка (решение владельца Q-B: «несколько можно, последний закрывает сеанс»). + */ +export function sessionProduces(step) { + const p = step && step.produces; + const arr = Array.isArray(p) ? p : (p != null ? [p] : []); + return arr.map((x) => String(x ?? '').trim()).filter(Boolean); +} + +/** + * B+C ч.2 §3.3 (жёсткий предохранитель, единственное место списка запрещённого): tools сеанса + * НИКОГДА не содержат: + * — репо-мутаторов (Write/Edit/MultiEdit/NotebookEdit/Bash/PowerShell) — правки репозитория + * остаются ШАГАМИ плана (вкл. сам produces-файл сеанса — это обычный Write-шаг); + * — floor-опасного по имени (ssh/cloud-CLI/destructive) — остаётся за полом/escape. + * Это defense-in-depth поверх default-deny стены (decide не пускает действие не из набора и не + * query-only; floorDecide рубит floor-опасное при реальном вызове). Запрещённое имя ОТБРАСЫВАЕТСЯ + * с видимым предупреждением (решение владельца Q2 = «не валить план целиком из-за описки»). + */ +export const SESSION_FORBIDDEN_TOOLS = new Set([ + 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell', +]); +// Floor-опасные имена-команды, которые наивно могли бы оказаться в tools (§3.3 примеры: +// install/cloud-CLI/ssh/destructive). Эти НЕ являются настоящими tool-name'ами харнесса — +// вызвать их как инструмент нельзя, но отбрасываем+предупреждаем явно (зеркало §3.3). +const SESSION_FORBIDDEN_KEYWORD_RE = /^(?:ssh|scp|sftp|rm|mv|cp|chmod|chown|chgrp|curl|wget|nc|ncat|netcat|socat|eval|kubectl|aws|gcloud|az|docker|terraform|helm|composer|npm|yarn|pnpm|pip|brew|apt|apt-get)$/i; + +export function sanitizeSessionTools(tools) { + const list = Array.isArray(tools) ? tools : []; + const allowed = []; + const dropped = []; + for (const raw of list) { + const name = String(raw ?? '').trim(); + if (!name) continue; // пустые/пробельные/нестроковые — молча отброшены (не инструмент) + if (SESSION_FORBIDDEN_TOOLS.has(name) || SESSION_FORBIDDEN_KEYWORD_RE.test(name)) dropped.push(name); + else allowed.push(name); + } + return { allowed, dropped }; +} + /** * Детерминированный матч действия и шага (P15-a + P15-e): op И object обязаны * совпасть. Файловые object — через normalize (default pathNormalize); Bash — через @@ -88,6 +131,18 @@ function normCommand(c) { return String(c || '').split(/\s+/).filter(Boolean).jo */ export function actionMatchesStep(step, action, { normalize = pathNormalize } = {}) { if (!step || !action) return false; + // B+C ч.2: сеанс осмотра закрывается записью ПОСЛЕДНЕГО produces-файла (матч-якорь, Q-B). + // Клики/инструменты сеанса и промежуточные produces сеанс НЕ закрывают (это решает decide, + // указатель не двигая) — здесь матчится только закрывающий Write по последнему produces. + if (step.op === 'session') { + if (String(action.op) !== 'Write') return false; + const produces = sessionProduces(step); + if (produces.length === 0) return false; // сеанс без produces — не валиден, джокером быть не может + let closer, target; + try { closer = normalize(String(produces[produces.length - 1])); target = normalize(String(action.object)); } + catch { return false; } + return !!closer && closer === target; + } if (String(step.op) !== String(action.op)) return false; if (step.op === 'Bash') { const stepCmd = normCommand(step.object); @@ -140,20 +195,29 @@ export function treeLeafAt(steps, serializedPtr) { /** Структурная валидация дерева ДО доверия (fail-CLOSED): SE-2 контейнер не несёт op/object/ref; * SE-4 непустой substeps + глубина ≤ предела; substeps только массив. */ export function validatePlanTree(steps) { + let reason = null; + const fail = (r) => { if (!reason) reason = r; return false; }; const check = (arr, depth) => { - if (depth > MAX_TREE_DEPTH) return false; - if (!Array.isArray(arr)) return false; + if (depth > MAX_TREE_DEPTH) return fail('превышена глубина дерева плана (fail-CLOSED)'); + if (!Array.isArray(arr)) return fail('шаги плана не массив (fail-CLOSED)'); for (const s of arr || []) { - if (!s || typeof s !== 'object') return false; + if (!s || typeof s !== 'object') return fail('шаг плана не объект (fail-CLOSED)'); if ('substeps' in s) { - if (!Array.isArray(s.substeps) || s.substeps.length === 0) return false; // SE-4 - if (s.op != null || s.object != null || s.ref != null) return false; // SE-2 + if (!Array.isArray(s.substeps) || s.substeps.length === 0) return fail('пустой/невалидный контейнер (SE-4)'); // SE-4 + if (s.op != null || s.object != null || s.ref != null) return fail('контейнер не несёт op/object/ref (SE-2)'); // SE-2 if (!check(s.substeps, depth + 1)) return false; + } else { + // Лист. B+C ч.2 (точка 4): op:'Skill' не может быть шагом плана — навык объявляется в + // skills-json и вызывается свободно (isPlanDeclaredSkill), указатель не двигая. Это снимает + // дедлок «op:Skill опечатывается, но указатель не двигает». + if (String(s.op) === 'Skill') return fail("op:'Skill' не может быть шагом плана — объяви навык в skills-json (он вызывается свободно)"); + // B+C ч.2 (точка 1): сеанс осмотра обязан объявить ≥1 produces — итоговый файл якоря закрытия. + if (String(s.op) === 'session' && sessionProduces(s).length === 0) return fail('сеанс осмотра без produces — недопустим (нужен ≥1 итоговый файл, он закрывает сеанс)'); } } return true; }; - return { ok: check(steps, 1) }; + return check(steps, 1) ? { ok: true } : { ok: false, reason }; } function planPath(runtimeDir, sessionId) { diff --git a/tools/plan-lock.test.mjs b/tools/plan-lock.test.mjs index 5e57f7b..ab7a0f3 100644 --- a/tools/plan-lock.test.mjs +++ b/tools/plan-lock.test.mjs @@ -358,6 +358,133 @@ describe('validatePlanTree (SE-2/SE-4 fail-CLOSED)', () => { }); }); +// B+C ч.2: validatePlanTree знает op:session и запрещает op:Skill как шаг (точки 1+4 handoff). +describe('validatePlanTree — сеанс осмотра и запрет op:Skill', () => { + it('валидный сеанс (есть produces) → ok', () => { + const tree = [{ n: 1, op: 'session', goal: 'осмотр логина', tools: ['browser_click'], produces: 'docs/observer/s.md' }]; + expect(validatePlanTree(tree).ok).toBe(true); + }); + it('сеанс без produces → не ok (нужен ≥1 итоговый файл)', () => { + const r = validatePlanTree([{ n: 1, op: 'session', goal: 'осмотр', tools: [] }]); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/produces|итогов/i); + }); + it('сеанс с пустым produces (пустая строка/пустой массив) → не ok', () => { + expect(validatePlanTree([{ n: 1, op: 'session', goal: 'g', produces: '' }]).ok).toBe(false); + expect(validatePlanTree([{ n: 1, op: 'session', goal: 'g', produces: [] }]).ok).toBe(false); + }); + it('op:"Skill" как шаг → не ok с явным сообщением (объяви в skills-json)', () => { + const r = validatePlanTree([{ n: 1, op: 'Skill', object: 'superpowers:test-driven-development' }]); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/skills-json|Skill/i); + }); + it('сеанс как лист внутри контейнера → ok', () => { + const tree = [{ n: 1, substeps: [{ n: '1.1', op: 'session', goal: 'g', produces: 'r.md' }] }]; + expect(validatePlanTree(tree).ok).toBe(true); + }); +}); + +// ── B+C ч.2: сеанс осмотра op:'session' (спека 2026-06-18-wall-interactive-session-design §3.2) ── +import { sessionProduces } from './plan-lock.mjs'; + +describe('sessionProduces (нормализация produces сеанса в непустой массив путей)', () => { + it('строка → [строка]', () => { + expect(sessionProduces({ op: 'session', produces: 'docs/observer/s.md' })).toEqual(['docs/observer/s.md']); + }); + it('массив → массив (пустые/пробельные отброшены)', () => { + expect(sessionProduces({ op: 'session', produces: ['a.md', '', ' ', 'b.md'] })).toEqual(['a.md', 'b.md']); + }); + it('нет produces → []', () => { + expect(sessionProduces({ op: 'session' })).toEqual([]); + expect(sessionProduces({ op: 'session', produces: '' })).toEqual([]); + }); +}); + +describe('сеанс+tools/produces входят в хеш и подпись плана (§3.3: подмена ломает печать)', () => { + const K = 'sess-hash-key'; + const base = { op: 'session', goal: 'осмотр', tools: ['browser_click'], produces: ['r.md'] }; + it('другой набор tools → другой plan_id', () => { + expect(planId([base])).not.toBe(planId([{ ...base, tools: ['browser_type'] }])); + }); + it('другой produces → другой plan_id', () => { + expect(planId([base])).not.toBe(planId([{ ...base, produces: ['other.md'] }])); + }); + it('подмена tools сеанса после печати → verifyFrozenPlan false', () => { + const p = freezePlan({ steps: [base], key: K, nowMs: 1 }); + const tampered = { ...p, steps: [{ ...p.steps[0], tools: ['Write'] }] }; + expect(verifyFrozenPlan(tampered, K)).toBe(false); + }); + it('подмена produces сеанса после печати → verifyFrozenPlan false', () => { + const p = freezePlan({ steps: [base], key: K, nowMs: 1 }); + const tampered = { ...p, steps: [{ ...p.steps[0], produces: ['evil.md'] }] }; + expect(verifyFrozenPlan(tampered, K)).toBe(false); + }); +}); + +describe('actionMatchesStep для op:session — матч-якорь = ПОСЛЕДНИЙ produces (Q-B: последний закрывает)', () => { + const norm = (p) => String(p).replace(/\\/g, '/').toLowerCase(); + it('Write последнего produces → матч (сеанс закрывается)', () => { + const sess = { op: 'session', goal: 'осмотр', tools: ['browser_click'], produces: ['notes.md', 'report.md'] }; + expect(actionMatchesStep(sess, { op: 'Write', object: 'REPORT.md' }, { normalize: norm })).toBe(true); + }); + it('Write НЕ-последнего produces → НЕ матч (промежуточный, сеанс не закрыт)', () => { + const sess = { op: 'session', goal: 'осмотр', tools: [], produces: ['notes.md', 'report.md'] }; + expect(actionMatchesStep(sess, { op: 'Write', object: 'notes.md' }, { normalize: norm })).toBe(false); + }); + it('один produces → Write по нему → матч', () => { + const sess = { op: 'session', goal: 'осмотр', tools: [], produces: 'login-session.md' }; + expect(actionMatchesStep(sess, { op: 'Write', object: 'login-session.md' }, { normalize: norm })).toBe(true); + }); + it('действующий инструмент (browser_click) НЕ матчит сеанс (не Write)', () => { + const sess = { op: 'session', goal: 'осмотр', tools: ['browser_click'], produces: 'r.md' }; + expect(actionMatchesStep(sess, { op: 'browser_click', object: '' }, { normalize: norm })).toBe(false); + }); + it('Write постороннего файла → НЕ матч', () => { + const sess = { op: 'session', goal: 'осмотр', tools: [], produces: 'r.md' }; + expect(actionMatchesStep(sess, { op: 'Write', object: 'other.md' }, { normalize: norm })).toBe(false); + }); + it('сеанс без produces → ничего не матчит (джокером быть не может)', () => { + const sess = { op: 'session', goal: 'осмотр', tools: [] }; + expect(actionMatchesStep(sess, { op: 'Write', object: 'r.md' }, { normalize: norm })).toBe(false); + }); +}); + +// B+C ч.2 §3.3: жёсткий предохранитель — tools сеанса НИКОГДА не содержат репо-мутаторов +// (Write/Edit/MultiEdit/мут-Bash) и floor-опасного (ssh/cloud-CLI). Запрещённое отбрасывается +// с видимым предупреждением (Q2 = «не валить план из-за описки»). +import { sanitizeSessionTools } from './plan-lock.mjs'; + +describe('sanitizeSessionTools (предохранитель §3.3 — дроп мутаторов/floor + видимый warn)', () => { + it('репо-мутаторы отброшены (Write/Edit/MultiEdit/NotebookEdit/Bash/PowerShell)', () => { + const r = sanitizeSessionTools(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell']); + expect(r.allowed).toEqual([]); + expect(r.dropped).toEqual(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell']); + }); + it('floor-опасное по имени (ssh/cloud-CLI/rm) отброшено', () => { + const r = sanitizeSessionTools(['ssh', 'scp', 'rm', 'curl', 'kubectl']); + expect(r.allowed).toEqual([]); + expect(r.dropped).toEqual(['ssh', 'scp', 'rm', 'curl', 'kubectl']); + }); + it('легитимные действующие инструменты сеанса сохранены', () => { + const tools = ['browser_click', 'browser_type', 'mcp__playwright__browser_fill_form', 'mcp__linear__create_issue']; + expect(sanitizeSessionTools(tools).allowed).toEqual(tools); + expect(sanitizeSessionTools(tools).dropped).toEqual([]); + }); + it('смешанный список: Write+ssh отброшены, browser_click сохранён (Q2 — не валить план)', () => { + const r = sanitizeSessionTools(['browser_click', 'Write', 'ssh']); + expect(r.allowed).toEqual(['browser_click']); + expect(r.dropped).toEqual(['Write', 'ssh']); + }); + it('не массив / пустой → {allowed:[],dropped:[]}', () => { + expect(sanitizeSessionTools(undefined)).toEqual({ allowed: [], dropped: [] }); + expect(sanitizeSessionTools([])).toEqual({ allowed: [], dropped: [] }); + }); + it('пустые/нестроковые элементы отброшены', () => { + const r = sanitizeSessionTools(['browser_click', '', ' ', null]); + expect(r.allowed).toEqual(['browser_click']); + }); +}); + describe('freezePlan покрывает substeps подписью + criterion_id рекурсия (I9/I10)', () => { it('правка под-шага ломает sig', () => { const p = freezePlan({ steps: R08TREE, key: KEY, nowMs: 1 }); diff --git a/tools/plan-steps-parse.mjs b/tools/plan-steps-parse.mjs index e01cb9f..0c674f8 100644 --- a/tools/plan-steps-parse.mjs +++ b/tools/plan-steps-parse.mjs @@ -3,6 +3,8 @@ * 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']); @@ -34,10 +36,24 @@ export function parsePlanSteps(md) { return arr.map((s, i) => { if (!s || typeof s !== 'object') throw new Error(`plan-steps-parse: шаг ${i} не объект`); const op = String(s.op || ''); - const object = String(s.object ?? ''); - const ref = String(s.ref ?? ''); 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 }; diff --git a/tools/plan-steps-parse.test.mjs b/tools/plan-steps-parse.test.mjs index 6872f36..fa1148d 100644 --- a/tools/plan-steps-parse.test.mjs +++ b/tools/plan-steps-parse.test.mjs @@ -36,4 +36,32 @@ describe('plan-steps-parse', () => { it('non-JSON block → throws', () => { expect(() => parsePlanSteps('```steps-json\nnot json\n```')).toThrow(); }); + + // B+C ч.2: сеанс осмотра op:'session' — особый тип шага (нет object/ref; goal+tools+produces). + it('парсит op:session (goal/tools/produces сохранены, object/ref не нужны)', () => { + const md = '```steps-json\n[{"op":"session","goal":"осмотр логина","tools":["browser_click","browser_type"],"produces":["docs/observer/notes.md","docs/observer/report.md"]}]\n```'; + const s = parsePlanSteps(md); + expect(s).toHaveLength(1); + expect(s[0]).toMatchObject({ + op: 'session', goal: 'осмотр логина', + tools: ['browser_click', 'browser_type'], + produces: ['docs/observer/notes.md', 'docs/observer/report.md'], + }); + }); + it('сеанс со строковым produces → нормализован в массив', () => { + const md = '```steps-json\n[{"op":"session","goal":"g","tools":[],"produces":"docs/observer/s.md"}]\n```'; + expect(parsePlanSteps(md)[0].produces).toEqual(['docs/observer/s.md']); + }); + it('сеанс без goal → throws', () => { + const md = '```steps-json\n[{"op":"session","tools":[],"produces":"r.md"}]\n```'; + expect(() => parsePlanSteps(md)).toThrow(/goal/i); + }); + it('сеанс без produces → throws (нужен ≥1 итоговый файл)', () => { + const md = '```steps-json\n[{"op":"session","goal":"g","tools":[]}]\n```'; + expect(() => parsePlanSteps(md)).toThrow(/produces/i); + }); + it('op:Skill как шаг → throws с явным сообщением (объяви в skills-json)', () => { + const md = '```steps-json\n[{"op":"Skill","object":"superpowers:test-driven-development","ref":"r"}]\n```'; + expect(() => parsePlanSteps(md)).toThrow(/skills-json|Skill/i); + }); });