revert(wall): откат Post-advance — PostToolUse не срабатывает на упавшем Bash

Live-смоук: PostToolUse не запускается на exit≠0 → Post не двигает указатель на RED-шагах. Код возвращён к Pre-advance (3928 GREEN). Спека/план помечены ОТВЕРГНУТО. Настоящий фикс desync = перестановка skill-discipline перед supreme-gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-14 06:11:11 +03:00
parent 820ff23ccc
commit f3ac36bef1
5 changed files with 43 additions and 161 deletions
+2
View File
@@ -2102,3 +2102,5 @@ econnreset
исполнённый
Ptr
брифа
агностичен
desync
@@ -1,5 +1,11 @@
# supreme-gate PostToolUse-advance — Implementation Plan
**ОТВЕРГНУТО (2026-06-14).** Подход Post-advance реализован и откатан: live-смоук
показал, что PostToolUse-хуки не срабатывают на упавшем (exit≠0) инструменте → RED-шаги
TDD не продвигаются. Корректный фикс — Pre-advance + перестановка `enforce-domain-skill-discipline`
ПЕРЕД `enforce-supreme-gate`. Детали — в спеке `2026-06-14-supreme-gate-post-advance-design.md`
(статус ОТВЕРГНУТ). План сохранён как запись урока.
> **For agentic workers:** REQUIRED SUB-SKILL: исполнять inline через superpowers:executing-plans
> (деликатное ядро стены — НЕ субагенты, per Pravila §15 / брифа эпика). Steps — чекбоксы.
@@ -2,7 +2,15 @@
**Дата:** 2026-06-14
**Эпик:** роутер-наставник. Машина 2 (верховная стена).
**Статус:** дизайн одобрен (подход A), готов к writing-plans.
**Статус:** ⛔ ОТВЕРГНУТ (2026-06-14, по итогам live-смоука). Подход Post-advance
нереализуем: **PostToolUse-хуки не запускаются на упавшем (exit≠0) инструменте**,
поэтому Post-такт не двигает указатель на RED-шагах (намеренно падающий тест в TDD).
Доказано журналом сессии (Pre записал намерение Bash-шага, указатель остался на месте,
тогда как успешный Write-шаг продвигался). **Корректный фикс** — оставить исходный
Pre-advance (exit-агностичен) и переставить `enforce-domain-skill-discipline` ПЕРЕД
`enforce-supreme-gate` в PreToolUse (блокирующий навык-судья срабатывает первым →
supreme-gate не успевает сдвинуть; тот же desync закрыт, RED-шаги целы; остаётся
редкий residual user-deny). Код фикса откатан. Спека сохранена как запись урока.
## Проблема
+26 -67
View File
@@ -336,26 +336,38 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k
}
/**
* PreToolUse-такт (фикс рассинхрона 2026-06-14): ТОЛЬКО ворота. decideMode → block при
* несовпадении; escape/finishPlan — здесь (они от исполнения не зависят); на allow+advance —
* журнал-намерение (Δ3, «нет записи → нет действия»), но указатель НЕ двигается (сдвиг — в
* runGatePost после подтверждённого исполнения).
* Чистая оркестрация: decideMode → на allow журналирует действие и продвигает шаг.
* journal/saveStep инъектируются (в main — реальные Node fs).
*/
export function runGatePre({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, journal, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
const toolUse = { name: event.tool_name, input: event.tool_input };
const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now });
// FIX-3: out-of-band аварийный выход владельца (G-1 α) — best-effort пред-запись в журнал
// (escape:true), указатель НЕ двигается. В отличие от Δ3 для агентских шагов, сбой/отсутствие
// журнала escape НЕ блокирует (escape санкционирован владельцем — иначе git-заминка снова
// закирпичила бы дверь). Помеченная escape-запись снимает будущий false-positive реконсилера
// «action-without-record (обход стены)» для легитимного escape.
if (r.mode === 'escape') {
if (typeof journal === 'function') {
try { journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr, at: event.nowMs ?? null, escape: true }); } catch { /* best-effort */ }
}
return { block: false, message: r.reason };
}
// Фаза 5 Task 5.2 (Вариант А): владелец завершил план досрочно (finish-грант) → снять печать
// (best-effort, сбой не ломает allow) и вернуться в разговорный. Указатель не двигаем.
if (r.finishPlan) {
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
return { block: false, message: r.reason };
}
// W4 (✅O18, C2): warn от decideMode (judge_mode рассинхрон) НЕ роняется — дописывается
// в message вывода хука (владелец видит «энфорсмент off» громко; полное owner-резюме
// гейта-1 — поведенческая сборка контроллера, owner-activation).
const withWarn = (msg) => (r.warn ? `${msg}${r.warnReason}` : msg);
if (r.decision === 'allow' && r.advance === true) {
// Δ3 (8.1): пред-запись НАМЕРЕНИЯ в журнал ДО allow (PreToolUse не видит факт исполнения —
// честный максимум: «нет записи → нет действия»). Журнал вернул false ИЛИ бросил → стена НЕ
// разрешает (block), указатель НЕ двигается. Сверку «произошло ровно записанное» делает
// PostToolUse-реконсилер (8.2). journal-успех = не-false и без исключения (push → length, ок).
let recorded;
try {
recorded = journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr + 1, at: event.nowMs ?? null }) !== false;
@@ -363,49 +375,15 @@ export function runGatePre({ event, frozenPlan, frozenArtifact, stepPtr, key, ve
if (!recorded) {
return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' };
}
// Сдвиг указателя НЕ здесь — runGatePost после подтверждённого исполнения (фикс рассинхрона).
}
return { block: r.decision === 'block', message: withWarn(r.reason) };
}
/**
* Совместимость/прямой вызов: Pre-такт, затем (если не блок) Post-такт — эквивалент
* прежнего одно-проходного поведения для ПРЯМОГО вызова (тесты/инструменты). В реальной
* хук-цепочке main() зовёт runGatePre (PreToolUse) и runGatePost (PostToolUse) РАЗДЕЛЬНО,
* иначе поздний блок / user-deny рассинхронят указатель (спека 2026-06-14). journal/saveStep/
* removeFrozenPlan инъектируются (в main — реальные Node fs); args общий для обоих тактов.
*/
export function runGate(args) {
const pre = runGatePre(args);
if (pre.block) return pre;
runGatePost(args);
return pre;
}
/** Различение такта по событию harness: PostToolUse → сдвиг указателя; иначе → ворота. */
export function isPostEvent(event) {
return !!event && event.hook_event_name === 'PostToolUse';
}
/**
* PostToolUse-такт (фикс рассинхрона 2026-06-14): сдвиг указателя ТОЛЬКО после
* подтверждённого исполнения инструмента. Пере-выводит decideMode по ТЕКУЩЕМУ
* (не сдвинутому) stepPtr; на allow+advance — saveStep(advanceTo) (+ removeFrozenPlan
* при planComplete). НИКОГДА не блокирует (инструмент уже исполнился).
* seed/observe/escape/finishPlan/несовпадение → не двигает. Если инструмент был
* заблокирован поздним хуком / user-deny — PostToolUse не сработал, сдвига нет.
*/
export function runGatePost({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
const toolUse = { name: event.tool_name, input: event.tool_input };
const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now });
if (r.decision === 'allow' && r.advance === true) {
saveStep(r.advanceTo);
// Фаза 5 (Task 5.1, чистое завершение): последний шаг плана выполнен → стена САМА снимает
// печать (removeFrozenPlan) → следующее действие в разговорном режиме (нет «план исчерпан»,
// не нужно ручное удаление файла). Best-effort: сбой снятия НЕ ломает allow.
if (r.planComplete && typeof removeFrozenPlan === 'function') {
try { removeFrozenPlan(); } catch { /* best-effort */ }
}
return { advanced: true, planComplete: !!r.planComplete };
}
return { advanced: false, planComplete: false };
return { block: r.decision === 'block', message: withWarn(r.reason) };
}
/**
@@ -437,35 +415,16 @@ async function main() {
const stepPath = stepStatePath(runtimeDir, sess); // N3-shared guard формы sessionId
let stored = null; try { stored = JSON.parse(fs.readFileSync(stepPath, 'utf8')); } catch {}
const stepPtr = resolveStepPtr(stored, frozenPlan?.plan_id, key ? (s) => verifyStepState(s, key) : null); // R-27 привязка + R-19 подпись
const common = { event, frozenPlan, frozenArtifact, stepPtr, key, escapeGrants, escapeConsumed };
if (isPostEvent(event)) {
// PostToolUse-такт (фикс рассинхрона): сдвиг указателя по ПОДТВЕРЖДЁННОМУ исполнению.
// Никогда не блокирует (инструмент уже исполнился). Если действие было заблокировано
// поздним хуком / user-deny — PostToolUse не сработал → сдвига нет.
runGatePost({
...common,
saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key))), // R-19: подписано
removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение
});
exitDecision({ block: false });
return;
}
// PreToolUse-такт: ТОЛЬКО ворота + журнал-намерение (Δ3). Указатель НЕ двигается здесь.
const r = runGatePre({
...common,
const r = runGate({
event, frozenPlan, frozenArtifact, stepPtr, key, escapeGrants, escapeConsumed,
journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }),
removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // finishPlan (досрочно владельцем)
saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key))), // R-19: подписано
removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение
});
if (r.block) logGuardBlock(event, 'М2 Стена', r.message);
exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined });
} catch {
// Post-такт: fail-safe — никогда не блокирует уже исполнённое (но fail-loud WARN).
if (isPostEvent(event)) {
try { process.stderr.write('[supreme-gate] PostToolUse: внутренняя ошибка — сдвиг указателя пропущен (fail-safe)\n'); } catch { /* ignore */ }
exitDecision({ block: false });
return;
}
// Pre-такт panic (правило 7б): сетап бросил ДО decideMode → escape владельца всё равно оценён.
// Panic-ветка (правило 7б): сетап бросил ДО decideMode → escape владельца всё равно оценён.
const p = panicEscapeDecision(event, escapeGrants, escapeConsumed);
exitDecision({ block: p.block, message: p.block ? '[supreme-gate] внутренняя ошибка — fail-CLOSED' : undefined });
}
-93
View File
@@ -781,96 +781,3 @@ describe('Фаза 5 — чистое завершение плана (стен
expect(r.block).toBe(false);
});
});
// 2026-06-14 — фикс рассинхрона указателя: сдвиг переехал на PostToolUse (спека 2026-06-14).
import { runGatePost } from './enforce-supreme-gate.mjs';
describe('runGatePost (сдвиг по подтверждённому исполнению)', () => {
const baseArgs = (over) => ({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
frozenPlan: { ...PLAN, judge_mode: 'live-block' },
frozenArtifact: { sig: 'ok', judge_mode: 'live-block' },
stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub,
normalize: (p) => p.toLowerCase(), saveStep: () => {}, ...over,
});
it('allow+advance → saveStep(advanceTo) вызван, advanced:true, не блокирует', () => {
let saved = null;
const r = runGatePost(baseArgs({ saveStep: (n) => { saved = n; } }));
expect(saved).toBe(1);
expect(r.advanced).toBe(true);
expect(r.block).toBeUndefined();
});
it('несовпадение шага → saveStep НЕ вызван, advanced:false', () => {
let saved = 'untouched';
const r = runGatePost(baseArgs({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/evil.mjs' } },
saveStep: (n) => { saved = n; },
}));
expect(saved).toBe('untouched');
expect(r.advanced).toBe(false);
});
it('seed (Skill brainstorming) → не двигает (advance не true)', () => {
let saved = 'untouched';
const r = runGatePost(baseArgs({
event: { tool_name: 'Skill', tool_input: { skill: 'superpowers:brainstorming' } },
saveStep: (n) => { saved = n; },
}));
expect(saved).toBe('untouched');
expect(r.advanced).toBe(false);
});
});
import { runGatePre } from './enforce-supreme-gate.mjs';
describe('runGatePre (ворота + журнал-намерение, БЕЗ сдвига)', () => {
const baseArgs = (over) => ({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
frozenPlan: { ...PLAN, judge_mode: 'live-block' },
frozenArtifact: { sig: 'ok', judge_mode: 'live-block' },
stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub,
normalize: (p) => p.toLowerCase(), journal: () => true, ...over,
});
it('allow+advance → журналирует намерение, block:false (указатель НЕ двигает — нет saveStep)', () => {
const journaled = [];
const r = runGatePre(baseArgs({ journal: (e) => { journaled.push(e); return true; } }));
expect(r.block).toBe(false);
expect(journaled).toHaveLength(1);
expect(journaled[0].step).toBe(1);
});
it('несовпадение шага → block, не журналирует', () => {
const journaled = [];
const r = runGatePre(baseArgs({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/evil.mjs' } },
journal: (e) => { journaled.push(e); return true; },
}));
expect(r.block).toBe(true);
expect(journaled).toHaveLength(0);
});
it('Δ3: journal вернул false → block', () => {
const r = runGatePre(baseArgs({ journal: () => false }));
expect(r.block).toBe(true);
expect(r.message).toMatch(/пред-запис|нет записи/i);
});
it('Δ3: journal бросил → block', () => {
const r = runGatePre(baseArgs({ journal: () => { throw new Error('io'); } }));
expect(r.block).toBe(true);
});
});
import { isPostEvent } from './enforce-supreme-gate.mjs';
describe('isPostEvent (различение такта по событию harness)', () => {
it('PostToolUse → true', () => { expect(isPostEvent({ hook_event_name: 'PostToolUse' })).toBe(true); });
it('PreToolUse → false', () => { expect(isPostEvent({ hook_event_name: 'PreToolUse' })).toBe(false); });
it('нет поля / null → false', () => {
expect(isPostEvent({})).toBe(false);
expect(isPostEvent(null)).toBe(false);
});
});