@ngockhoale/ukit 2.7.6 → 2.7.7

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.
@@ -0,0 +1,271 @@
1
+ # perf-measure.md — raw measurement notes (TASK-233)
2
+
3
+ Generated: 2026-09-20T23:07:33.744Z
4
+
5
+ Appendix source for `docs/AI_REPORT/AI_REVIEW_BUGS_REPORT.md`. Every number in
6
+ `perf-findings.json` comes from one of the four sources below. TASK-236 re-runs
7
+ the identical command for the before/after table (SPEC §5). Two numbers from two
8
+ different snapshots are only comparable once their provenance line agrees.
9
+
10
+ ## 1. Reproduce
11
+
12
+ ```bash
13
+ node scripts/perf/audit-perf.mjs \
14
+ --telemetry-dir "/Volumes/KHOA_EXTENAL/WORKING_PROJECT/WORKING/UKit/.ukit/storage/cache/hook-latency" \
15
+ --out "/Volumes/KHOA_EXTENAL/WORKING_PROJECT/WORKING/UKit/scripts/perf/perf-findings.json"
16
+ node scripts/perf/diff-perf-findings.mjs <earlier-snapshot>.json scripts/perf/perf-findings.json
17
+ node --test tests/handoff/c33/perfFindings.test.js
18
+
19
+ - root: `/Volumes/KHOA_EXTENAL/WORKING_PROJECT/WORKING/UKit`
20
+ - settings.json: `/Volumes/KHOA_EXTENAL/WORKING_PROJECT/WORKING/UKit/templates/.claude/settings.json`
21
+ - hooks resolved from: `NOT FOUND`
22
+ - telemetry: `/Volumes/KHOA_EXTENAL/WORKING_PROJECT/WORKING/UKit/.ukit/storage/cache/hook-latency` — 62854 rows in 120 files
23
+ - snapshot provenance: generatedAt `2026-09-20T23:07:33.744Z`, telemetryRows: 62854, telemetryFiles: 120 — hooks append continuously, so a report is a point-in-time view, not a stable dataset
24
+ - benchmark iterations per subject: 2
25
+
26
+ ## 2. Process floor (live spawnSync, ms)
27
+
28
+ | subject | p50 | p95 | max |
29
+ |---|---|---|---|
30
+ | `bash -c true` | 3.13 | 3.13 | 3.13 |
31
+ | `node -e 0` | 16.11 | 16.11 | 16.11 |
32
+ | `node hook-telemetry.mjs --finish` | 19.50 | 19.50 | 19.50 |
33
+
34
+ A hook row = wrapper bash + node runtime + advisory telemetry child = **3 processes**.
35
+
36
+ ## 3. Telemetry append overhead (O5 — in-process `appendTelemetryRow`, ms)
37
+
38
+ | subject | iterations | p50 | p95 | max |
39
+ |---|---|---|---|---|
40
+ | `appendTelemetryRow` → throwaway temp root | 2 | 0.407 | 0.407 | 0.407 |
41
+
42
+ Writer: `templates/.claude/ukit/runtime/hook-telemetry.mjs`. Measured against a temp project root, so auditing a tree never adds rows to the dataset it is counting.
43
+
44
+ ## 4. Per-hook standalone re-run (`/bin/bash <script>` with `{}` on stdin, ms)
45
+ | hook | p50 | p95 | max |
46
+ |---|---|---|---|
47
+ | _(no settings hook resolvable from this root)_ | | | |
48
+
49
+ ## 5. Router/index helper cold start (ms)
50
+
51
+ | helper | p50 | p95 | max |
52
+ |---|---|---|---|
53
+ | `skill-router.sh` | 105.2 | 105.2 | 105.2 |
54
+ | `route-task.mjs` | 26.4 | 26.4 | 26.4 |
55
+ | `query-index.mjs` | 23.6 | 23.6 | 23.6 |
56
+ | `resolve-context.mjs` | 21.9 | 21.9 | 21.9 |
57
+
58
+ ## 6. Per-hook telemetry (every hook with rows)
59
+
60
+ | hook | n | p50 | p95 | max | total |
61
+ |---|---|---|---|---|---|
62
+ | `hook-chain-runner` | 9453 | 80 | 375 | 2020 | 1269815 |
63
+ | `block-dangerous.sh` | 5107 | 83 | 237 | 426 | 461434 |
64
+ | `sensitive-data-guard.sh` | 5001 | 45 | 91 | 1591 | 398855 |
65
+ | `context-hardcap-gate.sh` | 5650 | 55 | 88 | 291 | 334184 |
66
+ | `auto-allow-bash.sh` | 4864 | 50 | 86 | 285 | 264888 |
67
+ | `compress-output.sh` | 4701 | 49 | 66 | 207 | 237025 |
68
+ | `record-execution.sh` | 4880 | 39 | 63 | 178 | 207226 |
69
+ | `handoff-model-guard.sh` | 5603 | 22 | 53 | 189 | 136670 |
70
+ | `verification-guard.sh` | 3520 | 33 | 61 | 137 | 136027 |
71
+ | `skill-router.sh` | 1089 | 52 | 134 | 311 | 68680 |
72
+ | `protect-files.sh` | 1544 | 21 | 156 | 344 | 58507 |
73
+ | `task-watchdog.sh` | 930 | 38 | 216 | 237 | 42736 |
74
+ | `pre-edit-backup.sh` | 732 | 50 | 72 | 216 | 35743 |
75
+ | `stale-spec-guard.sh` | 745 | 46 | 70 | 169 | 34187 |
76
+ | `post-edit-verify.sh` | 716 | 43 | 60 | 145 | 32444 |
77
+ | `vision-router.sh` | 325 | 73 | 220 | 395 | 29834 |
78
+ | `record-execution.mjs` | 2864 | 5 | 16 | 53 | 17607 |
79
+ | `context-window-guard.sh` | 325 | 45 | 84 | 109 | 15798 |
80
+ | `sensitive-data-guard.mjs` | 2812 | 4 | 6 | 15 | 11498 |
81
+ | `completion-gate.sh` | 119 | 80 | 108 | 174 | 9881 |
82
+ | `handoff-resume.sh` | 122 | 31 | 55 | 97 | 4307 |
83
+ | `block-dangerous.mjs` | 1632 | 1 | 2 | 3 | 2334 |
84
+ | `project-important.sh` | 47 | 38 | 61 | 62 | 1962 |
85
+ | `auto-prune-bash.sh` | 19 | 34 | 44 | 44 | 679 |
86
+ | `reset-compact-pressure.sh` | 19 | 34 | 38 | 38 | 647 |
87
+ | `probe-source-only.sh` | 20 | 10 | 22 | 22 | 216 |
88
+ | `probe-s2.sh` | 15 | 10 | 25 | 25 | 164 |
89
+
90
+ ## 7. Stdin stage vs execution split (O3 — the wall-time tail attributed)
91
+
92
+ A row's `elapsedMs` is the stdin stage plus the hook's own execution. `stageMs`
93
+ (direct rows) and `stdinStageMs` (chain rows) are the first half — the producer's
94
+ time, not the hook's — and `exec` below is the remainder, derived from the SAME
95
+ row so the two halves always sum to it. A row carrying neither field predates the
96
+ split or never staged stdin: it is excluded, never counted as a 0ms stage.
97
+
98
+ - rows with the split: 0 of 62854
99
+ - overall: stage n/ams p50 / n/ams p95, exec n/ams p50 / n/ams p95
100
+
101
+ | hook | field | n | stage p50 | stage p95 | exec p50 | exec p95 |
102
+ |---|---|---|---|---|---|---|
103
+ | _(no row carries the split on this tree)_ | | | | | | |
104
+
105
+ ## 8. Per tool / per event cost (chain-runner total where present, else direct sum)
106
+
107
+ | scope | calls | cost p50 | cost p95 | hook rows/call p50 | direct / chain-runner calls |
108
+ |---|---|---|---|---|---|
109
+ | PostToolUse Bash | 4701 | 87 | 150 | 2 | 2406 / 2295 |
110
+ | PostToolUse Edit | 610 | 129 | 222 | 3 | 351 / 259 |
111
+ | PostToolUse Glob | 60 | 78 | 117 | 2 | 0 / 60 |
112
+ | PostToolUse Grep | 275 | 15 | 101 | 2 | 0 / 275 |
113
+ | PostToolUse Read | 1994 | 37 | 122 | 2 | 393 / 1601 |
114
+ | PostToolUse Write | 103 | 132 | 219 | 4 | 46 / 57 |
115
+ | PreToolUse Bash | 4853 | 307 | 474 | 6 | 2519 / 2334 |
116
+ | PreToolUse Edit | 616 | 328 | 432 | 6 | 356 / 260 |
117
+ | PreToolUse Glob | 60 | 59 | 74 | 2 | 0 / 60 |
118
+ | PreToolUse Grep | 275 | 7 | 74 | 2 | 0 / 275 |
119
+ | PreToolUse Read | 2005 | 39 | 71 | 2 | 404 / 1601 |
120
+ | PreToolUse Write | 112 | 344 | 418 | 7 | 46 / 66 |
121
+ | event (unattributed) (clustered, gap ≤ 1500ms) | 775 | 313 | 1567 | 1 | 752 / 23 |
122
+ | event PostToolUse (clustered, gap ≤ 1500ms) | 7886 | 84 | 154 | 2 | 3339 / 4547 |
123
+ | event PreCompact (clustered, gap ≤ 1500ms) | 6 | 49 | 53 | 1 | 0 / 6 |
124
+ | event PreToolUse (clustered, gap ≤ 1500ms) | 8045 | 255 | 442 | 6 | 3431 / 4614 |
125
+ | event SessionEnd (clustered, gap ≤ 1500ms) | 1 | 2020 | 2020 | 1 | 0 / 1 |
126
+ | event SessionStart (clustered, gap ≤ 1500ms) | 113 | 119 | 289 | 2 | 38 / 75 |
127
+ | event Stop (clustered, gap ≤ 1500ms) | 116 | 81 | 111 | 1 | 113 / 3 |
128
+ | event UserPromptSubmit (clustered, gap ≤ 1500ms) | 349 | 291 | 582 | 4 | 200 / 149 |
129
+
130
+ ## 9. Hook multiplicity proof (repeat-fire check)
131
+
132
+ | hook | max fires in one tool call | calls observed | fire-count distribution |
133
+ |---|---|---|---|
134
+ | `auto-allow-bash.sh` | 6 | 4859 | {"1":4858,"6":1} |
135
+ | `auto-prune-bash.sh` | 2 | 17 | {"1":15,"2":2} |
136
+ | `block-dangerous.mjs` | 1 | 1632 | {"1":1632} |
137
+ | `block-dangerous.sh` | 24 | 3651 | {"1":3475,"2":59,"3":55,"6":1,"20":1,"22":58,"23":1,"24":1} |
138
+ | `completion-gate.sh` | 3 | 117 | {"1":116,"3":1} |
139
+ | `compress-output.sh` | 3 | 4699 | {"1":4698,"3":1} |
140
+ | `context-hardcap-gate.sh` | 46 | 5601 | {"1":5599,"5":1,"46":1} |
141
+ | `context-window-guard.sh` | 4 | 295 | {"1":272,"2":17,"3":5,"4":1} |
142
+ | `handoff-model-guard.sh` | 46 | 5553 | {"1":5550,"2":1,"5":1,"46":1} |
143
+ | `handoff-resume.sh` | 4 | 109 | {"1":99,"2":8,"3":1,"4":1} |
144
+ | `post-edit-verify.sh` | 4 | 713 | {"1":712,"4":1} |
145
+ | `pre-edit-backup.sh` | 6 | 727 | {"1":726,"6":1} |
146
+ | `probe-s2.sh` | 15 | 1 | {"15":1} |
147
+ | `probe-source-only.sh` | 20 | 1 | {"20":1} |
148
+ | `project-important.sh` | 5 | 37 | {"1":33,"2":1,"3":1,"4":1,"5":1} |
149
+ | `protect-files.sh` | 26 | 992 | {"1":881,"2":57,"3":1,"10":52,"26":1} |
150
+ | `record-execution.mjs` | 1 | 2864 | {"1":2864} |
151
+ | `record-execution.sh` | 4 | 4875 | {"1":4873,"3":1,"4":1} |
152
+ | `reset-compact-pressure.sh` | 2 | 17 | {"1":15,"2":2} |
153
+ | `sensitive-data-guard.mjs` | 3 | 2806 | {"1":2801,"2":4,"3":1} |
154
+ | `sensitive-data-guard.sh` | 21 | 4949 | {"1":4927,"2":15,"3":3,"4":1,"5":2,"21":1} |
155
+ | `skill-router.sh` | 26 | 1029 | {"1":1000,"2":22,"3":5,"4":1,"26":1} |
156
+ | `stale-spec-guard.sh` | 6 | 736 | {"1":731,"2":4,"6":1} |
157
+ | `task-watchdog.sh` | 4 | 925 | {"1":922,"2":2,"4":1} |
158
+ | `verification-guard.sh` | 6 | 3515 | {"1":3514,"6":1} |
159
+ | `vision-router.sh` | 4 | 295 | {"1":272,"2":17,"3":5,"4":1} |
160
+
161
+ ## 10. hook-chain-runner nested per-script (already-consolidated omp path)
162
+
163
+ | script | n | p50 | p95 | max |
164
+ |---|---|---|---|---|
165
+ | `record-execution.mjs` | 2864 | 5 | 16 | 53 |
166
+ | `sensitive-data-guard.mjs` | 2816 | 4 | 6 | 15 |
167
+ | `handoff-model-guard.sh` | 2641 | 39 | 58 | 166 |
168
+ | `context-hardcap-gate.sh` | 2635 | 68 | 82 | 152 |
169
+ | `auto-allow-bash.sh` | 2350 | 72 | 82 | 426 |
170
+ | `compress-output.sh` | 2295 | 73 | 92 | 169 |
171
+ | `verification-guard.sh` | 2290 | 55 | 67 | 305 |
172
+ | `record-execution.sh` | 1683 | 66 | 129 | 253 |
173
+ | `block-dangerous.mjs` | 1636 | 1 | 2 | 3 |
174
+ | `sensitive-data-guard.sh` | 1634 | 60 | 76 | 203 |
175
+ | `block-dangerous.sh` | 737 | 79 | 150 | 425 |
176
+ | `skill-router.sh` | 486 | 69 | 166 | 280 |
177
+ | `protect-files.sh` | 336 | 50 | 59 | 360 |
178
+ | `stale-spec-guard.sh` | 336 | 57 | 70 | 149 |
179
+ | `pre-edit-backup.sh` | 325 | 59 | 72 | 115 |
180
+ | `post-edit-verify.sh` | 316 | 67 | 90 | 184 |
181
+ | `task-watchdog.sh` | 316 | 59 | 72 | 118 |
182
+ | `vision-router.sh` | 160 | 83 | 161 | 231 |
183
+ | `context-window-guard.sh` | 160 | 59 | 74 | 86 |
184
+ | `project-important.sh` | 87 | 15 | 104 | 529 |
185
+ | `auto-prune-bash.sh` | 86 | 26 | 64 | 75 |
186
+ | `reset-compact-pressure.sh` | 86 | 26 | 56 | 70 |
187
+ | `handoff-resume.sh` | 86 | 56 | 69 | 86 |
188
+ | `reinject-context.sh` | 6 | 49 | 53 | 53 |
189
+ | `completion-gate.sh` | 3 | 111 | 134 | 134 |
190
+ | `t234-silent.sh` | 3 | 2 | 106 | 106 |
191
+ | `session-episode.sh` | 1 | 2020 | 2020 | 2020 |
192
+
193
+ ## 11. settings.json groups (spawn budget per call)
194
+
195
+ | event | matcher | hooks | registered timeouts | spawns/call |
196
+ |---|---|---|---|---|
197
+ | PreToolUse | Read|Grep|Glob | sensitive-data-guard.mjs:8 | 18s | 3 |
198
+ | PreToolUse | Edit|Write | context-hardcap-gate.sh:8 | 73s | 3 |
199
+ | PreToolUse | Bash | verification-guard.sh:8 | 65s | 3 |
200
+ | PostToolUse | Read|Grep|Glob | record-execution.mjs:8 | 18s | 3 |
201
+ | PostToolUse | Edit|Write | task-watchdog.sh:8 | 34s | 3 |
202
+ | PostToolUse | Bash | record-execution.mjs:8 | 30s | 3 |
203
+ | UserPromptSubmit | (all) | context-window-guard.sh:10 | 60s | 3 |
204
+ | Stop | (all) | completion-gate.sh:8 | 18s | 3 |
205
+ | PreCompact | (all) | reinject-context.sh:8 | 18s | 3 |
206
+ | SessionStart | (all) | handoff-resume.sh:8 | 50s | 3 |
207
+ | SessionEnd | (all) | session-episode.sh:8 | 18s | 3 |
208
+
209
+ ## 12. Recent hot-path additions (`git log --diff-filter=A`)
210
+
211
+ | commit | date | subject | files added |
212
+ |---|---|---|---|
213
+ | `c0443e73` | 2026-09-20 | handoff: wave 2 — TASK-003 (block-dangerous.mjs port) + TASK-004 (record-execution.mjs + ledger emitter) + manifest module entries | `templates/.claude/hooks/block-dangerous.mjs`, `templates/.claude/hooks/record-execution.mjs` |
214
+ | `79f747b6` | 2026-09-20 | handoff: wave 1 — TASK-001 (runner .mjs module-steps) + TASK-002 (sensitive-data-guard port + field-salvage) | `templates/.claude/hooks/sensitive-data-guard.mjs`, `templates/.claude/ukit/runtime/hook-field-salvage.mjs` |
215
+ | `6b339905` | 2026-09-20 | handoff: wave 3 — TASK-231, TASK-232 | `templates/.claude/hooks/session-episode.sh` |
216
+ | `4903ee62` | 2026-09-18 | handoff: restore wave-4a files removed by stale worktree diff | `templates/.claude/hooks/project-important.sh` |
217
+ | `2b367850` | 2026-09-18 | handoff: wave 4a — TASK-041 (sessionstart wiring, hook template+manifest) | `templates/.claude/hooks/project-important.sh` |
218
+ | `5ffefb09` | 2026-09-18 | handoff: wave 3 — TASK-038 | `templates/.claude/ukit/runtime/project-important.mjs`, `templates/.claude/ukit/runtime/sensitive-value-scanner.mjs` |
219
+ | `ebb846b8` | 2026-09-17 | handoff: R4.5 fix round 1 — TASK-018, TASK-023 | `templates/.claude/ukit/runtime/hook-chain-budget.mjs` |
220
+ | `c9da9734` | 2026-09-17 | handoff: wave 6 — TASK-025 copy-back (Stop coordinator) | `templates/.claude/ukit/runtime/stop-coordinator.mjs` |
221
+ | `a1361afd` | 2026-09-17 | handoff: wave 5 — TASK-031 copy-back | `templates/.claude/ukit/runtime/hook-payload-store.mjs` |
222
+ | `66787f98` | 2026-09-17 | handoff: wave 5 — TASK-019 copy-back | `templates/.claude/ukit/runtime/hook-telemetry.mjs`, `templates/.claude/ukit/runtime/hook-telemetry.sh` |
223
+ | `3afc88e6` | 2026-09-17 | handoff: wave 5 — TASK-029 copy-back (context capacity negotiation) | `templates/.claude/ukit/runtime/context-capacity.mjs` |
224
+ | `6f224875` | 2026-09-17 | handoff: wave 3 — TASK-028 copy-back | `templates/.claude/ukit/runtime/async-lock.mjs` |
225
+ | `e4b647a3` | 2026-09-17 | handoff: wave 3 — TASK-016 copy-back | `templates/.claude/ukit/runtime/hook-process.mjs` |
226
+ | `7aa89361` | 2026-09-17 | handoff: wave 2 — TASK-015, TASK-017 | `templates/.claude/ukit/runtime/transcript-tail.mjs` |
227
+ | `2652bbff` | 2026-09-17 | milestone: TASK-014 bounded stdin transport (H01) | `templates/.claude/ukit/runtime/hook-input.mjs`, `templates/.claude/ukit/runtime/hook-input.sh` |
228
+ | `6b40da83` | 2026-09-12 | handoff: wave 1 batch 2 — TASK-016 wall-clock watchdog hook (hardPolicy=split) | `templates/.claude/hooks/task-watchdog.sh`, `templates/.claude/ukit/runtime/task-watchdog.mjs` |
229
+ | `0fc81692` | 2026-09-12 | handoff: wave 1 fix — anchor runtime-dir ignores, track lost TASK-014 CLI twin | `templates/.claude/ukit/index/task-budget-validator.mjs` |
230
+ | `cfc3b7f1` | 2026-09-08 | 2.3.0 — fail-closed sensitive-data gate; contract tables unified to executionContracts.js; tierLane wiring; evidence + vision hardening | `templates/.claude/hooks/sensitive-data-guard.sh` |
231
+
232
+ ## 13. Findings emitted
233
+
234
+ | id | evidence | p50 | p95 | status | fixTask |
235
+ |---|---|---|---|---|---|
236
+ | `chain-pretooluse-read-grep-glob` | telemetry | 37 | 72 | confirmed | TASK-234 |
237
+ | `chain-pretooluse-edit-write` | telemetry | 331 | 429 | confirmed | TASK-234 |
238
+ | `chain-pretooluse-bash` | telemetry | 307 | 474 | confirmed | TASK-234 |
239
+ | `chain-posttooluse-read-grep-glob` | telemetry | 37 | 118 | confirmed | TASK-234 |
240
+ | `chain-posttooluse-edit-write` | telemetry | 130 | 222 | confirmed | TASK-234 |
241
+ | `chain-posttooluse-bash` | telemetry | 87 | 150 | confirmed | TASK-234 |
242
+ | `chain-userpromptsubmit-all` | telemetry | 291 | 582 | confirmed | TASK-234 |
243
+ | `chain-stop-all` | telemetry | 81 | 111 | confirmed | TASK-234 |
244
+ | `chain-precompact-all` | telemetry | 49 | 53 | confirmed | TASK-234 |
245
+ | `chain-sessionstart-all` | telemetry | 119 | 289 | confirmed | TASK-234 |
246
+ | `chain-sessionend-all` | telemetry | 2020 | 2020 | confirmed | TASK-234 |
247
+ | `hook-hook-chain-runner` | telemetry | 80 | 375 | confirmed | TASK-234 |
248
+ | `hook-skill-router-sh` | telemetry | 52 | 134 | confirmed | TASK-234 |
249
+ | `hook-vision-router-sh` | telemetry | 73 | 220 | confirmed | TASK-234 |
250
+ | `hook-block-dangerous-sh` | telemetry | 83 | 237 | confirmed | TASK-234 |
251
+ | `hook-context-hardcap-gate-sh` | telemetry | 55 | 88 | confirmed | TASK-234 |
252
+ | `hook-completion-gate-sh` | telemetry | 80 | 108 | confirmed | TASK-234 |
253
+ | `hook-project-important-tail` | telemetry | n/a | 61 | confirmed | TASK-235 |
254
+ | `record-execution-multiplicity` | telemetry | 39 | 63 | not-a-bug | - |
255
+ | `index-router-cold-start` | telemetry | 291 | 582 | confirmed | TASK-234 |
256
+ | `c29-c32-hot-path-additions` | git-log | n/a | n/a | confirmed | TASK-234 |
257
+ | `existing-chain-runner-baseline` | telemetry | 80 | 375 | confirmed | TASK-234 |
258
+ | `process-floor` | micro-benchmark | 38.73741700000001 | 38.73741700000001 | not-a-bug | TASK-234 |
259
+ | `telemetry-append-overhead` | micro-benchmark | 0.407125 | 0.407125 | not-a-bug | - |
260
+ | `stdin-stage-vs-exec-split` | unavailable | n/a | n/a | deferred | - |
261
+ | `hook-sensitive-data-guard-mjs-8-unmeasured` | unavailable | n/a | n/a | deferred | - |
262
+ | `hook-context-hardcap-gate-sh-8-unmeasured` | unavailable | n/a | n/a | deferred | - |
263
+ | `hook-verification-guard-sh-8-unmeasured` | unavailable | n/a | n/a | deferred | - |
264
+ | `hook-record-execution-mjs-8-unmeasured` | unavailable | n/a | n/a | deferred | - |
265
+ | `hook-task-watchdog-sh-8-unmeasured` | unavailable | n/a | n/a | deferred | - |
266
+ | `hook-context-window-guard-sh-10-unmeasured` | unavailable | n/a | n/a | deferred | - |
267
+ | `hook-completion-gate-sh-8-unmeasured` | unavailable | n/a | n/a | deferred | - |
268
+ | `hook-reinject-context-sh-8-unmeasured` | unavailable | n/a | n/a | deferred | - |
269
+ | `hook-handoff-resume-sh-8-unmeasured` | unavailable | n/a | n/a | deferred | - |
270
+ | `hook-session-episode-sh-8-unmeasured` | unavailable | n/a | n/a | deferred | - |
271
+
@@ -26,6 +26,10 @@ const FAILURE_OUTCOMES = new Set([
26
26
  'signal',
27
27
  ]);
28
28
 
29
+ // The pre-O6 remedy, kept verbatim: a failure with no gate evidence must read
30
+ // exactly as it did before escalation existed (SPEC §FR-012).
31
+ const BASE_REMEDY = 'Inspect recent rows in .ukit/storage/cache/hook-latency/ for the failing scriptName/failureKind and fix or widen the gate budget.';
32
+
29
33
  function telemetryDirFor(projectRoot) {
30
34
  return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
31
35
  }
@@ -80,6 +84,13 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
80
84
 
81
85
  const counts = { rowsScanned: 0, filesScanned: files.length, ok: 0 };
82
86
  let rowsLeft = MAX_ROWS;
87
+ // O6 (SPEC §FR-012): a failure that landed on a fail-closed gate is a
88
+ // different signal from a generic timeout — it means a real budget is too
89
+ // tight (or a gate broke), not that "something was slow". These are the two
90
+ // evidence shapes that prove it, collected in the same pass that counts
91
+ // outcomes so no second read of the rows is needed.
92
+ const gateFailures = new Set();
93
+ let budgetExhaustedChains = 0;
83
94
 
84
95
  for (const file of files) {
85
96
  if (rowsLeft <= 0) break;
@@ -102,6 +113,34 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
102
113
  counts.rowsScanned += 1;
103
114
  const outcome = typeof row.outcome === 'string' && row.outcome.length > 0 ? row.outcome : 'ok';
104
115
  counts[outcome] = (counts[outcome] ?? 0) + 1;
116
+
117
+ // Escalation evidence is only ever read off a FAILING row — a gate that
118
+ // ran fine is not evidence of anything, and SPEC §FR-012 scopes the
119
+ // budget signal to a non-ok row ("a non-ok row ... escalates only when
120
+ // its own outcome is budget-exhausted").
121
+ if (!FAILURE_OUTCOMES.has(outcome)) continue;
122
+ // A row that exhausted the chain budget never ran its later steps; that is
123
+ // the tight-budget signal whether or not any per-script detail survived
124
+ // into `scripts[]`. Recorded alongside the per-script scan below, never
125
+ // instead of it: one row can carry both a failed gate AND exhaustion
126
+ // (a gate step is added with failureKind 'budget-exhausted' when the
127
+ // budget ran out before it), and dropping either would lose signal.
128
+ if (row.budgetExhausted === true || outcome === 'budget-exhausted') {
129
+ budgetExhaustedChains += 1;
130
+ }
131
+ // Each failing entry names the step that failed. `failClosed` is the
132
+ // runner's OWN verdict on which paths it gated — the doctor reads that
133
+ // flag and never re-derives the gate list: a second copy of a
134
+ // security-relevant registry is a drift hazard, and only the runner knows
135
+ // which paths it treated as gates. Malformed entries are skipped, never
136
+ // fatal (a liveness/doctor check must not throw on hostile telemetry).
137
+ if (!Array.isArray(row.scripts)) continue;
138
+ for (const entry of row.scripts) {
139
+ if (!entry || typeof entry !== 'object') continue;
140
+ if (entry.failClosed !== true) continue;
141
+ if (typeof entry.failureKind !== 'string' || entry.failureKind === 'ok') continue;
142
+ gateFailures.add(typeof entry.scriptName === 'string' ? entry.scriptName : 'unknown');
143
+ }
105
144
  }
106
145
  }
107
146
 
@@ -136,14 +175,38 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
136
175
  }
137
176
 
138
177
  const breakdown = failureKinds.map((kind) => `${kind}=${counts[kind]}`).join(', ');
178
+ const baseDetail = `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`;
179
+
180
+ // O6 (SPEC §FR-012): escalate only when a failure actually landed ON a gate —
181
+ // a gate that merely ran is not evidence. The escalation is purely additive:
182
+ // `baseDetail`/`BASE_REMEDY` stay the leading clause so the no-escalation
183
+ // branch is still byte-identical to the pre-O6 output, and a warning still
184
+ // never blocks (severity/remediationClass are unchanged), so doctor's exit
185
+ // code is unaffected. This is a richer signal, not a new alert channel (§14).
186
+ const gateList = [...gateFailures].sort();
187
+ const escalations = [];
188
+ const advices = [];
189
+ if (gateList.length > 0) {
190
+ escalations.push(`fail-closed gate(s) failed: ${gateList.join(', ')}`);
191
+ advices.push(
192
+ `A fail-closed gate actually failed (${gateList.join(', ')}) — that is a real budget or path problem, not a slow advisory hook; widen the failing gate's budget or fix that step.`,
193
+ );
194
+ }
195
+ if (budgetExhaustedChains > 0) {
196
+ escalations.push(`${budgetExhaustedChains} chain row(s) exhausted the total budget`);
197
+ advices.push(
198
+ `The chain total budget was exhausted before every step ran (${budgetExhaustedChains} row(s)) — raise it (UKIT_HOOK_CHAIN_BASE_MS or the per-script ":N" suffix) so the later steps, including any gate, still run.`,
199
+ );
200
+ }
201
+
139
202
  return {
140
203
  label,
141
204
  passed: false,
142
205
  failed: true,
143
206
  severity: 'warning',
144
207
  remediationClass: 'advisory',
145
- detail: `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`,
146
- remedy: 'Inspect recent rows in .ukit/storage/cache/hook-latency/ for the failing scriptName/failureKind and fix or widen the gate budget.',
208
+ detail: escalations.length > 0 ? `${baseDetail}; escalated: ${escalations.join('; ')}` : baseDetail,
209
+ remedy: [BASE_REMEDY, ...advices].join(' '),
147
210
  counts,
148
211
  };
149
212
  }
@@ -480,6 +480,13 @@ async function readProjectMemoryForId(runtimePaths, projectId) {
480
480
 
481
481
  const DEFAULT_MAX_ARCHIVED_SESSIONS = 50;
482
482
 
483
+ // Same end-time key `archiveSessions` uses to decide whether a session is expired, so
484
+ // "oldest" means the same thing on both sides of the archive boundary.
485
+ function archivedSessionEndTime(session) {
486
+ const endedAt = Number(session?.endedAt ?? session?.startedAt ?? 0);
487
+ return Number.isFinite(endedAt) ? endedAt : 0;
488
+ }
489
+
483
490
  async function appendSessionArchive(runtimePaths, projectId, archivedSessions, maxArchivedSessions) {
484
491
  if (!archivedSessions || archivedSessions.length === 0) {
485
492
  return;
@@ -493,7 +500,21 @@ async function appendSessionArchive(runtimePaths, projectId, archivedSessions, m
493
500
  const archivePath = path.join(runtimePaths.projectsDir, `${sanitizeProjectId(projectId)}.archive.json`);
494
501
  const existing = (await readMemoryJson(archivePath)) ?? { sessions: [] };
495
502
  const sessions = Array.isArray(existing.sessions) ? existing.sessions : [];
496
- await writeJson(archivePath, { sessions: [...sessions, ...archivedSessions].slice(-cap) });
503
+
504
+ // The cap must drop the OLDEST sessions, not simply the earliest-written ones: appending
505
+ // and slicing capped the count but evicted by insertion position, so a session archived
506
+ // late carrying an older end time survived while a newer one written earlier was
507
+ // dropped. Ties keep insertion order, which is what the old equal-timestamp behavior did.
508
+ const kept = [...sessions, ...archivedSessions]
509
+ .map((session, index) => ({ session, index }))
510
+ .sort((left, right) => {
511
+ const delta = archivedSessionEndTime(left.session) - archivedSessionEndTime(right.session);
512
+ return delta !== 0 ? delta : left.index - right.index;
513
+ })
514
+ .slice(-cap)
515
+ .map((entry) => entry.session);
516
+
517
+ await writeJson(archivePath, { sessions: kept });
497
518
  }
498
519
 
499
520
  async function persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory) {
@@ -8,8 +8,10 @@
8
8
  // DEFAULT_CRITERIA — frozen thresholds used by `validateTaskFile` by default.
9
9
  // VERIFICATION_MINUTE_TABLE — frozen literal table for `yarn test:release-core` and
10
10
  // `node scripts/release/verify-release.mjs`. `yarn vitest run`
11
- // is NOT in the table — its cost is computed dynamically
12
- // (0.5 per file argument), see `estimateVerificationMinutes`.
11
+ // and the `yarn test` alias (`package.json`:
12
+ // `"test": "vitest run"`) are NOT in the table — their cost
13
+ // is computed dynamically (0.5 per file argument), see
14
+ // `estimateVerificationMinutes`.
13
15
  // estimateVerificationMinutes — sums per-command minutes over a verification list.
14
16
  // validateTaskFile — parses a TASK-xxx.md and returns
15
17
  // `{ verdict, reasons: [{ criterion, detail }] }`.
@@ -39,8 +41,9 @@ export const VERIFICATION_MINUTE_TABLE = Object.freeze({
39
41
  'node scripts/release/verify-release.mjs': 2,
40
42
  });
41
43
 
42
- // Per-command minute estimate. `yarn vitest run` → 0.5 per FILE argument
43
- // (i.e. per non-flag token after `run`); anything else falls back to 1 flat.
44
+ // Per-command minute estimate. `yarn vitest [run]` and the `yarn test` alias (the repo's
45
+ // dominant verification form) → 0.5 per FILE argument (per non-flag token after the runner
46
+ // word); anything else falls back to 1 flat.
44
47
  export function estimateVerificationMinutes(commands) {
45
48
  if (!Array.isArray(commands)) return 0;
46
49
  let total = 0;
@@ -51,8 +54,8 @@ export function estimateVerificationMinutes(commands) {
51
54
  total += VERIFICATION_MINUTE_TABLE[cmd];
52
55
  continue;
53
56
  }
54
- if (/^yarn\s+vitest(\s+run)?\b/.test(cmd)) {
55
- // 0.5 minutes per non-flag argument after `run` (or after `yarn vitest`).
57
+ if (/^yarn\s+(?:vitest(\s+run)?|test)(?=\s|$)/.test(cmd)) {
58
+ // 0.5 minutes per non-flag argument after the runner word (`run`, or `yarn test` itself).
56
59
  const tokens = cmd.split(/\s+/);
57
60
  const runIdx = tokens.indexOf('run');
58
61
  const tail = runIdx >= 0 ? tokens.slice(runIdx + 1) : tokens.slice(2);
@@ -16,6 +16,13 @@ const INSTALL_REPAIR = 'Run ukit install';
16
16
  const OMP_CONFIG_TIMEOUT_MS = 5000;
17
17
  const COMPLETION_LOOP_HEADER = '## Unattended Completion Loop';
18
18
 
19
+ // B3 / FR-003 — the shipped template carries `{{token}}` placeholders that `ukit install`
20
+ // renders. `parse()` must not see them raw: `{{compact.autoCompactWindow}}` reads as a flow
21
+ // mapping in KEY position, and the `yaml` lib then warns "Keys with collection values will be
22
+ // stringified" on every doctor run. Same neutralization `tests/consistency/ompAgentParity.test.js`
23
+ // already applies; deliberately generic (no key-specific case) so a new placeholder is covered.
24
+ const TEMPLATE_PLACEHOLDER_PATTERN = /\{\{[^}]+\}\}/g;
25
+
19
26
  async function readOmpConfig(projectRoot) {
20
27
  const configPath = path.join(projectRoot, '.omp', 'config.yml');
21
28
  try {
@@ -51,7 +58,7 @@ function execOmpConfigGet(projectRoot, ompPath) {
51
58
  async function readTemplateDenyMatches() {
52
59
  try {
53
60
  const text = await fs.readFile(path.join(PACKAGE_ROOT, 'templates', '.omp', 'config.yml'), 'utf8');
54
- const parsed = parse(text);
61
+ const parsed = parse(text.replace(TEMPLATE_PLACEHOLDER_PATTERN, '0'));
55
62
  const patterns = parsed?.bash?.patterns;
56
63
  if (!Array.isArray(patterns)) return [];
57
64
  return patterns
@@ -11,6 +11,25 @@ if [ ! -f "$SETTINGS_LOCAL" ] || [ ! -f "$USAGE_FILE" ]; then
11
11
  exit 0
12
12
  fi
13
13
 
14
+ # O1 (TASK-009): armed AFTER the missing-state short-circuit above, so the
15
+ # nothing-to-prune path stays as free as it is today and only real work is measured.
16
+ # This hook stages no stdin (SPEC §14), so it owns its own EXIT trap: the trap
17
+ # returns the status it captured and the always-exit-0 posture is unchanged.
18
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ # shellcheck source=/dev/null
20
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
21
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
22
+ ukit_hook_telemetry_arm
23
+ __ukit_tel_finish() {
24
+ local __ukit_tel_rc=$?
25
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
26
+ UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionStart" ukit_hook_telemetry_finish
27
+ fi
28
+ return "$__ukit_tel_rc"
29
+ }
30
+ trap __ukit_tel_finish EXIT
31
+ fi
32
+
14
33
  NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
15
34
 
16
35
  node -e '
@@ -9,6 +9,28 @@ fi
9
9
  HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
10
10
  SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/reinject-context.mjs"
11
11
 
12
+ # O1 (TASK-009): this hook stages no stdin (SPEC §14), so the shared cleanup path in
13
+ # hook-input.sh never runs for it and nothing here would ever be measured. Arm the
14
+ # no-staging start marker and finish it from this hook's own EXIT trap: arming costs
15
+ # one mktemp and reads no clock, and the single bounded `--finish` child is the
16
+ # telemetry process rather than a hook work step. A missing runtime (pre-install
17
+ # tree) leaves the flag unset — exactly as unmeasured as today — and the trap returns
18
+ # the status it captured, so the `exit $?` below still reaches the host unchanged.
19
+ # shellcheck source=/dev/null
20
+ source "$HOOK_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
21
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
22
+ ukit_hook_telemetry_arm
23
+ __ukit_tel_finish() {
24
+ local __ukit_tel_rc=$?
25
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
26
+ # The event travels per call — no staged envelope exists to carry it.
27
+ UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="PreCompact" ukit_hook_telemetry_finish
28
+ fi
29
+ return "$__ukit_tel_rc"
30
+ }
31
+ trap __ukit_tel_finish EXIT
32
+ fi
33
+
12
34
  if [ -f "$SCRIPT_PATH" ]; then
13
35
  UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT_PATH"
14
36
  exit $?
@@ -24,6 +24,25 @@ if [ ! -f "$PRESSURE_FILE" ]; then
24
24
  exit 0
25
25
  fi
26
26
 
27
+ # O1 (TASK-009): armed AFTER the no-pressure-file short-circuit above, so the common
28
+ # "nothing to reset" start stays unmeasured while real resets get timed. This hook
29
+ # stages no stdin (SPEC §14), so it owns its EXIT trap; the trap restores the captured
30
+ # status and the always-exit-0 posture below is unchanged.
31
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+ # shellcheck source=/dev/null
33
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
34
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
35
+ ukit_hook_telemetry_arm
36
+ __ukit_tel_finish() {
37
+ local __ukit_tel_rc=$?
38
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
39
+ UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionStart" ukit_hook_telemetry_finish
40
+ fi
41
+ return "$__ukit_tel_rc"
42
+ }
43
+ trap __ukit_tel_finish EXIT
44
+ fi
45
+
27
46
  # The lock directory lives beside the state file; its parent exists because the file does.
28
47
  node -e '
29
48
  // Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
@@ -37,6 +37,26 @@ if [ "$__ukit_ep_gate" != "1" ]; then
37
37
  fi
38
38
  unset __ukit_ep_gate
39
39
 
40
+ # O1 (TASK-009): armed only past the gate above, so a gate-off SessionEnd stays a pure
41
+ # fast exit. This hook stages no stdin (SPEC §14) and owns its EXIT trap; the trap
42
+ # returns the captured status, so the always-exit-0 contract is unchanged. The row is
43
+ # not session-attributed: the staged payload is reaped below before this trap runs, and
44
+ # re-deriving the id in shell would mean another spawn on the teardown path.
45
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
46
+ # shellcheck source=/dev/null
47
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
48
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
49
+ ukit_hook_telemetry_arm
50
+ __ukit_tel_finish() {
51
+ local __ukit_tel_rc=$?
52
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
53
+ UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionEnd" ukit_hook_telemetry_finish
54
+ fi
55
+ return "$__ukit_tel_rc"
56
+ }
57
+ trap __ukit_tel_finish EXIT
58
+ fi
59
+
40
60
  # Bounded stdin read (existing hook style): cap +1 byte in background so a
41
61
  # producer that never closes the pipe cannot park the session teardown.
42
62
  UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-episode-in.XXXXXX")" || exit 0
@@ -27,7 +27,7 @@ const VERIFICATION_MINUTE_TABLE = Object.freeze({
27
27
  'node scripts/release/verify-release.mjs': 2,
28
28
  });
29
29
 
30
- function estimateVerificationMinutes(commands) {
30
+ export function estimateVerificationMinutes(commands) {
31
31
  if (!Array.isArray(commands)) return 0;
32
32
  let total = 0;
33
33
  for (const raw of commands) {
@@ -37,7 +37,11 @@ function estimateVerificationMinutes(commands) {
37
37
  total += VERIFICATION_MINUTE_TABLE[cmd];
38
38
  continue;
39
39
  }
40
- if (/^yarn\s+vitest(\s+run)?\b/.test(cmd)) {
40
+ // `yarn vitest [run]` plus the `yarn test` alias (package.json: `"test": "vitest run"`).
41
+ // The `(?=\s|$)` boundary is deliberate — `\b` would also swallow `yarn test:artifact` /
42
+ // `yarn test:liveness`, which are *different* scripts that must keep the 1-minute fallback.
43
+ if (/^yarn\s+(?:vitest(\s+run)?|test)(?=\s|$)/.test(cmd)) {
44
+ // 0.5 minutes per non-flag argument after the runner word (`run`, or `yarn test` itself).
41
45
  const tokens = cmd.split(/\s+/);
42
46
  const runIdx = tokens.indexOf('run');
43
47
  const tail = runIdx >= 0 ? tokens.slice(runIdx + 1) : tokens.slice(2);