@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.
- package/CHANGELOG.md +105 -0
- package/package.json +1 -1
- package/scripts/install/sync-installed-mirror.mjs +250 -0
- package/scripts/perf/audit-perf.mjs +287 -36
- package/scripts/perf/diff-perf-findings.mjs +136 -0
- package/scripts/perf/perf-findings.json +260 -206
- package/scripts/perf/perf-measure.md +271 -0
- package/src/context/detectProjectContext.js +5 -0
- package/src/core/codeintel/invalidation.js +4 -0
- package/src/core/fileOps.js +40 -117
- package/src/core/hookChainDoctor.js +65 -2
- package/src/core/memory/store.js +22 -1
- package/src/core/taskBudgetValidator.js +9 -6
- package/src/core/unattendedDoctor.js +8 -1
- package/src/render/buildVariables.js +10 -0
- package/templates/.claude/agents/bug-debugger.md +1 -1
- package/templates/.claude/agents/feature-implementer.md +2 -2
- package/templates/.claude/commands/ukit/handoff-create.md +1 -1
- package/templates/.claude/commands/ukit/handoff-fullstack.md +1 -1
- package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
- package/templates/.claude/commands/ukit/handoff-review.md +1 -1
- package/templates/.claude/hooks/auto-prune-bash.sh +19 -0
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
- package/templates/.claude/hooks/reinject-context.sh +22 -0
- package/templates/.claude/hooks/reset-compact-pressure.sh +29 -0
- package/templates/.claude/hooks/session-episode.sh +20 -0
- package/templates/.claude/hooks/skill-router.sh +15 -8
- package/templates/.claude/hooks/verification-guard.sh +3 -0
- package/templates/.claude/ukit/index/route-task.mjs +237 -32
- package/templates/.claude/ukit/index/task-budget-validator.mjs +6 -2
- package/templates/.claude/ukit/runtime/async-lock.mjs +144 -10
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +156 -24
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +57 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +84 -12
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +50 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
- package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
- package/templates/.codex/settings.json +1 -5
- package/templates/.omp/agents/bug-debugger.md +1 -1
- package/templates/.omp/agents/feature-implementer.md +2 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +157 -26
- package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
- package/templates/docs/AI_HANDOFF/RULES.md +6 -6
- 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;
|
package/src/core/fileOps.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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:
|
|
146
|
-
remedy:
|
|
208
|
+
detail: escalations.length > 0 ? `${baseDetail}; escalated: ${escalations.join('; ')}` : baseDetail,
|
|
209
|
+
remedy: [BASE_REMEDY, ...advices].join(' '),
|
|
147
210
|
counts,
|
|
148
211
|
};
|
|
149
212
|
}
|
package/src/core/memory/store.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|