docs(brain): plan amendment — factor analysis в Task A1 ADR-011 + Task B3 + Task B6
Соответствует spec v1.1 (fd16a10). Изменения: Task A1 (ADR-011 text inside plan): - Decision #2 «Observer scope B» расширено: упоминание 5 mandatory fields (включая primary_rationale 7 sub-fields) + routing_decision events для цепочек + что это enables factor analysis. Task B3 (observer-stop-hook.test.mjs + observer-stop-hook.mjs): - REQUIRED_FIELDS расширен с 4 до 5 ('primary_rationale'). - Новая константа RATIONALE_FIELDS (7 полей) + validateRationale() функция, вызываемая внутри appendEpisode после top-level validation. - buildEpisodeFromContext возвращает primary_rationale (либо из ctx, либо default с extracted hints из ctx.skill_id/triggers_matched/etc). - Tests: было 5 → стало 8. Новые: «throws when primary_rationale field missing», «persists routing_decision events with structured fields», «preserves user-provided primary_rationale unchanged». Все old fixtures обогащены primary_rationale: defaultRat(). Task B6 (aggregation-template.md): - Новая большая секция «Factor analysis matrix (v1.1+)» с 5 осями факторов + cross-tab factor×factor. Tables для каждой оси: triggers_matched, candidates_dropped_because, boundaries_applied, hard_floor.rules, task_classification. Self-review: - Spec coverage table +row для §5.2.1. Связано: spec v1.1 (fd16a10), plan v1.0 (e1772da), spec v1.0 (e2a5394). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -152,6 +152,8 @@ Canonical chains L1–L12 in [`docs/routing-off-phase.md`](../routing-off-phase.
|
||||
|
||||
A passive Stop-event hook appends one JSONL line per session to `docs/observer/episodes-YYYY-MM.jsonl` and optionally a MD note in `docs/observer/notes/`. **Observer only writes; never intervenes.** PII-filter (gitleaks-like regex) is mandatory pre-write.
|
||||
|
||||
**Each episode has 5 mandatory fields** including a structured `primary_rationale` (7 sub-fields per spec §5.2.1: `step` / `node_chosen` / `triggers_matched` / `candidates_considered` / `boundaries_applied` / `hard_floor` / `task_classification`). Each individual router decision is also recorded as a `routing_decision` event in `events[]` (one per node-choice for chains). This enables **factor analysis** through `/brain-retro` — answers «which factors most often resolve conflicts between nodes X and Y» rather than just «node X used N times».
|
||||
|
||||
A `/brain-retro` skill aggregates evidence once per sprint and proposes regulatory candidates; the owner accepts or rejects manually.
|
||||
|
||||
### 3. 4 mechanical controllers (first wave)
|
||||
@@ -878,6 +880,17 @@ afterEach(() => {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper for v1.1 — primary_rationale fixture (factor analysis amendment)
|
||||
const defaultRat = () => ({
|
||||
step: 1,
|
||||
node_chosen: '#1',
|
||||
triggers_matched: [],
|
||||
candidates_considered: [],
|
||||
boundaries_applied: [],
|
||||
hard_floor: { invoked: false, rules: [] },
|
||||
task_classification: 'other',
|
||||
});
|
||||
|
||||
describe('appendEpisode', () => {
|
||||
it('appends one JSONL line to monthly file', () => {
|
||||
const ep = {
|
||||
@@ -885,29 +898,32 @@ describe('appendEpisode', () => {
|
||||
timestamps: { started_at: '2026-05-19T10:00:00+03:00', ended_at: '2026-05-19T10:05:00+03:00' },
|
||||
path_type: 'regulated',
|
||||
outcome: 'success',
|
||||
primary_rationale: defaultRat(),
|
||||
};
|
||||
appendEpisode(ep, workdir, '2026-05');
|
||||
const file = join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl');
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
expect(content).toContain('"task_id":"abc-123"');
|
||||
expect(content).toContain('"primary_rationale"');
|
||||
expect(content.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('appends to existing file without overwrite', () => {
|
||||
appendEpisode({ task_id: 'a', timestamps: {}, path_type: 'regulated', outcome: 'success' }, workdir, '2026-05');
|
||||
appendEpisode({ task_id: 'b', timestamps: {}, path_type: 'improvised', outcome: 'partial' }, workdir, '2026-05');
|
||||
appendEpisode({ task_id: 'a', timestamps: {}, path_type: 'regulated', outcome: 'success', primary_rationale: defaultRat() }, workdir, '2026-05');
|
||||
appendEpisode({ task_id: 'b', timestamps: {}, path_type: 'improvised', outcome: 'partial', primary_rationale: defaultRat() }, workdir, '2026-05');
|
||||
const lines = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim().split('\n');
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(JSON.parse(lines[0]).task_id).toBe('a');
|
||||
expect(JSON.parse(lines[1]).task_id).toBe('b');
|
||||
});
|
||||
|
||||
it('applies PII filter before write', () => {
|
||||
it('applies PII filter before write (including events[])', () => {
|
||||
appendEpisode({
|
||||
task_id: 'c',
|
||||
timestamps: {},
|
||||
path_type: 'regulated',
|
||||
outcome: 'success',
|
||||
primary_rationale: defaultRat(),
|
||||
events: [{ kind: 'error', message: 'call +79991234567 / mail x@y.com' }],
|
||||
}, workdir, '2026-05');
|
||||
const content = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8');
|
||||
@@ -916,14 +932,47 @@ describe('appendEpisode', () => {
|
||||
expect(content).not.toContain('79991234567');
|
||||
});
|
||||
|
||||
it('throws on missing required fields', () => {
|
||||
it('throws on missing required top-level fields', () => {
|
||||
expect(() => appendEpisode({}, workdir, '2026-05')).toThrow(/required/i);
|
||||
expect(() => appendEpisode({ task_id: 'x' }, workdir, '2026-05')).toThrow(/required/i);
|
||||
expect(() => appendEpisode({ task_id: 'x', timestamps: {}, path_type: 'regulated', outcome: 'success' }, workdir, '2026-05')).toThrow(/primary_rationale/i);
|
||||
});
|
||||
|
||||
it('throws when primary_rationale field is missing', () => {
|
||||
const ep = {
|
||||
task_id: 'd',
|
||||
timestamps: {},
|
||||
path_type: 'regulated',
|
||||
outcome: 'success',
|
||||
primary_rationale: { step: 1, node_chosen: '#1' }, // missing other 5 fields
|
||||
};
|
||||
expect(() => appendEpisode(ep, workdir, '2026-05')).toThrow(/primary_rationale field missing/i);
|
||||
});
|
||||
|
||||
it('persists routing_decision events with structured fields', () => {
|
||||
appendEpisode({
|
||||
task_id: 'e',
|
||||
timestamps: {},
|
||||
path_type: 'regulated',
|
||||
outcome: 'success',
|
||||
primary_rationale: defaultRat(),
|
||||
events: [
|
||||
{ kind: 'routing_decision', step: 1, node_chosen: '#55', triggers_matched: ['discovery'],
|
||||
candidates_considered: [{ node_id: '#53', dropped_because: 'ADR-009' }],
|
||||
boundaries_applied: ['ADR-009'], hard_floor: { invoked: false, rules: [] },
|
||||
task_classification: 'discovery' },
|
||||
],
|
||||
}, workdir, '2026-05');
|
||||
const line = JSON.parse(readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim());
|
||||
expect(line.events[0].kind).toBe('routing_decision');
|
||||
expect(line.events[0].triggers_matched).toEqual(['discovery']);
|
||||
expect(line.events[0].candidates_considered[0].dropped_because).toBe('ADR-009');
|
||||
expect(line.events[0].boundaries_applied).toEqual(['ADR-009']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEpisodeFromContext', () => {
|
||||
it('extracts 4 mandatory fields from context object', () => {
|
||||
it('extracts 5 mandatory fields from context object', () => {
|
||||
const ctx = {
|
||||
sessionId: 'sess-1',
|
||||
started: '2026-05-19T09:00:00+03:00',
|
||||
@@ -935,6 +984,20 @@ describe('buildEpisodeFromContext', () => {
|
||||
expect(ep.timestamps.started_at).toBe(ctx.started);
|
||||
expect(ep.outcome).toBe('success');
|
||||
expect(['regulated', 'improvised', 'alternative', 'mixed']).toContain(ep.path_type);
|
||||
expect(ep.primary_rationale).toBeDefined();
|
||||
expect(ep.primary_rationale.step).toBe(1);
|
||||
expect(ep.primary_rationale.hard_floor).toEqual({ invoked: false, rules: [] });
|
||||
});
|
||||
|
||||
it('preserves user-provided primary_rationale unchanged', () => {
|
||||
const rat = {
|
||||
step: 1, node_chosen: '#55', triggers_matched: ['discovery'],
|
||||
candidates_considered: [], boundaries_applied: ['ADR-009'],
|
||||
hard_floor: { invoked: true, rules: ['Pravila §12'] },
|
||||
task_classification: 'discovery',
|
||||
};
|
||||
const ep = buildEpisodeFromContext({ sessionId: 'x', primary_rationale: rat });
|
||||
expect(ep.primary_rationale).toEqual(rat);
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -957,12 +1020,21 @@ import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { sanitize } from './observer-pii-filter.mjs';
|
||||
|
||||
const REQUIRED_FIELDS = ['task_id', 'timestamps', 'path_type', 'outcome'];
|
||||
const REQUIRED_FIELDS = ['task_id', 'timestamps', 'path_type', 'outcome', 'primary_rationale'];
|
||||
|
||||
const RATIONALE_FIELDS = ['step', 'node_chosen', 'triggers_matched', 'candidates_considered', 'boundaries_applied', 'hard_floor', 'task_classification'];
|
||||
|
||||
function validateRationale(rationale) {
|
||||
for (const f of RATIONALE_FIELDS) {
|
||||
if (rationale[f] === undefined) throw new Error(`primary_rationale field missing: ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function appendEpisode(episode, baseDir = process.cwd(), month = currentMonth()) {
|
||||
for (const f of REQUIRED_FIELDS) {
|
||||
if (episode[f] === undefined) throw new Error(`required field missing: ${f}`);
|
||||
}
|
||||
validateRationale(episode.primary_rationale);
|
||||
const sanitized = sanitize(episode);
|
||||
const dir = join(baseDir, 'docs', 'observer');
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
@@ -979,6 +1051,15 @@ export function buildEpisodeFromContext(ctx = {}) {
|
||||
},
|
||||
path_type: ctx.path_type || 'regulated',
|
||||
outcome: ctx.result || ctx.outcome || 'success',
|
||||
primary_rationale: ctx.primary_rationale || {
|
||||
step: 1,
|
||||
node_chosen: ctx.node_chosen || ctx.skill_id || 'unknown',
|
||||
triggers_matched: ctx.triggers_matched || [],
|
||||
candidates_considered: ctx.candidates_considered || [],
|
||||
boundaries_applied: ctx.boundaries_applied || [],
|
||||
hard_floor: ctx.hard_floor || { invoked: false, rules: [] },
|
||||
task_classification: ctx.task_classification || 'other',
|
||||
},
|
||||
events: ctx.events || [],
|
||||
};
|
||||
}
|
||||
@@ -1253,6 +1334,52 @@ YYYY-MM-DD .. YYYY-MM-DD ({N} sessions)
|
||||
| node | times used | first / last |
|
||||
|---|---|---|
|
||||
|
||||
## Factor analysis matrix (v1.1+ — from `routing_decision` events + `primary_rationale`)
|
||||
|
||||
Per-node breakdown by 5 factor axes (per spec §5.2.1).
|
||||
|
||||
### Axis 1: triggers_matched → node
|
||||
|
||||
«Какие триггеры чаще всего ведут к этому узлу»
|
||||
|
||||
| node | trigger | count |
|
||||
|---|---|---|
|
||||
|
||||
### Axis 2: candidates_considered.dropped_because → node
|
||||
|
||||
«Какие альтернативы и почему отбрасываются в пользу этого узла»
|
||||
|
||||
| node | dropped alternative | dropped_because (top reason) | count |
|
||||
|---|---|---|---|
|
||||
|
||||
### Axis 3: boundaries_applied → node
|
||||
|
||||
«Какие ADR / R-rules чаще всего разруливают в пользу этого узла»
|
||||
|
||||
| node | boundary (ADR-NNN / PSR R-rule) | count |
|
||||
|---|---|---|
|
||||
|
||||
### Axis 4: hard_floor.rules → invoked frequency
|
||||
|
||||
«Как часто узел вынуждается hard-floor §12/§14/§15»
|
||||
|
||||
| node | rule | invoked-count | total decisions | % forced |
|
||||
|---|---|---|---|---|
|
||||
|
||||
### Axis 5: task_classification → node
|
||||
|
||||
«В каких классах задач узел доминирует»
|
||||
|
||||
| task_classification | top node | secondary nodes |
|
||||
|---|---|---|
|
||||
|
||||
### Cross-tab: factor × factor
|
||||
|
||||
Pairs of factors that co-occur to resolve specific conflicts (e.g. «ADR-009 ↔ triggers_matched=['discovery']» — 8 раз).
|
||||
|
||||
| factor pair | combined count | interpretation |
|
||||
|---|---|---|
|
||||
|
||||
## Canonical chains L1–L12 hit rate
|
||||
|
||||
| chain | times | notes |
|
||||
@@ -2300,8 +2427,9 @@ git log origin/main --oneline | head -5
|
||||
|---|---|---|
|
||||
| §4 Router | A1, A2, A3 | ✅ |
|
||||
| §5 Observer scope B | B1, B2, B3, B5, B6 | ✅ |
|
||||
| §5.2.1 routing_decision + primary_rationale (v1.1 amendment) | B3 (validation + buildEpisodeFromContext extraction); ADR-011 Decision 2 (A1) | ✅ |
|
||||
| §5.4 PII filter | B2 | ✅ |
|
||||
| §5.5 /brain-retro | B6 | ✅ |
|
||||
| §5.5 /brain-retro (incl. factor matrix) | B6 (aggregation-template Factor analysis matrix) | ✅ |
|
||||
| §6.1 C1 L1-watcher | C1 + C5 | ✅ |
|
||||
| §6.2 C2 cross-ref | C2 + C5 | ✅ |
|
||||
| §6.3 C3 observer-of-observer 54 weeks | C3 + C5 | ✅ |
|
||||
|
||||
Reference in New Issue
Block a user