@ngockhoale/ukit 2.6.11 → 2.7.1

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 (40) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/manifests/documentation.yaml +24 -2
  3. package/manifests/instructionRules.yaml +62 -0
  4. package/package.json +1 -1
  5. package/scripts/perf/audit-perf.mjs +920 -0
  6. package/src/cli/commands/doctor.js +23 -4
  7. package/src/core/diffPlan.js +8 -0
  8. package/src/core/ompConfigMerge.js +251 -0
  9. package/src/core/runInstallPipeline.js +11 -0
  10. package/src/core/runtimeConfig.js +16 -3
  11. package/src/core/unattendedDoctor.js +227 -0
  12. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  13. package/templates/.claude/commands/ukit/handoff-fullstack.md +2 -2
  14. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  15. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  16. package/templates/.claude/hooks/block-dangerous.sh +76 -9
  17. package/templates/.claude/hooks/context-hardcap-gate.sh +26 -8
  18. package/templates/.claude/hooks/project-important.sh +70 -9
  19. package/templates/.claude/hooks/protect-files.sh +24 -7
  20. package/templates/.claude/hooks/sensitive-data-guard.sh +57 -5
  21. package/templates/.claude/settings.json +22 -117
  22. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +217 -10
  23. package/templates/.claude/ukit/runtime/hook-input.sh +119 -0
  24. package/templates/.omp/README.md +11 -7
  25. package/templates/.omp/RULES.md +7 -6
  26. package/templates/.omp/agents/bug-debugger.md +1 -1
  27. package/templates/.omp/agents/code-reviewer.md +1 -1
  28. package/templates/.omp/agents/feature-implementer.md +1 -1
  29. package/templates/.omp/agents/handoff-planner.md +1 -1
  30. package/templates/.omp/agents/ukit-small-task-maintainer.md +2 -2
  31. package/templates/.omp/config.yml +42 -8
  32. package/templates/.omp/hooks/pre/ukit-bridge.js +12 -1
  33. package/templates/AGENTS.md +23 -11
  34. package/templates/CLAUDE.md +23 -11
  35. package/templates/adapter-presets/opencode/opencode.template.json +1 -1
  36. package/templates/docs/UKIT_INTERNALS.md +1 -1
  37. package/templates/instructions/core.md +23 -11
  38. package/templates/instructions/layout.yaml +12 -12
  39. package/templates/instructions/overlays/omp-rules.md +7 -6
  40. package/templates/ukit/storage/config.json +9 -3
@@ -0,0 +1,920 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * audit-perf.mjs — TASK-233 (SPEC §2 FR-001) performance audit generator.
4
+ *
5
+ * Measures every SPEC §2 area from four independent sources and writes
6
+ * `docs/AI_REPORT/perf-findings.json` (machine-readable, consumed by TASK-234,
7
+ * TASK-235 and TASK-236) plus `docs/AI_REPORT/perf-measure.md` (raw appendix).
8
+ * Running this again with the same arguments IS the "measure it the same way
9
+ * both times" requirement from SPEC §5 — TASK-236 re-runs it for before/after.
10
+ *
11
+ * Sources, in the order they are consulted:
12
+ * 1. telemetry — `.ukit/storage/cache/hook-latency/*.jsonl`: per-hook rows,
13
+ * per-tool-call aggregates reconstructed on `toolUseId`, non-tool events
14
+ * clustered by time gap, and the nested per-script rows that
15
+ * `hook-chain-runner.mjs` already emits.
16
+ * 2. structure — `settings.json` hook groups, which decide the process-spawn
17
+ * count per (event, matcher) pair.
18
+ * 3. micro-benchmark — live spawn measurements: the `bash` and `node` boot
19
+ * floor, the advisory telemetry child, each settings row's script, and the
20
+ * index/router helpers invoked per prompt.
21
+ * 4. git-log — hot-path files added in the regression window (SPEC §2.3).
22
+ *
23
+ * Honesty contract: a number is emitted only when it was measured. An area with
24
+ * no telemetry rows keeps `null` numbers, `evidence: 'unavailable'` and
25
+ * `status: 'deferred'` — never a plausible-looking guess.
26
+ *
27
+ * Usage:
28
+ * node scripts/perf/audit-perf.mjs [--root <dir>] [--telemetry-dir <dir>]
29
+ * [--settings <file>] [--out <file>] [--since <days>] [--bench-iterations <n>]
30
+ *
31
+ * Advisory: exits 0 with a diagnostic when a source is missing, so the audit is
32
+ * runnable against a tree that has never installed UKit.
33
+ */
34
+
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { execFileSync, spawnSync } from 'node:child_process';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
41
+ const DEFAULT_ROOT = path.resolve(SCRIPT_DIR, '..', '..');
42
+
43
+ // Event clusters (non-tool events carry no toolUseId) are reconstructed by
44
+ // grouping consecutive rows of the same event within this window. A host fires
45
+ // a group's hooks back-to-back; the gap before an unrelated event of the same
46
+ // kind is normally model turn latency, orders of magnitude wider.
47
+ const CLUSTER_GAP_MS = 1500;
48
+
49
+ // SPEC §2.6: anything measured above this p50 on a per-call / per-prompt path
50
+ // is a finding in its own right, not merely a term inside a chain total.
51
+ const HOTSPOT_P50_MS = 50;
52
+
53
+ // A hook row costs the wrapper `bash`, its `node` runtime, and the advisory
54
+ // telemetry `node --finish` child armed by hook-telemetry.sh.
55
+ const PROCESSES_PER_HOOK_ROW = 3;
56
+
57
+ const HOOKS_SUBPATH = ['templates', '.claude', 'hooks'];
58
+ const CHAIN_RUNNER_LABEL = 'hook-chain-runner';
59
+
60
+ // Index/router helpers a prompt or an edit pays a cold start for (SPEC §2.5).
61
+ const ROUTER_HELPERS = [
62
+ ['skill-router.sh', ['.claude', 'hooks', 'skill-router.sh'], true],
63
+ ['query-index.mjs', ['scripts', 'index', 'query-index.mjs'], false],
64
+ ['route-task.mjs', ['.claude', 'ukit', 'index', 'route-task.mjs'], false],
65
+ ['resolve-context.mjs', ['.claude', 'ukit', 'index', 'resolve-context.mjs'], false],
66
+ ];
67
+
68
+ function parseArgs(argv) {
69
+ const options = {
70
+ root: DEFAULT_ROOT,
71
+ telemetryDir: null,
72
+ settings: null,
73
+ out: null,
74
+ sinceDays: 14,
75
+ benchIterations: 10,
76
+ };
77
+ for (let i = 0; i < argv.length; i += 1) {
78
+ const arg = argv[i];
79
+ const next = argv[i + 1];
80
+ switch (arg) {
81
+ case '--root': options.root = path.resolve(next); i += 1; break;
82
+ case '--telemetry-dir': options.telemetryDir = path.resolve(next); i += 1; break;
83
+ case '--settings': options.settings = path.resolve(next); i += 1; break;
84
+ case '--out': options.out = path.resolve(next); i += 1; break;
85
+ case '--since': options.sinceDays = Number.parseInt(next, 10); i += 1; break;
86
+ case '--bench-iterations': options.benchIterations = Number.parseInt(next, 10); i += 1; break;
87
+ default: break;
88
+ }
89
+ }
90
+ if (!Number.isFinite(options.sinceDays) || options.sinceDays <= 0) options.sinceDays = 14;
91
+ if (!Number.isFinite(options.benchIterations) || options.benchIterations <= 0) options.benchIterations = 10;
92
+ return options;
93
+ }
94
+
95
+ // --- statistics ------------------------------------------------------------
96
+
97
+ function percentile(values, p) {
98
+ if (!values.length) return null;
99
+ const sorted = [...values].sort((a, b) => a - b);
100
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.floor(sorted.length * p)));
101
+ return sorted[index];
102
+ }
103
+
104
+ function summarise(values) {
105
+ const clean = values.filter((value) => typeof value === 'number' && Number.isFinite(value));
106
+ if (!clean.length) return { n: 0, p50: null, p95: null, max: null, total: null };
107
+ return {
108
+ n: clean.length,
109
+ p50: percentile(clean, 0.5),
110
+ p95: percentile(clean, 0.95),
111
+ max: Math.max(...clean),
112
+ total: clean.reduce((sum, value) => sum + value, 0),
113
+ };
114
+ }
115
+
116
+ // --- source 1: telemetry ---------------------------------------------------
117
+
118
+ function loadTelemetry(dir) {
119
+ if (!dir || !fs.existsSync(dir)) {
120
+ return { available: false, dir: dir || null, files: 0, rows: [], malformed: 0 };
121
+ }
122
+ const files = fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl'));
123
+ const rows = [];
124
+ let malformed = 0;
125
+ for (const name of files) {
126
+ const body = fs.readFileSync(path.join(dir, name), 'utf8');
127
+ for (const line of body.split('\n')) {
128
+ if (!line.trim()) continue;
129
+ try {
130
+ rows.push(JSON.parse(line));
131
+ } catch {
132
+ malformed += 1;
133
+ }
134
+ }
135
+ }
136
+ const timestamps = rows.map((row) => row.ts).filter((ts) => typeof ts === 'number');
137
+ const span = timestamps.length
138
+ ? `${new Date(Math.min(...timestamps)).toISOString()} → ${new Date(Math.max(...timestamps)).toISOString()}`
139
+ : 'n/a';
140
+ return { available: true, dir, files: files.length, rows, malformed, span };
141
+ }
142
+
143
+ /** Per-hook stats, keyed by hook basename (includes the `(unattributed)` bucket). */
144
+ function perHookStats(rows) {
145
+ const byHook = new Map();
146
+ for (const row of rows) {
147
+ const key = row.hook || '(unattributed)';
148
+ const bucket = byHook.get(key) || [];
149
+ bucket.push(row.elapsedMs);
150
+ byHook.set(key, bucket);
151
+ }
152
+ const out = new Map();
153
+ for (const [hook, values] of byHook) out.set(hook, summarise(values));
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * One record per tool call (rows sharing an event and `toolUseId`) and per
159
+ * non-tool event cluster. Each record exposes `costMs` — the wall time the host
160
+ * actually waited — the execution path it came from, and a per-hook fire count
161
+ * that proves or refutes repeats.
162
+ *
163
+ * The two paths must never be summed: when a `hook-chain-runner` row is present
164
+ * its child rows and the direct per-hook rows describe the SAME executions (the
165
+ * runner emits both), so the chain total is authoritative there and the direct
166
+ * rows are kept only for multiplicity.
167
+ */
168
+ function callRecords(rows) {
169
+ const toolCalls = new Map();
170
+ const eventRows = new Map();
171
+ for (const row of rows) {
172
+ if (row.toolUseId) {
173
+ // Keyed by event too: PreToolUse and PostToolUse for one tool call share a
174
+ // toolUseId and are separate hook chains this audit must not merge.
175
+ const key = `${row.hookEvent}|${row.toolUseId}`;
176
+ const bucket = toolCalls.get(key) || [];
177
+ bucket.push(row);
178
+ toolCalls.set(key, bucket);
179
+ } else {
180
+ const event = row.hookEvent || '(unattributed)';
181
+ const bucket = eventRows.get(event) || [];
182
+ bucket.push(row);
183
+ eventRows.set(event, bucket);
184
+ }
185
+ }
186
+
187
+ const records = [];
188
+ for (const [, bucket] of toolCalls) {
189
+ const first = bucket[0];
190
+ records.push(buildCallRecord({ event: first.hookEvent, tool: first.toolName || null }, bucket));
191
+ }
192
+
193
+ // A cluster's rows are only complete once the group closes, so clusters are
194
+ // collected first and turned into records with the identical cost model after.
195
+ const clusters = new Map();
196
+ for (const [event, list] of eventRows) {
197
+ list.sort((a, b) => (a.ts || 0) - (b.ts || 0));
198
+ let current = null;
199
+ for (const row of list) {
200
+ if (!current || (row.ts || 0) - current.last > CLUSTER_GAP_MS) {
201
+ current = { rows: [] };
202
+ clusters.set(event, (clusters.get(event) || []).concat([current]));
203
+ }
204
+ current.rows.push(row);
205
+ current.last = row.ts || 0;
206
+ }
207
+ }
208
+ let index = 0;
209
+ for (const [event, list] of clusters) {
210
+ for (const cluster of list) {
211
+ index += 1;
212
+ records.push(buildCallRecord({ event, tool: null, cluster: index }, cluster.rows));
213
+ }
214
+ }
215
+ return records;
216
+ }
217
+
218
+ function countHooks(rows) {
219
+ const counts = {};
220
+ for (const row of rows) {
221
+ const key = row.hook || '(unattributed)';
222
+ counts[key] = (counts[key] || 0) + 1;
223
+ }
224
+ return counts;
225
+ }
226
+
227
+ function sumElapsed(rows) {
228
+ return rows.reduce((sum, row) => sum + (row.elapsedMs || 0), 0);
229
+ }
230
+
231
+ /**
232
+ * Cost model shared by tool calls and event clusters. `hook-chain-runner` is
233
+ * the consolidated path; direct rows around it are the same child executions,
234
+ * so the chain total is authoritative and `path` records which model applies.
235
+ */
236
+ function buildCallRecord(identity, rows) {
237
+ const direct = rows.filter((row) => row.hook !== CHAIN_RUNNER_LABEL);
238
+ const chained = rows.filter((row) => row.hook === CHAIN_RUNNER_LABEL);
239
+ return {
240
+ ...identity,
241
+ path: chained.length ? 'chain-runner' : 'direct',
242
+ costMs: chained.length ? sumElapsed(chained) : sumElapsed(direct),
243
+ direct: sumElapsed(direct),
244
+ chained: chained.length ? sumElapsed(chained) : null,
245
+ hookCounts: countHooks(direct),
246
+ directHooks: direct.length,
247
+ chainedHooks: chained.length,
248
+ };
249
+ }
250
+
251
+ /** Aggregate records by event, optionally restricted to a set of tools. */
252
+ function aggregateCalls(records, event, tools) {
253
+ const selected = records.filter((record) => record.event === event
254
+ && (tools === null || (record.tool !== null && tools.includes(record.tool))));
255
+ return {
256
+ calls: selected.length,
257
+ cost: summarise(selected.map((record) => record.costMs)),
258
+ hookRows: summarise(selected.map((record) => record.directHooks + record.chainedHooks)),
259
+ directOnly: summarise(selected.filter((record) => record.path === 'direct')
260
+ .map((record) => record.costMs)),
261
+ chainOnly: summarise(selected.filter((record) => record.path === 'chain-runner')
262
+ .map((record) => record.costMs)),
263
+ byPath: {
264
+ direct: selected.filter((record) => record.path === 'direct').length,
265
+ chainRunner: selected.filter((record) => record.path === 'chain-runner').length,
266
+ },
267
+ };
268
+ }
269
+
270
+ /** Non-tool event aggregates, in the same shape as a tool-call aggregate. */
271
+ function eventAggregates(records) {
272
+ const names = new Set(records.filter((record) => record.tool === null
273
+ && record.cluster !== undefined).map((record) => record.event));
274
+ const out = new Map();
275
+ for (const event of names) out.set(event, aggregateCalls(records, event, null));
276
+ return out;
277
+ }
278
+
279
+ /** Fire count of each hook inside a single tool call — proves or refutes repeats. */
280
+ function hookMultiplicity(records) {
281
+ const perHook = new Map();
282
+ for (const record of records) {
283
+ if (!record.hookCounts) continue;
284
+ for (const [hook, count] of Object.entries(record.hookCounts)) {
285
+ const tally = perHook.get(hook) || new Map();
286
+ tally.set(count, (tally.get(count) || 0) + 1);
287
+ perHook.set(hook, tally);
288
+ }
289
+ }
290
+ const out = new Map();
291
+ for (const [hook, tally] of perHook) {
292
+ const distribution = {};
293
+ for (const count of [...tally.keys()].sort((a, b) => a - b)) distribution[count] = tally.get(count);
294
+ out.set(hook, {
295
+ maxPerCall: Math.max(...tally.keys()),
296
+ calls: [...tally.values()].reduce((sum, value) => sum + value, 0),
297
+ distribution,
298
+ });
299
+ }
300
+ return out;
301
+ }
302
+
303
+ /** Nested per-script stats from `hook-chain-runner` rows (the omp path). */
304
+ function nestedScriptStats(rows) {
305
+ const byScript = new Map();
306
+ for (const row of rows) {
307
+ if (row.hook !== CHAIN_RUNNER_LABEL || !Array.isArray(row.scripts)) continue;
308
+ for (const child of row.scripts) {
309
+ const bucket = byScript.get(child.scriptName) || [];
310
+ bucket.push(child.elapsedMs);
311
+ byScript.set(child.scriptName, bucket);
312
+ }
313
+ }
314
+ const out = new Map();
315
+ for (const [name, values] of byScript) out.set(name, summarise(values));
316
+ return out;
317
+ }
318
+
319
+ // --- source 2: settings.json structure -------------------------------------
320
+
321
+ function loadSettings(root, override) {
322
+ const candidates = override
323
+ ? [override]
324
+ : [
325
+ path.join(root, 'templates', '.claude', 'settings.json'),
326
+ path.join(root, '.claude', 'settings.json'),
327
+ ];
328
+ for (const candidate of candidates) {
329
+ if (fs.existsSync(candidate)) {
330
+ return { file: candidate, settings: JSON.parse(fs.readFileSync(candidate, 'utf8')) };
331
+ }
332
+ }
333
+ return { file: null, settings: null };
334
+ }
335
+
336
+ function describeGroups(settings) {
337
+ if (!settings || !settings.hooks) return [];
338
+ const groups = [];
339
+ for (const [event, list] of Object.entries(settings.hooks)) {
340
+ for (const group of list) {
341
+ groups.push({
342
+ event,
343
+ matcher: group.matcher || null,
344
+ tools: group.matcher ? group.matcher.split('|') : null,
345
+ scripts: (group.hooks || []).map((hook) => ({
346
+ command: hook.command,
347
+ script: String(hook.command || '').replace(/.*\//, '').replace(/"/g, ''),
348
+ timeout: hook.timeout,
349
+ })),
350
+ });
351
+ }
352
+ }
353
+ return groups;
354
+ }
355
+
356
+ // --- source 3: micro-benchmarks -------------------------------------------
357
+
358
+ function benchSpawn(file, args, iterations, env) {
359
+ const samples = [];
360
+ for (let i = 0; i < iterations; i += 1) {
361
+ const start = process.hrtime.bigint();
362
+ spawnSync(file, args, { env, input: '{}', stdio: ['pipe', 'ignore', 'ignore'] });
363
+ samples.push(Number(process.hrtime.bigint() - start) / 1e6);
364
+ }
365
+ return summarise(samples);
366
+ }
367
+
368
+ function benchmarkFloor(root, iterations) {
369
+ const env = { ...process.env, CLAUDE_PROJECT_DIR: root };
370
+ const telemetryTwin = path.join(root, 'templates', '.claude', 'ukit', 'runtime', 'hook-telemetry.mjs');
371
+ return {
372
+ floor: {
373
+ bash: benchSpawn('bash', ['-c', 'true'], iterations, env),
374
+ node: benchSpawn('node', ['-e', '0'], iterations, env),
375
+ },
376
+ telemetryFinish: fs.existsSync(telemetryTwin)
377
+ ? benchSpawn('node', [telemetryTwin, '--finish'], iterations, {
378
+ ...env,
379
+ UKIT_TEL_HOOK: 'audit-bench.sh',
380
+ UKIT_TEL_RC: '0',
381
+ PROJECT_ROOT: root,
382
+ })
383
+ : null,
384
+ };
385
+ }
386
+
387
+ /** Resolves the directory holding the shipped hooks, preferring the template. */
388
+ function hooksDir(root) {
389
+ const candidates = [
390
+ path.join(root, ...HOOKS_SUBPATH),
391
+ path.join(root, '.claude', 'hooks'),
392
+ ];
393
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
394
+ }
395
+
396
+ function benchmarkHooks(root, groups, iterations) {
397
+ const dir = hooksDir(root);
398
+ const results = new Map();
399
+ if (!dir) return { results, dir: null };
400
+ const env = { ...process.env, CLAUDE_PROJECT_DIR: root };
401
+ const names = new Set(groups.flatMap((group) => group.scripts.map((script) => script.script)));
402
+ for (const name of names) {
403
+ const file = path.join(dir, name);
404
+ if (!fs.existsSync(file)) continue;
405
+ results.set(name, benchSpawn('/bin/bash', [file], iterations, env));
406
+ }
407
+ return { results, dir };
408
+ }
409
+
410
+ function benchmarkRouterHelpers(root, iterations) {
411
+ const env = { ...process.env, CLAUDE_PROJECT_DIR: root };
412
+ const results = new Map();
413
+ for (const [label, relative, isHook] of ROUTER_HELPERS) {
414
+ const file = path.join(root, ...relative);
415
+ if (!fs.existsSync(file)) continue;
416
+ const args = isHook ? [file] : [file];
417
+ results.set(label, benchSpawn(isHook ? '/bin/bash' : 'node', args, iterations, env));
418
+ }
419
+ return results;
420
+ }
421
+
422
+ // --- source 4: git log ----------------------------------------------------
423
+
424
+ function recentHotPathAdditions(root, sinceDays) {
425
+ try {
426
+ const stdout = execFileSync(
427
+ 'git',
428
+ [
429
+ '-C', root, 'log',
430
+ `--since=${sinceDays} days ago`,
431
+ '--format=__C__%h|%ad|%s',
432
+ '--date=short',
433
+ '--name-only',
434
+ '--diff-filter=A',
435
+ '--',
436
+ 'templates/.claude/hooks',
437
+ 'templates/.claude/ukit/runtime',
438
+ 'templates/.claude/ukit/index',
439
+ '.claude/hooks',
440
+ '.claude/ukit/runtime',
441
+ ],
442
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
443
+ );
444
+ const commits = [];
445
+ let current = null;
446
+ for (const line of stdout.split('\n')) {
447
+ if (line.startsWith('__C__')) {
448
+ const [hash, date, subject] = line.slice(5).split('|');
449
+ current = { hash, date, subject, files: [] };
450
+ commits.push(current);
451
+ } else if (line.trim() && current) {
452
+ current.files.push(line.trim());
453
+ }
454
+ }
455
+ return commits.filter((commit) => commit.files.length);
456
+ } catch {
457
+ return [];
458
+ }
459
+ }
460
+
461
+ // --- findings --------------------------------------------------------------
462
+
463
+ function finding(entry) {
464
+ return {
465
+ id: entry.id,
466
+ area: entry.area,
467
+ evidence: entry.evidence,
468
+ evidenceDetail: entry.evidenceDetail,
469
+ p50ms: entry.p50ms ?? null,
470
+ p95ms: entry.p95ms ?? null,
471
+ totalMs: entry.totalMs ?? null,
472
+ invocations: entry.invocations ?? 0,
473
+ rootCause: entry.rootCause,
474
+ fixTask: entry.fixTask ?? null,
475
+ status: entry.status,
476
+ };
477
+ }
478
+
479
+ function slug(value) {
480
+ return String(value).replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase();
481
+ }
482
+
483
+ function buildFindings(context) {
484
+ const {
485
+ telemetry, telemetryDetail, groups, floor, telemetryFinish, hookBench,
486
+ routerBench, perHook, records, events, multiplicity, nested, additions, spawns,
487
+ } = context;
488
+
489
+ const findings = [];
490
+ const measuredEvidence = telemetry.available ? 'telemetry' : 'unavailable';
491
+
492
+ // Every settings.json group is a spawn-count finding: whatever its measured
493
+ // p50, the fix is the same consolidation. `deferred` is reserved for the case
494
+ // where this tree has no rows to measure the group at all.
495
+ const classify = (stats) => (stats && stats.p50 !== null
496
+ ? { status: 'confirmed', fixTask: 'TASK-234' }
497
+ : { status: 'deferred', fixTask: null });
498
+
499
+ // 1. One finding per settings.json group — the spawn-count unit.
500
+ for (const group of groups) {
501
+ const measured = group.matcher
502
+ ? aggregateCalls(records, group.event, group.tools)
503
+ : (events.get(group.event) || null);
504
+ const stats = measured ? measured.cost : null;
505
+ const hasMeasurement = Boolean(stats && stats.n > 0);
506
+ const spawnsPerCall = group.scripts.length * PROCESSES_PER_HOOK_ROW;
507
+ const hookList = group.scripts
508
+ .map((script) => {
509
+ const bench = hookBench.get(script.script);
510
+ return `${script.script} (${script.timeout}s${bench ? `, ${bench.p50.toFixed(0)}ms` : ''})`;
511
+ })
512
+ .join(', ');
513
+ const verdict = classify(hasMeasurement ? stats : null);
514
+
515
+ findings.push(finding({
516
+ id: `chain-${slug(group.event)}-${slug(group.matcher || 'all')}`,
517
+ area: `hook chain — ${group.event}${group.matcher ? ` ${group.matcher}` : ' (all tools)'} — ${group.scripts.length} hook${group.scripts.length === 1 ? '' : 's'}`,
518
+ evidence: hasMeasurement ? measuredEvidence : 'settings.json',
519
+ evidenceDetail: hasMeasurement
520
+ ? `${telemetryDetail}; measured per-call hook cost for the group's matching tools (n=${stats.n}, p50 ${stats.p50}ms, ${measured.hookRows ? measured.hookRows.p50 : 'n/a'} hook rows/call; paths: ${measured.byPath.direct} direct / ${measured.byPath.chainRunner} chain-runner). Hooks: ${hookList}`
521
+ : `settings.json group only — no telemetry rows are attributable to this group, so no per-call cost is reported. Hooks: ${hookList}`,
522
+ p50ms: hasMeasurement ? stats.p50 : null,
523
+ p95ms: hasMeasurement ? stats.p95 : null,
524
+ totalMs: hasMeasurement ? stats.total : null,
525
+ invocations: hasMeasurement ? stats.n : 0,
526
+ rootCause: `${spawnsPerCall} process spawns per call (${group.scripts.length} settings rows × ${PROCESSES_PER_HOOK_ROW}: wrapper bash + node runtime + advisory telemetry node child), against a measured floor of bash ${floor.bash.p50.toFixed(1)}ms + node ${floor.node.p50.toFixed(1)}ms + telemetry finish ${telemetryFinish ? telemetryFinish.p50.toFixed(1) : 'n/a'}ms. ${
527
+ hasMeasurement && stats.p50 > HOTSPOT_P50_MS
528
+ ? 'Measured p50 exceeds the 50ms hotspot bar, so the spawn multiplier is the dominant term.'
529
+ : hasMeasurement
530
+ ? 'Measured p50 is under the 50ms hotspot bar; consolidation is still the mechanism that removes the spawns.'
531
+ : 'Not measured on this tree.'
532
+ }`,
533
+ fixTask: verdict.fixTask,
534
+ status: verdict.status,
535
+ }));
536
+ }
537
+
538
+ // 2. Per-hook hotspots above the SPEC §2.6 bar.
539
+ for (const [hook, stats] of perHook) {
540
+ if (stats.p50 === null || stats.p50 <= HOTSPOT_P50_MS) continue;
541
+ const bench = hookBench.get(hook);
542
+ findings.push(finding({
543
+ id: `hook-${slug(hook)}`,
544
+ area: `single hook — ${hook}`,
545
+ evidence: measuredEvidence,
546
+ evidenceDetail: `${telemetryDetail}; standalone re-run p50 ${bench ? `${bench.p50.toFixed(1)}ms` : 'not benchmarked (script not resolvable from this root)'}`,
547
+ p50ms: stats.p50,
548
+ p95ms: stats.p95,
549
+ totalMs: stats.total,
550
+ invocations: stats.n,
551
+ rootCause: `per-invocation cost is dominated by the ${PROCESSES_PER_HOOK_ROW}-spawn hook row (wrapper bash ${floor.bash.p50.toFixed(1)}ms + node ${floor.node.p50.toFixed(1)}ms + telemetry finish ${telemetryFinish ? telemetryFinish.p50.toFixed(1) : 'n/a'}ms), plus this script's own work`,
552
+ fixTask: 'TASK-234',
553
+ status: 'confirmed',
554
+ }));
555
+ }
556
+
557
+ // 3. project-important.sh tail (SPEC §2.2).
558
+ const pi = perHook.get('project-important.sh');
559
+ if (pi && pi.p95 !== null) {
560
+ const piClusters = records.filter((record) => record.hookCounts
561
+ && record.hookCounts['project-important.sh']);
562
+ const piEvents = [...new Set(piClusters.map((record) => record.event || '(no event)'))].join(', ');
563
+ findings.push(finding({
564
+ id: 'hook-project-important-tail',
565
+ area: 'project-important.sh — PROJECT_IMPORTANT.md injection tail',
566
+ evidence: measuredEvidence,
567
+ evidenceDetail: `${telemetryDetail}; bimodal across ${pi.n} invocations: p50 ${pi.p50}ms vs p95 ${pi.p95}ms vs max ${pi.max}ms (paths seen: ${piEvents}) — a cheap path and an expensive path with no cache between them`,
568
+ p95ms: pi.p95,
569
+ totalMs: pi.total,
570
+ invocations: pi.n,
571
+ rootCause: 'the renderer re-reads PROJECT_IMPORTANT.md and re-runs its full work (strict UTF-8 decode, control-character validation, sensitive-value scan, envelope render) on every invocation; there is no mtime/content-keyed cache, so repeat runs pay the whole cost instead of replaying the previous result',
572
+ fixTask: 'TASK-235',
573
+ status: pi.p95 > HOTSPOT_P50_MS ? 'confirmed' : 'not-a-bug',
574
+ }));
575
+ } else {
576
+ findings.push(finding({
577
+ id: 'hook-project-important-tail',
578
+ area: 'project-important.sh — PROJECT_IMPORTANT.md injection tail',
579
+ evidence: 'unavailable',
580
+ evidenceDetail: `${telemetryDetail}; the 1.5s p95 tail reported by SPEC §2.2 cannot be sized on this tree`,
581
+ p50ms: null,
582
+ p95ms: null,
583
+ totalMs: null,
584
+ invocations: 0,
585
+ rootCause: 'no telemetry rows for project-important.sh exist here; the tail must be measured before TASK-235 can claim a fix',
586
+ fixTask: null,
587
+ status: 'deferred',
588
+ }));
589
+ }
590
+
591
+ // 4. record-execution.sh multiplicity (SPEC §2.4) — hypothesis check.
592
+ const recordExec = multiplicity.get('record-execution.sh');
593
+ const recordStats = perHook.get('record-execution.sh');
594
+ const recordGroups = groups.filter((group) => group.scripts.some((script) => script.script === 'record-execution.sh')).length;
595
+ findings.push(finding({
596
+ id: 'record-execution-multiplicity',
597
+ area: `record-execution.sh — PostToolUse receipt writer registered in ${recordGroups} matcher groups`,
598
+ evidence: recordExec ? measuredEvidence : 'settings.json',
599
+ evidenceDetail: recordExec
600
+ ? `${telemetryDetail}; measured fire count per tool call = ${recordExec.maxPerCall} (distribution ${JSON.stringify(recordExec.distribution)} over ${recordExec.calls} calls). The "once per matching matcher group" repeat hypothesis is REFUTED: settings.json names the hook in ${recordGroups} groups, but those matchers are mutually exclusive per tool, so it runs exactly once per call. Its real cost is breadth — highest-invocation hook measured (n=${recordStats ? recordStats.n : 0}, p50 ${recordStats ? recordStats.p50 : 'n/a'}ms, total ${recordStats ? recordStats.total : 'n/a'}ms).`
601
+ : `settings.json lists record-execution.sh in ${recordGroups} matcher groups; without telemetry the per-call fire count cannot be proven`,
602
+ p50ms: recordStats ? recordStats.p50 : null,
603
+ p95ms: recordStats ? recordStats.p95 : null,
604
+ totalMs: recordStats ? recordStats.total : null,
605
+ invocations: recordStats ? recordStats.n : (recordExec ? recordExec.calls : 0),
606
+ rootCause: recordExec
607
+ ? 'not a duplication bug: the matchers (Read|Grep|Glob, Edit|Write, Bash) are mutually exclusive, so at most one group fires per tool. The cost is that one full 3-spawn hook row is paid on every tool call of every kind — breadth, not repeats.'
608
+ : 'unverified: a repeat can only be distinguished from breadth with per-tool-call telemetry',
609
+ fixTask: recordStats && recordStats.p50 > HOTSPOT_P50_MS ? 'TASK-234' : null,
610
+ status: recordExec && recordStats && recordStats.p50 > HOTSPOT_P50_MS ? 'confirmed' : 'not-a-bug',
611
+ }));
612
+
613
+ // 5. index/router cold start (SPEC §2.5).
614
+ const promptEvents = events.get('UserPromptSubmit') || null;
615
+ const routerStats = routerBench.get('skill-router.sh') || hookBench.get('skill-router.sh') || null;
616
+ if (promptEvents || routerStats) {
617
+ const helperText = ROUTER_HELPERS
618
+ .map(([label]) => {
619
+ const stats = routerBench.get(label) || hookBench.get(label);
620
+ return `${label} ${stats ? `${stats.p50.toFixed(1)}ms` : 'not benchmarked'}`;
621
+ })
622
+ .join(', ');
623
+ findings.push(finding({
624
+ id: 'index-router-cold-start',
625
+ area: 'index/router helpers invoked per prompt (skill-router → resolve-context / route-task / query-index)',
626
+ evidence: promptEvents && promptEvents.cost.n ? measuredEvidence : 'micro-benchmark',
627
+ evidenceDetail: `${telemetryDetail}; UserPromptSubmit groups: ${promptEvents ? `n=${promptEvents.cost.n}, p50 ${promptEvents.cost.p50}ms, p95 ${promptEvents.cost.p95}ms, ${promptEvents.hookRows.p50} hook rows/group` : 'none'}. Standalone cold starts: ${helperText}`,
628
+ p50ms: promptEvents ? promptEvents.cost.p50 : (routerStats ? routerStats.p50 : null),
629
+ p95ms: promptEvents ? promptEvents.cost.p95 : (routerStats ? routerStats.p95 : null),
630
+ totalMs: promptEvents ? promptEvents.cost.total : null,
631
+ invocations: promptEvents ? promptEvents.cost.n : 0,
632
+ rootCause: 'every router helper pays a fresh node cold start (no warm process is reused), and skill-router.sh additionally imports the index core in-process per invocation; the cost is process boot plus module load, not the routing decision itself',
633
+ fixTask: 'TASK-234',
634
+ status: 'confirmed',
635
+ }));
636
+ }
637
+
638
+ // 6. Recent hot-path additions (SPEC §2.3).
639
+ const addedFiles = additions.flatMap((commit) => commit.files);
640
+ if (addedFiles.length) {
641
+ findings.push(finding({
642
+ id: 'c29-c32-hot-path-additions',
643
+ area: `recent hot-path additions — ${addedFiles.length} files across ${additions.length} commits`,
644
+ evidence: 'git-log',
645
+ evidenceDetail: `git log --since=${context.sinceDays} days ago --diff-filter=A -- templates/.claude/hooks templates/.claude/ukit/runtime templates/.claude/ukit/index: ${addedFiles.slice(0, 12).join(', ')}${addedFiles.length > 12 ? `, +${addedFiles.length - 12} more` : ''}`,
646
+ p50ms: null,
647
+ p95ms: null,
648
+ totalMs: null,
649
+ invocations: addedFiles.length,
650
+ rootCause: 'each added hook file becomes another settings.json row on the event it joins, i.e. another 3-spawn hook row per call (SPEC §1.3 consolidation is what removes them); each added runtime module is an extra cold-start import paid per invocation. The regression is additive spawn count, not one slow file.',
651
+ fixTask: 'TASK-234',
652
+ status: 'confirmed',
653
+ }));
654
+ }
655
+
656
+ // 7. The already-consolidated path — the TASK-234 reuse evidence.
657
+ const chainStats = perHook.get(CHAIN_RUNNER_LABEL);
658
+ if (chainStats && chainStats.n) {
659
+ const nestedText = [...nested.entries()]
660
+ .sort((a, b) => b[1].n - a[1].n)
661
+ .slice(0, 6)
662
+ .map(([name, stats]) => `${name} p50 ${stats.p50}ms`)
663
+ .join(', ');
664
+ findings.push(finding({
665
+ id: 'existing-chain-runner-baseline',
666
+ area: 'hook-chain-runner.mjs — already-consolidated omp path (TASK-234 reuse target)',
667
+ evidence: measuredEvidence,
668
+ evidenceDetail: `${telemetryDetail}; a whole chain of ${spawns.chainScripts ? `${spawns.chainScripts} ` : ''}scripts runs in ONE node process at p50 ${chainStats.p50}ms / p95 ${chainStats.p95}ms (n=${chainStats.n}), while a Claude settings group of comparable size pays ${PROCESSES_PER_HOOK_ROW} spawns per script. Nested per-script rows: ${nestedText}`,
669
+ p50ms: chainStats.p50,
670
+ p95ms: chainStats.p95,
671
+ totalMs: chainStats.total,
672
+ invocations: chainStats.n,
673
+ rootCause: 'no defect — this is the measured proof that one-process-per-group execution is achievable with per-script telemetry intact, so TASK-234 should re-point settings.json at this runner rather than build a new one',
674
+ fixTask: 'TASK-234',
675
+ status: 'confirmed',
676
+ }));
677
+ }
678
+
679
+ // 8. The measured floor itself.
680
+ findings.push(finding({
681
+ id: 'process-floor',
682
+ area: 'process spawn floor (bash + node + advisory telemetry child)',
683
+ evidence: 'micro-benchmark',
684
+ evidenceDetail: `live spawnSync over ${context.iterations} iterations: bash -c true p50 ${floor.bash.p50.toFixed(2)}ms, node -e 0 p50 ${floor.node.p50.toFixed(2)}ms, node hook-telemetry.mjs --finish p50 ${telemetryFinish ? telemetryFinish.p50.toFixed(2) : 'n/a'}ms`,
685
+ p50ms: floor.bash.p50 + floor.node.p50 + (telemetryFinish ? telemetryFinish.p50 : 0),
686
+ p95ms: floor.bash.p95 + floor.node.p95 + (telemetryFinish ? telemetryFinish.p95 : 0),
687
+ totalMs: null,
688
+ invocations: 0,
689
+ rootCause: 'each settings.json hook row is a bash wrapper that boots node, then arms a second node child for advisory telemetry; this floor is paid per row, so it multiplies by the row count of the event',
690
+ fixTask: 'TASK-234',
691
+ status: 'not-a-bug',
692
+ }));
693
+
694
+ // 9. Registered hooks with no rows at all — evidenced absence, not a guess.
695
+ const settingsScripts = [...new Set(groups.flatMap((group) => group.scripts.map((script) => script.script)))];
696
+ for (const script of settingsScripts) {
697
+ if (perHook.has(script)) continue;
698
+ findings.push(finding({
699
+ id: `hook-${slug(script)}-unmeasured`,
700
+ area: `single hook — ${script} (registered in settings.json, no telemetry rows)`,
701
+ evidence: 'unavailable',
702
+ evidenceDetail: `${telemetryDetail}; no row exists for this script, so no per-invocation number is reported. It still costs ${PROCESSES_PER_HOOK_ROW} spawns on its event and is therefore already counted inside that chain's total.`,
703
+ p50ms: null,
704
+ p95ms: null,
705
+ totalMs: null,
706
+ invocations: 0,
707
+ rootCause: `no measurement exists for ${script} on this tree — its cost stays unset (evidenced by absence) rather than estimated; the chain it belongs to carries the spawn floor`,
708
+ fixTask: null,
709
+ status: 'deferred',
710
+ }));
711
+ }
712
+
713
+ return findings;
714
+ }
715
+
716
+ // --- markdown appendix -----------------------------------------------------
717
+
718
+ function renderMeasureDoc(context) {
719
+ const {
720
+ generatedAt, root, telemetry, telemetryDetail, perHook, records, events,
721
+ multiplicity, nested, groups, floor, telemetryFinish, hookBench, routerBench,
722
+ additions, findings, settingsFile, iterations, hooksResolvedFrom,
723
+ } = context;
724
+ const lines = [];
725
+ lines.push('# perf-measure.md — raw measurement notes (TASK-233)');
726
+ lines.push('');
727
+ lines.push(`Generated: ${generatedAt}`);
728
+ lines.push('');
729
+ lines.push('Appendix source for `docs/AI_REPORT/AI_REVIEW_BUGS_REPORT.md`. Every number in');
730
+ lines.push('`perf-findings.json` comes from one of the four sources below. TASK-236 re-runs');
731
+ lines.push('the identical command for the before/after table (SPEC §5).');
732
+ lines.push('');
733
+ lines.push('## 1. Reproduce');
734
+ lines.push('');
735
+ lines.push('```bash');
736
+ lines.push('node scripts/perf/audit-perf.mjs \\');
737
+ lines.push(` --telemetry-dir "${telemetry.dir || path.join(root, '.ukit', 'storage', 'cache', 'hook-latency')}" \\`);
738
+ lines.push(` --out "${root}/docs/AI_REPORT/perf-findings.json"`);
739
+ lines.push('node --test tests/handoff/c33/perfFindings.test.js');
740
+ lines.push('```');
741
+ lines.push('');
742
+ lines.push(`- root: \`${root}\``);
743
+ lines.push(`- settings.json: \`${settingsFile || 'NOT FOUND'}\``);
744
+ lines.push(`- hooks resolved from: \`${hooksResolvedFrom || 'NOT FOUND'}\``);
745
+ lines.push(`- telemetry: ${telemetry.available ? `\`${telemetry.dir}\` — ${telemetry.rows.length} rows in ${telemetry.files} files${telemetry.malformed ? ` (${telemetry.malformed} malformed lines skipped)` : ''}` : 'NOT PRESENT — every telemetry-sourced finding is `evidence: "unavailable"`'}`);
746
+ lines.push(`- benchmark iterations per subject: ${iterations}`);
747
+ lines.push('');
748
+ lines.push('## 2. Process floor (live spawnSync, ms)');
749
+ lines.push('');
750
+ lines.push('| subject | p50 | p95 | max |');
751
+ lines.push('|---|---|---|---|');
752
+ lines.push(`| \`bash -c true\` | ${floor.bash.p50.toFixed(2)} | ${floor.bash.p95.toFixed(2)} | ${floor.bash.max.toFixed(2)} |`);
753
+ lines.push(`| \`node -e 0\` | ${floor.node.p50.toFixed(2)} | ${floor.node.p95.toFixed(2)} | ${floor.node.max.toFixed(2)} |`);
754
+ if (telemetryFinish) {
755
+ lines.push(`| \`node hook-telemetry.mjs --finish\` | ${telemetryFinish.p50.toFixed(2)} | ${telemetryFinish.p95.toFixed(2)} | ${telemetryFinish.max.toFixed(2)} |`);
756
+ }
757
+ lines.push('');
758
+ lines.push(`A hook row = wrapper bash + node runtime + advisory telemetry child = **${PROCESSES_PER_HOOK_ROW} processes**.`);
759
+ lines.push('');
760
+ lines.push('## 3. Per-hook standalone re-run (`/bin/bash <script>` with `{}` on stdin, ms)');
761
+ lines.push('');
762
+ lines.push('| hook | p50 | p95 | max |');
763
+ lines.push('|---|---|---|---|');
764
+ for (const [name, stats] of [...hookBench.entries()].sort((a, b) => b[1].p50 - a[1].p50)) {
765
+ lines.push(`| \`${name}\` | ${stats.p50.toFixed(1)} | ${stats.p95.toFixed(1)} | ${stats.max.toFixed(1)} |`);
766
+ }
767
+ if (!hookBench.size) lines.push('| _(no settings hook resolvable from this root)_ | | | |');
768
+ lines.push('');
769
+ lines.push('## 4. Router/index helper cold start (ms)');
770
+ lines.push('');
771
+ lines.push('| helper | p50 | p95 | max |');
772
+ lines.push('|---|---|---|---|');
773
+ for (const [name, stats] of [...routerBench.entries()].sort((a, b) => b[1].p50 - a[1].p50)) {
774
+ lines.push(`| \`${name}\` | ${stats.p50.toFixed(1)} | ${stats.p95.toFixed(1)} | ${stats.max.toFixed(1)} |`);
775
+ }
776
+ if (!routerBench.size) lines.push('| _(no helper resolvable from this root)_ | | | |');
777
+ lines.push('');
778
+ lines.push('## 5. Per-hook telemetry (every hook with rows)');
779
+ lines.push('');
780
+ lines.push('| hook | n | p50 | p95 | max | total |');
781
+ lines.push('|---|---|---|---|---|---|');
782
+ for (const [hook, stats] of [...perHook.entries()].sort((a, b) => (b[1].total || 0) - (a[1].total || 0))) {
783
+ lines.push(`| \`${hook}\` | ${stats.n} | ${stats.p50 ?? 'n/a'} | ${stats.p95 ?? 'n/a'} | ${stats.max ?? 'n/a'} | ${stats.total ?? 'n/a'} |`);
784
+ }
785
+ lines.push('');
786
+ lines.push('## 6. Per tool / per event cost (chain-runner total where present, else direct sum)');
787
+ lines.push('');
788
+ lines.push('| scope | calls | cost p50 | cost p95 | hook rows/call p50 | direct / chain-runner calls |');
789
+ lines.push('|---|---|---|---|---|---|');
790
+ const toolScopes = [...new Set(records.filter((record) => record.cluster === undefined)
791
+ .map((record) => `${record.event} ${record.tool || '-'}`))].sort();
792
+ for (const scope of toolScopes) {
793
+ const [event, ...rest] = scope.split(' ');
794
+ const aggregate = aggregateCalls(records, event, [rest.join(' ')]);
795
+ lines.push(`| ${scope} | ${aggregate.calls} | ${aggregate.cost.p50 ?? 'n/a'} | ${aggregate.cost.p95 ?? 'n/a'} | ${aggregate.hookRows.p50 ?? 'n/a'} | ${aggregate.byPath.direct} / ${aggregate.byPath.chainRunner} |`);
796
+ }
797
+ for (const [event, entry] of [...events.entries()].sort()) {
798
+ lines.push(`| event ${event} (clustered, gap ≤ ${CLUSTER_GAP_MS}ms) | ${entry.calls} | ${entry.cost.p50 ?? 'n/a'} | ${entry.cost.p95 ?? 'n/a'} | ${entry.hookRows.p50 ?? 'n/a'} | ${entry.byPath.direct} / ${entry.byPath.chainRunner} |`);
799
+ }
800
+ lines.push('');
801
+ lines.push('## 7. Hook multiplicity proof (repeat-fire check)');
802
+ lines.push('');
803
+ lines.push('| hook | max fires in one tool call | calls observed | fire-count distribution |');
804
+ lines.push('|---|---|---|---|');
805
+ for (const [hook, entry] of [...multiplicity.entries()].sort()) {
806
+ lines.push(`| \`${hook}\` | ${entry.maxPerCall} | ${entry.calls} | ${JSON.stringify(entry.distribution)} |`);
807
+ }
808
+ lines.push('');
809
+ lines.push('## 8. hook-chain-runner nested per-script (already-consolidated omp path)');
810
+ lines.push('');
811
+ lines.push('| script | n | p50 | p95 | max |');
812
+ lines.push('|---|---|---|---|---|');
813
+ for (const [name, stats] of [...nested.entries()].sort((a, b) => b[1].n - a[1].n)) {
814
+ lines.push(`| \`${name}\` | ${stats.n} | ${stats.p50} | ${stats.p95} | ${stats.max} |`);
815
+ }
816
+ lines.push('');
817
+ lines.push('## 9. settings.json groups (spawn budget per call)');
818
+ lines.push('');
819
+ lines.push('| event | matcher | hooks | registered timeouts | spawns/call |');
820
+ lines.push('|---|---|---|---|---|');
821
+ for (const group of groups) {
822
+ lines.push(`| ${group.event} | ${group.matcher || '(all)'} | ${group.scripts.map((script) => script.script).join(', ')} | ${group.scripts.map((script) => `${script.timeout}s`).join(', ')} | ${group.scripts.length * PROCESSES_PER_HOOK_ROW} |`);
823
+ }
824
+ lines.push('');
825
+ lines.push('## 10. Recent hot-path additions (`git log --diff-filter=A`)');
826
+ lines.push('');
827
+ lines.push('| commit | date | subject | files added |');
828
+ lines.push('|---|---|---|---|');
829
+ for (const commit of additions) {
830
+ lines.push(`| \`${commit.hash}\` | ${commit.date} | ${commit.subject} | ${commit.files.map((file) => `\`${file}\``).join(', ')} |`);
831
+ }
832
+ if (!additions.length) lines.push('| _(none in window)_ | | | |');
833
+ lines.push('');
834
+ lines.push('## 11. Findings emitted');
835
+ lines.push('');
836
+ lines.push('| id | evidence | p50 | p95 | status | fixTask |');
837
+ lines.push('|---|---|---|---|---|---|');
838
+ for (const item of findings) {
839
+ lines.push(`| \`${item.id}\` | ${item.evidence} | ${item.p50ms ?? 'n/a'} | ${item.p95ms ?? 'n/a'} | ${item.status} | ${item.fixTask || '-'} |`);
840
+ }
841
+ lines.push('');
842
+ return `${lines.join('\n')}\n`;
843
+ }
844
+
845
+ // --- main ------------------------------------------------------------------
846
+
847
+ function main() {
848
+ const options = parseArgs(process.argv.slice(2));
849
+ const root = options.root;
850
+ const telemetryDir = options.telemetryDir || path.join(root, '.ukit', 'storage', 'cache', 'hook-latency');
851
+ const outFile = options.out || path.join(root, 'docs', 'AI_REPORT', 'perf-findings.json');
852
+ const measureFile = path.join(path.dirname(outFile), 'perf-measure.md');
853
+
854
+ const telemetry = loadTelemetry(telemetryDir);
855
+ const { file: settingsFile, settings } = loadSettings(root, options.settings);
856
+ const groups = describeGroups(settings);
857
+
858
+ const perHook = perHookStats(telemetry.rows);
859
+ const records = callRecords(telemetry.rows);
860
+ const events = eventAggregates(records);
861
+ const multiplicity = hookMultiplicity(records);
862
+ const nested = nestedScriptStats(telemetry.rows);
863
+
864
+ const { floor, telemetryFinish } = benchmarkFloor(root, options.benchIterations);
865
+ const { results: hookBench, dir: hooksResolvedFrom } = benchmarkHooks(root, groups, options.benchIterations);
866
+ const routerBench = benchmarkRouterHelpers(root, options.benchIterations);
867
+ const additions = recentHotPathAdditions(root, options.sinceDays);
868
+ const chainScripts = (() => {
869
+ const lengths = new Map();
870
+ for (const row of telemetry.rows) {
871
+ if (row.hook !== CHAIN_RUNNER_LABEL || !Array.isArray(row.scripts)) continue;
872
+ lengths.set(row.scripts.length, (lengths.get(row.scripts.length) || 0) + 1);
873
+ }
874
+ return [...lengths.entries()].sort((a, b) => b[1] - a[1]).map(([length]) => length)[0] || 0;
875
+ })();
876
+
877
+ const telemetryDetail = telemetry.available
878
+ ? `.ukit/storage/cache/hook-latency — ${telemetry.rows.length} rows across ${telemetry.files} session files, span ${telemetry.span || 'n/a'}`
879
+ : `telemetry directory not present (${telemetry.dir || 'n/a'})`;
880
+
881
+ const generatedAt = new Date().toISOString();
882
+ const context = {
883
+ telemetry, telemetryDetail, groups, floor, telemetryFinish, hookBench, routerBench,
884
+ root,
885
+ perHook, records, events, multiplicity, nested, additions, settingsFile,
886
+ iterations: options.benchIterations,
887
+ sinceDays: options.sinceDays,
888
+ spawns: { chainScripts },
889
+ hooksResolvedFrom,
890
+ };
891
+ const findings = buildFindings({ ...context, generatedAt });
892
+
893
+ const report = {
894
+ generatedAt,
895
+ schema: 'ukit-perf-findings/1',
896
+ sources: {
897
+ telemetry: telemetry.available
898
+ ? { dir: telemetryDir, rows: telemetry.rows.length, files: telemetry.files }
899
+ : { dir: telemetryDir, available: false },
900
+ settings: settingsFile,
901
+ hooksDir: hooksResolvedFrom,
902
+ benchmarkIterations: options.benchIterations,
903
+ gitLogWindowDays: options.sinceDays,
904
+ },
905
+ findings,
906
+ };
907
+
908
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
909
+ fs.writeFileSync(outFile, `${JSON.stringify(report, null, 2)}\n`);
910
+ fs.writeFileSync(measureFile, renderMeasureDoc({ ...context, findings, generatedAt }));
911
+
912
+ process.stdout.write(
913
+ `${findings.length} findings → ${path.relative(root, outFile)}`
914
+ + ` (telemetry ${telemetry.available ? `${telemetry.rows.length} rows` : 'absent'},`
915
+ + ` settings ${settingsFile ? 'found' : 'missing'},`
916
+ + ` ${groups.length} groups, ${hookBench.size} hooks benchmarked)\n`,
917
+ );
918
+ }
919
+
920
+ main();