refactor(observer): этап 3 сноса цепочек L — снос chain-detector + chain-map + retrofill
Снята зависимость парсера транскрипта наблюдателя от машинерии L-цепочек: - observer-transcript-parser больше не импортирует observer-chain-detector, не загружает observer-chain-map.json и не пишет primary_rationale.chain_ref; - KNOWN_NODES больше не черпает имена из карты цепочек (источники: known-nodes.txt, маркер direct, форменные правила #NN и плагин:навык); - удалены observer-chain-detector.mjs(+test), observer-chain-map.json, observer-retrofill-chain-ref.mjs(+test). Граница не тронута: recommended_chain/recommended_node/chain_progress/chain_completed, observer-stop-hook, командные цепочки, verifyChain. Полный свод зелёный. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"_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", "L16", "L17"],
|
||||
"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"],
|
||||
"security-go-live": ["L15"],
|
||||
"pdn-152fz-audit": ["L15"],
|
||||
"threat-model": ["L15"],
|
||||
"nuclei": ["L15"],
|
||||
"ward": ["L15"],
|
||||
"owasp-zap": ["L15"],
|
||||
"gitleaks": ["L15"],
|
||||
"semgrep": ["L15"],
|
||||
"trailofbits": ["L15"],
|
||||
"marketing": ["L16"],
|
||||
"marketing-ru": ["L16"],
|
||||
"yandex-metrika-mcp": ["L16"],
|
||||
"yandex-wordstat-mcp": ["L16"],
|
||||
"telegram-mcp": ["L16"],
|
||||
"postiz": ["L16"],
|
||||
"perplexity-mcp": ["L17"],
|
||||
"exa-mcp": ["L17"],
|
||||
"firecrawl-mcp": ["L17"]
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,28 +0,0 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import { readRouterState, extractRouterFields, extractClassifierOutput } from '.
|
||||
import { CLASSIFIER_MODEL } from './router-config.mjs';
|
||||
import { homedir } from 'node:os';
|
||||
import { detectChoiceProvenance, detectAskUserQuestionChoice } from './observer-choice-detector.mjs';
|
||||
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
|
||||
import { buildHookMap, resolveScriptCounts } from './observer-hook-resolver.mjs';
|
||||
// recommendNode / buildClassificationMap / buildDormancyMap были использованы
|
||||
// для слепого fallback на heuristic recommended_node — убрано 2026-05-26
|
||||
@@ -33,13 +32,6 @@ import { JUDGE_PER_CALL_USD } from './cost-pricing.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let CHAIN_MAP = null;
|
||||
try {
|
||||
CHAIN_MAP = loadChainMap();
|
||||
} catch {
|
||||
CHAIN_MAP = new Map(); // битый/отсутствующий JSON -> chainsFor вернёт null, observer не падает
|
||||
}
|
||||
|
||||
let HOOK_MAP = null;
|
||||
function getHookMap() {
|
||||
if (HOOK_MAP) return HOOK_MAP;
|
||||
@@ -57,7 +49,6 @@ function getHookMap() {
|
||||
* the regex on its own would happily slurp into candidates_considered.
|
||||
* Sources, in order:
|
||||
* - tools/observer-known-nodes.txt — bare names (brainstorming, ccpm, …)
|
||||
* - tools/observer-chain-map.json keys — incl. plugin:skill form
|
||||
* - sentinel "direct" (no-skill marker used by node_chosen)
|
||||
* Tooling IDs (#NN) and arbitrary plugin:skill forms pass via regex below.
|
||||
*/
|
||||
@@ -70,9 +61,8 @@ const KNOWN_NODES = (() => {
|
||||
if (t) set.add(t);
|
||||
}
|
||||
} catch {
|
||||
// file missing in some test sandboxes — fall back to chain-map keys only
|
||||
// file missing in some test sandboxes — whitelist falls back to "direct" + regex shapes
|
||||
}
|
||||
if (CHAIN_MAP) for (const node of CHAIN_MAP.keys()) set.add(node);
|
||||
return set;
|
||||
})();
|
||||
|
||||
@@ -960,7 +950,6 @@ export function parseTranscript(transcriptText, fallbackSessionId = null, option
|
||||
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 : []),
|
||||
|
||||
@@ -127,25 +127,6 @@ 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'),
|
||||
|
||||
Reference in New Issue
Block a user