simt-dev 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +271 -62
  3. package/package.json +6 -2
  4. package/rules/core.md +1 -1
  5. package/src/advice/advice.mjs +229 -0
  6. package/src/agenthooks/policy.mjs +10 -2
  7. package/src/agenthooks/prompt.mjs +81 -0
  8. package/src/agenthooks/render.mjs +138 -17
  9. package/src/argv.mjs +28 -1
  10. package/src/bench/grade.mjs +3 -0
  11. package/src/bench/run.mjs +88 -54
  12. package/src/bench/tasks.mjs +38 -0
  13. package/src/bench/variants.mjs +456 -0
  14. package/src/brief/assemble.mjs +368 -0
  15. package/src/brief/kind.mjs +134 -0
  16. package/src/brief/packet.mjs +200 -0
  17. package/src/commands/agenthook.mjs +84 -5
  18. package/src/commands/bench.mjs +72 -15
  19. package/src/commands/brief.mjs +78 -0
  20. package/src/commands/compare.mjs +10 -2
  21. package/src/commands/decide.mjs +116 -0
  22. package/src/commands/distill.mjs +36 -0
  23. package/src/commands/help.mjs +62 -3
  24. package/src/commands/index.mjs +29 -1
  25. package/src/commands/lessons.mjs +127 -0
  26. package/src/commands/local.mjs +97 -0
  27. package/src/commands/map.mjs +42 -7
  28. package/src/commands/repoindex.mjs +4 -1
  29. package/src/commands/review.mjs +65 -0
  30. package/src/commands/rules.mjs +17 -2
  31. package/src/commands/skills.mjs +5 -50
  32. package/src/commands/skillscompile.mjs +255 -0
  33. package/src/commands/sync.mjs +15 -0
  34. package/src/commands/tests.mjs +2 -49
  35. package/src/commands/why.mjs +23 -25
  36. package/src/compare/learn.mjs +680 -0
  37. package/src/decisions/index.mjs +116 -65
  38. package/src/distill/distill.mjs +246 -0
  39. package/src/distill/engine.mjs +187 -0
  40. package/src/federation/client.mjs +188 -0
  41. package/src/federation/config.mjs +134 -0
  42. package/src/federation/query.mjs +48 -0
  43. package/src/graph/compile.mjs +475 -0
  44. package/src/index/gate.mjs +1 -13
  45. package/src/index/govern.mjs +23 -11
  46. package/src/index/map.mjs +251 -17
  47. package/src/index/persist.mjs +74 -7
  48. package/src/index/store.mjs +222 -8
  49. package/src/kernel/variants.mjs +353 -0
  50. package/src/ledger/index.mjs +36 -1
  51. package/src/local/client.mjs +482 -0
  52. package/src/local/config.mjs +269 -0
  53. package/src/mcp/tools.mjs +138 -1
  54. package/src/memory/decide.mjs +462 -0
  55. package/src/memory/lessons.mjs +858 -0
  56. package/src/projection/lock.mjs +39 -7
  57. package/src/projection/rules.mjs +32 -8
  58. package/src/resolve/body.mjs +46 -0
  59. package/src/review/critic.mjs +509 -0
  60. package/src/toolwords.mjs +6 -0
@@ -0,0 +1,680 @@
1
+ // Estate learning through `compare` (plan 0003 R36).
2
+ //
3
+ // Aggregates `.simt-dev/ledger.jsonl`, `.simt-dev/lessons.jsonl`, and ablation/bench
4
+ // records across workspace repositories to surface:
5
+ // 1. the most-violated rules (from hook denials and `review-block` findings);
6
+ // 2. the most-failing and slowest gates (from evidence ledgers);
7
+ // 3. rules/sections with no measured value (`dR <= DEL_R_TOLERANCE` and `dC > 0`
8
+ // from stored ablation runs);
9
+ // 4. deterministic ADR drafts (`status: draft`) proposing rulings grounded in
10
+ // that workspace evidence.
11
+ //
12
+ // Determinism (Accept): given the same ledgers and records on disk, the report and
13
+ // every ADR draft are byte-identical (no wall-clock timestamps, fixed sorting and
14
+ // rounding).
15
+
16
+ import { readFile } from 'node:fs/promises';
17
+ import { basename, join } from 'node:path';
18
+
19
+ import { policiesFor } from '../agenthooks/policy.mjs';
20
+ import { compareArms, evaluateRunRecord, loadPrices, loadRunRecords } from '../bench/report.mjs';
21
+ import { GATES } from '../hooks/gates.mjs';
22
+ import { readLedger } from '../ledger/index.mjs';
23
+ import { readLessonCandidates } from '../memory/lessons.mjs';
24
+ import { readManifest } from '../projection/manifest.mjs';
25
+
26
+ const DEL_R_TOLERANCE = 0.01;
27
+
28
+ const POLICY_BY_ID = new Map(policiesFor('pre-tool-use').map((p) => [p.id, p]));
29
+ const POLICY_BY_ADR = new Map(policiesFor('pre-tool-use').map((p) => [p.adr, p]));
30
+
31
+ const GATE_ENTRIES = Object.values(GATES);
32
+
33
+ function round4(n) {
34
+ if (!Number.isFinite(n)) return 0;
35
+ const r = Math.round(n * 10000) / 10000;
36
+ return Object.is(r, -0) ? 0 : r;
37
+ }
38
+
39
+ function round1(n) {
40
+ if (!Number.isFinite(n)) return 0;
41
+ const r = Math.round(n * 10) / 10;
42
+ return Object.is(r, -0) ? 0 : r;
43
+ }
44
+
45
+ function median(values) {
46
+ if (!Array.isArray(values) || values.length === 0) return 0;
47
+ const sorted = [...values].sort((a, b) => a - b);
48
+ const mid = Math.floor(sorted.length / 2);
49
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
50
+ }
51
+
52
+ function percentile95(values) {
53
+ if (!Array.isArray(values) || values.length === 0) return 0;
54
+ const sorted = [...values].sort((a, b) => a - b);
55
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * 0.95) - 1));
56
+ return sorted[idx];
57
+ }
58
+
59
+ /**
60
+ * Map a ledger command string back to a canonical gate name when possible, or keep
61
+ * the trimmed command string so custom commands still aggregate cleanly.
62
+ */
63
+ export function classifyGateCommand(cmd, manifest = null) {
64
+ const raw = String(cmd ?? '').trim();
65
+ if (!raw) return { gate: 'unknown', adr: null };
66
+
67
+ const declaredCommands = manifest?.gates?.commands ?? {};
68
+ for (const [gateName, gateCmd] of Object.entries(declaredCommands)) {
69
+ if (typeof gateCmd === 'string' && gateCmd.trim() === raw) {
70
+ return { gate: gateName, adr: GATES[gateName]?.adr ?? null };
71
+ }
72
+ }
73
+
74
+ if (GATES[raw]) {
75
+ return { gate: raw, adr: GATES[raw].adr };
76
+ }
77
+
78
+ for (const gate of GATE_ENTRIES) {
79
+ if (gate.kind === 'script' && gate.script) {
80
+ const scriptRe = new RegExp(`(?:^|\\s)npm\\s+run(?:\\s+--silent)?\\s+${gate.script}(?:\\s|$)`);
81
+ if (scriptRe.test(raw) || (gate.script === 'test' && /\b(?:npm\s+test|node\s+--test|pytest|go\s+test)\b/.test(raw))) {
82
+ return { gate: gate.name, adr: gate.adr };
83
+ }
84
+ }
85
+ if (gate.kind === 'staged' && Array.isArray(gate.tools)) {
86
+ for (const tool of gate.tools) {
87
+ if (raw.includes(`node_modules/.bin/${tool}`) || new RegExp(`\\b${tool}\\b`).test(raw)) {
88
+ return { gate: gate.name, adr: gate.adr };
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ return { gate: raw, adr: null };
95
+ }
96
+
97
+ /**
98
+ * Extract violated rule/policy identifiers from a lesson candidate (`hook-denial` or `review-block`).
99
+ */
100
+ export function classifyRuleViolations(candidate) {
101
+ if (!candidate || typeof candidate !== 'object') return [];
102
+ const kind = candidate.kind;
103
+ if (kind !== 'hook-denial' && kind !== 'review-block') return [];
104
+
105
+ const text = `${candidate.errorLine ?? ''} ${candidate.summary ?? ''}`;
106
+ const keywords = Array.isArray(candidate.keywords) ? candidate.keywords.map(String) : [];
107
+ const found = new Map();
108
+
109
+ for (const kw of keywords) {
110
+ const policy = POLICY_BY_ID.get(kw.toLowerCase());
111
+ if (policy) {
112
+ found.set(policy.id, {
113
+ rule: policy.id,
114
+ adr: policy.adr,
115
+ kind: 'policy',
116
+ });
117
+ }
118
+ }
119
+
120
+ for (const m of text.matchAll(/\b(ADR-\d{3,})\b/gi)) {
121
+ const adr = m[1].toUpperCase();
122
+ const policy = POLICY_BY_ADR.get(adr);
123
+ if (policy) {
124
+ found.set(policy.id, {
125
+ rule: policy.id,
126
+ adr: policy.adr,
127
+ kind: 'policy',
128
+ });
129
+ } else if (![...found.values()].some((v) => v.adr === adr)) {
130
+ found.set(adr, {
131
+ rule: adr,
132
+ adr,
133
+ kind: 'adr',
134
+ });
135
+ }
136
+ }
137
+
138
+ if (found.size === 0) {
139
+ for (const policy of POLICY_BY_ID.values()) {
140
+ if (policy.id === 'push' && /\bpush\b/i.test(text)) {
141
+ found.set(policy.id, { rule: policy.id, adr: policy.adr, kind: 'policy' });
142
+ } else if (
143
+ policy.id === 'rewrite' &&
144
+ /\b(?:--no-verify|--amend|rebase|reset\s+--hard|force-push|--force)\b/i.test(text)
145
+ ) {
146
+ found.set(policy.id, { rule: policy.id, adr: policy.adr, kind: 'policy' });
147
+ } else if (policy.id === 'generated' && /\bgenerat(?:ed|es)\b/i.test(text)) {
148
+ found.set(policy.id, { rule: policy.id, adr: policy.adr, kind: 'policy' });
149
+ }
150
+ }
151
+ }
152
+
153
+ if (found.size === 0) {
154
+ const fallbackId = kind === 'hook-denial' ? 'hook-policy' : 'review-block';
155
+ found.set(fallbackId, {
156
+ rule: fallbackId,
157
+ adr: null,
158
+ kind,
159
+ });
160
+ }
161
+
162
+ return [...found.values()];
163
+ }
164
+
165
+ function parseAblatedSectionArm(arm) {
166
+ const raw = String(arm ?? '');
167
+ if (raw.startsWith('no-core-')) {
168
+ const slug = raw.slice('no-core-'.length);
169
+ return { sectionId: `core:${slug}`, arm: raw, kind: 'core', targetId: slug, affects: ['rules/core.md'] };
170
+ }
171
+ if (raw.startsWith('no-profile-')) {
172
+ const id = raw.slice('no-profile-'.length);
173
+ return { sectionId: `profile:${id}`, arm: raw, kind: 'profile', targetId: id, affects: [`rules/stacks/${id}.md`] };
174
+ }
175
+ if (raw.startsWith('no-process-')) {
176
+ const id = raw.slice('no-process-'.length);
177
+ return { sectionId: `process:${id}`, arm: raw, kind: 'process', targetId: id, affects: [`rules/mdc/${id}.mdc`] };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ async function collectRepoAblationRecords(repoPath) {
183
+ const [records, prices] = await Promise.all([
184
+ loadRunRecords(repoPath).catch(() => []),
185
+ loadPrices(repoPath).catch(() => null),
186
+ ]);
187
+ if (records.length === 0) return [];
188
+ return records.map((rec) => evaluateRunRecord(rec, prices));
189
+ }
190
+
191
+ async function readExplicitNoValueSections(repoPath) {
192
+ const file = join(repoPath, '.simt-dev', 'ablation.json');
193
+ try {
194
+ const raw = JSON.parse(await readFile(file, 'utf8'));
195
+ const list = Array.isArray(raw?.sections) ? raw.sections : Array.isArray(raw) ? raw : [];
196
+ return list;
197
+ } catch {
198
+ return [];
199
+ }
200
+ }
201
+
202
+ function formatAffectsList(affects) {
203
+ const sorted = [...new Set(affects.map(String).filter(Boolean))].sort();
204
+ return sorted.length > 0 ? `[${sorted.map((a) => JSON.stringify(a)).join(', ')}]` : '[]';
205
+ }
206
+
207
+ function formatSupersedesList(supersedes) {
208
+ if (!Array.isArray(supersedes) || supersedes.length === 0) return '[]';
209
+ return `[${supersedes
210
+ .map((s) => `{ adr: ${JSON.stringify(s.adr)}, ruling: ${s.ruling === null ? 'null' : s.ruling} }`)
211
+ .join(', ')}]`;
212
+ }
213
+
214
+ /**
215
+ * Render a deterministic ADR draft markdown string (`Status: draft`).
216
+ * Contains no wall-clock date so two runs over the same ledgers produce byte-identical output.
217
+ */
218
+ export function renderAdrDraft({
219
+ draftId,
220
+ category,
221
+ title,
222
+ affects = [],
223
+ supersedes = [],
224
+ context,
225
+ rulings = [],
226
+ consequences = [],
227
+ }) {
228
+ const statusParts = ['draft'];
229
+ for (const s of supersedes) {
230
+ statusParts.push(s.ruling === null ? `supersedes ${s.adr}` : `supersedes ${s.adr} ruling ${s.ruling}`);
231
+ }
232
+ const lines = [
233
+ '---',
234
+ `simt-affects: ${formatAffectsList(affects)}`,
235
+ `simt-supersedes: ${formatSupersedesList(supersedes)}`,
236
+ '---',
237
+ `# ${draftId} — ${title}`,
238
+ '',
239
+ `Status: ${statusParts.join(' · ')}`,
240
+ `Category: ${category}`,
241
+ '',
242
+ '## Context',
243
+ '',
244
+ context,
245
+ '',
246
+ '## Rulings',
247
+ '',
248
+ ...rulings.flatMap((r, idx) => [`${idx + 1}. ${r}`, '']),
249
+ '## Consequences',
250
+ '',
251
+ ...consequences.map((c) => `- ${c}`),
252
+ '',
253
+ ];
254
+ return lines.join('\n');
255
+ }
256
+
257
+ /**
258
+ * Aggregate ledgers, hook denials, and ablation records across `repos` (sorted paths).
259
+ *
260
+ * @param {string[]} repos
261
+ * @returns {Promise<{
262
+ * reposWithLedgers: number,
263
+ * reposWithDenials: number,
264
+ * totalLedgerRuns: number,
265
+ * totalHookDenials: number,
266
+ * mostViolatedRules: Array<{rule: string, adr: string|null, kind: string, count: number, repos: string[], sample: string}>,
267
+ * mostFailingGates: Array<{gate: string, adr: string|null, runs: number, failures: number, failRate: number, meanMs: number, medianMs: number, p95Ms: number, maxMs: number, repos: string[], topError: string|null}>,
268
+ * slowestGates: Array<{gate: string, adr: string|null, runs: number, failures: number, failRate: number, meanMs: number, medianMs: number, p95Ms: number, maxMs: number, repos: string[], topError: string|null}>,
269
+ * noValueRules: Array<{sectionId: string, kind: string, targetId: string, dR: number, dC: number, tasks: number, repos: string[]}>,
270
+ * adrDrafts: Array<{id: string, category: string, target: string, title: string, affects: string[], supersedes: Array<{adr: string, ruling: number|null}>, markdown: string}>
271
+ * }>}
272
+ */
273
+ export async function learnAcrossEstate(repos) {
274
+ const sortedRepos = [...(repos ?? [])].sort();
275
+
276
+ const ruleViolations = new Map();
277
+ const gateStats = new Map();
278
+ const ablationByArm = new Map();
279
+ const currentRunsAll = [];
280
+ const explicitSections = [];
281
+
282
+ let reposWithLedgers = 0;
283
+ let reposWithDenials = 0;
284
+ let totalLedgerRuns = 0;
285
+ let totalHookDenials = 0;
286
+
287
+ for (const repoPath of sortedRepos) {
288
+ const repoName = basename(repoPath);
289
+ const [manifestRes, ledgerRes, lessonsRes, benchEvaluated, explicitAblation] = await Promise.all([
290
+ readManifest(repoPath).catch(() => ({ manifest: null })),
291
+ readLedger(repoPath).catch(() => ({ entries: [] })),
292
+ readLessonCandidates(repoPath).catch(() => ({ candidates: [] })),
293
+ collectRepoAblationRecords(repoPath),
294
+ readExplicitNoValueSections(repoPath),
295
+ ]);
296
+
297
+ const manifest = manifestRes?.manifest ?? null;
298
+ const ledgerEntries = ledgerRes?.entries ?? [];
299
+ const candidates = lessonsRes?.candidates ?? [];
300
+
301
+ if (ledgerEntries.length > 0) reposWithLedgers += 1;
302
+ totalLedgerRuns += ledgerEntries.length;
303
+
304
+ let repoDenials = 0;
305
+ for (const cand of candidates) {
306
+ if (cand.kind === 'hook-denial' || cand.kind === 'review-block') {
307
+ if (cand.kind === 'hook-denial') repoDenials += 1;
308
+ const hits = classifyRuleViolations(cand);
309
+ for (const hit of hits) {
310
+ const entry = ruleViolations.get(hit.rule) ?? {
311
+ rule: hit.rule,
312
+ adr: hit.adr ?? null,
313
+ kind: hit.kind,
314
+ count: 0,
315
+ repos: new Set(),
316
+ samples: [],
317
+ };
318
+ entry.count += 1;
319
+ entry.repos.add(repoName);
320
+ const sample = String(cand.errorLine ?? cand.summary ?? '').trim();
321
+ if (sample && !entry.samples.includes(sample)) entry.samples.push(sample);
322
+ ruleViolations.set(hit.rule, entry);
323
+ }
324
+ }
325
+ }
326
+ if (repoDenials > 0) reposWithDenials += 1;
327
+ totalHookDenials += repoDenials;
328
+
329
+ for (const line of ledgerEntries) {
330
+ const { gate, adr } = classifyGateCommand(line.cmd, manifest);
331
+ const entry = gateStats.get(gate) ?? {
332
+ gate,
333
+ adr: adr ?? null,
334
+ runs: 0,
335
+ failures: 0,
336
+ durations: [],
337
+ repos: new Set(),
338
+ errorCounts: new Map(),
339
+ };
340
+ if (!entry.adr && adr) entry.adr = adr;
341
+ entry.runs += 1;
342
+ if (line.exit !== 0) {
343
+ entry.failures += 1;
344
+ const errText = String(line.errorLine ?? line.summary ?? '').trim();
345
+ if (errText) {
346
+ entry.errorCounts.set(errText, (entry.errorCounts.get(errText) ?? 0) + 1);
347
+ }
348
+ }
349
+ if (typeof line.ms === 'number' && Number.isFinite(line.ms) && line.ms >= 0) {
350
+ entry.durations.push(line.ms);
351
+ }
352
+ entry.repos.add(repoName);
353
+ gateStats.set(gate, entry);
354
+ }
355
+
356
+ for (const row of benchEvaluated) {
357
+ const tagged = { ...row, task: `${repoName}:${row.task}` };
358
+ if (row.arm === 'current') {
359
+ currentRunsAll.push(tagged);
360
+ } else if (parseAblatedSectionArm(row.arm)) {
361
+ const list = ablationByArm.get(row.arm) ?? [];
362
+ list.push({ row: tagged, repoName });
363
+ ablationByArm.set(row.arm, list);
364
+ }
365
+ }
366
+
367
+ for (const sec of explicitAblation) {
368
+ explicitSections.push({ sec, repoName });
369
+ }
370
+ }
371
+
372
+ const mostViolatedRules = [...ruleViolations.values()]
373
+ .map((v) => ({
374
+ rule: v.rule,
375
+ adr: v.adr,
376
+ kind: v.kind,
377
+ count: v.count,
378
+ repos: [...v.repos].sort(),
379
+ sample: [...v.samples].sort()[0] ?? '',
380
+ }))
381
+ .sort((a, b) => b.count - a.count || a.rule.localeCompare(b.rule));
382
+
383
+ const allGates = [...gateStats.values()].map((g) => {
384
+ const totalMs = g.durations.reduce((acc, n) => acc + n, 0);
385
+ const meanMs = g.durations.length > 0 ? round1(totalMs / g.durations.length) : 0;
386
+ const medianMs = round1(median(g.durations));
387
+ const p95Ms = round1(percentile95(g.durations));
388
+ const maxMs = g.durations.length > 0 ? round1(Math.max(...g.durations)) : 0;
389
+ const failRate = g.runs > 0 ? round4(g.failures / g.runs) : 0;
390
+ const topError =
391
+ [...g.errorCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? null;
392
+ return {
393
+ gate: g.gate,
394
+ adr: g.adr,
395
+ runs: g.runs,
396
+ failures: g.failures,
397
+ failRate,
398
+ meanMs,
399
+ medianMs,
400
+ p95Ms,
401
+ maxMs,
402
+ repos: [...g.repos].sort(),
403
+ topError,
404
+ };
405
+ });
406
+
407
+ const mostFailingGates = allGates
408
+ .filter((g) => g.failures > 0)
409
+ .sort((a, b) => b.failures - a.failures || b.failRate - a.failRate || a.gate.localeCompare(b.gate));
410
+
411
+ const slowestGates = [...allGates].sort(
412
+ (a, b) => b.p95Ms - a.p95Ms || b.meanMs - a.meanMs || a.gate.localeCompare(b.gate),
413
+ );
414
+
415
+ const noValueMap = new Map();
416
+
417
+ for (const [arm, entries] of [...ablationByArm.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
418
+ const parsed = parseAblatedSectionArm(arm);
419
+ if (!parsed || currentRunsAll.length === 0) continue;
420
+ const ablatedRuns = entries.map((e) => e.row);
421
+ const cmp = compareArms(arm, ablatedRuns, 'current', currentRunsAll);
422
+ if (cmp.pairedTasks === 0) continue;
423
+ const dR = round4(cmp.deltas.R);
424
+ const dC = round4(cmp.deltas.C);
425
+ if (dR <= DEL_R_TOLERANCE && dC > 0) {
426
+ noValueMap.set(parsed.sectionId, {
427
+ sectionId: parsed.sectionId,
428
+ kind: parsed.kind,
429
+ targetId: parsed.targetId,
430
+ affects: parsed.affects,
431
+ dR,
432
+ dC,
433
+ tasks: cmp.pairedTasks,
434
+ repos: [...new Set(entries.map((e) => e.repoName))].sort(),
435
+ });
436
+ }
437
+ }
438
+
439
+ for (const { sec, repoName } of explicitSections) {
440
+ const sectionId = String(sec.id ?? sec.sectionId ?? '');
441
+ if (!sectionId) continue;
442
+ const dR = round4(Number(sec.dR ?? 0));
443
+ const dC = round4(Number(sec.dC ?? 0));
444
+ const isCandidate = sec.deletionCandidate === true || (sec.measured !== false && dR <= DEL_R_TOLERANCE && dC > 0);
445
+ if (!isCandidate) continue;
446
+ const [kind = 'core', targetId = sectionId] = sectionId.split(':');
447
+ const affects =
448
+ kind === 'profile'
449
+ ? [`rules/stacks/${targetId}.md`]
450
+ : kind === 'process'
451
+ ? [`rules/mdc/${targetId}.mdc`]
452
+ : ['rules/core.md'];
453
+ const existing = noValueMap.get(sectionId);
454
+ if (existing) {
455
+ existing.repos = [...new Set([...existing.repos, repoName])].sort();
456
+ } else {
457
+ noValueMap.set(sectionId, {
458
+ sectionId,
459
+ kind,
460
+ targetId,
461
+ affects,
462
+ dR,
463
+ dC,
464
+ tasks: Number(sec.tasks ?? 1),
465
+ repos: [repoName],
466
+ });
467
+ }
468
+ }
469
+
470
+ const noValueRules = [...noValueMap.values()]
471
+ .map(({ affects, ...rest }) => rest)
472
+ .sort((a, b) => a.dR - b.dR || b.dC - a.dC || a.sectionId.localeCompare(b.sectionId));
473
+
474
+ const adrDrafts = [];
475
+ let draftCounter = 1;
476
+ const nextDraftId = () => `ADR-DRAFT-${String(draftCounter++).padStart(3, '0')}`;
477
+
478
+ for (const v of mostViolatedRules) {
479
+ const draftId = nextDraftId();
480
+ const title = `Reinforce or narrow ${v.rule}${v.adr ? ` (${v.adr})` : ''} after ${v.count} violation(s)`;
481
+ const affects = ['rules/core.md', 'src/agenthooks/policy.mjs'];
482
+ const supersedes = v.adr ? [{ adr: v.adr, ruling: null }] : [];
483
+ const context =
484
+ `Workspace aggregation across ${v.repos.length} repository(ies) (${v.repos.join(', ')}) recorded ` +
485
+ `${v.count} violation(s) of \`${v.rule}\`${v.adr ? ` (${v.adr})` : ''}` +
486
+ `${v.sample ? ` — sample: "${v.sample}"` : ''}.`;
487
+ const rulings = [
488
+ `**Keep or narrow \`${v.rule}\`.** Either promote a targeted lesson/check for \`${v.rule}\` or narrow its scope in \`src/agenthooks/policy.mjs\` if the denials are false positives.`,
489
+ ];
490
+ const consequences = [
491
+ `Updates enforcement or guidance for \`${v.rule}\` across ${v.repos.join(', ')}.`,
492
+ ];
493
+ adrDrafts.push({
494
+ id: draftId,
495
+ category: 'violated-rule',
496
+ target: v.rule,
497
+ title,
498
+ affects,
499
+ supersedes,
500
+ markdown: renderAdrDraft({
501
+ draftId,
502
+ category: 'violated-rule',
503
+ title,
504
+ affects,
505
+ supersedes,
506
+ context,
507
+ rulings,
508
+ consequences,
509
+ }),
510
+ });
511
+ }
512
+
513
+ for (const g of mostFailingGates) {
514
+ const draftId = nextDraftId();
515
+ const pct = round1(g.failRate * 100);
516
+ const title = `Stabilise gate "${g.gate}" (${g.failures}/${g.runs} failed, p95 ${g.p95Ms} ms)`;
517
+ const affects = ['src/hooks/gates.mjs', 'src/hooks/dispatcher.mjs'];
518
+ const supersedes = g.adr ? [{ adr: g.adr, ruling: null }] : [];
519
+ const context =
520
+ `Across ${g.repos.length} repository(ies) (${g.repos.join(', ')}), gate \`${g.gate}\` failed ` +
521
+ `${g.failures} of ${g.runs} run(s) (${pct}%), with median ${g.medianMs} ms and p95 ${g.p95Ms} ms` +
522
+ `${g.topError ? ` (most common error: "${g.topError}")` : ''}.`;
523
+ const rulings = [
524
+ `**Remediate or split \`${g.gate}\`.** Capture the recurring failure signature as a lesson or move slow verification to diff-aware pre-push gating so pre-commit stays fast and deterministic.`,
525
+ ];
526
+ const consequences = [
527
+ `Reduces failure rate (${pct}%) and p95 latency (${g.p95Ms} ms) for \`${g.gate}\`.`,
528
+ ];
529
+ adrDrafts.push({
530
+ id: draftId,
531
+ category: 'failing-gate',
532
+ target: g.gate,
533
+ title,
534
+ affects,
535
+ supersedes,
536
+ markdown: renderAdrDraft({
537
+ draftId,
538
+ category: 'failing-gate',
539
+ title,
540
+ affects,
541
+ supersedes,
542
+ context,
543
+ rulings,
544
+ consequences,
545
+ }),
546
+ });
547
+ }
548
+
549
+ for (const g of slowestGates) {
550
+ if (mostFailingGates.some((f) => f.gate === g.gate)) continue;
551
+ if (g.p95Ms < 1000) continue;
552
+ const draftId = nextDraftId();
553
+ const title = `Bound latency of slow gate "${g.gate}" (p95 ${g.p95Ms} ms)`;
554
+ const affects = ['src/hooks/gates.mjs', 'src/hooks/dispatcher.mjs'];
555
+ const supersedes = g.adr ? [{ adr: g.adr, ruling: null }] : [];
556
+ const context =
557
+ `Across ${g.repos.length} repository(ies) (${g.repos.join(', ')}), gate \`${g.gate}\` ran ${g.runs} time(s) ` +
558
+ `with median ${g.medianMs} ms and p95 ${g.p95Ms} ms.`;
559
+ const rulings = [
560
+ `**Scope \`${g.gate}\` to affected targets.** Route \`${g.gate}\` through diff-aware test selection or move it from pre-commit to pre-push.`,
561
+ ];
562
+ const consequences = [
563
+ `Lowers hook latency for \`${g.gate}\` across ${g.repos.join(', ')}.`,
564
+ ];
565
+ adrDrafts.push({
566
+ id: draftId,
567
+ category: 'slow-gate',
568
+ target: g.gate,
569
+ title,
570
+ affects,
571
+ supersedes,
572
+ markdown: renderAdrDraft({
573
+ draftId,
574
+ category: 'slow-gate',
575
+ title,
576
+ affects,
577
+ supersedes,
578
+ context,
579
+ rulings,
580
+ consequences,
581
+ }),
582
+ });
583
+ }
584
+
585
+ for (const s of noValueRules) {
586
+ const rawEntry = noValueMap.get(s.sectionId);
587
+ const affects = rawEntry?.affects ?? ['rules/core.md'];
588
+ const draftId = nextDraftId();
589
+ const title = `Prune or demote zero-value section "${s.sectionId}" (dR=${s.dR}, dC=+$${s.dC})`;
590
+ const context =
591
+ `Leave-one-out ablation across ${s.tasks} paired task(s) in ${s.repos.join(', ')} measured ` +
592
+ `\`${s.sectionId}\` at dR=${s.dR} (<= ${DEL_R_TOLERANCE}) and dC=+$${s.dC} (> 0).`;
593
+ const rulings = [
594
+ `**Demote or remove \`${s.sectionId}\`.** Move \`${s.sectionId}\` out of the always-on kernel or delete the redundant prose so every repository saves its token cost.`,
595
+ ];
596
+ const consequences = [
597
+ `Removes unmeasured always-on cost (+$${s.dC} per task) without reducing resolution rate (dR=${s.dR}).`,
598
+ ];
599
+ adrDrafts.push({
600
+ id: draftId,
601
+ category: 'no-value-rule',
602
+ target: s.sectionId,
603
+ title,
604
+ affects,
605
+ supersedes: [],
606
+ markdown: renderAdrDraft({
607
+ draftId,
608
+ category: 'no-value-rule',
609
+ title,
610
+ affects,
611
+ supersedes: [],
612
+ context,
613
+ rulings,
614
+ consequences,
615
+ }),
616
+ });
617
+ }
618
+
619
+ return {
620
+ reposWithLedgers,
621
+ reposWithDenials,
622
+ totalLedgerRuns,
623
+ totalHookDenials,
624
+ mostViolatedRules,
625
+ mostFailingGates,
626
+ slowestGates,
627
+ noValueRules,
628
+ adrDrafts,
629
+ };
630
+ }
631
+
632
+ /**
633
+ * Format the estate learning block for text output in `simt compare`.
634
+ * Returns `[]` when no ledger runs, hook denials, or no-value sections exist,
635
+ * unless `force` (`--learn`) is true.
636
+ */
637
+ export function renderLearningLines(learning, { force = false } = {}) {
638
+ if (!learning) return [];
639
+ const hasData =
640
+ learning.totalLedgerRuns > 0 ||
641
+ learning.mostViolatedRules.length > 0 ||
642
+ learning.noValueRules.length > 0 ||
643
+ learning.adrDrafts.length > 0;
644
+ if (!hasData && !force) return [];
645
+
646
+ const lines = [
647
+ `learning ${learning.totalLedgerRuns} ledger run(s) across ${learning.reposWithLedgers} repo(s), ${learning.totalHookDenials} hook denial(s) across ${learning.reposWithDenials} repo(s), ${learning.adrDrafts.length} ADR draft(s)`,
648
+ ];
649
+
650
+ for (const v of learning.mostViolatedRules) {
651
+ lines.push(
652
+ `violated ${v.rule}${v.adr ? ` (${v.adr})` : ''} count=${v.count} repos=${v.repos.join(',')}`,
653
+ );
654
+ }
655
+ for (const g of learning.mostFailingGates) {
656
+ lines.push(
657
+ `failing ${g.gate}${g.adr ? ` (${g.adr})` : ''} failures=${g.failures}/${g.runs} (${round1(g.failRate * 100)}%) p95=${g.p95Ms}ms repos=${g.repos.join(',')}`,
658
+ );
659
+ }
660
+ for (const g of learning.slowestGates) {
661
+ lines.push(
662
+ `slowest ${g.gate}${g.adr ? ` (${g.adr})` : ''} p95=${g.p95Ms}ms median=${g.medianMs}ms runs=${g.runs} repos=${g.repos.join(',')}`,
663
+ );
664
+ }
665
+ for (const s of learning.noValueRules) {
666
+ lines.push(
667
+ `novalue ${s.sectionId} dR=${s.dR} dC=${s.dC} tasks=${s.tasks} repos=${s.repos.join(',')}`,
668
+ );
669
+ }
670
+ for (const d of learning.adrDrafts) {
671
+ lines.push(`draft ${d.id} [${d.category}] ${d.title}`);
672
+ if (force) {
673
+ for (const mdLine of d.markdown.trimEnd().split('\n')) {
674
+ lines.push(` ${mdLine}`);
675
+ }
676
+ }
677
+ }
678
+
679
+ return lines;
680
+ }