e46295e313
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>
955 lines
36 KiB
Markdown
955 lines
36 KiB
Markdown
# 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.
|
||
|
||
**Какой подход?**
|