feat: B+C часть 2 — сеанс осмотра op:"session" под стеной
Новый тип шага плана 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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: слот = строки) браковал содержательный вердикт. Тип — явно.
|
||||
|
||||
@@ -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');
|
||||
|
||||
+70
-6
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user