diff --git a/.claude/skills/brain-retro/references/aggregation-template.md b/.claude/skills/brain-retro/references/aggregation-template.md index e62c38c9..dbe4c8ee 100644 --- a/.claude/skills/brain-retro/references/aggregation-template.md +++ b/.claude/skills/brain-retro/references/aggregation-template.md @@ -70,10 +70,14 @@ For each factor below, render a table: factor value × outcome counts - `observerErrorCount` from the analyzer — observer_error markers in the period. Non-zero = the observer failed silently somewhere; investigate. -## Canonical chains L1–L12 hit rate +## Canonical chains L1–L13+ hit rate (from analyzer `factorMatrix.chain_ref`) -| chain | times | notes | -|---|---|---| +| chain | times | outcome split | notes | +|---|---|---|---| + +Each node may belong to several L (a multi-chain episode is counted in each). +`null` = episodes outside any chain (`direct` + nodes not in L1–L13+) — **not a +problem** per `memory/feedback_brain_unused_tools_not_problem`. ## Improvised chains (path_type=improvised, repeated ≥2) diff --git a/docs/automation-graph.html b/docs/automation-graph.html index 1d09bea4..e160861c 100644 --- a/docs/automation-graph.html +++ b/docs/automation-graph.html @@ -580,6 +580,40 @@ const NODE_DETAILS = { [{ name: 'billing-audit', cond: 'выручка C6 → налог.база C7' }, { name: 'finance plugin', cond: 'US-механика' }] ), + // ── A1 BACKEND-TOOLING (20.05.2026, ADR-013) ── + rector: nd( + 'Composer dev-dep (Rector + rector-laravel): авто-рефакторинг и version-upgrade PHP-кода — dead-code, code-quality наборы, апгрейды под версию Laravel.', + 'При «обнови/почини/рефактори backend-код», апгрейде Laravel-версии, удалении мёртвого кода. Запуск manual/CI (composer rector / rector:fix).', + 'Composer dev-dep, app/rector.php (deadCode+codeQuality conservative). manual/CI — НЕ блокирующий lefthook (dry-run baseline 16 файлов). Не UI → вне R6/R14. Tooling §4.39 #64, CLAUDE.md §3.3 #64, ADR-013.', + [{ name: 'Tooling', cond: '§4.39 #64 — реестр' }], + [{ name: 'BT1', cond: '↔ Pint трансформация vs стиль' }, { name: 'BT2', cond: '↔ Larastan чинит vs находит' }, { name: 'BT3', cond: '↔ deptrac vs граф слоёв' }], + [{ name: 'PHP Insights', cond: 'backend-quality chain L14' }, { name: 'Larastan', cond: 'L14 типы' }] + ), + php_insights: nd( + 'Composer dev-dep: метрики качества кода — complexity / architecture / maintainability (cyclomatic, code smells, распределение архитектуры).', + 'При «оцени качество/сложность кода», «где код запутан», в портальном аудите. on-demand/CI (composer insights).', + 'Composer dev-dep, app/config/insights.php (SyntaxCheck removed — Windows-краш, style-ось off — владелец Pint). on-demand/CI — НЕ блокирующий (BT9). Не UI → вне R6/R14. Tooling §4.40 #65, CLAUDE.md §3.3 #65, ADR-013.', + [{ name: 'Tooling', cond: '§4.40 #65 — реестр' }], + [{ name: 'BT4', cond: 'style/code оси off — уникум complexity+architecture' }, { name: 'BT9', cond: 'не блокирующий — без четверного гейта' }], + [{ name: 'Rector', cond: 'backend-quality chain L14' }, { name: 'Larastan', cond: 'L14 типы' }] + ), + backend_patterns: nd( + 'Self-authored скил: backend-конвенции Лидерры — слоистость controller→service→job, RLS-aware Eloquent, деньги bcmath/LedgerService, идемпотентные джобы, partition-aware запросы.', + 'При «как писать backend в Лидерре», «паттерн контроллера/сервиса/джоба», scaffolding новой backend-фичи.', + 'Свой project-скил .claude/skills/laravel-backend-patterns/ (линтуется, LINT1). Не UI → вне R6/R14. Tooling §4.41 #66, CLAUDE.md §3.3 #66, ADR-013.', + [{ name: 'Tooling', cond: '§4.41 #66 — реестр' }], + [{ name: 'BT5', cond: '≠ architecture-patterns #38 (generic)' }, { name: 'BT6', cond: '≠ billing-audit #62 (аудит)' }], + [{ name: 'billing-audit', cond: '«как писать» ↔ «аудит денег»' }, { name: 'Boost', cond: 'Eloquent-контекст' }] + ), + nightowl: nd( + 'Self-hosted runtime-телеметрия (laravel/nightwatch + nightowl-agent): коррелированный трейс request↔job↔query↔cache в свой PostgreSQL. DEFERRED.', + 'DEFERRED — при появлении Linux/боевого сервера (Б-1). Сейчас не маршрутизировать (нет pcntl/posix на Windows, OSS без MCP, hosted = 152-ФЗ).', + 'DEFERRED pending-слот (как Sentry #34 / Figma #44 / Jupyter #50). Spike docs/backend/nightowl-spike.md. Не UI → вне R6/R14. Tooling §4.42 #67, CLAUDE.md §3.3 #67, ADR-013.', + [{ name: 'Tooling', cond: '§4.42 #67 — реестр' }], + [{ name: 'BT7', cond: '↔ Sentry трейс vs ошибки' }, { name: 'BT8', cond: '↔ Pail/Boost трейс vs tail/снапшот' }], + [{ name: 'Sentry', cond: 'трейс ↔ ошибки (ADR-013)' }] + ), + // ── СКИЛЫ SUPERPOWERS ──────────────────────────── sk_brainstorm: nd( 'Продумывает задачу вместе с заказчиком, формулирует варианты A/B/C и согласует дизайн до написания кода.', @@ -1807,6 +1841,12 @@ const NODE_META = { billing_audit: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' }, ru_tax: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' }, + // ── A1 BACKEND-TOOLING (20.05.2026, ADR-013) ── + rector: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' }, + php_insights: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' }, + backend_patterns: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' }, + nightowl: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел (DEFERRED)' }, + // ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ── // uses: observer_stophook=31 эпизодов; lh_obs_obs/status_md/obs_cov=112 коммитов с 19.05 // (glob-less, каждый коммит); lh_l1watcher=10, lh_crossref=13 (коммиты по glob с 19.05); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index d9da907a..2cf07844 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-21T01:29:26.077Z +Last updated: 2026-05-21T01:53:48.034Z | Контролёр | Состояние | Детали | |---|---|---| @@ -8,11 +8,12 @@ Last updated: 2026-05-21T01:29:26.077Z | C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files | | C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago | | C4 Сигнальный статус | ✅ | This file (self-reference) | -| C5 Observer-coverage | ✅ | 37 episode(s) this month · Stop-hook + post-commit OK | +| C5 Observer-coverage | ⚠️ | 16 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) | +| C6 Chain map sync | ✅ | [chain-map-checker] OK — 14 chains in sync | ## Метрики (информационные, не алерты) -- Observer evidence: 37 episodes this month, 0 observer_error markers, 36 PII matches before filter +- Observer evidence: 16 episodes this month, 0 observer_error markers, 0 PII matches before filter - Legacy v1 episodes (not in factor analysis): 5 - Last /brain-retro: 2 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). diff --git a/lefthook.yml b/lefthook.yml index 0a9544f9..fff6e345 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -196,6 +196,16 @@ pre-commit: observer-coverage-checker reports a gap (coverage or registration). See docs/observer/STATUS.md C5 row for details. + # 16. observer-chain-map-checker — brain governance C6 (chain attribution). + # Сверяет tools/observer-chain-map.json с таблицей L1-L13 в + # docs/routing-off-phase.md по множествам L-номеров (обе стороны). Блокирует + # коммит при дрейфе: несуществующая L в JSON или потерянная цепочка из .md. + - name: observer-chain-map-checker + run: node tools/observer-chain-map-checker.mjs + fail_text: | + observer-chain-map-checker: дрейф chain-map <-> routing-off-phase.md. + Обновите tools/observer-chain-map.json под таблицу L1-LN. + # Post-commit: regenerate STATUS.md dashboard (informational, not gate) post-commit: parallel: false diff --git a/tools/brain-retro-analyzer.mjs b/tools/brain-retro-analyzer.mjs index 9727b35f..b4ed2008 100644 --- a/tools/brain-retro-analyzer.mjs +++ b/tools/brain-retro-analyzer.mjs @@ -177,6 +177,18 @@ export function buildFactorMatrix(episodesWithOutcome) { matrix[fname][val][outcome] = (matrix[fname][val][outcome] || 0) + 1; } } + // chain_ref is multi-value: a multi-chain episode counts once per chain; + // null/absent → key "null". Handled outside FACTOR_FNS (single-value loop). + matrix.chain_ref = {}; + for (const e of episodesWithOutcome) { + const cr = (e.primary_rationale || {}).chain_ref; + const outcome = e._inferredOutcome || 'unknown'; + const keys = Array.isArray(cr) && cr.length ? cr : ['null']; + for (const k of keys) { + matrix.chain_ref[k] = matrix.chain_ref[k] || {}; + matrix.chain_ref[k][outcome] = (matrix.chain_ref[k][outcome] || 0) + 1; + } + } return matrix; } diff --git a/tools/brain-retro-analyzer.test.mjs b/tools/brain-retro-analyzer.test.mjs index ffb59ce8..ca19b156 100644 --- a/tools/brain-retro-analyzer.test.mjs +++ b/tools/brain-retro-analyzer.test.mjs @@ -230,6 +230,23 @@ describe('buildFactorMatrix — session_segment_turn axis rename (Task 14)', () }); }); +describe('buildFactorMatrix — chain_ref axis (multi-chain)', () => { + it('counts a multi-chain episode in each chain and null for direct', () => { + const m = buildFactorMatrix([ + { _inferredOutcome: 'success', primary_rationale: { node_chosen: 'discovery-interview', chain_ref: ['L1', 'L2'] } }, + { _inferredOutcome: 'unknown', primary_rationale: { node_chosen: 'direct', chain_ref: null } }, + ]); + expect(m.chain_ref.L1).toEqual({ success: 1 }); + expect(m.chain_ref.L2).toEqual({ success: 1 }); + expect(m.chain_ref.null).toEqual({ unknown: 1 }); + }); + + it('chain_ref axis present via analyze()', () => { + const result = analyze([ep({ primary_rationale: { node_chosen: 'billing-audit', chain_ref: ['L13'], task_classification: 'other' } })]); + expect(result.factorMatrix).toHaveProperty('chain_ref'); + }); +}); + describe('inferOutcome — neutral → soft_success (Task 16)', () => { it('returns soft_success when next prompt is neutral', () => { const a = { events: [] }; diff --git a/tools/observer-chain-detector.mjs b/tools/observer-chain-detector.mjs new file mode 100644 index 00000000..606c1de1 --- /dev/null +++ b/tools/observer-chain-detector.mjs @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_MAP_PATH = join(__dirname, 'observer-chain-map.json'); + +/** Load the node->chains map. Throws on missing/invalid JSON (caller handles). */ +export function loadChainMap(path = DEFAULT_MAP_PATH) { + const raw = JSON.parse(readFileSync(path, 'utf8')); + const map = new Map(); + for (const [node, chains] of Object.entries(raw)) { + if (node === '_note') continue; + if (Array.isArray(chains) && chains.length > 0) map.set(node, chains); + } + return map; +} + +/** node_chosen -> array of L-chains, or null if not in any chain. */ +export function chainsFor(node, map) { + if (!node || typeof node !== 'string') return null; + const chains = map.get(node); + return chains && chains.length > 0 ? chains : null; +} diff --git a/tools/observer-chain-detector.test.mjs b/tools/observer-chain-detector.test.mjs new file mode 100644 index 00000000..8ad6708f --- /dev/null +++ b/tools/observer-chain-detector.test.mjs @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { loadChainMap, chainsFor } from './observer-chain-detector.mjs'; + +const map = loadChainMap(); + +describe('chainsFor', () => { + it('returns chain array for a single-chain node', () => { + expect(chainsFor('billing-audit', map)).toEqual(['L13']); + }); + + it('returns all chains for a multi-chain node', () => { + expect(chainsFor('discovery-interview', map)).toEqual(['L1', 'L2']); + }); + + it('returns null for direct', () => { + expect(chainsFor('direct', map)).toBeNull(); + }); + + it('returns null for an unknown node', () => { + expect(chainsFor('totally-unknown-xyz', map)).toBeNull(); + }); + + it('returns null for empty/null/undefined', () => { + expect(chainsFor('', map)).toBeNull(); + expect(chainsFor(null, map)).toBeNull(); + expect(chainsFor(undefined, map)).toBeNull(); + }); + + it('ignores the _note metadata key', () => { + expect(chainsFor('_note', map)).toBeNull(); + }); +}); diff --git a/tools/observer-chain-map-checker.mjs b/tools/observer-chain-map-checker.mjs new file mode 100644 index 00000000..eb53f546 --- /dev/null +++ b/tools/observer-chain-map-checker.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Brain governance controller C6 — chain-map sync checker. + * Verifies tools/observer-chain-map.json against the L1-L13 table in + * docs/routing-off-phase.md. Sync is checked by L-number sets (both + * directions), not by node names — node_chosen values (skill-id) differ + * from the human display names in the .md table. Pure fs/regex, no LLM. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MD_PATH = join(__dirname, '..', 'docs', 'routing-off-phase.md'); +const JSON_PATH = join(__dirname, 'observer-chain-map.json'); + +/** Extract the set of L-numbers ("L1".."L13") from the routing-off-phase.md table. */ +export function parseChainsFromMd(md) { + const set = new Set(); + for (const line of md.split(/\r?\n/)) { + const m = /^\|\s*(L\d+)\s*\|/.exec(line.trim()); + if (m) set.add(m[1]); + } + return set; +} + +/** Compare JSON L-numbers against the md set, both directions. */ +export function checkSync(jsonMap, mdSet) { + const jsonSet = new Set(); + for (const [node, chains] of Object.entries(jsonMap)) { + if (node === '_note') continue; + if (Array.isArray(chains)) for (const c of chains) jsonSet.add(c); + } + const jsonOnly = [...jsonSet].filter((c) => !mdSet.has(c)); // ссылки на несуществующие L + const mdOnly = [...mdSet].filter((c) => !jsonSet.has(c)); // потерянные цепочки + return { ok: jsonOnly.length === 0 && mdOnly.length === 0, jsonOnly, mdOnly }; +} + +/** CLI entry — exit 1 on drift with a human-readable message. */ +function main() { + const md = readFileSync(MD_PATH, 'utf8'); + const jsonMap = JSON.parse(readFileSync(JSON_PATH, 'utf8')); + const mdSet = parseChainsFromMd(md); + if (mdSet.size === 0) { + console.error( + '[chain-map-checker] не нашёл ни одной L-строки в routing-off-phase.md — формат таблицы изменился?' + ); + process.exit(1); + } + const res = checkSync(jsonMap, mdSet); + if (res.ok) { + console.log(`[chain-map-checker] OK — ${mdSet.size} chains in sync`); + process.exit(0); + } + console.error('[chain-map-checker] дрейф маппинга chain-map <-> routing-off-phase.md:'); + if (res.jsonOnly.length) + console.error(` JSON ссылается на отсутствующие в .md цепочки: ${res.jsonOnly.join(', ')}`); + if (res.mdOnly.length) + console.error( + ` В .md есть цепочки без записи в JSON: ${res.mdOnly.join(', ')} — добавьте узлы в tools/observer-chain-map.json` + ); + process.exit(1); +} + +if (process.argv[1]?.endsWith('observer-chain-map-checker.mjs')) { + main(); +} diff --git a/tools/observer-chain-map-checker.test.mjs b/tools/observer-chain-map-checker.test.mjs new file mode 100644 index 00000000..da442766 --- /dev/null +++ b/tools/observer-chain-map-checker.test.mjs @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { parseChainsFromMd, checkSync } from './observer-chain-map-checker.mjs'; + +const SAMPLE_MD = [ + '| # | Цепочка | Зачем |', + '|---|---|---|', + '| L1 | `discovery-interview` (FEATURE) → `brainstorming` | text |', + '| L2 | `audit-portal` | text |', + '| L13 | `billing-audit` (#62) + `Pest` | text |', +].join('\n'); + +describe('parseChainsFromMd', () => { + it('extracts the set of L-numbers from the table', () => { + expect(parseChainsFromMd(SAMPLE_MD)).toEqual(new Set(['L1', 'L2', 'L13'])); + }); +}); + +describe('checkSync', () => { + it('passes when JSON L-numbers subset of md and md subset of json-union', () => { + const mdSet = new Set(['L1', 'L2', 'L13']); + const jsonMap = { a: ['L1'], b: ['L2'], c: ['L13'] }; + expect(checkSync(jsonMap, mdSet).ok).toBe(true); + }); + + it('fails when JSON references a chain absent from md', () => { + const mdSet = new Set(['L1', 'L2']); + const jsonMap = { a: ['L1'], b: ['L99'] }; + const res = checkSync(jsonMap, mdSet); + expect(res.ok).toBe(false); + expect(res.jsonOnly).toContain('L99'); + }); + + it('fails when md has a chain not covered by any JSON entry', () => { + const mdSet = new Set(['L1', 'L2', 'L14']); + const jsonMap = { a: ['L1'], b: ['L2'] }; + const res = checkSync(jsonMap, mdSet); + expect(res.ok).toBe(false); + expect(res.mdOnly).toContain('L14'); + }); + + it('ignores the _note metadata key in the JSON map', () => { + const mdSet = new Set(['L1']); + const jsonMap = { _note: 'meta', a: ['L1'] }; + expect(checkSync(jsonMap, mdSet).ok).toBe(true); + }); +}); diff --git a/tools/observer-chain-map.json b/tools/observer-chain-map.json new file mode 100644 index 00000000..e13a3acb --- /dev/null +++ b/tools/observer-chain-map.json @@ -0,0 +1,41 @@ +{ + "_note": "node_chosen -> L-цепочки. Только узлы, входящие хотя бы в одну L1-L13. Узлы вне цепочек (direct, прочее) НЕ включаются -> chainsFor вернёт null. Имена ключей = реальные значения primary_rationale.node_chosen (skill-id из skill_invoked). MCP/agent-узлы (laravel-boost, openapi-mcp-server, api-docs, sentry-mcp, redis-mcp, pest, github-mcp) в node_chosen не появляются, но включены для полноты покрытия цепочек L1-L13 (контролёр C6 требует, чтобы каждая L из routing-off-phase.md была покрыта). Синхронизируется с docs/routing-off-phase.md через tools/observer-chain-map-checker.mjs.", + "discovery-interview": ["L1", "L2"], + "superpowers:brainstorming": ["L1"], + "superpowers:writing-plans": ["L1"], + "superpowers:subagent-driven-development": ["L1"], + "audit-portal": ["L2"], + "process-analysis": ["L3"], + "process-modeling": ["L3", "L4"], + "mermaid": ["L4"], + "adr-kit:adr": ["L4", "L5"], + "adr-kit:judge": ["L5"], + "operations": ["L4"], + "architecture-patterns:architecture-patterns": ["L5"], + "deptrac": ["L5", "L14"], + "rector": ["L14"], + "php-insights": ["L14"], + "larastan": ["L14"], + "laravel-backend-patterns": ["L14"], + "security-review": ["L6"], + "openapi-mcp-server": ["L7"], + "api-docs": ["L7"], + "laravel-boost": ["L7", "L13"], + "superpowers:systematic-debugging": ["L8"], + "sentry-mcp": ["L8", "L13"], + "redis-mcp": ["L8", "L13"], + "ccpm": ["L9"], + "product-management:brainstorm": ["L9"], + "github-mcp": ["L9"], + "promptfoo": ["L10"], + "data-scientist": ["L10"], + "claude-api": ["L10"], + "skill-creator:skill-creator": ["L11"], + "hookify:hookify": ["L11"], + "plugin-dev:create-plugin": ["L11"], + "claude-md-management:claude-md-improver": ["L12"], + "claude-md-management:revise-claude-md": ["L12"], + "billing-audit": ["L13"], + "pest": ["L13"], + "ru-tax-accounting": ["L13"] +} diff --git a/tools/observer-retrofill-chain-ref.mjs b/tools/observer-retrofill-chain-ref.mjs new file mode 100644 index 00000000..3d6df8d2 --- /dev/null +++ b/tools/observer-retrofill-chain-ref.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * One-shot retrofill: add primary_rationale.chain_ref to existing v2 episodes + * in docs/observer/episodes-*.jsonl. Idempotent (skips lines that already have + * chain_ref), atomic per file (tmp + rename). Pure fs, no LLM. + * + * Usage: node tools/observer-retrofill-chain-ref.mjs [--dry-run] + */ +import { readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { loadChainMap, chainsFor } from './observer-chain-detector.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OBS_DIR = join(__dirname, '..', 'docs', 'observer'); + +/** Add chain_ref to a single parsed episode object (pure). Idempotent. */ +export function retrofillLine(ep, map) { + if (!ep || ep.schema_version !== 2 || !ep.primary_rationale) return ep; + if ('chain_ref' in ep.primary_rationale) return ep; // idempotent + ep.primary_rationale.chain_ref = chainsFor(ep.primary_rationale.node_chosen, map); + return ep; +} + +/** Process one JSONL file atomically (tmp + rename). Returns {changed, total}. */ +export function retrofillFile(path, map, { dryRun = false } = {}) { + const lines = readFileSync(path, 'utf8').split(/\r?\n/); + let changed = 0; + let total = 0; + const out = lines.map((line) => { + if (!line.trim()) return line; + total++; + const ep = JSON.parse(line); + const before = ep.primary_rationale && 'chain_ref' in ep.primary_rationale; + const next = retrofillLine(ep, map); + const after = next.primary_rationale && 'chain_ref' in next.primary_rationale; + if (!before && after) changed++; + return JSON.stringify(next); + }); + if (!dryRun && changed > 0) { + const tmp = `${path}.tmp`; + writeFileSync(tmp, out.join('\n'), 'utf8'); + renameSync(tmp, path); + } + return { changed, total }; +} + +function main() { + const dryRun = process.argv.includes('--dry-run'); + const map = loadChainMap(); + const files = readdirSync(OBS_DIR).filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f)); + for (const f of files) { + const { changed, total } = retrofillFile(join(OBS_DIR, f), map, { dryRun }); + console.log(`${dryRun ? '[dry-run] ' : ''}${f}: ${changed}/${total} lines get chain_ref`); + } +} + +if (process.argv[1]?.endsWith('observer-retrofill-chain-ref.mjs')) main(); diff --git a/tools/observer-retrofill-chain-ref.test.mjs b/tools/observer-retrofill-chain-ref.test.mjs new file mode 100644 index 00000000..9ba74107 --- /dev/null +++ b/tools/observer-retrofill-chain-ref.test.mjs @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { retrofillLine } from './observer-retrofill-chain-ref.mjs'; +import { loadChainMap } from './observer-chain-detector.mjs'; + +const map = loadChainMap(); + +describe('retrofillLine', () => { + it('adds chain_ref to a v2 episode with a known node', () => { + const ep = { schema_version: 2, primary_rationale: { node_chosen: 'billing-audit' } }; + const out = retrofillLine(ep, map); + expect(out.primary_rationale.chain_ref).toEqual(['L13']); + }); + + it('sets chain_ref null for a direct v2 episode', () => { + const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct' } }; + expect(retrofillLine(ep, map).primary_rationale.chain_ref).toBeNull(); + }); + + it('is idempotent — does not overwrite existing chain_ref', () => { + const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct', chain_ref: ['L1'] } }; + expect(retrofillLine(ep, map).primary_rationale.chain_ref).toEqual(['L1']); + }); + + it('skips v1 episodes (no schema_version 2)', () => { + const ep = { foo: 'bar' }; + expect(retrofillLine(ep, map)).toEqual({ foo: 'bar' }); + }); +}); diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 748f3b03..e50113ad 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -16,6 +16,14 @@ */ import { detectChoiceProvenance, detectAskUserQuestionChoice } from './observer-choice-detector.mjs'; +import { loadChainMap, chainsFor } from './observer-chain-detector.mjs'; + +let CHAIN_MAP = null; +try { + CHAIN_MAP = loadChainMap(); +} catch { + CHAIN_MAP = new Map(); // битый/отсутствующий JSON -> chainsFor вернёт null, observer не падает +} const SUPERPOWERS_PREFIX = 'superpowers:'; @@ -694,6 +702,7 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) { return { step: 1, node_chosen: skills.length > 0 ? skills[0] : 'direct', + chain_ref: chainsFor(skills.length > 0 ? skills[0] : 'direct', CHAIN_MAP), triggers_matched: merge(extractTriggers(turn), tag ? tag.triggers : []), candidates_considered: merge(extractCandidates(turn), tag ? tag.candidates : []), boundaries_applied: merge(extractBoundaries(turn), tag ? tag.boundaries : []), diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index a7df1560..09ac10e1 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -106,6 +106,25 @@ describe('parseTranscript', () => { expect(parseTranscript(t).primary_rationale.node_chosen).toBe('direct'); }); + it('attaches chain_ref for a node that belongs to a chain', () => { + const t = jsonl([ + userPrompt('go', '2026-05-19T10:00:00Z'), + assistantTurn( + [{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'billing-audit' } }], + '2026-05-19T10:01:00Z' + ), + ]); + expect(parseTranscript(t).primary_rationale.chain_ref).toEqual(['L13']); + }); + + it('sets chain_ref null for a direct episode', () => { + const t = jsonl([ + userPrompt('go', '2026-05-19T10:00:00Z'), + assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'), + ]); + expect(parseTranscript(t).primary_rationale.chain_ref).toBeNull(); + }); + it('hard_floor invoked when a superpowers skill is used', () => { const t = jsonl([ userPrompt('go', '2026-05-19T10:00:00Z'), diff --git a/tools/status-md-generator.mjs b/tools/status-md-generator.mjs index 86ea9bfe..b5feafed 100644 --- a/tools/status-md-generator.mjs +++ b/tools/status-md-generator.mjs @@ -10,6 +10,7 @@ function iconFor(status) { export function renderStatus(inputs) { const { now, c1, c2, c3, c5, observer, lastRetroDaysAgo } = inputs; + const c6 = inputs.c6 || { status: 'ok', detail: '—' }; const retroLine = (lastRetroDaysAgo === null || lastRetroDaysAgo === undefined) ? 'never' : `${lastRetroDaysAgo} day(s) ago`; @@ -24,6 +25,7 @@ Last updated: ${now} | C3 Observer-of-observer | ${iconFor(c3.status)} | ${c3.detail || '—'} | | C4 Сигнальный статус | ✅ | This file (self-reference) | | C5 Observer-coverage | ${iconFor(c5.status)} | ${c5.detail || '—'} | +| C6 Chain map sync | ${iconFor(c6.status)} | ${c6.detail || '—'} | ## Метрики (информационные, не алерты) @@ -114,6 +116,7 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/status-md- status: c5ok ? 'ok' : 'warn', detail: [cov.coverage.detail, cov.registration.detail].join(' · '), }, + c6: runControllerNode(['tools/observer-chain-map-checker.mjs']), observer: { episodeCount: countEpisodes(), observerErrors: countObserverErrors(), diff --git a/tools/status-md-generator.test.mjs b/tools/status-md-generator.test.mjs index c4c861db..daefdf5e 100644 --- a/tools/status-md-generator.test.mjs +++ b/tools/status-md-generator.test.mjs @@ -7,6 +7,7 @@ const baseInputs = (overrides = {}) => ({ c2: { status: 'ok', detail: '0 version drift' }, c3: { status: 'ok', detail: 'last read today' }, c5: { status: 'ok', detail: 'coverage OK · registration OK' }, + c6: { status: 'ok', detail: '14 chains in sync' }, observer: { episodeCount: 12, observerErrors: 0, piiMatches: 0 }, ...overrides, }); @@ -23,6 +24,11 @@ describe('renderStatus', () => { expect(md).toContain('12 episodes'); }); + it('includes a C6 chain-map row', () => { + const md = renderStatus(baseInputs()); + expect(md).toContain('| C6 Chain map sync | ✅'); + }); + it('shows a warn status for the coverage controller', () => { const md = renderStatus(baseInputs({ c5: { status: 'warn', detail: '3 commits, 0 episodes' } })); expect(md).toContain('| C5 Observer-coverage | ⚠️');