feat(m7-phase3): enforce-skill-journaler — PostToolUse(Skill) журнал op:Skill + мост K2 (SE-K)
This commit is contained in:
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user