# Safe-baseline live wiring Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make `enforce-safe-baseline-metering.mjs` a live PreToolUse hook that hard-blocks a mutating tool past a per-task safe-baseline threshold without a real skill match, with an always-available Skill/EnterPlanMode escape; plus a standalone `enforce-runtime-write-deny` hook that closes the self-write hole on `~/.claude/runtime` side-channels.

**Architecture:** All logic in pure functions; `main()` is I/O composition only. The pure metering core (`safe-baseline-metering.mjs`) is reused unchanged; new pure helpers (`extractKeywords`, `detectSkillMatch`, `runLiveDecision`) live in the wrapper. The stickiness contract (V2-1) is owned by `runLiveDecision`. The write-deny hook normalizes with the resolving `pathNormalize` (V2-2). Override subsystem is cut (G3).

**Tech Stack:** Node.js ESM (`.mjs`), vitest, existing helpers (`enforce-hook-helpers.mjs`, `safe-baseline-metering.mjs`, `path-normalization.mjs`).

**Spec:** `docs/superpowers/specs/2026-05-30-safe-baseline-live-wiring-design.md` (v4).

**NB (overnight autonomous run):** git commits require owner AskUserQuestion approval (gate) — not available while the owner sleeps. Implement on disk, keep `npm run test:tools` GREEN, leave commits + settings.json registration for the morning handoff.

---

## File Structure

| Path | Responsibility |
|---|---|
| `tools/enforce-safe-baseline-metering.mjs` (modify) | + `extractKeywords`, `detectSkillMatch`, `runLiveDecision`, live `main()` |
| `tools/enforce-safe-baseline-metering.test.mjs` (modify) | + tests for the three new pure functions |
| `tools/enforce-runtime-write-deny.mjs` (create) | standalone PreToolUse write-deny on `~/.claude/runtime/**` |
| `tools/enforce-runtime-write-deny.test.mjs` (create) | unit tests incl. V2-2 `.`-segment evasion |

---

### Task 1: `extractKeywords(promptText)` (pure)

**Files:** Modify `tools/enforce-safe-baseline-metering.mjs`; Test `tools/enforce-safe-baseline-metering.test.mjs`

- [ ] **Step 1: Write the failing test**

```js
import { extractKeywords } from './enforce-safe-baseline-metering.mjs';

describe('extractKeywords', () => {
  it('lowercases, drops <4-char tokens and stopwords, returns unique sorted', () => {
    expect(extractKeywords('Почини safe-baseline router gate')).toEqual(['baseline', 'gate', 'router', 'safe']);
  });
  it('drops common RU imperatives so unrelated tasks do not falsely overlap', () => {
    const a = extractKeywords('сделай проверь биллинг тариф');
    const b = extractKeywords('сделай проверь регион маршрут');
    const overlap = a.filter((k) => b.includes(k));
    expect(overlap).toEqual([]); // only the topic words survive, no shared imperatives
  });
  it('returns [] for empty/non-string', () => {
    expect(extractKeywords('')).toEqual([]);
    expect(extractKeywords(null)).toEqual([]);
  });
});
```

- [ ] **Step 2: Run test to verify it fails** — `npx vitest run tools/enforce-safe-baseline-metering.test.mjs` → FAIL (extractKeywords not exported).

- [ ] **Step 3: Write minimal implementation**

```js
const STOPWORDS = new Set([
  // RU common + imperatives
  'сделай', 'сделать', 'проверь', 'проверить', 'посмотри', 'добавь', 'добавить',
  'напиши', 'написать', 'нужно', 'надо', 'давай', 'можешь', 'потом', 'после',
  'перед', 'через', 'очень', 'если', 'чтобы', 'этот', 'эта', 'это', 'эти',
  'или', 'тоже', 'также', 'когда', 'пока', 'весь', 'всё', 'все', 'теперь',
  'здесь', 'там', 'нет', 'есть', 'будет', 'было', 'твой', 'мой', 'самый',
  // EN common + imperatives
  'then', 'this', 'that', 'with', 'from', 'your', 'please', 'just', 'make',
  'check', 'look', 'need', 'want', 'also', 'into', 'more', 'very', 'should',
  'will', 'have', 'does', 'done', 'them', 'they', 'here', 'there',
]);

export function extractKeywords(promptText) {
  if (typeof promptText !== 'string') return [];
  const tokens = promptText
    .toLowerCase()
    .split(/[^\p{L}\p{N}]+/u)
    .filter((t) => t.length >= 4 && !STOPWORDS.has(t));
  return [...new Set(tokens)].sort();
}
```

- [ ] **Step 4: Run test to verify it passes** — expected PASS.

- [ ] **Step 5: Commit** — `git add tools/enforce-safe-baseline-metering.mjs tools/enforce-safe-baseline-metering.test.mjs` / `git commit -m "feat(safe-baseline): extractKeywords pure tokenizer (H1)"` *(defer overnight)*

---

### Task 2: `detectSkillMatch(turnEntries)` (pure)

**Files:** Modify both as above.

- [ ] **Step 1: Write the failing test**

```js
import { detectSkillMatch } from './enforce-safe-baseline-metering.mjs';

function asstToolUse(name, input = {}) {
  return { message: { role: 'assistant', content: [{ type: 'tool_use', name, input }] } };
}

describe('detectSkillMatch', () => {
  it('true when the turn has a Skill tool_use', () => {
    expect(detectSkillMatch([asstToolUse('Skill', { skill: 'superpowers:brainstorming' })])).toBe(true);
  });
  it('true when the turn has an EnterPlanMode tool_use', () => {
    expect(detectSkillMatch([asstToolUse('EnterPlanMode')])).toBe(true);
  });
  it('false for Read/Grep/text-only turns (no self-grant via text)', () => {
    expect(detectSkillMatch([asstToolUse('Read', { file_path: 'docs/superpowers/plans/x.md' })])).toBe(false);
    expect(detectSkillMatch([{ message: { role: 'assistant', content: [{ type: 'text', text: 'docs/superpowers/plans/x.md' }] } }])).toBe(false);
  });
  it('false for empty/non-array', () => {
    expect(detectSkillMatch([])).toBe(false);
    expect(detectSkillMatch(null)).toBe(false);
  });
});
```

- [ ] **Step 2: Run to verify FAIL** (detectSkillMatch not exported).

- [ ] **Step 3: Write minimal implementation**

```js
const SKILL_MATCH_TOOLS = new Set(['Skill', 'EnterPlanMode']);

export function detectSkillMatch(turnEntries) {
  if (!Array.isArray(turnEntries)) return false;
  for (const e of turnEntries) {
    const c = e && e.message && e.message.content;
    if (!Array.isArray(c)) continue;
    for (const b of c) {
      if (b && b.type === 'tool_use' && SKILL_MATCH_TOOLS.has(b.name)) return true;
    }
  }
  return false;
}
```

- [ ] **Step 4: Run to verify PASS.**

- [ ] **Step 5: Commit** *(defer overnight)*.

---

### Task 3: `runLiveDecision(...)` (pure — V2-1 stickiness contract)

**Files:** Modify both as above.

- [ ] **Step 1: Write the failing test** — cover BOTH V2-1 failure modes.

```js
import { runLiveDecision } from './enforce-safe-baseline-metering.mjs';
import { newCounterState } from './safe-baseline-metering.mjs';

function ledgerWith(counts, skill, keywords) {
  return {
    state: { ...newCounterState({ taskId: 't', startedAtIso: '2026-05-30T00:00:00Z', firstPromptExcerpt: 'p' }),
             counts: { Read: 0, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0, ...counts },
             skill_match_within_task: skill },
    lastKeywords: keywords,
  };
}

describe('runLiveDecision — stickiness contract (V2-1)', () => {
  it('persists skillMatchedThisTurn into the ledger (stickiness not lost)', () => {
    const r = runLiveDecision({
      event: { tool_name: 'Read' }, priorLedger: null,
      promptText: 'router gate safe baseline', currentKeywords: ['router', 'gate', 'safe', 'baseline'],
      skillMatchedThisTurn: true,
    });
    expect(r.ledger.state.skill_match_within_task).toBe(true);
  });

  it('a skill earlier in a task keeps later mutating ops allowed past the hard limit (no false block)', () => {
    const prior = ledgerWith({ Read: 60 }, true, ['router', 'gate', 'safe', 'baseline']);
    const r = runLiveDecision({
      event: { tool_name: 'Edit' }, priorLedger: prior,
      promptText: 'продолжаем router gate safe baseline', currentKeywords: ['router', 'gate', 'safe', 'baseline'],
      skillMatchedThisTurn: false,
    });
    expect(r.action).toBe('allow');
  });

  it('skill match in task A does NOT exempt an unrelated task B (no cross-task leak)', () => {
    const prior = ledgerWith({ Read: 60 }, true, ['router', 'gate', 'safe', 'baseline']);
    const r = runLiveDecision({
      event: { tool_name: 'Edit' }, priorLedger: prior,
      promptText: 'другая тема регион маршрут лиды', currentKeywords: ['регион', 'маршрут', 'лиды'],
      skillMatchedThisTurn: false,
    });
    // fresh task (overlap < 2) → counters reset to 0 → Edit allowed BUT skill_match must be false now
    expect(r.ledger.state.skill_match_within_task).toBe(false);
    expect(r.ledger.state.counts.Read).toBe(0);
  });

  it('hard-blocks a mutating tool past the limit in a no-skill task', () => {
    const prior = ledgerWith({ Read: 60 }, false, ['router', 'gate', 'safe', 'baseline']);
    const r = runLiveDecision({
      event: { tool_name: 'Edit' }, priorLedger: prior,
      promptText: 'router gate safe baseline', currentKeywords: ['router', 'gate', 'safe', 'baseline'],
      skillMatchedThisTurn: false,
    });
    expect(r.action).toBe('hard_block');
  });
});
```

- [ ] **Step 2: Run to verify FAIL.**

- [ ] **Step 3: Write minimal implementation**

```js
import { shouldInheritTaskId } from './safe-baseline-metering.mjs';

export function runLiveDecision({ event, priorLedger, promptText, currentKeywords, skillMatchedThisTurn, thresholds }) {
  const inherit = !!(priorLedger && priorLedger.state &&
    shouldInheritTaskId(priorLedger.lastKeywords || [], currentKeywords, promptText));
  const priorSticky = inherit ? !!priorLedger.state.skill_match_within_task : false;
  const effectiveSkillMatched = priorSticky || !!skillMatchedThisTurn;

  const res = processEvent({
    event, priorLedger, currentKeywords, promptText,
    skillMatched: effectiveSkillMatched, thresholds,
  });
  // V2-1: persist stickiness — processEvent does not.
  res.ledger.state.skill_match_within_task = effectiveSkillMatched;
  return res;
}
```

- [ ] **Step 4: Run to verify PASS.**

- [ ] **Step 5: Commit** *(defer overnight)*.

---

### Task 4: Live `main()` wiring + integration test

**Files:** Modify both as above.

- [ ] **Step 1: Write the failing integration test** (injected runtimeDir + transcript fixture)

```js
import { runMain } from './enforce-safe-baseline-metering.mjs';
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

function fixtureTranscript(path, entries) { writeFileSync(path, entries.map((e) => JSON.stringify(e)).join('\n')); }

describe('safe-baseline live main (runMain)', () => {
  it('blocks an Edit when Read past hard with no skill, and the message names the escape', async () => {
    const dir = mkdtempSync(join(tmpdir(), 'sbm-'));
    const tpath = join(dir, 't.jsonl');
    // prior ledger: Read=60, no skill, same task keywords
    writeFileSync(join(dir, 'safe-baseline-ledger-S.json'), JSON.stringify({
      state: { schema_version: 1, task_id: 't', counts: { Read: 60, Grep: 0, Glob: 0, LS: 0, TodoWrite_writes: 0, AskUserQuestion: 0 }, skill_match_within_task: false },
      lastKeywords: ['router', 'gate', 'safe', 'baseline'],
    }));
    fixtureTranscript(tpath, [{ type: 'user', message: { role: 'user', content: 'router gate safe baseline' } }]);
    const res = await runMain({
      event: { tool_name: 'Edit', session_id: 'S', transcript_path: tpath },
      runtimeDir: dir,
    });
    expect(res.block).toBe(true);
    expect(res.message).toMatch(/EnterPlanMode|Skill/);
  });

  it('allows a fresh task and persists the ledger', async () => {
    const dir = mkdtempSync(join(tmpdir(), 'sbm-'));
    const tpath = join(dir, 't.jsonl');
    fixtureTranscript(tpath, [{ type: 'user', message: { role: 'user', content: 'новая тема регион' } }]);
    const res = await runMain({
      event: { tool_name: 'Read', session_id: 'S2', transcript_path: tpath },
      runtimeDir: dir,
    });
    expect(res.block).toBe(false);
    expect(existsSync(join(dir, 'safe-baseline-ledger-S2.json'))).toBe(true);
  });
});
```

- [ ] **Step 2: Run to verify FAIL** (runMain not exported).

- [ ] **Step 3: Write minimal implementation** — replace the no-op `main()` with a testable `runMain` + thin `main()`.

```js
import { readFileSync as _rf, writeFileSync as _wf, appendFileSync as _af, mkdirSync as _mk } from 'node:fs';
import { join as _join } from 'node:path';
import { homedir as _home } from 'node:os';
import { readStdin, parseEventJson, readTranscript, lastUserPromptText, lastTurnEntries, exitDecision } from './enforce-hook-helpers.mjs';

const ESCAPE_MSG = 'invoke the recommended Skill, or EnterPlanMode, to proceed (skill/plan invocations are never blocked by this layer).';

function rtDir(o) { return o || _join(_home(), '.claude', 'runtime'); }
function loadLedger(dir, sess) {
  try { return JSON.parse(_rf(_join(dir, `safe-baseline-ledger-${sess || 'unknown'}.json`), 'utf8')); }
  catch { return null; }
}
function saveLedger(dir, sess, ledger) {
  try { _mk(dir, { recursive: true }); _wf(_join(dir, `safe-baseline-ledger-${sess || 'unknown'}.json`), JSON.stringify(ledger)); }
  catch { /* fail-quiet */ }
}
function logFlag(dir, sess, entry) {
  try { _mk(dir, { recursive: true }); _af(_join(dir, `safe-baseline-flags-${sess || 'unknown'}.jsonl`), JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n'); }
  catch { /* ignore */ }
}

export async function runMain({ event, runtimeDir, transcript: injectedTranscript } = {}) {
  try {
    const sess = event.session_id;
    const dir = rtDir(runtimeDir);
    const transcript = injectedTranscript || readTranscript(event.transcript_path);
    const promptText = lastUserPromptText(transcript) || '';
    const currentKeywords = extractKeywords(promptText);
    const skillMatchedThisTurn = detectSkillMatch(lastTurnEntries(transcript)) ||
      ['Skill', 'EnterPlanMode'].includes(event.tool_name);
    const priorLedger = loadLedger(dir, sess);

    const res = runLiveDecision({ event, priorLedger, promptText, currentKeywords, skillMatchedThisTurn });
    saveLedger(dir, sess, res.ledger);

    if (res.action === 'soft_flag') logFlag(dir, sess, { tool: event.tool_name, reason: res.reason });
    if (res.action === 'hard_block') return { block: true, message: `[safe-baseline] ${res.reason}\n${ESCAPE_MSG}` };
    return { block: false };
  } catch {
    return { block: false }; // fail-quiet
  }
}

async function main() {
  const event = parseEventJson(await readStdin());
  const res = await runMain({ event });
  exitDecision(res);
}

if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-safe-baseline-metering.mjs')) {
  main().catch(() => process.exit(0));
}
```

(Remove the old no-op `main()` and its CLI guard.)

- [ ] **Step 4: Run to verify PASS** + `npm run test:tools` GREEN.

- [ ] **Step 5: Commit** *(defer overnight)*.

---

### Task 5: `enforce-runtime-write-deny.mjs` (standalone, V2-2)

**Files:** Create `tools/enforce-runtime-write-deny.mjs` + `tools/enforce-runtime-write-deny.test.mjs`.

- [ ] **Step 1: Write the failing test**

```js
import { decide } from './enforce-runtime-write-deny.mjs';
import { homedir } from 'node:os';
import { join } from 'node:path';

const HOME = homedir();

describe('enforce-runtime-write-deny decide()', () => {
  it('blocks a Write into ~/.claude/runtime', () => {
    const r = decide({ toolName: 'Write', filePath: join(HOME, '.claude', 'runtime', 'askuser-decisions-S.jsonl') });
    expect(r.block).toBe(true);
  });
  it('blocks the .-segment evasion (V2-2)', () => {
    const r = decide({ toolName: 'Write', filePath: join(HOME, '.claude', '.', 'runtime', 'x.jsonl') });
    expect(r.block).toBe(true);
  });
  it('allows a Write to a normal project path', () => {
    const r = decide({ toolName: 'Write', filePath: join(HOME, 'project', 'src', 'x.mjs') });
    expect(r.block).toBe(false);
  });
  it('ignores non-write tools', () => {
    expect(decide({ toolName: 'Read', filePath: join(HOME, '.claude', 'runtime', 'x') }).block).toBe(false);
  });
});
```

- [ ] **Step 2: Run to verify FAIL.**

- [ ] **Step 3: Write minimal implementation**

```js
#!/usr/bin/env node
/**
 * enforce-runtime-write-deny — PreToolUse(Edit|Write|MultiEdit|NotebookEdit).
 * Blocks the Write/Edit TOOL from writing under ~/.claude/runtime/** (closes a
 * pre-existing self-write hole on the v4 git-approval anchor). Standalone —
 * independent of safe-baseline. Uses the resolving pathNormalize (V2-2) so
 * `.`/`..` segments cannot evade the match. Fail-OPEN on inability to determine
 * the path (never bricks the session); blocks only on a confirmed runtime match.
 */
import { pathNormalize } from './path-normalization.mjs';
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';

const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i;

export function decide({ toolName, filePath, normalizeImpl = pathNormalize }) {
  if (!WRITE_TOOLS.has(toolName)) return { block: false };
  const fp = String(filePath || '');
  if (!fp) return { block: false };
  let norm;
  try { norm = normalizeImpl(fp); } catch { return { block: false }; } // can't determine → fail-open (no brick)
  if (RUNTIME_RE.test(norm)) {
    return { block: true, reason: `Write to «${norm}» denied — ~/.claude/runtime is a protected side-channel (git-approval anchor).` };
  }
  return { block: false };
}

async function main() {
  try {
    const event = parseEventJson(await readStdin());
    const r = decide({
      toolName: event.tool_name,
      filePath: (event.tool_input && (event.tool_input.file_path || event.tool_input.notebook_path)) || '',
    });
    exitDecision({ block: r.block, message: r.reason });
  } catch {
    exitDecision({ block: false }); // fail-quiet
  }
}

const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-runtime-write-deny.mjs');
if (isCli) main();
```

- [ ] **Step 4: Run to verify PASS** + `npm run test:tools` GREEN.

- [ ] **Step 5: Commit** *(defer overnight)*.

---

### Task 6: Full regression + handoff

- [ ] **Step 1:** `npm run test:tools` — confirm full GREEN count (baseline 1859 + new tests).
- [ ] **Step 2:** Write the morning handoff note (`docs/observer/notes/2026-05-30-safe-baseline-overnight.md`): queued commits, exact `.claude/settings.json` registration block, the fail-OPEN deviation note for owner review, and the "flip to enforce" status (already enforce per owner; observe-mode was not requested).
- [ ] **Step 3:** Commit everything in a batch with owner approval *(morning)*.

---

## Registration block (owner-applied, morning)

Add to `.claude/settings.json` `hooks.PreToolUse` (Claude cannot edit settings.json — gate-blocked):

```json
{ "matcher": "Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode",
  "hooks": [{ "type": "command", "command": "node tools/enforce-safe-baseline-metering.mjs", "timeout": 10 }] }
```

```json
{ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
  "hooks": [{ "type": "command", "command": "node tools/enforce-runtime-write-deny.mjs", "timeout": 5 }] }
```

Until registered, both hooks are inert (no behavior change).
