From 1b66fd2076142df97ea96a41fc183bf3f628e625 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: Mon, 8 Jun 2026 10:34:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(m7-phase3):=20enforce-skill-journaler=20?= =?UTF-8?q?=E2=80=94=20PostToolUse(Skill)=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20op:Skill=20+=20=D0=BC=D0=BE=D1=81=D1=82=20K2=20(SE-K)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/enforce-skill-journaler.mjs | 62 ++++++++++++++++++++++++++ tools/enforce-skill-journaler.test.mjs | 48 ++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tools/enforce-skill-journaler.mjs create mode 100644 tools/enforce-skill-journaler.test.mjs diff --git a/tools/enforce-skill-journaler.mjs b/tools/enforce-skill-journaler.mjs new file mode 100644 index 00000000..f7ba4dfa --- /dev/null +++ b/tools/enforce-skill-journaler.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * enforce-skill-journaler (Машина 1, Фаза 3 SE-K) — PostToolUse(Skill). + * Пишет КАЖДЫЙ Skill-вызов в action-journal (op:'Skill') независимо от членства в плане — + * seed/observe/escape стеной М2 НЕ журналятся, а K2 судьи М4 (навык-по-журналу, не по тексту) + * обязан их видеть. Дедуп vs wall-pre-write плановых Skill: стена пишет op:'Skill' на plan-step + * (PreToolUse) → журналер видит её хвостом цепи и пропускает. + * Best-effort: PostToolUse — навык уже отработал, блокировать нечего. fail-CLOSE-дисциплина + * живёт в потреблении K2 (Фаза 4); сам журналер числится в FAIL_CLOSE_DISCIPLINE_HOOKS + * для манифест-самопроверки Фазы 6 (страж обязан быть зарегистрирован). + */ +import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; + +/** + * Дедуп: писать ли этот Skill в журнал. false если хвост цепи — уже та же op:'Skill' + * запись (её записала стена на plan-step) — иначе плановый навык задвоился бы. + */ +export function shouldJournalSkill(skillName, journalEntries) { + if (!skillName || typeof skillName !== 'string') return false; + const arr = Array.isArray(journalEntries) ? journalEntries : []; + const last = arr.length ? arr[arr.length - 1] : null; + if (last && last.payload && last.payload.op === 'Skill' && last.payload.object === skillName) return false; + return true; +} + +/** Мост журнал → K2: имена (object) всех op:'Skill' записей по порядку (битые игнор). */ +export function extractSkillCalls(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const e of entries) { + const p = e && e.payload; + if (p && p.op === 'Skill' && typeof p.object === 'string' && p.object) out.push(p.object); + } + return out; +} + +async function main() { + try { + const event = parseEventJson(await readStdin()); + if (event.tool_name !== 'Skill') { exitDecision({ block: false }); return; } + const skillName = String((event.tool_input && event.tool_input.skill) || ''); + if (skillName) { + const os = await import('node:os'); + const { loadJournal, journalAppend } = await import('./action-journal.mjs'); + const { resolveReceiptKey } = await import('./receipt-key-config.mjs'); + const runtimeDir = `${os.homedir()}/.claude/runtime`; + const sess = event.session_id || 'unknown'; + const { entries } = loadJournal({ sessionId: sess, runtimeDir }); + if (shouldJournalSkill(skillName, entries)) { + journalAppend({ + payload: { op: 'Skill', object: skillName, step: null, at: event.nowMs ?? null, source: 'skill-journaler' }, + key: resolveReceiptKey(), sessionId: sess, runtimeDir, + }); + } + } + } catch { /* best-effort recorder — PostToolUse не блокирует */ } + exitDecision({ block: false }); +} + +import { fileURLToPath } from 'node:url'; +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); diff --git a/tools/enforce-skill-journaler.test.mjs b/tools/enforce-skill-journaler.test.mjs new file mode 100644 index 00000000..926cabe4 --- /dev/null +++ b/tools/enforce-skill-journaler.test.mjs @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { shouldJournalSkill, extractSkillCalls } from './enforce-skill-journaler.mjs'; +import { skillTakenByJournal } from './judge-gate-floor.mjs'; + +const entry = (op, object) => ({ seq: 1, ts: 0, payload: { op, object } }); + +describe('shouldJournalSkill — дедуп vs wall pre-write (M7 Фаза 3, SE-K)', () => { + it('новый seed-навык (хвост ≠ этот Skill) → true', () => { + expect(shouldJournalSkill('systematic-debugging', [entry('Bash', 'git status')])).toBe(true); + }); + it('пустой журнал → true', () => { + expect(shouldJournalSkill('test-driven-development', [])).toBe(true); + }); + it('хвост уже = этот Skill (wall pre-write) → false (дедуп)', () => { + expect(shouldJournalSkill('writing-plans', [entry('Skill', 'writing-plans')])).toBe(false); + }); + it('хвост = другой Skill → true', () => { + expect(shouldJournalSkill('brainstorming', [entry('Skill', 'writing-plans')])).toBe(true); + }); + it('пустое/не-строковое имя → false', () => { + expect(shouldJournalSkill('', [])).toBe(false); + expect(shouldJournalSkill(null, [])).toBe(false); + }); +}); + +describe('extractSkillCalls — мост журнал → K2 (M7 Фаза 3)', () => { + it('возвращает object всех op:Skill записей по порядку', () => { + const entries = [entry('Bash', 'x'), entry('Skill', 'writing-plans'), entry('Edit', 'f'), entry('Skill', 'systematic-debugging')]; + expect(extractSkillCalls(entries)).toEqual(['writing-plans', 'systematic-debugging']); + }); + it('нет Skill-записей → []', () => { + expect(extractSkillCalls([entry('Bash', 'x')])).toEqual([]); + }); + it('битые/без payload записи игнорируются', () => { + expect(extractSkillCalls([null, {}, { payload: null }, entry('Skill', 's')])).toEqual(['s']); + }); + it('не-массив → []', () => { + expect(extractSkillCalls(null)).toEqual([]); + }); +}); + +describe('end-to-end: зажурналенный навык виден K2 (M7 Фаза 3 acceptance)', () => { + it('extractSkillCalls журнала → skillTakenByJournal.ok', () => { + const entries = [entry('Skill', 'systematic-debugging')]; + const calls = extractSkillCalls(entries); + expect(skillTakenByJournal({ requiredSkills: ['systematic-debugging'], journalSkillCalls: calls }).ok).toBe(true); + }); +});