# R-08 — Иерархические волны стены М2 — Implementation Plan

> **For agentic workers:** ИНЛАЙН через superpowers:executing-plans (субагенты/Task/Workflow
> ЗАПРЕЩЕНЫ). Наблюдаемый RED до каждой prod-правки `tools/*.mjs`. commit-not-push. НЕ
> requesting-code-review. Steps — checkbox.

**Goal:** Стена М2 ходит по иерархическому подписанному плану (крупный шаг несёт `substeps`),
не открывая дыру и не ломая плоские планы. Спек:
`docs/superpowers/specs/2026-06-09-router-mentor-r08-hierarchical-waves-design.md`.

**Architecture:** Единый дерево-указатель. **§4.0:** целое N ⟺ depth-1 стек `[{index:N}]`;
глубже → массив индексов `[i0,i1,…]`. Указатель нормализуется к текущему **листу** (спуск в
контейнеры); хранимая позиция — нормализованная. Плоский план round-trip'ит через целое →
существующие тесты GREEN без правок. Несущий инвариант I2: лист ВСЕГДА из `frozenPlan.steps`
(под печатью). SE-1: явный флаг `advance:true` (не `typeof number`).

**Tech Stack:** Node ESM `tools/*.mjs`, vitest tools-only, TDD.

**Регрессия-цель:** tools-only ≥ baseline текущей ветки (замерить в Task 0), 0 failed; ВСЕ
существующие тесты стены/plan-lock/step-pointer GREEN без правок (SE-7 regression-guard).

**Quirks:** vitest `npx vitest run --root "<wt>/app" --config "<wt>/app/vitest.config.tools.mjs" <фильтр> --reporter dot`; git только PowerShell `git -C "<wt>"`; короткие ASCII commit-msg без редких латинских слов и без `>`/`>=`; правка `*.test.mjs` через Edit обязана сама нести `it`+`expect`+ссылку на prod (импорт+тесты одной правкой); TDD-gate ждёт тест в СОБСТВЕННОМ тест-файле правимого prod-файла; comment-only правка без RED — не фабриковать фальш-RED.

---

## File Structure

| Файл | Действие |
|---|---|
| `tools/step-pointer.mjs` | +`MAX_TREE_DEPTH`, `serializePointer`, `deserializePointer`, `nodeAt`, `isContainer`, `normalizeToLeaf`, `advanceOverTree` |
| `tools/step-pointer.test.mjs` | +тесты новых функций (существующие не трогаем) |
| `tools/plan-lock.mjs` | +`treeLeaves`, `treeLeafAt`, `validatePlanTree`; `withCriterionIds` рекурсия; `sealedCriterionIds`/`reconcileJournalToPlan` → `treeLeaves` (V1/V2) |
| `tools/plan-lock.test.mjs` | +тесты дерева + flat-регрессия существующих |
| `tools/enforce-supreme-gate.mjs` | `signStepState`/`verifyStepState`/`resolveStepPtr` принимают сериализованный указатель; `decide`/`decideMode`/`runGate` — `treeLeafAt`+`advanceOverTree`+флаг `advance` |
| `tools/enforce-supreme-gate.test.mjs` | +дерево-тесты + flat-регрессия |

---

## Task 0: Baseline

- [ ] **Step 1: Замерить регрессию ДО изменений**

Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" --reporter dot 2>&1 | tail -4`
Записать число passed (baseline B0) — финал Task 6 обязан быть ≥ B0 + новые тесты, 0 failed.

---

## Task 1: step-pointer — сериализация + навигация по дереву

**Files:** `tools/step-pointer.mjs`, `tools/step-pointer.test.mjs`

- [ ] **Step 1: Падающий тест (импорт+тесты одной правкой, в конец файла)**

```js
// ── R-08: сериализация + дерево-навигация ──
import { serializePointer, deserializePointer, nodeAt, isContainer, normalizeToLeaf, advanceOverTree, MAX_TREE_DEPTH } from './step-pointer.mjs';

const FLAT = [{ n: 1, op: 'Write', object: 'a' }, { n: 2, op: 'Bash', object: 'b' }];
const TREE = [
  { n: 1, op: 'Write', object: 'a' },
  { n: 2, substeps: [{ n: '2.1', op: 'Edit', object: 'x' }, { n: '2.2', op: 'Bash', object: 'y' }] },
  { n: 3, op: 'Write', object: 'c' },
];

describe('serialize/deserialize (§4.0: depth-1 = целое)', () => {
  it('depth-1 ↔ целое', () => {
    expect(serializePointer(createPointer({ length: 3 }))).toBe(0);
    expect(serializePointer(advance(createPointer({ length: 3 })))).toBe(1);
    expect(deserializePointer(1, FLAT).stack).toEqual([{ index: 1, length: 2 }]);
  });
  it('глубже ↔ массив индексов', () => {
    let p = enterSubPlan(advance(createPointer({ length: 3 })), { length: 2 }); // root idx1 → sub idx0
    p = advance(p); // sub idx1
    expect(serializePointer(p)).toEqual([1, 1]);
    const d = deserializePointer([1, 1], TREE);
    expect(d.stack).toEqual([{ index: 1, length: 3 }, { index: 1, length: 2 }]);
  });
  it('строгий deserialize: мусор → null (SE-5)', () => {
    expect(deserializePointer('x', FLAT)).toBe(null);
    expect(deserializePointer([0, 'a'], TREE)).toBe(null);
    expect(deserializePointer(-1, FLAT)).toBe(null);
    expect(deserializePointer([1, 0], FLAT)).toBe(null); // путь требует контейнер, а FLAT[1] лист
  });
});

describe('nodeAt / isContainer', () => {
  it('nodeAt следует currentPath', () => {
    expect(nodeAt(TREE, deserializePointer(0, TREE)).n).toBe(1);
    expect(nodeAt(TREE, deserializePointer([1, 1], TREE)).n).toBe('2.2');
    expect(nodeAt(TREE, deserializePointer(9, TREE))).toBe(null); // за концом
  });
  it('isContainer', () => {
    expect(isContainer(TREE[1])).toBe(true);
    expect(isContainer(TREE[0])).toBe(false);
    expect(isContainer({ substeps: [] })).toBe(true);
  });
});

describe('normalizeToLeaf (спуск к листу, SE-2)', () => {
  it('лист → сам', () => {
    expect(nodeAt(TREE, normalizeToLeaf(TREE, deserializePointer(0, TREE))).n).toBe(1);
  });
  it('контейнер → спуск к первому листу', () => {
    const p = normalizeToLeaf(TREE, deserializePointer(1, TREE)); // root idx1 = контейнер
    expect(serializePointer(p)).toEqual([1, 0]);
    expect(nodeAt(TREE, p).n).toBe('2.1');
  });
  it('за концом → null', () => {
    expect(normalizeToLeaf(TREE, deserializePointer(9, TREE))).toBe(null);
  });
  it('плоский план: normalizeToLeaf — тождество', () => {
    expect(serializePointer(normalizeToLeaf(FLAT, deserializePointer(1, FLAT)))).toBe(1);
  });
});

describe('advanceOverTree (depth-first, SE-4/SE-6)', () => {
  it('плоский: +1 как целое', () => {
    let p = deserializePointer(0, FLAT);
    expect(serializePointer(advanceOverTree(FLAT, p))).toBe(1);
  });
  it('depth-first полный обход листьев TREE (snapshot, без пропусков/повторов)', () => {
    let p = normalizeToLeaf(TREE, deserializePointer(0, TREE));
    const seq = [];
    let guard = 0;
    while (p && nodeAt(TREE, p) && guard++ < 20) {
      seq.push(nodeAt(TREE, p).n);
      p = advanceOverTree(TREE, p);
    }
    expect(seq).toEqual([1, '2.1', '2.2', 3]); // вход в контейнер #2, выход, далее #3
  });
  it('предел глубины → throw (SE-4)', () => {
    const deep = (d) => d === 0 ? [{ n: 'leaf', op: 'Write', object: 'z' }] : [{ substeps: deep(d - 1) }];
    const bad = deep(MAX_TREE_DEPTH + 2);
    expect(() => normalizeToLeaf(bad, createPointer({ length: bad.length }))).toThrow();
  });
});
```

- [ ] **Step 2: RED** — `npx vitest run --root "<...>/app" --config "<...>/app/vitest.config.tools.mjs" step-pointer --reporter dot` → FAIL (функции не экспортированы).

- [ ] **Step 3: Реализация** — добавить в `tools/step-pointer.mjs`:

```js
/** Предел глубины дерева плана (SE-4: терминируемость + анти-DoS). */
export const MAX_TREE_DEPTH = 8;

/** §4.0: depth-1 стек → целое N; глубже → массив индексов. */
export function serializePointer(p) {
  if (!p || !Array.isArray(p.stack) || p.stack.length === 0) return 0;
  if (p.stack.length === 1) return p.stack[0].index;
  return p.stack.map((l) => l.index);
}

/** §4.0 строгий (SE-5): целое/массив-индексов + steps (length уровней из дерева) → стек; иначе null. */
export function deserializePointer(state, steps) {
  if (!Array.isArray(steps)) return null;
  const idxs = Number.isInteger(state) ? [state]
    : (Array.isArray(state) && state.length > 0 && state.every(Number.isInteger)) ? state : null;
  if (!idxs || idxs.length === 0 || idxs.some((n) => n < 0)) return null;
  let level = steps;
  const stack = [];
  for (let d = 0; d < idxs.length; d++) {
    if (!Array.isArray(level)) return null;
    stack.push({ index: idxs[d], length: level.length });
    if (d < idxs.length - 1) {
      const node = level[idxs[d]];
      if (!node || !Array.isArray(node.substeps)) return null; // путь требует контейнер
      level = node.substeps;
    }
  }
  return { stack, indexAtRoot: stack[0].index };
}

/** Узел по currentPath (или null за пределами). */
export function nodeAt(steps, p) {
  if (!p || !Array.isArray(p.stack)) return null;
  let level = steps;
  let node = null;
  for (const lvl of p.stack) {
    if (!Array.isArray(level) || lvl.index < 0 || lvl.index >= level.length) return null;
    node = level[lvl.index];
    level = node && Array.isArray(node.substeps) ? node.substeps : null;
  }
  return node ?? null;
}

/** Контейнер = есть массив substeps. */
export function isContainer(node) {
  return !!node && Array.isArray(node.substeps);
}

/** Спуск к листу: контейнер → enterSubPlan к первому ребёнку; лист → сам; за концом → null.
 *  Пустой контейнер → null (validatePlanTree это заранее блокирует). Предел глубины → throw. */
export function normalizeToLeaf(steps, p) {
  let cur = p;
  for (let guard = 0; guard <= MAX_TREE_DEPTH + 1; guard++) {
    const node = nodeAt(steps, cur);
    if (node == null) return null;
    if (isContainer(node)) {
      if (node.substeps.length === 0) return null;
      cur = enterSubPlan(cur, { length: node.substeps.length });
      continue;
    }
    return cur; // лист
  }
  throw new Error('normalizeToLeaf: превышен предел глубины дерева');
}

/** Один ход depth-first: +1 на текущем уровне; новый узел-контейнер → спуск; уровень исчерпан →
 *  подъём + сиблинг родителя; корень исчерпан → done-указатель. Возвращает нормализованный к листу
 *  указатель ИЛИ done-указатель (nodeAt=null). Предел итераций → throw (SE-4/SE-6). */
export function advanceOverTree(steps, p) {
  let np = advance(p);
  for (let guard = 0; guard <= (MAX_TREE_DEPTH + 1) * 64; guard++) {
    const node = nodeAt(steps, np);
    if (node != null) {
      if (isContainer(node)) {
        if (node.substeps.length === 0) return np; // validate блокирует; здесь — стоп (нодAt null далее)
        np = enterSubPlan(np, { length: node.substeps.length });
        continue;
      }
      return np; // лист
    }
    if (np.stack.length === 1) return np; // корень исчерпан → done
    np = advance(exitSubPlan(np)); // подъём + сиблинг родителя
  }
  throw new Error('advanceOverTree: превышен предел итераций');
}
```

- [ ] **Step 4: GREEN** — тот же vitest-фильтр `step-pointer` → PASS.

- [ ] **Step 5: Commit**

```
git -C "<wt>" add tools/step-pointer.mjs tools/step-pointer.test.mjs
git -C "<wt>" commit -m "feat(step-pointer): serialize + tree navigation for R-08 waves"
```

---

## Task 2: plan-lock — treeLeaves / treeLeafAt / validatePlanTree + criterion_id рекурсия

**Files:** `tools/plan-lock.mjs`, `tools/plan-lock.test.mjs`

- [ ] **Step 1: Падающий тест (в конец plan-lock.test.mjs)**

```js
// ── R-08: дерево листьев / лист по указателю / валидация дерева ──
import { treeLeaves, treeLeafAt, validatePlanTree, freezePlan as freezePlan2, verifyFrozenPlan as verify2 } from './plan-lock.mjs';

const TREE = [
  { n: 1, op: 'Write', object: 'a' },
  { n: 2, substeps: [{ n: '2.1', op: 'Edit', object: 'x' }, { n: '2.2', op: 'Bash', object: 'y' }] },
  { n: 3, op: 'Write', object: 'c' },
];

describe('treeLeaves (depth-first, V1/V2 вход)', () => {
  it('дерево → все листья по порядку', () => {
    expect(treeLeaves(TREE).map((s) => s.n)).toEqual([1, '2.1', '2.2', 3]);
  });
  it('плоский план → сами шаги (идентичность)', () => {
    const flat = [{ n: 1, op: 'Write', object: 'a' }, { n: 2, op: 'Bash', object: 'b' }];
    expect(treeLeaves(flat)).toEqual(flat);
  });
});

describe('treeLeafAt (один лист по указателю, SE-2)', () => {
  it('целое 0 → первый лист', () => {
    expect(treeLeafAt(TREE, 0).n).toBe(1);
  });
  it('целое 1 (контейнер) → спуск к первому листу, не контейнер', () => {
    const leaf = treeLeafAt(TREE, 1);
    expect(leaf.n).toBe('2.1');
    expect(leaf.substeps).toBeUndefined();
  });
  it('массив [1,1] → 2.2', () => {
    expect(treeLeafAt(TREE, [1, 1]).n).toBe('2.2');
  });
  it('за концом → null', () => {
    expect(treeLeafAt(TREE, 9)).toBe(null);
  });
  it('битый указатель → null', () => {
    expect(treeLeafAt(TREE, 'x')).toBe(null);
  });
});

describe('validatePlanTree (SE-2/SE-4 fail-CLOSED)', () => {
  it('валидное дерево → ok', () => {
    expect(validatePlanTree(TREE).ok).toBe(true);
  });
  it('пустой контейнер → не ok (SE-4)', () => {
    expect(validatePlanTree([{ n: 1, substeps: [] }]).ok).toBe(false);
  });
  it('контейнер с op/object/ref → не ok (SE-2)', () => {
    expect(validatePlanTree([{ n: 1, op: 'Write', object: 'a', substeps: [{ n: '1.1', op: 'Edit', object: 'x' }] }]).ok).toBe(false);
  });
  it('substeps не массив → не ok', () => {
    expect(validatePlanTree([{ n: 1, substeps: 'x' }]).ok).toBe(false);
  });
  it('превышение глубины → не ok', () => {
    let node = { n: 'leaf', op: 'Write', object: 'z' };
    for (let i = 0; i < 12; i++) node = { substeps: [node] };
    expect(validatePlanTree([node]).ok).toBe(false);
  });
});

describe('freezePlan покрывает substeps подписью + criterion_id рекурсия (I9/I10)', () => {
  it('правка под-шага ломает sig', () => {
    const p = freezePlan2({ steps: TREE, key: 'K', nowMs: 1 });
    const tampered = { ...p, steps: [p.steps[0], { ...p.steps[1], substeps: [{ ...p.steps[1].substeps[0], object: 'evil' }, p.steps[1].substeps[1]] }, p.steps[2]] };
    expect(verify2(tampered, 'K')).toBe(false);
  });
  it('под-шаги получают criterion_id', () => {
    const p = freezePlan2({ steps: TREE, key: 'K', nowMs: 1 });
    expect(p.steps[1].substeps[0].criterion_id).toMatch(/^[0-9a-f]{64}$/);
  });
});
```

- [ ] **Step 2: RED** — `... plan-lock --reporter dot` → FAIL (функции не экспортированы; criterion_id под-шага undefined).

- [ ] **Step 3: Реализация** — `tools/plan-lock.mjs`:

(a) импорт навигаторов сверху (после существующих import):
```js
import { deserializePointer, nodeAt, normalizeToLeaf, isContainer, MAX_TREE_DEPTH } from './step-pointer.mjs';
```

(b) `withCriterionIds` → рекурсия (заменить тело):
```js
function withCriterionIds(steps) {
  return (steps || []).map((s) => {
    const base = { ...s, criterion_id: stepCriterionId(s) };
    if (Array.isArray(s.substeps)) base.substeps = withCriterionIds(s.substeps);
    return base;
  });
}
```
*(NB: `stepCriterionId(s)` уже считает sha256 содержимого без criterion_id; для контейнера это включает substeps до их обогащения — детерминизм сохраняется, т.к. id считается ДО рекурсивной замены; freezePlan сам по planId покрывает всё дерево независимо.)*

(c) новые функции (после `nextStep`):
```js
/** Все листья дерева depth-first (V1/V2 вход). Плоский план → сами steps. */
export function treeLeaves(steps) {
  const out = [];
  const walk = (arr) => {
    for (const s of arr || []) {
      if (s && Array.isArray(s.substeps)) walk(s.substeps);
      else out.push(s);
    }
  };
  walk(steps);
  return out;
}

/** Один лист по сериализованному указателю (целое/массив). Спуск через контейнеры (SE-2):
 *  возвращает узел БЕЗ substeps либо null (за концом / битый указатель / контейнер-тупик). */
export function treeLeafAt(steps, serializedPtr) {
  const p0 = deserializePointer(serializedPtr, steps);
  if (!p0) return null;
  let leafPtr;
  try { leafPtr = normalizeToLeaf(steps, p0); } catch { return null; }
  if (!leafPtr) return null;
  const node = nodeAt(steps, leafPtr);
  if (node == null || isContainer(node)) return null;
  return node;
}

/** Структурная валидация дерева ДО доверия (fail-CLOSED): SE-2 контейнер не несёт op/object/ref;
 *  SE-4 непустой substeps + глубина ≤ предела; substeps только массив. */
export function validatePlanTree(steps) {
  const check = (arr, depth) => {
    if (depth > MAX_TREE_DEPTH) return false;
    if (!Array.isArray(arr)) return false;
    for (const s of arr || []) {
      if (!s || typeof s !== 'object') return false;
      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 (!check(s.substeps, depth + 1)) return false;
      }
    }
    return true;
  };
  return { ok: check(steps, 1) };
}
```

(d) V1 — `sealedCriterionIds` (заменить тело):
```js
export function sealedCriterionIds(frozenPlan) {
  return treeLeaves((frozenPlan && frozenPlan.steps) || []).map((s) => s && s.criterion_id).filter(Boolean);
}
```

(e) V2 — `reconcileJournalToPlan` (заменить `.some`-источник на листья):
```js
export function reconcileJournalToPlan(journal, steps, { normalize = pathNormalize } = {}) {
  const leaves = treeLeaves(steps);
  const orphans = (journal || []).filter(
    (a) => !leaves.some((s) => actionMatchesStep(s, a, { normalize }))
  );
  return { ok: orphans.length === 0, orphans };
}
```

- [ ] **Step 4: GREEN** — `... plan-lock --reporter dot` → PASS (новые + все существующие, включая flat sealedCriterionIds/reconcile — `treeLeaves`=steps для плоских).

- [ ] **Step 5: Commit**

```
git -C "<wt>" add tools/plan-lock.mjs tools/plan-lock.test.mjs
git -C "<wt>" commit -m "feat(plan-lock): tree leaves, leaf-at, validate, recursive criterion ids; V1/V2 consumers"
```

---

## Task 3: enforce-supreme-gate — формат указателя (стек/целое) + reset-to-root

**Files:** `tools/enforce-supreme-gate.mjs`, `tools/enforce-supreme-gate.test.mjs`

Цель: `signStepState`/`verifyStepState` подписывают сериализованную позицию (целое ИЛИ массив);
`resolveStepPtr` возвращает сериализованную позицию (целое/массив) либо 0 (корень) при mismatch/
битой подписи/легаси/мусоре (SE-3 secure default). Существующие тесты (целое) GREEN без правок.

- [ ] **Step 1: Падающий тест (в конец supreme-gate.test.mjs)**

```js
// ── R-08: указатель может быть массивом индексов (дерево), не только целым ──
import { signStepState as sign3, verifyStepState as verify3, resolveStepPtr as resolve3 } from './enforce-supreme-gate.mjs';

describe('R-08: сериализованный указатель (целое ИЛИ массив) подписывается и резолвится', () => {
  const SK = 'sk';
  it('массив-позиция round-trip через подпись', () => {
    const s = sign3('P1', [1, 1], SK);
    expect(resolve3(s, 'P1', (x) => verify3(x, SK))).toEqual([1, 1]);
  });
  it('подмена массива-позиции ломает подпись → 0', () => {
    const s = sign3('P1', [1, 1], SK);
    const tampered = { ...s, ptr: [1, 0] };
    expect(resolve3(tampered, 'P1', (x) => verify3(x, SK))).toBe(0);
  });
  it('мусор-форма ptr → 0 (SE-3 secure default)', () => {
    expect(resolve3({ plan_id: 'P1', ptr: 'x' }, 'P1')).toBe(0);
    expect(resolve3({ plan_id: 'P1', ptr: [1, 'a'] }, 'P1')).toBe(0);
  });
  it('легаси-целое по-прежнему работает (обратная совместимость)', () => {
    expect(resolve3({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3);
  });
});
```

- [ ] **Step 2: RED** — `... enforce-supreme-gate --reporter dot` → FAIL (массив-ptr возвращает 0 / не round-trip'ит).

- [ ] **Step 3: Реализация** — `tools/enforce-supreme-gate.mjs`:

(a) `resolveStepPtr` — принять целое ИЛИ массив-индексов (заменить L86-87 хвост):
```js
export function resolveStepPtr(stored, currentPlanId, verify = null) {
  if (!currentPlanId || !stored || typeof stored !== 'object') return 0;
  if (stored.plan_id !== currentPlanId) return 0;
  if (verify && !verify(stored)) return 0;
  const ptr = stored.ptr;
  if (Number.isInteger(ptr) && ptr >= 0) return ptr;                 // легаси/depth-1
  if (Array.isArray(ptr) && ptr.length > 0 && ptr.every((n) => Number.isInteger(n) && n >= 0)) return ptr; // дерево
  return 0; // SE-3: мусор → корень
}
```

(b) `signStepState`/`verifyStepState` — `ptr` уже произвольной формы под `signPayload`; verifyStepState восстановить ту же форму:
```js
export function signStepState(planId, ptr, key) {
  return { plan_id: planId, ptr, sig: signPayload({ plan_id: planId, ptr }, key, RECEIPT_DOMAINS.STEP_PTR) };
}
export function verifyStepState(stored, key) {
  if (!stored || typeof stored !== 'object') return false;
  return verifyReceipt({ plan_id: stored.plan_id, ptr: stored.ptr, sig: stored.sig }, key, RECEIPT_DOMAINS.STEP_PTR);
}
```
*(verifyStepState не меняется по сути — `signPayload`/`verifyReceipt` через canonicalJson сериализуют массив корректно; SE-5 единая каноническая форма. Подтвердить тестом round-trip массива.)*

- [ ] **Step 4: GREEN** — `... enforce-supreme-gate --reporter dot` → PASS (новые + существующие integer-тесты дословно).

- [ ] **Step 5: Commit**

```
git -C "<wt>" add tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs
git -C "<wt>" commit -m "feat(supreme-gate): pointer state accepts tree position (int or index array)"
```

---

## Task 4: enforce-supreme-gate — decide/decideMode/runGate дерево-врезка + SE-1 флаг

**Files:** `tools/enforce-supreme-gate.mjs`, `tools/enforce-supreme-gate.test.mjs`

- [ ] **Step 1: Падающий тест (в конец supreme-gate.test.mjs)**

```js
// ── R-08: decide ходит по дереву; SE-1 явный advance; flat-регрессия ──
import { decide as decideTree } from './enforce-supreme-gate.mjs';
import { freezePlan as fp4, verifyFrozenPlan as vfp4 } from './plan-lock.mjs';

const K4 = 'k4';
const TREE4 = [
  { n: 1, op: 'Write', object: 'tools/a.mjs' },
  { n: 2, substeps: [{ n: '2.1', op: 'Edit', object: 'tools/x.mjs' }, { n: '2.2', op: 'Bash', object: 'npx vitest' }] },
];
const norm4 = (p) => p.toLowerCase();

describe('R-08 decide — дерево', () => {
  const plan = fp4({ steps: TREE4, key: K4, nowMs: 1 });
  it('указатель 0 (целое) → матч первого листа, advance:true', () => {
    const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/a.mjs' } }, frozenPlan: plan, stepPtr: 0, key: K4, normalize: norm4 });
    expect(r.decision).toBe('allow');
    expect(r.advance).toBe(true);
  });
  it('указатель 1 (контейнер) → спуск, матчит лист 2.1', () => {
    const r = decideTree({ toolUse: { name: 'Edit', input: { file_path: 'tools/x.mjs' } }, frozenPlan: plan, stepPtr: 1, key: K4, normalize: norm4 });
    expect(r.decision).toBe('allow');
    expect(r.advanceTo).toEqual([1, 1]); // следующий лист 2.2
  });
  it('действие не лист текущей позиции → block', () => {
    const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } }, frozenPlan: plan, stepPtr: 0, key: K4, normalize: norm4 });
    expect(r.decision).toBe('block');
  });
  it('контейнер сам действие не матчит (SE-2): Write на контейнер-метку → block', () => {
    const r = decideTree({ toolUse: { name: 'Write', input: { file_path: '2' } }, frozenPlan: plan, stepPtr: 1, key: K4, normalize: norm4 });
    expect(r.decision).toBe('block');
  });
  it('битое дерево (контейнер с op) → block (validatePlanTree)', () => {
    const bad = fp4({ steps: [{ n: 1, op: 'Write', object: 'a', substeps: [{ n: '1.1', op: 'Edit', object: 'x' }] }], key: K4, nowMs: 1 });
    const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'a' } }, frozenPlan: bad, stepPtr: 0, key: K4, normalize: norm4 });
    expect(r.decision).toBe('block');
  });
});

describe('R-08 flat-регрессия decide (SE-7)', () => {
  const flat = fp4({ steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], key: K4, nowMs: 1 });
  it('плоский план: advanceTo целое (как раньше)', () => {
    const r = decideTree({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: flat, stepPtr: 0, key: K4, normalize: norm4 });
    expect(r.decision).toBe('allow');
    expect(r.advanceTo).toBe(1);
    expect(r.advance).toBe(true);
  });
});
```
*(Эти тесты используют РЕАЛЬНЫЙ `freezePlan`+`verifyFrozenPlan` — не stub — чтобы печать покрывала дерево по-настоящему.)*

- [ ] **Step 2: RED** — FAIL (decide на дереве не резолвит лист / advance отсутствует / advanceTo не массив).

- [ ] **Step 3: Реализация** — `tools/enforce-supreme-gate.mjs`:

(a) импорт навигаторов + plan-lock дерева сверху:
```js
import { advanceOverTree, serializePointer, deserializePointer, normalizeToLeaf } from './step-pointer.mjs';
import { treeLeafAt, validatePlanTree } from './plan-lock.mjs';
```
(в существующем import из plan-lock добавить `treeLeafAt, validatePlanTree`; nextStep больше не нужен в decide, но оставить экспорт-импорт для совместимости тестов plan-lock.)

(b) `decide` — заменить L135 (`nextStep`) и L165 (`advanceTo: stepPtr+1`):
```js
  // R-08: структурная валидация дерева ДО доверия (fail-CLOSED)
  if (!validatePlanTree(frozenPlan.steps)) return { decision: 'block', reason: 'структура плана-дерева невалидна (fail-CLOSED)' };
  const step = treeLeafAt(frozenPlan.steps, stepPtr);
  if (!step) return { decision: 'block', reason: 'план исчерпан / указатель не резолвится в лист' };
```
… (середина decide — actionMatchesStep / artifact / ref / Δ7 без изменений на листе) …
последняя строка allow:
```js
  let advanceTo;
  try {
    const cur = normalizeToLeaf(frozenPlan.steps, deserializePointer(stepPtr, frozenPlan.steps));
    advanceTo = serializePointer(advanceOverTree(frozenPlan.steps, cur));
  } catch { return { decision: 'block', reason: 'навигация по дереву превысила предел (fail-CLOSED)' }; }
  return { decision: 'allow', reason: `шаг ${step.n} плана`, advance: true, advanceTo };
```

(c) `runGate` — SE-1: гейтить по `r.advance === true`, не `typeof number` (заменить L213):
```js
  if (r.decision === 'allow' && r.advance === true) {
```
(внутри — `journal({... step: stepPtr ...})` и `saveStep(r.advanceTo)` без изменений; `step:` в журнале — оставить `stepPtr` как метку, не критично к форме.)

(d) `decideMode` — пробрасывает `advance` (spread `...decide(...)` уже несёт advance; проверить, что L192 `return { ...decide(...), mode }` сохраняет поле — да).

- [ ] **Step 4: GREEN** — `... enforce-supreme-gate --reporter dot` → PASS (дерево + flat-регрессия + ВСЕ существующие decide/decideMode/runGate тесты — flat advanceTo целое).

- [ ] **Step 5: Commit**

```
git -C "<wt>" add tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs
git -C "<wt>" commit -m "feat(supreme-gate): tree leaf resolution + advance flag for R-08 waves"
```

---

## Task 5: main() врезка дерева-указателя (saveStep сериализует)

**Files:** `tools/enforce-supreme-gate.mjs` (только `main`, не чистые функции)

`main` уже: `resolveStepPtr(stored, plan_id, verify)` → теперь может вернуть целое/массив;
`saveStep: (n) => writeFileSync(..., signStepState(plan_id, n, key))` → `n` = `r.advanceTo`
(целое/массив) — `signStepState` уже принимает любую форму (Task 3). **Изменений в `main` по сути
нет** — поток уже форма-агностичен после Task 3+4. Проверка: comment-only/нет — пропустить, если
RED не наблюдается (quirk). Если нужна правка (например, лог) — отдельный наблюдаемый тест.

- [ ] **Step 1: Проверить, нужна ли правка main** — прочитать `main` (L239-270). Если `resolveStepPtr` результат и `saveStep(r.advanceTo)` уже форма-агностичны (Task 3 signStepState принимает массив; Task 4 advanceTo сериализован) → **правка main не требуется**, зафиксировать в ответе, перейти к Task 6. Не фабриковать фальш-RED.

---

## Task 6: Полная регрессия + verification

- [ ] **Step 1: Полный прогон** — `npx vitest run --root "<...>/app" --config "<...>/app/vitest.config.tools.mjs" --reporter dot 2>&1 | tail -6`
Expected: 0 failed; total = B0 (Task 0) + новые тесты Task 1-4. ВСЕ существующие тесты стены/plan-lock/step-pointer GREEN.

- [ ] **Step 2: Verification-before-completion** — verbatim vitest summary, дельта от B0, явно: 0 failed, flat-регрессия сохранена.

- [ ] **Step 3: commit-not-push** — НЕ пушить (пуш по «пуш» через finishing-a-development-branch).

---

## Self-Review

**Spec coverage:** §4.0 целое-depth1 (Task 1 serialize/deserialize) · §4.1 step-pointer (Task 1) ·
§4.2 plan-lock treeLeaves/treeLeafAt/validatePlanTree/criterion-рекурсия + V1/V2 (Task 2) · §4.3
supreme-gate указатель-формат (Task 3) + decide/runGate дерево+SE-1 (Task 4) + main (Task 5) ·
§5 flat-регрессия (Task 2/4 regression-блоки + Task 6) · §6 fail-CLOSED (validatePlanTree/null-лист/
предел→throw→block, Task 1/2/4) · §7 7 классов дыр (SE-1 Task4 / SE-2 Task2+4 / SE-3 Task3 / SE-4
Task1+2 / SE-5 Task1+3 / SE-6 Task1 snapshot / SE-7 Task2/4/6) · §8 тесты (все Task). V3 — future,
вне кода (§9), не задача плана.

**Placeholder scan:** все code-steps несут реальный код тестов+impl; нет TBD/«handle edge cases».

**Type consistency:** `serializePointer`→целое|массив; `deserializePointer(state, steps)`→стек|null;
`treeLeafAt(steps, serializedPtr)`→листовой узел|null; `validatePlanTree(steps)`→`{ok}`;
`advanceOverTree(steps, ptr)`→указатель; `decide(...)`→`{decision, advance?, advanceTo?}`;
`resolveStepPtr`→целое|массив|0. Согласованы между задачами.

**Границы:** только step-pointer/plan-lock/enforce-supreme-gate (+тесты). Стена усилена под волны,
инвариант I2 + fail-CLOSED сохранены. Пол/судья/escape не тронуты. V3 (normative-content-rules)
— future Ф8, задокументировано.

## Execution Handoff

ИНЛАЙН через superpowers:executing-plans (субагенты запрещены). Batch с чекпоинтами после каждой
задачи (наблюдаемый RED до prod-правки). НЕ requesting-code-review. commit-not-push.
