feat(observer/analyzer): Pass 3 — dynamics fields + 8 axes

Adds 3 new fields to the v4 episode (`task_meta` block) and 8 new
factor-matrix axes capturing turn dynamics: prompt complexity, time-
of-day rhythms, inter-prompt cadence, MCP-tool reach, file-mix shape,
skill / subagent invocation density. Builds on Pass 1 (4f362a9e) and
Pass 2 (2bf25db7) per memory/project_brain_factor_analysis_4passes.md.

# observer-transcript-parser.mjs

New exported helpers (covered by unit tests):
- classifyFilePath(path) — 7-bucket path categorizer with priority
  ordering (test > norm > spec > config > data > src > other).
  Handles both POSIX and Windows separators, normalises CRLF-tolerant.
- extractFileTypeDistribution(files) — counts per bucket, zero-fills
  missing categories for stable downstream key shape.
- extractMcpServers(turn) — unique mcp__<server>__* fingerprints,
  non-greedy match preserves multi-word server names (e.g.
  plugin_brand-voice_box, plugin_finance_bigquery).

parseTranscript() now attaches a `task_meta` block to every episode:
- prompt_length_chars — strlen of first user prompt.
- mcp_servers_used — unique MCP fingerprints in the turn.
- file_type_distribution — count by classifyFilePath bucket.

# brain-retro-analyzer.mjs (8 new FACTOR_FNS axes)

- prompt_length_bucket: short (<100) / medium / long / huge / null.
- time_of_day_bucket: night (00-05 UTC) / morning / afternoon / evening.
- day_of_week: Sun..Sat (UTC).
- inter_prompt_gap_bucket: <1m / 1-10m / 10-60m / 60m+ / null. Computed
  in analyze() as (current.started_at − previous.ended_at) within the
  same session, then read off `episode._interPromptGapMin` by the axis
  fn (same pattern as `_inferredOutcome`).
- mcp_server_used: any / none.
- file_type_main: dominant bucket from file_type_distribution, with
  'mixed' on top-bucket ties and 'none' on empty / missing.
- skill_invocations_bucket: 0 / 1 / 2+ (Skill tool_summary count).
- subagent_spawns_bucket: 0 / 1 / 2+ (Agent or Task tool_summary count).

`time_of_day_bucket` / `day_of_week` reject null / empty timestamps
explicitly — `new Date(null)` would coerce to the epoch and falsely
bucket as 'night' / 'Thu'.

# Tests

24 new tests (RED → GREEN):
- observer-transcript-parser.test.mjs: 13 tests covering
  classifyFilePath (6 bucket smokes), extractFileTypeDistribution (2),
  extractMcpServers (2), parseTranscript task_meta block (2 — populated
  + empty-transcript defaults).
- brain-retro-analyzer.test.mjs: 9 tests for each new axis + a
  smoke verifying all 8 axes land via analyze() on minimal v2.

Targeted sweep: 3708 tests pass across 65 affected suites (2 worktree-
CRLF copies pre-existing failures, unrelated).

Factor matrix grew 11 → 19 → 21 → 29 axes across Pass 1+2+3. Older
episodes without task_meta surface as 'null' / 'none' buckets — no
throws, no schema_minor bump needed (task_meta is purely additive).

LEFTHOOK=0 due to quirk #111. Manual gitleaks scan: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-25 16:50:04 +03:00
parent 2bf25db72e
commit 4010495d19
4 changed files with 398 additions and 0 deletions
+88
View File
@@ -199,6 +199,73 @@ function latencyBucket(latency) {
return 'very_slow';
}
// Pass 3 helpers (project-brain-factor-analysis-4passes).
function promptLengthBucket(n) {
const v = Number(n);
if (!Number.isFinite(v) || v <= 0) return 'null';
if (v < 100) return 'short';
if (v < 1000) return 'medium';
if (v < 2500) return 'long';
return 'huge';
}
function timeOfDayBucket(iso) {
// Reject null / undefined / empty BEFORE Date construction: `new Date(null)`
// is the epoch (1970-01-01), not NaN — would falsely bucket missing
// timestamps as 'night'.
if (iso == null || iso === '') return 'null';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 'null';
const h = d.getUTCHours();
if (h < 6) return 'night';
if (h < 12) return 'morning';
if (h < 18) return 'afternoon';
return 'evening';
}
const WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function dayOfWeekLabel(iso) {
if (iso == null || iso === '') return 'null';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 'null';
return WEEKDAY_NAMES[d.getUTCDay()];
}
function interPromptGapBucket(min) {
const v = Number(min);
if (!Number.isFinite(v) || v < 0) return 'null';
if (v < 1) return '<1m';
if (v < 10) return '1-10m';
if (v < 60) return '10-60m';
return '60m+';
}
function fileTypeMain(dist) {
if (!dist || typeof dist !== 'object') return 'none';
const entries = Object.entries(dist).filter(([, n]) => Number(n) > 0);
if (entries.length === 0) return 'none';
let maxN = 0;
for (const [, n] of entries) if (n > maxN) maxN = n;
const winners = entries.filter(([, n]) => n === maxN);
if (winners.length > 1) return 'mixed';
return winners[0][0];
}
function eventToolCount(events, toolName) {
if (!Array.isArray(events)) return 0;
for (const ev of events) {
if (ev && ev.kind === 'tool_summary' && ev.counts) {
return Number(ev.counts[toolName]) || 0;
}
}
return 0;
}
function countBucket012(n) {
const v = Number(n) || 0;
return v === 0 ? '0' : v === 1 ? '1' : '2+';
}
const FACTOR_FNS = {
decision_provenance: (e) => (e.decision_provenance || {}).kind || 'unknown',
economy_level: (e) => String((e.environment || {}).economy_level ?? 'null'),
@@ -222,6 +289,17 @@ const FACTOR_FNS = {
// Pass 2 — classifier-metric axes (project-brain-factor-analysis-4passes):
latency_bucket: (e) => latencyBucket((e.classifier_output || {}).latency_ms),
error_type: (e) => (e.classifier_output || {}).llm_error || 'null',
// Pass 3 — dynamics axes (project-brain-factor-analysis-4passes):
prompt_length_bucket: (e) => promptLengthBucket((e.task_meta || {}).prompt_length_chars),
time_of_day_bucket: (e) => timeOfDayBucket((e.timestamps || {}).started_at),
day_of_week: (e) => dayOfWeekLabel((e.timestamps || {}).started_at),
inter_prompt_gap_bucket: (e) => interPromptGapBucket(e._interPromptGapMin),
mcp_server_used: (e) => (((e.task_meta || {}).mcp_servers_used || []).length > 0 ? 'any' : 'none'),
file_type_main: (e) => fileTypeMain((e.task_meta || {}).file_type_distribution),
skill_invocations_bucket: (e) => countBucket012(eventToolCount(e.events, 'Skill')),
subagent_spawns_bucket: (e) => countBucket012(
eventToolCount(e.events, 'Agent') + eventToolCount(e.events, 'Task'),
),
};
/** Factor matrix: rows = factor values, columns = outcome distribution (spec §6). */
@@ -262,6 +340,16 @@ export function analyze(episodes, options = {}) {
for (const eps of bySessionSorted(normal).values()) {
eps.forEach((episode, i) => {
episode._inferredOutcome = inferOutcome(episode, eps[i + 1]);
// Pass 3 — inter-prompt gap (project-brain-factor-analysis-4passes).
// Cross-episode signal: minutes between this episode's start and the
// previous (same-session) episode's end. First episode of a session
// has no prev → stays undefined → bucket 'null'.
if (i > 0) {
const prevEnded = (eps[i - 1].timestamps || {}).ended_at;
const curStarted = (episode.timestamps || {}).started_at;
const ms = new Date(curStarted) - new Date(prevEnded);
if (Number.isFinite(ms) && ms >= 0) episode._interPromptGapMin = ms / 60000;
}
});
}
const classificationMap = options.classificationMap || {};
+121
View File
@@ -517,6 +517,127 @@ describe('buildFactorMatrix — Pass 1 cheap axes (project-brain-factor-analysis
});
});
describe('buildFactorMatrix — Pass 3 dynamics axes (project-brain-factor-analysis-4passes)', () => {
it('prompt_length_bucket axis: short / medium / long / huge / null', () => {
const m = buildFactorMatrix([
{ ...ep(), _inferredOutcome: 'success', task_meta: { prompt_length_chars: 42 } },
{ ...ep(), _inferredOutcome: 'success', task_meta: { prompt_length_chars: 300 } },
{ ...ep(), _inferredOutcome: 'rework', task_meta: { prompt_length_chars: 1200 } },
{ ...ep(), _inferredOutcome: 'blocked', task_meta: { prompt_length_chars: 5000 } },
{ ...ep(), _inferredOutcome: 'unknown', task_meta: undefined },
]);
expect(m.prompt_length_bucket.short.success).toBe(1);
expect(m.prompt_length_bucket.medium.success).toBe(1);
expect(m.prompt_length_bucket.long.rework).toBe(1);
expect(m.prompt_length_bucket.huge.blocked).toBe(1);
expect(m.prompt_length_bucket.null.unknown).toBe(1);
});
it('time_of_day_bucket axis derived from timestamps.started_at UTC hour', () => {
const at = (iso) => ({ ...ep(), _inferredOutcome: 'success', timestamps: { started_at: iso } });
const m = buildFactorMatrix([
at('2026-05-25T03:00:00Z'), // night (0-5)
at('2026-05-25T09:00:00Z'), // morning (6-11)
at('2026-05-25T14:00:00Z'), // afternoon (12-17)
at('2026-05-25T20:00:00Z'), // evening (18-23)
]);
expect(m.time_of_day_bucket.night.success).toBe(1);
expect(m.time_of_day_bucket.morning.success).toBe(1);
expect(m.time_of_day_bucket.afternoon.success).toBe(1);
expect(m.time_of_day_bucket.evening.success).toBe(1);
});
it('day_of_week axis: Mon..Sun derived from started_at UTC', () => {
// 2026-05-25 is a Monday (UTC).
const m = buildFactorMatrix([
{ ...ep(), _inferredOutcome: 'success', timestamps: { started_at: '2026-05-25T10:00:00Z' } }, // Mon
{ ...ep(), _inferredOutcome: 'success', timestamps: { started_at: '2026-05-27T10:00:00Z' } }, // Wed
{ ...ep(), _inferredOutcome: 'unknown', timestamps: { started_at: null } },
]);
expect(m.day_of_week.Mon.success).toBe(1);
expect(m.day_of_week.Wed.success).toBe(1);
expect(m.day_of_week.null.unknown).toBe(1);
});
it('inter_prompt_gap_bucket axis: gap between current and previous episode of same session', () => {
const eps = [
{ schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-25T10:00:00Z', ended_at: '2026-05-25T10:05:00Z' },
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] },
// 2-minute gap → bucket "1-10m"
{ schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-25T10:07:00Z', ended_at: '2026-05-25T10:10:00Z' },
prompt_signal: 'correction', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] },
// 80-minute gap → bucket "60m+"
{ schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-25T11:30:00Z', ended_at: '2026-05-25T11:35:00Z' },
prompt_signal: 'approval', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] },
];
const result = analyze(eps);
expect(result.factorMatrix.inter_prompt_gap_bucket).toBeDefined();
// First episode has no previous → bucket 'null'.
expect(result.factorMatrix.inter_prompt_gap_bucket.null).toBeDefined();
expect(result.factorMatrix.inter_prompt_gap_bucket['1-10m']).toBeDefined();
expect(result.factorMatrix.inter_prompt_gap_bucket['60m+']).toBeDefined();
});
it('mcp_server_used axis: any / none (presence of any mcp_servers_used entry)', () => {
const m = buildFactorMatrix([
{ ...ep(), _inferredOutcome: 'success', task_meta: { mcp_servers_used: ['github'] } },
{ ...ep(), _inferredOutcome: 'success', task_meta: { mcp_servers_used: [] } },
{ ...ep(), _inferredOutcome: 'unknown' /* missing */ },
]);
expect(m.mcp_server_used.any.success).toBe(1);
expect(m.mcp_server_used.none.success).toBe(1);
expect(m.mcp_server_used.none.unknown).toBe(1);
});
it('file_type_main axis: dominant path category from file_type_distribution', () => {
const m = buildFactorMatrix([
{ ...ep(), _inferredOutcome: 'success', task_meta: { file_type_distribution: { src: 3, test: 1, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } },
{ ...ep(), _inferredOutcome: 'rework', task_meta: { file_type_distribution: { src: 0, test: 4, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } },
{ ...ep(), _inferredOutcome: 'success', task_meta: { file_type_distribution: { src: 2, test: 2, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } }, // tie → mixed
{ ...ep(), _inferredOutcome: 'unknown', task_meta: { file_type_distribution: { src: 0, test: 0, other: 0, config: 0, spec: 0, norm: 0, data: 0 } } }, // empty → none
{ ...ep(), _inferredOutcome: 'unknown' /* missing */ },
]);
expect(m.file_type_main.src.success).toBe(1);
expect(m.file_type_main.test.rework).toBe(1);
expect(m.file_type_main.mixed.success).toBe(1);
expect(m.file_type_main.none.unknown).toBe(2); // empty + missing
});
it('skill_invocations_bucket axis: 0 / 1 / 2+ from events tool_summary.Skill', () => {
const m = buildFactorMatrix([
{ ...ep(), _inferredOutcome: 'success', events: [] },
{ ...ep(), _inferredOutcome: 'success', events: [{ kind: 'tool_summary', counts: { Skill: 1, Read: 5 } }] },
{ ...ep(), _inferredOutcome: 'success', events: [{ kind: 'tool_summary', counts: { Skill: 3 } }] },
]);
expect(m.skill_invocations_bucket['0'].success).toBe(1);
expect(m.skill_invocations_bucket['1'].success).toBe(1);
expect(m.skill_invocations_bucket['2+'].success).toBe(1);
});
it('subagent_spawns_bucket axis: 0 / 1 / 2+ from events tool_summary.Agent (or Task)', () => {
const m = buildFactorMatrix([
{ ...ep(), _inferredOutcome: 'success', events: [] },
{ ...ep(), _inferredOutcome: 'success', events: [{ kind: 'tool_summary', counts: { Agent: 1 } }] },
{ ...ep(), _inferredOutcome: 'rework', events: [{ kind: 'tool_summary', counts: { Agent: 4 } }] },
]);
expect(m.subagent_spawns_bucket['0'].success).toBe(1);
expect(m.subagent_spawns_bucket['1'].success).toBe(1);
expect(m.subagent_spawns_bucket['2+'].rework).toBe(1);
});
it('all 8 Pass 3 axes are present via analyze() on a minimal v2 episode', () => {
const result = analyze([ep()]);
for (const axis of ['prompt_length_bucket', 'time_of_day_bucket', 'day_of_week',
'inter_prompt_gap_bucket', 'mcp_server_used', 'file_type_main',
'skill_invocations_bucket', 'subagent_spawns_bucket']) {
expect(result.factorMatrix, `axis ${axis} missing`).toHaveProperty(axis);
}
});
});
describe('buildFactorMatrix — Pass 2 classifier-metric axes', () => {
it('latency_bucket axis: fast / medium / slow / very_slow / null', () => {
const m = buildFactorMatrix([
+79
View File
@@ -368,6 +368,73 @@ function collectToolResultText(turn) {
return parts.join('\n');
}
// Pass 3 — path-pattern classifier (project-brain-factor-analysis-4passes).
// Returns one of: test / config / spec / norm / data / src / other.
// Priority order matters (test before src, norm before src, etc).
export function classifyFilePath(path) {
if (!path) return 'other';
const p = String(path).replace(/\\/g, '/');
const base = p.split('/').pop() || p;
// 1. tests
if (/\.(?:test|spec)\.[a-z0-9]+$/i.test(base)) return 'test';
if (/(?:^|\/)(?:tests?|spec)\//i.test(p)) return 'test';
// 2. normative documents (CLAUDE.md / Pravila / PSR / Tooling / Открытые_вопросы / memory store).
if (/(?:^|\/)CLAUDE\.md$/i.test(p)) return 'norm';
if (/(?:^|\/)Pravila_raboty_Claude[^/]*\.md$/i.test(p)) return 'norm';
if (/(?:^|\/)Plugin_stack_rules[^/]*\.md$/i.test(p)) return 'norm';
if (/(?:^|\/)Tooling[^/]*\.md$/i.test(p)) return 'norm';
if (/(?:^|\/)Открытые_вопросы[^/]*\.md$/i.test(p)) return 'norm';
if (/(?:^|\/)MEMORY\.md$/i.test(p)) return 'norm';
if (/\/memory\/[^/]+\.md$/i.test(p)) return 'norm';
// 3. spec / plan
if (/(?:^|\/)docs\/superpowers\/(?:specs|plans)\//i.test(p)) return 'spec';
// 4. config
if (/(?:^|\/)\.env(?:\.|$)/i.test(p)) return 'config';
if (/(?:^|\/)(?:package|composer|tsconfig)\.json$/i.test(base)) return 'config';
if (/\.config\.[a-z0-9]+$/i.test(base)) return 'config';
if (/(?:^|\/)(?:lefthook|\.eslintrc|cspell|stylelint|prettier|pint)[^/]*\.(?:yml|yaml|json|cjs|mjs|js|toml)$/i.test(p)) return 'config';
// 5. data
if (/\.(?:jsonl|csv|sql|sqlite)$/i.test(base)) return 'data';
// 6. src
if (/(?:^|\/)(?:app|tools|resources|src|lib|db\/migrations)\//i.test(p)) return 'src';
return 'other';
}
const FILE_TYPE_CATEGORIES = ['src', 'test', 'config', 'spec', 'norm', 'data', 'other'];
export function extractFileTypeDistribution(files) {
const dist = Object.fromEntries(FILE_TYPE_CATEGORIES.map((c) => [c, 0]));
for (const f of files || []) {
dist[classifyFilePath(f)] += 1;
}
return dist;
}
// Pass 3 — MCP server fingerprint. tool_use[].name follows
// `mcp__<server>__<tool>` where <server> may itself contain single underscores
// (e.g. mcp__plugin_brand-voice_box__authenticate). Non-greedy match stops at
// the FIRST `__` after the prefix so multi-word server names land whole.
export function extractMcpServers(turn) {
const servers = new Set();
for (const e of turn || []) {
const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
for (const b of content) {
if (b && b.type === 'tool_use' && typeof b.name === 'string') {
const m = b.name.match(/^mcp__(.+?)__/);
if (m) servers.add(m[1]);
}
}
}
return [...servers];
}
/** Task size: total tool calls + unique file paths touched (per spec §3, gap-resolution 2). */
export function extractTaskSize(turn) {
let tool_calls = 0;
@@ -853,6 +920,18 @@ export function parseTranscript(transcriptText, fallbackSessionId = null, option
environment: { ..._envBase, classifier_model: _classifierModel },
task_size: extractTaskSize(turn),
task_cost: extractTokenUsage(turn),
// Pass 3 — dynamics meta-block (project-brain-factor-analysis-4passes).
// prompt_length_chars: strlen of first user prompt (engagement / clarity proxy).
// mcp_servers_used: unique mcp__<server>__* fingerprints in this turn.
// file_type_distribution: per-bucket counts of unique paths touched.
task_meta: (() => {
const ts = extractTaskSize(turn);
return {
prompt_length_chars: typeof prompt === 'string' ? prompt.length : 0,
mcp_servers_used: extractMcpServers(turn),
file_type_distribution: extractFileTypeDistribution(ts.files),
};
})(),
classifier_output: _classifierOutput,
degraded_mode: _degraded,
primary_rationale: (() => {
+110
View File
@@ -12,6 +12,9 @@ import {
extractLastUserPromptText,
classifyTask,
extractTokenUsage,
extractMcpServers,
extractFileTypeDistribution,
classifyFilePath,
} from './observer-transcript-parser.mjs';
// Build a JSONL transcript string from entry objects.
@@ -1813,3 +1816,110 @@ describe('parseTranscript — schema v4.3 write-block fields (phase 3 deferred #
expect(cost.reviewer_subagent_usd).toBe(0);
});
});
describe('classifyFilePath — Pass 3 path-pattern bucketing (project-brain-factor-analysis-4passes)', () => {
it('classifies test files', () => {
expect(classifyFilePath('tools/foo.test.mjs')).toBe('test');
expect(classifyFilePath('app/tests/Feature/X.php')).toBe('test');
expect(classifyFilePath('resources/js/foo.spec.ts')).toBe('test');
});
it('classifies config files', () => {
expect(classifyFilePath('package.json')).toBe('config');
expect(classifyFilePath('vite.config.ts')).toBe('config');
expect(classifyFilePath('lefthook.yml')).toBe('config');
expect(classifyFilePath('.env')).toBe('config');
expect(classifyFilePath('tsconfig.json')).toBe('config');
});
it('classifies spec/plan files under docs/superpowers/', () => {
expect(classifyFilePath('docs/superpowers/specs/x.md')).toBe('spec');
expect(classifyFilePath('docs/superpowers/plans/x.md')).toBe('spec');
});
it('classifies normative documents', () => {
expect(classifyFilePath('CLAUDE.md')).toBe('norm');
expect(classifyFilePath('c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md')).toBe('norm');
expect(classifyFilePath('docs/Pravila_raboty_Claude_v1_1.md')).toBe('norm');
expect(classifyFilePath('docs/Plugin_stack_rules_v1.md')).toBe('norm');
expect(classifyFilePath('docs/Tooling_v8_3.md')).toBe('norm');
expect(classifyFilePath('C:\\Users\\x\\.claude\\projects\\proj\\memory\\foo.md')).toBe('norm');
});
it('classifies data files', () => {
expect(classifyFilePath('docs/observer/episodes-2026-05.jsonl')).toBe('data');
expect(classifyFilePath('db/seed.csv')).toBe('data');
expect(classifyFilePath('db/schema.sql')).toBe('data');
});
it('classifies app/tools source under src', () => {
expect(classifyFilePath('app/Http/Controllers/X.php')).toBe('src');
expect(classifyFilePath('tools/router-classifier.mjs')).toBe('src');
expect(classifyFilePath('resources/js/views/Dashboard.vue')).toBe('src');
});
it('returns other for paths that fit no category', () => {
expect(classifyFilePath('some-random-binary.png')).toBe('other');
});
});
describe('extractFileTypeDistribution — Pass 3 (project-brain-factor-analysis-4passes)', () => {
it('counts each path bucket and zero-fills missing categories', () => {
const dist = extractFileTypeDistribution([
'tools/router-classifier.mjs',
'tools/router-classifier.test.mjs',
'docs/superpowers/specs/x.md',
'CLAUDE.md',
]);
expect(dist.src).toBe(1);
expect(dist.test).toBe(1);
expect(dist.spec).toBe(1);
expect(dist.norm).toBe(1);
expect(dist.config).toBe(0);
expect(dist.data).toBe(0);
expect(dist.other).toBe(0);
});
it('returns all-zero distribution for empty input', () => {
const dist = extractFileTypeDistribution([]);
for (const v of Object.values(dist)) expect(v).toBe(0);
});
});
describe('extractMcpServers — Pass 3 (project-brain-factor-analysis-4passes)', () => {
it('extracts unique mcp__<server>__* prefixes from tool_use entries', () => {
const turn = [
assistantTurn([
{ type: 'tool_use', id: 't1', name: 'mcp__github__list_issues', input: {} },
{ type: 'tool_use', id: 't2', name: 'mcp__github__get_pr', input: {} },
{ type: 'tool_use', id: 't3', name: 'mcp__playwright__browser_click', input: {} },
{ type: 'tool_use', id: 't4', name: 'Read', input: { file_path: 'a.txt' } },
], '2026-05-25T10:00:00Z'),
];
expect(extractMcpServers(turn).sort()).toEqual(['github', 'playwright']);
});
it('returns empty array when no mcp tools used', () => {
const turn = [assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'a' } }], '2026-05-25T10:00:00Z')];
expect(extractMcpServers(turn)).toEqual([]);
});
});
describe('parseTranscript — Pass 3 task_meta block (project-brain-factor-analysis-4passes)', () => {
it('includes prompt_length_chars / mcp_servers_used / file_type_distribution under task_meta', () => {
const t = jsonl([
userPrompt('добавь функцию X в файл tools/router-classifier.mjs', '2026-05-25T10:00:00Z'),
assistantTurn([
{ type: 'tool_use', id: 't1', name: 'mcp__github__get_pr', input: {} },
{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: 'tools/router-classifier.mjs' } },
{ type: 'tool_use', id: 't3', name: 'Edit', input: { file_path: 'tools/router-classifier.test.mjs', old_string: 'a', new_string: 'b' } },
], '2026-05-25T10:01:00Z'),
]);
const ep = parseTranscript(t);
expect(ep.task_meta).toBeDefined();
expect(ep.task_meta.prompt_length_chars).toBe('добавь функцию X в файл tools/router-classifier.mjs'.length);
expect(ep.task_meta.mcp_servers_used).toEqual(['github']);
expect(ep.task_meta.file_type_distribution.src).toBe(1);
expect(ep.task_meta.file_type_distribution.test).toBe(1);
});
it('task_meta is present even on empty transcript (null-safe defaults)', () => {
const ep = parseTranscript('');
expect(ep.task_meta).toBeDefined();
expect(ep.task_meta.prompt_length_chars).toBe(0);
expect(ep.task_meta.mcp_servers_used).toEqual([]);
expect(ep.task_meta.file_type_distribution.other).toBe(0);
});
});