Files
portal/docs/superpowers/plans/2026-05-23-observer-parser-skill-hook-expand.md
T
Дмитрий e46295e313 docs(plan): observer parser skill/hook expand — 5-task TDD plan
Spec terminology aligned with codebase: recommended_skill →
recommended_node (classification-map хранит Tooling IDs `#NN`, не имена
skill'ов). Test runner — vitest (npm run test:tools), не node --test.
Missed-activations filter тоже поднимается до >=2.

5 atomic TDD commits: hook-resolver, recommended-node, parser+smoke,
analyzer factor-axis, brain-retro template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:10:06 +03:00

955 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Observer parser — skill/hook expand (schema v3) 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:** Расширить observer-парсер двумя полями для дисциплинарного анализа: имена хук-скриптов (reverse-lookup `.claude/settings.json`) и `recommended_node` для direct-эпизодов (из classification-map). Forward-only schema v2 → v3.
**Architecture:** Два новых pure-модуля (`observer-hook-resolver.mjs`, `observer-recommended-node.mjs`) + ~15 LoC delta в `observer-transcript-parser.mjs` + минимальная правка `brain-retro-analyzer.mjs` (фильтр `>= 2`, +1 factor-ось) + `missed-activations.mjs` (фильтр `< 2`) + новая секция в `brain-retro` aggregation-template.
**Tech Stack:** Node.js ES modules (`.mjs`), pure (no exec, no fs side-effects per Security Guidance #40), vitest для тестов через `npm run test:tools` (config `app/vitest.config.tools.mjs`), Node `node:crypto` для SHA-fallback.
**Spec:** [docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md](../specs/2026-05-23-observer-parser-skill-hook-expand-design.md)
---
## Pre-flight (обязательно перед стартом, Pravila §15.2)
- [ ] **Pre-flight sync**
```bash
git fetch && git log HEAD..origin/main --oneline
```
Expected: пусто, либо ясный понятный список коммитов параллельной сессии. Если в списке есть `docs/observer/`, `tools/observer-*`, `tools/brain-retro-*`, `tools/missed-activations*`, `.claude/skills/brain-retro/`**СТОП**, мерджить/ребейзить сначала.
- [ ] **Branch + worktree note**
Текущая ветка проверяется заказчиком. План рассчитан на ту же ветку, в которой коммитнут spec (`feat/supplier-group-sync-fix` или последующая). Каждый Task = один atomic commit, не push'им внутри плана.
---
## File Structure
- **Create:** `tools/observer-hook-resolver.mjs` (~80 LoC) — pure resolver matcher → script names.
- **Create:** `tools/observer-hook-resolver.test.mjs` — 8 vitest cases.
- **Create:** `tools/observer-recommended-node.mjs` (~30 LoC) — pure: classification → first live node ID.
- **Create:** `tools/observer-recommended-node.test.mjs` — 5 vitest cases.
- **Modify:** `tools/observer-transcript-parser.mjs` — ~15 LoC delta (import + extractProcessEvents расширение + parseTranscript primary_rationale `recommended_node` + bump `schema_version: 2 → 3`).
- **Modify:** `tools/observer-transcript-parser.test.mjs` — +3 case (hook scripts, direct recommended, skill no-recommended).
- **Modify:** `tools/brain-retro-analyzer.mjs` — строка 202 фильтр `=== 2 → >= 2`; добавить `recommended_node_for_direct` в `FACTOR_FNS`.
- **Modify:** `tools/brain-retro-analyzer.test.mjs` — +1 case (mix v2 + v3).
- **Modify:** `tools/missed-activations.mjs` — строка 22 фильтр `!== 2 → < 2` (чтобы v3 тоже попадал).
- **Modify:** `tools/missed-activations.test.mjs` — +1 case (v3 episode).
- **Modify:** `.claude/skills/brain-retro/references/aggregation-template.md` — +Hook script breakdown section + Missed Activations note про `recommended_node`.
- **Modify:** `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` — добавить cross-ref note внизу: «schema v3 → 2026-05-23-observer-parser-skill-hook-expand-design.md».
---
## Task 1: observer-hook-resolver.mjs + tests
**Files:**
- Create: `tools/observer-hook-resolver.mjs`
- Create: `tools/observer-hook-resolver.test.mjs`
- [ ] **Step 1.1: Создать failing test file**
Create `tools/observer-hook-resolver.test.mjs`:
```js
import { describe, it, expect } from 'vitest';
import { buildHookMap, resolveScriptCounts, extractScriptName } from './observer-hook-resolver.mjs';
describe('extractScriptName', () => {
it('extracts tools/X.mjs from "node tools/observer-stop-hook.mjs"', () => {
expect(extractScriptName('node tools/observer-stop-hook.mjs')).toBe('tools/observer-stop-hook.mjs');
});
it('extracts tools/X.mjs from quoted path with cwd', () => {
expect(extractScriptName('node "C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs"'))
.toBe('tools/subagent-prompt-prefix.mjs');
});
it('extracts npx package name', () => {
expect(extractScriptName('npx -y markdownlint-cli2 --fix file.md')).toBe('markdownlint-cli2');
});
it('falls back to inline:<sha-16> for node -e inline scripts', () => {
const result = extractScriptName('node -e "const f=process.env.X; if(f) process.stderr.write(\'warn\');"');
expect(result).toMatch(/^inline:[0-9a-f]{16}$/);
});
it('inline fallback is stable across whitespace formatting', () => {
const a = extractScriptName('node -e "const f = 1;\n\nif(f) process.exit(0);"');
const b = extractScriptName('node -e "const f = 1; if(f) process.exit(0);"');
expect(a).toBe(b);
});
it('inline fallback differs for different commands', () => {
const a = extractScriptName('node -e "process.exit(0);"');
const b = extractScriptName('node -e "process.exit(1);"');
expect(a).not.toBe(b);
});
});
describe('buildHookMap', () => {
it('returns empty Map for empty settings', () => {
expect(buildHookMap({}).size).toBe(0);
});
it('handles missing hooks key', () => {
expect(buildHookMap({ permissions: {} }).size).toBe(0);
});
it('builds matcher → [scripts] for single-matcher single-script', () => {
const settings = {
hooks: {
PreToolUse: [
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/foo.mjs' }] },
],
},
};
const map = buildHookMap(settings);
expect(map.get('PreToolUse:Bash')).toEqual(['tools/foo.mjs']);
});
it('aggregates multiple scripts per matcher', () => {
const settings = {
hooks: {
PreToolUse: [
{ matcher: 'Bash', hooks: [
{ type: 'command', command: 'node tools/foo.mjs' },
{ type: 'command', command: 'node tools/bar.mjs' },
]},
],
},
};
expect(buildHookMap(settings).get('PreToolUse:Bash')).toEqual(['tools/foo.mjs', 'tools/bar.mjs']);
});
it('uses event name without matcher for UserPromptSubmit-style hooks', () => {
const settings = {
hooks: {
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: 'node tools/economy.mjs' }] },
],
},
};
expect(buildHookMap(settings).get('UserPromptSubmit')).toEqual(['tools/economy.mjs']);
});
it('merges project + user settings (project takes precedence on dup matcher)', () => {
const project = {
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/a.mjs' }] }] },
};
const user = {
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/b.mjs' }] }] },
};
const map = buildHookMap(project, user);
// both contribute; project listed first
expect(map.get('PreToolUse:Bash')).toEqual(['tools/a.mjs', 'tools/b.mjs']);
});
});
describe('resolveScriptCounts', () => {
it('returns {} for empty matcherCounts', () => {
expect(resolveScriptCounts({}, new Map())).toEqual({});
});
it('returns {} when matcher not in map', () => {
expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, new Map())).toEqual({});
});
it('duplicates count for each script on the matcher', () => {
const map = new Map([['PreToolUse:Bash', ['tools/a.mjs', 'tools/b.mjs']]]);
expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, map)).toEqual({
'tools/a.mjs': 5,
'tools/b.mjs': 5,
});
});
it('sums across multiple matchers that share a script', () => {
const map = new Map([
['PreToolUse:Bash', ['tools/x.mjs']],
['PostToolUse:Bash', ['tools/x.mjs']],
]);
expect(resolveScriptCounts({ 'PreToolUse:Bash': 3, 'PostToolUse:Bash': 2 }, map))
.toEqual({ 'tools/x.mjs': 5 });
});
});
```
- [ ] **Step 1.2: Run test — verify it fails**
```bash
npm run test:tools -- observer-hook-resolver
```
Expected: FAIL — "Failed to load url ./observer-hook-resolver.mjs" or similar.
- [ ] **Step 1.3: Write implementation**
Create `tools/observer-hook-resolver.mjs`:
```js
#!/usr/bin/env node
/**
* Hook resolver for the brain governance observer.
* Reverse-lookup .claude/settings.json (+ ~/.claude/settings.json):
* matcher (event:tool) → list of hook-script names.
*
* Pure — no exec, no fs side-effects (Security Guidance #40).
* Caller is responsible for reading the JSON; this module operates on
* already-parsed settings objects.
*
* Per spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
*/
import { createHash } from 'node:crypto';
const TOOL_SCRIPT_RE = /(?:^|[\s"'])(tools\/[\w-]+\.(?:mjs|py|sh))/;
const NPX_RE = /(?:^|[\s"'])npx\s+(?:-y\s+)?([\w@/.-]+)/;
/**
* Normalize a command string for stable hashing:
* - strip surrounding whitespace
* - collapse internal whitespace runs to single space
* No lowercase (script names are case-sensitive in Windows-aware contexts).
*/
function normalizeCommand(s) {
return String(s || '').trim().replace(/\s+/g, ' ');
}
/**
* Extract a stable, human-readable identifier from a hook command string.
* Priority: tools/X.{mjs,py,sh} → npx <pkg> → inline:<sha-16>.
*/
export function extractScriptName(command) {
const cmd = String(command || '');
const toolMatch = cmd.match(TOOL_SCRIPT_RE);
if (toolMatch) return toolMatch[1];
const npxMatch = cmd.match(NPX_RE);
if (npxMatch) return npxMatch[1];
const sha = createHash('sha256').update(normalizeCommand(cmd)).digest('hex').slice(0, 16);
return `inline:${sha}`;
}
/**
* Build matcher → [scriptName, ...] from one or two settings objects.
* Matcher key format:
* - "<event>:<tool>" when entry has `matcher` (e.g. "PreToolUse:Bash")
* - "<event>" when entry has no `matcher` (UserPromptSubmit, SessionStart)
*
* Project settings listed before user settings on shared matchers.
*/
export function buildHookMap(projectSettings = {}, userSettings = {}) {
const map = new Map();
for (const settings of [projectSettings, userSettings]) {
const hooks = settings && settings.hooks;
if (!hooks || typeof hooks !== 'object') continue;
for (const [event, entries] of Object.entries(hooks)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const matcher = entry.matcher ? `${event}:${entry.matcher}` : event;
const scripts = Array.isArray(entry.hooks) ? entry.hooks : [];
const existing = map.get(matcher) || [];
for (const h of scripts) {
if (!h || h.type !== 'command') continue;
existing.push(extractScriptName(h.command));
}
map.set(matcher, existing);
}
}
}
return map;
}
/**
* Given matcher counts (from parser hook_fired.counts) and a hook map,
* return per-script counts. Each script's count = sum over matchers that
* include it of matcherCounts[matcher]. Matchers not in map are skipped
* silently (their counts remain reflected in the original `counts` field).
*/
export function resolveScriptCounts(matcherCounts, hookMap) {
const result = {};
for (const [matcher, count] of Object.entries(matcherCounts || {})) {
const scripts = hookMap.get(matcher);
if (!scripts || scripts.length === 0) continue;
for (const script of scripts) {
result[script] = (result[script] || 0) + count;
}
}
return result;
}
```
- [ ] **Step 1.4: Run test — verify it passes**
```bash
npm run test:tools -- observer-hook-resolver
```
Expected: PASS — all describe/it green.
- [ ] **Step 1.5: Commit**
```bash
git add tools/observer-hook-resolver.mjs tools/observer-hook-resolver.test.mjs
git commit -m "$(cat <<'EOF'
feat(observer): hook-resolver — matcher → script names (schema v3 prep)
Pure module. buildHookMap(project, user) reverse-lookup settings.json,
resolveScriptCounts duplicates counts per script. No exec.
EOF
)"
```
---
## Task 2: observer-recommended-node.mjs + tests
**Files:**
- Create: `tools/observer-recommended-node.mjs`
- Create: `tools/observer-recommended-node.test.mjs`
- [ ] **Step 2.1: Создать failing test file**
Create `tools/observer-recommended-node.test.mjs`:
```js
import { describe, it, expect } from 'vitest';
import { recommendNode } from './observer-recommended-node.mjs';
const MAP = {
feature: ['#19'],
refactor: ['#11', '#12', '#43'],
question: [],
other: [],
};
describe('recommendNode', () => {
it('returns first live node ID for a known classification', () => {
expect(recommendNode('feature', MAP, { '#19': false })).toBe('#19');
});
it('skips dormant first node, returns next live', () => {
expect(recommendNode('refactor', MAP, { '#11': true, '#12': false, '#43': false })).toBe('#12');
});
it('returns null when all recommended nodes are dormant', () => {
expect(recommendNode('refactor', MAP, { '#11': true, '#12': true, '#43': true })).toBeNull();
});
it('returns null for classification absent from map', () => {
expect(recommendNode('nonexistent', MAP, {})).toBeNull();
});
it('returns null for empty-array classification (question/memory-sync)', () => {
expect(recommendNode('question', MAP, {})).toBeNull();
expect(recommendNode('other', MAP, {})).toBeNull();
});
it('treats missing dormancy entry as live (defensive, parity with missed-activations)', () => {
// missed-activations uses dormancy[id] === false; recommendNode mirrors:
// unknown/missing → not live (paranoid — only positive false counts as live).
expect(recommendNode('feature', MAP, {})).toBeNull();
});
it('handles null/undefined inputs without throwing', () => {
expect(recommendNode(null, MAP, {})).toBeNull();
expect(recommendNode('feature', null, {})).toBeNull();
expect(recommendNode('feature', MAP, null)).toBeNull();
});
});
```
- [ ] **Step 2.2: Run test — verify it fails**
```bash
npm run test:tools -- observer-recommended-node
```
Expected: FAIL — module not found.
- [ ] **Step 2.3: Write implementation**
Create `tools/observer-recommended-node.mjs`:
```js
#!/usr/bin/env node
/**
* Recommended-node resolver for direct episodes.
* Pure — read-only, no exec, no fs (Security Guidance #40).
*
* For an episode classified as `taskClassification` with node_chosen='direct',
* return the first live (non-dormant) recommended node ID from the
* classification map. Mirrors missed-activations.mjs dormancy logic:
* dormancy[id] === false strictly (missing/true → not live).
*
* Per spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
*/
export function recommendNode(taskClassification, classificationMap, dormancy) {
if (!taskClassification || !classificationMap || !dormancy) return null;
const recommended = classificationMap[taskClassification];
if (!Array.isArray(recommended) || recommended.length === 0) return null;
for (const id of recommended) {
if (dormancy[id] === false) return id;
}
return null;
}
```
- [ ] **Step 2.4: Run test — verify it passes**
```bash
npm run test:tools -- observer-recommended-node
```
Expected: PASS.
- [ ] **Step 2.5: Commit**
```bash
git add tools/observer-recommended-node.mjs tools/observer-recommended-node.test.mjs
git commit -m "$(cat <<'EOF'
feat(observer): recommended-node resolver for direct episodes
Mirrors missed-activations dormancy logic (id === false strict).
First live recommended node from classification-map, else null.
EOF
)"
```
---
## Task 3: parser extension + smoke
**Files:**
- Modify: `tools/observer-transcript-parser.mjs`
- Modify: `tools/observer-transcript-parser.test.mjs`
- [ ] **Step 3.1: Прочитать существующий test-файл и понять стиль фикстур**
```bash
head -100 tools/observer-transcript-parser.test.mjs
```
Идентифицировать существующие фабрики (`makeUserPrompt`, `makeAssistantMsg`, или подобные) — переиспользовать.
- [ ] **Step 3.2: Добавить 3 failing tests**
В `tools/observer-transcript-parser.test.mjs` (append к существующему `describe('parseTranscript', ...)` блоку, или новый describe block):
```js
import fs from 'node:fs';
import path from 'node:path';
import { tmpdir } from 'node:os';
describe('parseTranscript v3 fields', () => {
// helper: minimal valid transcript with one user prompt + one assistant + tool_use Skill
// Adapt to existing fixture pattern in this file — fallback below if no helper exists.
function transcriptWithSkill(skillName) {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'добавь endpoint /api/foo' },
timestamp: '2026-05-23T10:00:00Z',
uuid: 'u-1',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 't-1', name: 'Skill', input: { skill: skillName } },
],
},
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-2',
sessionId: 'sess-1',
}),
].join('\n');
}
function transcriptDirectFeature() {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'добавь новый endpoint /api/foo' },
timestamp: '2026-05-23T10:00:00Z',
uuid: 'u-1',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'делаю' }] },
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-2',
sessionId: 'sess-1',
}),
].join('\n');
}
function transcriptWithHookAttachment() {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'ls' },
timestamp: '2026-05-23T10:00:00Z',
uuid: 'u-1',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 't-1', name: 'Bash', input: { command: 'ls' } }],
},
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-2',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'attachment',
attachment: { type: 'hook_success', hookName: 'PreToolUse:Bash', hookEvent: 'PreToolUse' },
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-3',
sessionId: 'sess-1',
}),
].join('\n');
}
it('emits schema_version: 3', () => {
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
expect(ep.schema_version).toBe(3);
});
it('sets recommended_node for direct feature-classified episode', () => {
// Inject a tiny classification map + dormancy via module mock or by
// relying on the real files. Simpler: read real files; expect '#19'.
// (If parser uses dependency injection, prefer that.)
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
expect(ep.primary_rationale.recommended_node).toBe('#19');
});
it('recommended_node is null when a skill was invoked', () => {
const ep = parseTranscript(transcriptWithSkill('superpowers:writing-plans'), 'sess-1');
expect(ep.primary_rationale.recommended_node).toBeNull();
});
it('hook_fired event includes both counts and scripts keys', () => {
const ep = parseTranscript(transcriptWithHookAttachment(), 'sess-1');
const hookEvent = ep.events.find((e) => e.kind === 'hook_fired');
expect(hookEvent).toBeDefined();
expect(hookEvent.counts).toBeDefined();
expect(hookEvent.scripts).toBeDefined();
expect(typeof hookEvent.scripts).toBe('object');
});
});
```
NB: если существующий test-файл уже импортирует `parseTranscript` — переиспользовать. Иначе добавить `import { parseTranscript } from './observer-transcript-parser.mjs';`.
- [ ] **Step 3.3: Run tests — verify they fail**
```bash
npm run test:tools -- observer-transcript-parser
```
Expected: 4 FAIL — `schema_version === 3`, `recommended_node` field absent, `hookEvent.scripts` absent.
- [ ] **Step 3.4: Modify parser**
В `tools/observer-transcript-parser.mjs`:
**Patch 1 (top, after existing imports):**
```js
import { buildHookMap, resolveScriptCounts } from './observer-hook-resolver.mjs';
import { recommendNode } from './observer-recommended-node.mjs';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
let HOOK_MAP = null;
function getHookMap() {
if (HOOK_MAP) return HOOK_MAP;
const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return {}; } };
HOOK_MAP = buildHookMap(read('.claude/settings.json'), read(join(homedir(), '.claude/settings.json')));
return HOOK_MAP;
}
let CLASSIFICATION_MAP = null;
function getClassificationMap() {
if (CLASSIFICATION_MAP) return CLASSIFICATION_MAP;
try {
CLASSIFICATION_MAP = JSON.parse(readFileSync('tools/observer-classification-map.json', 'utf-8')).map || {};
} catch { CLASSIFICATION_MAP = {}; }
return CLASSIFICATION_MAP;
}
let DORMANCY = null;
function getDormancy() {
if (DORMANCY) return DORMANCY;
try { DORMANCY = JSON.parse(readFileSync('tools/.node-dormancy.json', 'utf-8')); }
catch { DORMANCY = {}; }
return DORMANCY;
}
```
**Patch 2 (`extractProcessEvents` — replace the hook_fired emit block):**
Locate:
```js
if (Object.keys(hookCounts).length > 0) {
events.push({ kind: 'hook_fired', counts: hookCounts, errors: hookErrors });
}
```
Replace with:
```js
if (Object.keys(hookCounts).length > 0) {
const scripts = resolveScriptCounts(hookCounts, getHookMap());
events.push({ kind: 'hook_fired', counts: hookCounts, scripts, errors: hookErrors });
}
```
**Patch 3 (`parseTranscript` — bump schema_version + add `recommended_node`):**
Locate `schema_version: 2,` in the returned object — change to `schema_version: 3,`.
Locate the `primary_rationale` IIFE return object. Inside that object, after `task_classification: classifyTask(prompt),` add:
```js
recommended_node:
skills.length === 0
? recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy())
: null,
```
- [ ] **Step 3.5: Run tests — verify they pass**
```bash
npm run test:tools -- observer-transcript-parser
```
Expected: PASS — все 4 новых case + все существующие.
- [ ] **Step 3.6: Smoke на живом JSONL**
```bash
node -e "import('./tools/observer-transcript-parser.mjs').then(m => { const c = require('fs').readFileSync('docs/observer/episodes-2026-05.jsonl', 'utf-8'); /* just ensure parser loads and exports are intact */ console.log('parser loaded, parseTranscript=', typeof m.parseTranscript); })"
```
Expected: `parser loaded, parseTranscript= function` — без throw.
Note: парсер потребляет transcript-формат (`~/.claude/projects/.../*.jsonl`), не output-формат (`docs/observer/episodes-*.jsonl`). Smoke лишь проверяет, что модуль грузится с новыми импортами. Полная end-to-end проверка — следующий Stop-хук на реальной сессии.
- [ ] **Step 3.7: Run full tools test suite — regression check**
```bash
npm run test:tools
```
Expected: all green, including `observer-of-observer`, `observer-coverage-checker`, `missed-activations`, и т.д. (missed-activations пока ещё фильтрует `!== 2` — отдельная задача 4, регрессия не ожидается).
- [ ] **Step 3.8: Commit**
```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "$(cat <<'EOF'
feat(observer): parser v3 — hook_fired.scripts + recommended_node
schema_version 2 → 3. hook_fired event now carries `scripts` map
(reverse-lookup .claude/settings.json + user). primary_rationale gets
`recommended_node` (Tooling node ID) for direct episodes via
classification-map + dormancy. Existing `counts`/skill paths unchanged
— backward-compat preserved.
EOF
)"
```
---
## Task 4: analyzer >=2 + factor axis + missed-activations <2
**Files:**
- Modify: `tools/brain-retro-analyzer.mjs`
- Modify: `tools/brain-retro-analyzer.test.mjs`
- Modify: `tools/missed-activations.mjs`
- Modify: `tools/missed-activations.test.mjs`
- [ ] **Step 4.1: Добавить failing tests**
В `tools/brain-retro-analyzer.test.mjs` (append):
```js
describe('analyze: schema_version filter', () => {
it('accepts both v2 and v3 episodes', () => {
const v2 = { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
const v3 = { ...v2, schema_version: 3, primary_rationale: { ...v2.primary_rationale, recommended_node: '#19' } };
const result = analyze([v2, v3]);
expect(result.episodeCount).toBe(2);
});
it('factorMatrix has recommended_node_for_direct axis', () => {
const v3 = { schema_version: 3, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature', recommended_node: '#19' },
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
const result = analyze([v3]);
expect(result.factorMatrix.recommended_node_for_direct).toBeDefined();
expect(result.factorMatrix.recommended_node_for_direct['#19']).toBeDefined();
});
it('v2 episode bucket=none in recommended_node_for_direct', () => {
const v2 = { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
const result = analyze([v2]);
expect(result.factorMatrix.recommended_node_for_direct.none).toBeDefined();
});
});
```
В `tools/missed-activations.test.mjs` (append):
```js
it('detects missed activation on v3 episode', () => {
const v3 = { schema_version: 3, primary_rationale: { node_chosen: 'direct', task_classification: 'feature', recommended_node: '#19' } };
const result = detectMissedActivations([v3], { feature: ['#19'] }, { '#19': false });
expect(result.totalMissed).toBe(1);
});
```
- [ ] **Step 4.2: Run tests — verify they fail**
```bash
npm run test:tools -- brain-retro-analyzer missed-activations
```
Expected: FAIL — `recommended_node_for_direct` missing; v3 not counted.
- [ ] **Step 4.3: Modify analyzer**
В `tools/brain-retro-analyzer.mjs` строка 202:
```js
const normal = allNormal.filter((e) => e.schema_version === 2);
```
```js
const normal = allNormal.filter((e) => e.schema_version >= 2);
```
В `FACTOR_FNS` (object literal): добавить запись после `task_classification`:
```js
recommended_node_for_direct: (e) => (e.primary_rationale || {}).recommended_node || 'none',
```
- [ ] **Step 4.4: Modify missed-activations**
В `tools/missed-activations.mjs` строка 22:
```js
if (e.schema_version !== 2) continue;
```
```js
if (typeof e.schema_version !== 'number' || e.schema_version < 2) continue;
```
Update doc-комментарий выше (строки 7-12), пункт 1:
```js
* 1. schema_version >= 2 (v1 lacks factor data)
```
- [ ] **Step 4.5: Run tests — verify they pass**
```bash
npm run test:tools -- brain-retro-analyzer missed-activations
```
Expected: PASS.
- [ ] **Step 4.6: Full regression**
```bash
npm run test:tools
```
Expected: all green.
- [ ] **Step 4.7: Smoke на живом JSONL**
```bash
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl | head -40
```
Expected: JSON output, ненулевой `episodeCount`, `factorMatrix.recommended_node_for_direct` присутствует (даже если только `'none'` bucket — все эпизоды v2).
- [ ] **Step 4.8: Commit**
```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs \
tools/missed-activations.mjs tools/missed-activations.test.mjs
git commit -m "$(cat <<'EOF'
feat(observer): analyzer >=2 + recommended_node_for_direct factor axis
brain-retro-analyzer accepts schema_version >= 2 (v2+v3 mix).
FACTOR_FNS +recommended_node_for_direct ('none' bucket for v2).
missed-activations also raised to >= 2.
EOF
)"
```
---
## Task 5: brain-retro template + spec cross-ref
**Files:**
- Modify: `.claude/skills/brain-retro/references/aggregation-template.md`
- Modify: `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`
- [ ] **Step 5.1: Расширить aggregation-template.md**
В `.claude/skills/brain-retro/references/aggregation-template.md` после секции «Top nodes used (from `skill_invoked` events)» добавить:
```markdown
## Hook script breakdown (from `hook_fired.scripts`, schema v3+)
Per-script counts across the period. Surfaces which discipline-enforcing hooks fired (and which silently failed to fire). Aggregate from `events[].hook_fired.scripts` of v3 episodes — v2 episodes have only matcher-level `counts` and contribute nothing here.
| script | times fired | notes |
|---|---|---|
| `tools/observer-stop-hook.mjs` | N | should fire once per turn — gaps = observer drop |
| `tools/subagent-prompt-prefix.mjs` | N | once per Task-tool call |
| `inline:<sha-16>` | N | inline `node -e "..."` — see settings.json for body |
**Discipline highlights:**
- `tools/observer-stop-hook.mjs` count < turn count → observer skipped turns; cross-check `observerErrorCount` and STATUS.md C5.
- `tools/subagent-prompt-prefix.mjs` count vs `Agent` tool_use count — mismatch = missing pre-flight injection.
- Inline `claude-md`/`schema.sql` guards — fired iff someone touched those files.
## Recommended-node candidates (from `primary_rationale.recommended_node`, schema v3+)
Distinct from `missedActivations` (which aggregates): this is the per-episode signal embedded in each direct episode.
| recommended_node | times direct | top classifications |
|---|---|---|
| #19 | N | feature, planning |
| none (v2 or no recommendation) | N | — |
Cross-reference with `factorMatrix.recommended_node_for_direct` and `missedActivations.byNode`. A persistent (#NN, count > threshold) — strong missed-activation pattern, candidate for retro discussion.
```
- [ ] **Step 5.2: Расширить Missed Activations section в template**
В существующей секции «Missed Activations (Pravila §16.4 v1.36)», в конце добавить:
```markdown
**Schema v3 NB:** since 2026-05-23, each direct episode carries `primary_rationale.recommended_node` directly. The analyzer's `missedActivations` aggregates these into `byNode`/`byClassification`. For per-episode forensics (which prompt, which session), grep episodes-*.jsonl on `"recommended_node":"#NN"`.
```
- [ ] **Step 5.3: Cross-ref note в factor-analysis spec**
В конце `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` добавить (если нет секции «Amendments» — создать):
```markdown
## Amendments
### 2026-05-23 — schema v3 (parser skill/hook expand)
Spec extension: forward-only bump `schema_version` 2 → 3. Two new fields:
- `events[].hook_fired.scripts: { script_name: count, ... }` — reverse-lookup `.claude/settings.json` → имена хук-скриптов. Old `counts` (matcher level) preserved для backward-compat.
- `primary_rationale.recommended_node: "#NN" | null` — для direct-эпизодов derived из `classification-map` + dormancy. null при использованном skill / отсутствии рекомендации / всех dormant.
Analyzer фильтр `schema_version === 2``>= 2`; `missed-activations` фильтр `!== 2``< 2`. FACTOR_FNS +recommended_node_for_direct.
Полный spec: `docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md`.
```
- [ ] **Step 5.4: Markdownlint + cspell**
```bash
npx markdownlint-cli2 --fix \
.claude/skills/brain-retro/references/aggregation-template.md \
docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md
```
Если cspell ругнётся на новые термины — добавить в `cspell-words.txt`.
- [ ] **Step 5.5: Commit**
```bash
git add .claude/skills/brain-retro/references/aggregation-template.md \
docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md \
cspell-words.txt
git commit -m "$(cat <<'EOF'
docs(observer): brain-retro template +hook breakdown + recommended_node
aggregation-template.md gets two new sections (Hook script breakdown,
Recommended-node candidates). factor-analysis spec gets a v3 amendment
cross-ref to the 2026-05-23 spec.
EOF
)"
```
---
## Self-Review checklist (после Task 5, перед handoff)
- [ ] **Spec coverage:** Каждая секция spec'а покрыта?
- hook-resolver → Task 1 ✓
- recommended-node → Task 2 ✓
- parser extension + schema v3 + smoke → Task 3 ✓
- analyzer >=2 + factor axis + missed-activations <2 → Task 4 ✓
- template + cross-ref → Task 5 ✓
- [ ] **Pravila §15.2** Pre-flight sync — Task 0 ✓
- [ ] **Security Guidance #40** — no exec/execSync — все 3 модуля + parser delta используют только readFileSync + JSON.parse + regex ✓
- [ ] **Type consistency:** `recommendNode` (camelCase) везде; `recommended_node` (snake_case) в episode/spec — паттерн `recommendNode → recommended_node` consistent ✓
---
## Risks during execution
| Risk | Mitigation |
|---|---|
| Existing parser test использует custom helper, не описанный здесь | Step 3.1 — прочитать существующий тест-файл, переиспользовать helper. Fallback transcript-фикстуры в Step 3.2 — самодостаточные. |
| `recommendNode` через DI vs file-read — тест из Step 3.2 ожидает реальный classification-map | Тест использует реальный `tools/observer-classification-map.json` (он стабилен и commited). `feature: ['#19']` — это факт, проверено в Step 0 exploration. Dormancy `.node-dormancy.json``#19` non-dormant. |
| lefthook pre-commit может ругнуться на `.mjs` (eslint-vue ignorePaths) | tools/*.mjs уже исключены в lefthook конфиге (прецедент: 32 существующих .mjs скрипта). Если ругнётся — проверить lefthook конфиг до коммита. |
| Изменение `missed-activations` фильтра ломает существующие missed-activation тесты | Тесты в missed-activations.test.mjs используют `schema_version: 2` явно — `>= 2` для них тоже true. Backward-compat preserved. |
| Параллельная Claude-сессия трогает те же файлы | Pre-flight Task 0; если detected — STOP, ребейз/мердж. |
---
## Execution
**Plan complete and saved to `docs/superpowers/plans/2026-05-23-observer-parser-skill-hook-expand.md`. Two execution options:**
**1. Subagent-Driven (recommended)** — controller (этот session) dispatches Sonnet/Opus subagent per Task (Pravila §15.1), reviews commit between Tasks; fast iteration, isolated context per subagent.
**2. Inline Execution** — Tasks 1-5 в этой же session через executing-plans skill; batch с checkpoint между Tasks.
**Какой подход?**