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:
@@ -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). Код фикса откатан. Спека сохранена как запись урока.
|
||||
|
||||
## Проблема
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user