@ngockhoale/ukit 2.7.6 → 2.7.8

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 (47) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +1 -1
  3. package/scripts/install/sync-installed-mirror.mjs +250 -0
  4. package/scripts/perf/audit-perf.mjs +287 -36
  5. package/scripts/perf/diff-perf-findings.mjs +136 -0
  6. package/scripts/perf/perf-findings.json +260 -206
  7. package/scripts/perf/perf-measure.md +271 -0
  8. package/src/context/detectProjectContext.js +5 -0
  9. package/src/core/codeintel/invalidation.js +4 -0
  10. package/src/core/fileOps.js +40 -117
  11. package/src/core/hookChainDoctor.js +65 -2
  12. package/src/core/memory/store.js +22 -1
  13. package/src/core/taskBudgetValidator.js +9 -6
  14. package/src/core/unattendedDoctor.js +8 -1
  15. package/src/render/buildVariables.js +10 -0
  16. package/templates/.claude/agents/bug-debugger.md +1 -1
  17. package/templates/.claude/agents/feature-implementer.md +2 -2
  18. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  19. package/templates/.claude/commands/ukit/handoff-fullstack.md +1 -1
  20. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  21. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  22. package/templates/.claude/hooks/auto-prune-bash.sh +19 -0
  23. package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
  24. package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
  25. package/templates/.claude/hooks/reinject-context.sh +22 -0
  26. package/templates/.claude/hooks/reset-compact-pressure.sh +29 -0
  27. package/templates/.claude/hooks/session-episode.sh +20 -0
  28. package/templates/.claude/hooks/skill-router.sh +15 -8
  29. package/templates/.claude/hooks/verification-guard.sh +3 -0
  30. package/templates/.claude/ukit/index/route-task.mjs +237 -32
  31. package/templates/.claude/ukit/index/task-budget-validator.mjs +6 -2
  32. package/templates/.claude/ukit/runtime/async-lock.mjs +144 -10
  33. package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
  34. package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
  35. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +156 -24
  36. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +57 -0
  37. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +84 -12
  38. package/templates/.claude/ukit/runtime/hook-telemetry.sh +50 -0
  39. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
  40. package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
  41. package/templates/.codex/settings.json +1 -5
  42. package/templates/.omp/agents/bug-debugger.md +1 -1
  43. package/templates/.omp/agents/feature-implementer.md +2 -2
  44. package/templates/.omp/hooks/pre/ukit-bridge.js +157 -26
  45. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  46. package/templates/docs/AI_HANDOFF/RULES.md +6 -6
  47. package/templates/ukit/storage/config.json +2 -2
@@ -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
+
@@ -24,5 +24,10 @@ export async function detectProjectContext(projectRoot) {
24
24
  os: process.platform,
25
25
  nodeVersion: process.version,
26
26
  },
27
+ // CX-10: raw package.json scripts so renderers can emit only commands that
28
+ // actually exist (verify-context.mjs:186-191 does the same existence check).
29
+ scripts: packageJson?.scripts && typeof packageJson.scripts === 'object'
30
+ ? packageJson.scripts
31
+ : {},
27
32
  };
28
33
  }
@@ -105,6 +105,10 @@ export async function notifyEdit(projectRoot, relPaths) {
105
105
  for (let attempt = 0; attempt < MERGE_MAX_ATTEMPTS; attempt += 1) {
106
106
  try {
107
107
  const paths = await withFileLock(dirtyPath, mergeOnce);
108
+ // TASK-004: withFileLock is fail-closed — a contended lock skips the merge
109
+ // (journaled to dirty.json.lock-drops.jsonl) and resolves undefined. Return
110
+ // the current state like the exhausted-retry path; never retry unlocked.
111
+ if (paths === undefined) break;
108
112
  return { dirty: paths };
109
113
  } catch (error) {
110
114
  lastError = error;
@@ -1,7 +1,11 @@
1
- import crypto from 'node:crypto';
2
1
  import fs from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
 
4
+ // The shipped runtime carries the single lock implementation; src and the
5
+ // installed package always ship templates/ together (package.json files list),
6
+ // so the protocol twin delegates instead of keeping a driftable copy.
7
+ import { journalDroppedLockMutation, withAsyncLock } from '../../templates/.claude/ukit/runtime/async-lock.mjs';
8
+
5
9
  export async function pathExists(targetPath) {
6
10
  try {
7
11
  await fs.access(targetPath);
@@ -104,7 +108,17 @@ export async function writeFileAtomic(filePath, content) {
104
108
  } else {
105
109
  await fs.writeFile(tempPath, content, 'utf8');
106
110
  }
107
- await fs.rename(tempPath, filePath);
111
+ try {
112
+ await fs.rename(tempPath, filePath);
113
+ } catch (renameError) {
114
+ // EXDEV: the tmp file and the destination sit on different mounts (union
115
+ // mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
116
+ // The payload is already fully written — copy it over and unlink the tmp.
117
+ // Less atomic than rename, but the update must not be silently lost.
118
+ if (renameError?.code !== 'EXDEV') throw renameError;
119
+ await fs.copyFile(tempPath, filePath);
120
+ await fs.rm(tempPath, { force: true });
121
+ }
108
122
  } catch (error) {
109
123
  try {
110
124
  await fs.rm(tempPath, { force: true });
@@ -161,130 +175,39 @@ export async function writeJson(filePath, data) {
161
175
  const LOCK_STALE_MS = 10_000;
162
176
  const LOCK_MAX_WAIT_MS = 5_000;
163
177
 
164
- function lockBackoffDelayMs() {
165
- return 3 + Math.floor(Math.random() * 9);
166
- }
167
-
168
- function sleep(ms) {
169
- return new Promise((resolve) => setTimeout(resolve, ms));
170
- }
171
-
172
- function isPidAlive(pid) {
173
- try {
174
- process.kill(pid, 0);
175
- return true;
176
- } catch (error) {
177
- // EPERM: the process exists but belongs to another user — still alive.
178
- return error?.code === 'EPERM';
179
- }
180
- }
181
-
182
- async function readLockOwner(lockPath) {
183
- try {
184
- const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
185
- const pid = Number(raw?.pid);
186
- return Number.isInteger(pid) && pid > 0
187
- ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
188
- : null;
189
- } catch {
190
- return null;
191
- }
192
- }
193
-
194
- // Same-pid holders are parallel async flows whose liveness a pid probe cannot prove.
195
- const inProcessLockHolders = new Map();
196
-
197
178
  /**
198
179
  * Serialize read-modify-write mutations of a shared state file — across processes
199
180
  * (hook invocations run as separate node processes) and across concurrent async
200
181
  * flows in one process (parallel subagents). The lock is a directory created next
201
182
  * to the target file: `mkdir` is atomic, so exactly one caller can create it.
202
- * Ownership is recorded in an `owner` file inside the lock dir: stale reclaim first
203
- * proves the recorded holder is gone (dead pid, or no in-process holder for our own
204
- * pid — no owner file keeps legacy mtime-only recovery). Release only removes a dir
205
- * this acquisition still owns, so a reclaimed-then-reacquired lock is never deleted
206
- * out from under its successor.
207
- * Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
208
- * the callback runs anyway (the pre-lock behaviour) — these state files are
209
- * advisory caches, and losing an update beats freezing a hook mid-flight.
210
- * Protocol-compatible with the runtime token-utils.mjs lock (same `<file>.lock`
211
- * path and owner-file format), so CLI and hook processes serialize against each other.
183
+ *
184
+ * The entire lock protocol lives in templates/.claude/ukit/runtime/async-lock.mjs
185
+ * (TASK-004 fix round 1): owner stamping (pid + token + pstart), recycled-pid
186
+ * detection via recordedProcessGone, claim+quarantine stale reclaim, and verified
187
+ * release exist exactly once there — this function and the token-utils.mjs twin
188
+ * both delegate to it so the two protocol copies can never drift apart again.
189
+ *
190
+ * FAIL-CLOSED (TASK-004, unified with async-lock/ledger policy): if the lock cannot
191
+ * be acquired within maxWaitMs the callback is SKIPPED — never run unlocked — and
192
+ * the drop is journaled to `<file>.lock-drops.jsonl`. These state files are
193
+ * advisory caches: losing an update was already the accepted outcome of the old
194
+ * fail-open race; now it is explicit and journaled instead of a silent torn write.
212
195
  * @param {string} filePath - state file the mutation targets (lock lives beside it)
213
196
  * @param {() => Promise<*>} fn - critical section; its result is returned
214
- * @returns {Promise<*>} whatever fn resolves with
197
+ * @returns {Promise<*|undefined>} whatever fn resolves with, or undefined when the
198
+ * lock wait expired and the mutation was skipped (journaled)
215
199
  */
216
200
  export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
217
- const lockPath = `${filePath}.lock`;
218
- const startedAt = Date.now();
219
- const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
220
- let locked = false;
221
- let ownerStamped = false;
222
-
223
- while (!locked) {
224
- try {
225
- await ensureDir(path.dirname(lockPath));
226
- await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
227
- locked = true;
228
- inProcessLockHolders.set(lockPath, ownerToken);
229
- try {
230
- await fs.writeFile(
231
- path.join(lockPath, 'owner'),
232
- `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
233
- 'utf8',
234
- );
235
- ownerStamped = true;
236
- } catch {
237
- ownerStamped = false; // unverifiable release skips removal; stale reclaim cleans up
238
- }
239
- break;
240
- } catch (error) {
241
- if (error?.code !== 'EEXIST') throw error;
242
- }
243
-
244
- // Someone holds the lock. Reclaim it only when the holder is provably gone.
245
- try {
246
- const stat = await fs.stat(lockPath);
247
- if (Date.now() - stat.mtimeMs > staleMs) {
248
- const owner = await readLockOwner(lockPath);
249
- const liveInProcess = inProcessLockHolders.has(lockPath);
250
- const reclaimable = !owner || owner.pid === process.pid
251
- ? !liveInProcess
252
- : !isPidAlive(owner.pid);
253
- if (reclaimable) {
254
- await fs.rm(lockPath, { recursive: true, force: true });
255
- continue; // the slot is free now — retry immediately
256
- }
257
- }
258
- } catch (statError) {
259
- // BUG-C22-17: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
260
- // mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
261
- // EEXIST) must not busy-spin — a bare `continue` skipped both the
262
- // maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
263
- if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
264
- if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
265
- await sleep(lockBackoffDelayMs());
266
- continue;
267
- }
268
-
269
- if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
270
- await sleep(lockBackoffDelayMs());
271
- }
272
-
273
- try {
274
- return await fn();
275
- } finally {
276
- if (locked) {
277
- try {
278
- const current = ownerStamped ? await readLockOwner(lockPath) : null;
279
- if (current && current.token === ownerToken) {
280
- await fs.rm(lockPath, { recursive: true, force: true });
281
- }
282
- if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
283
- } catch {
284
- // best-effort release; a stale lock is reclaimed by the next waiter
285
- }
286
- }
287
- }
201
+ const outcome = await withAsyncLock(filePath, { deadlineMs: maxWaitMs, staleMs }, fn);
202
+ if (outcome?.ok === true) return outcome.value;
203
+ // Fail closed: the mutation is dropped, never run unlocked. The drop is
204
+ // journaled so the lost update is auditable; callers treat undefined as
205
+ // "update skipped" (they already tolerated losing it silently).
206
+ await journalDroppedLockMutation(filePath, {
207
+ reason: 'lock-wait-expired',
208
+ waitedMs: outcome?.waitedMs ?? maxWaitMs,
209
+ });
210
+ return undefined;
288
211
  }
289
212
 
290
213
  /**
@@ -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) {