Merge remote-tracking branch 'origin/main' into feat/project-migration-redesign
# Conflicts: # docs/observer/STATUS.md # docs/observer/episodes-2026-05.jsonl
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 : []),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 | ⚠️');
|
||||
|
||||
Reference in New Issue
Block a user