@ngockhoale/ukit 2.4.1 → 2.4.3

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 (56) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/manifests/platform.full.yaml +19 -111
  3. package/package.json +2 -1
  4. package/scripts/index/refresh-index.mjs +48 -18
  5. package/src/cli/commands/doctor.js +59 -2
  6. package/src/core/compact/threshold.js +36 -6
  7. package/src/core/gatewayProbe.js +143 -15
  8. package/src/core/gatewayResilienceEnv.js +136 -7
  9. package/src/diagnostics/classifyHang.js +246 -0
  10. package/src/index/buildIndex.js +1096 -75
  11. package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
  12. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  13. package/templates/.claude/hooks/block-dangerous.sh +46 -1
  14. package/templates/.claude/hooks/completion-gate.sh +65 -7
  15. package/templates/.claude/hooks/compress-output.sh +49 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
  17. package/templates/.claude/hooks/context-window-guard.sh +204 -71
  18. package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
  19. package/templates/.claude/hooks/handoff-resume.sh +47 -3
  20. package/templates/.claude/hooks/post-edit-verify.sh +45 -2
  21. package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
  22. package/templates/.claude/hooks/protect-files.sh +46 -1
  23. package/templates/.claude/hooks/record-execution.sh +46 -2
  24. package/templates/.claude/hooks/reinject-context.sh +1 -1
  25. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  26. package/templates/.claude/hooks/sensitive-data-guard.sh +101 -18
  27. package/templates/.claude/hooks/skill-router.sh +59 -5
  28. package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
  29. package/templates/.claude/hooks/task-watchdog.sh +129 -126
  30. package/templates/.claude/hooks/verification-guard.sh +136 -106
  31. package/templates/.claude/hooks/vision-router.sh +138 -18
  32. package/templates/.claude/settings.json +0 -5
  33. package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
  34. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  35. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  36. package/templates/.claude/ukit/index/refresh-index.mjs +48 -18
  37. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  38. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  40. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  41. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  42. package/templates/.claude/ukit/runtime/execution-ledger.mjs +672 -170
  43. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  44. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  45. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  46. package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
  47. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  48. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  49. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  51. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  52. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  53. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  54. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  55. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
  56. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -0,0 +1,246 @@
1
+ // classifyHang.js — evidence-only hang classifier for the C19 liveness wave (TASK-032).
2
+ //
3
+ // What this is for: C19 removed a family of unbounded paths, and the remaining failure
4
+ // mode is telling ONE stall class apart from another. A prompt freeze used to be
5
+ // indistinguishable from an indexing freeze or a gateway stall, so operators had no
6
+ // evidence-based next step. This module turns an evidence bundle into exactly one
7
+ // declared lane, or `unknown` when the evidence cannot prove any of them.
8
+ //
9
+ // Contracts (asserted by tests/liveness/classifyHang.test.js):
10
+ // * PURE and CLOCK-FREE. Every rule is a function of the bundle only, so identical
11
+ // evidence always produces byte-identical output. No Date.now(), no randomness, no
12
+ // I/O — the caller supplies both the measurement and the clock.
13
+ // * NEVER GUESSES. A lane is only named when the evidence PROVES it:
14
+ // - `deadline` with zero survivors is a clean reap, not a process-tree leak;
15
+ // - survivors beside `failureKind: 'signal'` are NOT a leak: `signal` is documented
16
+ // by the runner as a signal IT DID NOT SEND, so there is no escalation to blame
17
+ // and the bundle is `unknown` (the runner's own `deadline` verdict is the only
18
+ // escalation proof);
19
+ // - a non-zero hook exit that finished inside its budget is a verdict, not a hang;
20
+ // - a non-typed index error is not a discovery deadline.
21
+ // Everything else is `unknown` with a concrete next check.
22
+ // * The UPSTREAM lane is never a hook. Stream idle with no hook active classifies
23
+ // `gateway-stream-idle`; the presence of hook evidence removes that lane entirely, so
24
+ // a gateway stall can no longer be reported as UKit's fault (or the reverse).
25
+ // * Exactly four fields, always: `{class, confidence, evidenceIds, recommendedNextCheck}`.
26
+ // `evidenceIds` names the fields the decision rests on, so a reviewer can re-derive it.
27
+
28
+ /**
29
+ * The six declared stall lanes. One bundle resolves to at most one of these.
30
+ * Ordered by decision precedence (see LANE_PRECEDENCE) — the first lane whose evidence
31
+ * is present AND conclusive owns the classification.
32
+ */
33
+ export const HANG_CLASSES = Object.freeze([
34
+ 'process-tree-leak',
35
+ 'hook-overrun',
36
+ 'index-deadline',
37
+ 'lock-contention',
38
+ 'context-capacity',
39
+ 'gateway-stream-idle',
40
+ ]);
41
+
42
+ /** The explicit "could not prove a lane" result. Never a member of HANG_CLASSES. */
43
+ export const UNKNOWN_CLASS = 'unknown';
44
+
45
+ /**
46
+ * Evidence kinds a bundle may carry. Exported so callers and diagnostics can enumerate
47
+ * what the classifier understands instead of guessing at field names.
48
+ */
49
+ export const EVIDENCE_KINDS = Object.freeze([
50
+ 'hook-telemetry',
51
+ 'hook-active',
52
+ 'process-result',
53
+ 'index-error',
54
+ 'lock-outcome',
55
+ 'context-capacity',
56
+ 'stream-idle',
57
+ ]);
58
+
59
+ // Hook-runner outcomes that PROVE the runner ended the child (TASK-018 taxonomy). These
60
+ // are kill verdicts, so they name a lane; `ok` / `exit-code` are verdicts about the hook's
61
+ // own decision and prove nothing about liveness.
62
+ const KILL_OUTCOMES = new Set(['timeout', 'output-overflow', 'budget-exhausted']);
63
+
64
+ // A leak only exists AFTER the runner's OWN escalation attempt, and only if something is
65
+ // still alive. `failureKind: 'deadline'` is the runner's kill verdict — the deadline timer
66
+ // armed the TERM → KILL sequence, so survivors beside it really are an escapee (H03).
67
+ //
68
+ // `signal` is explicitly NOT escalation evidence: the runner documents it as "the child
69
+ // died from a signal this runner did not send" (hook-process.mjs), which is an unprompted
70
+ // EXTERNAL kill. Survivors beside an external signal may be unrelated processes, and naming
71
+ // a lane off it would fabricate a TERM → KILL diagnosis the evidence cannot support. Such a
72
+ // bundle is `unknown`, never `process-tree-leak`.
73
+ const RUNNER_ESCALATION_KINDS = new Set(['deadline']);
74
+
75
+ const UNKNOWN_CHECK = 'Collect hook telemetry, process-exit evidence, or an index/lock/context measurement for this stall — none of the current evidence proves a lane.';
76
+
77
+ const NEXT_CHECK = Object.freeze({
78
+ 'process-tree-leak': 'Re-run the failing command with the process-tree runner and list the surviving PIDs: a process group survived its TERM→KILL escalation (check for a child that ignores or detaches from SIGTERM).',
79
+ 'hook-overrun': 'Inspect the cited hook telemetry row: the hook was killed at its deadline or overflowed its output cap. Measure that hook alone and reduce its work below its budget.',
80
+ 'index-deadline': 'Re-run discovery with a bounded deadline and inspect the recorded phase: git enumeration or the filesystem fallback consumed the whole budget.',
81
+ 'lock-contention': 'Inspect the lock holder state for the named target: the acquisition budget expired or was aborted before the critical section ran (fail-closed, no mutation happened).',
82
+ 'context-capacity': 'Re-check the negotiated context capacity against the estimate: the session reached its advisory cap, so compaction must run before more context is added.',
83
+ 'gateway-stream-idle': 'Watch the stream idle window: no hook was active while the stream went silent, so treat this as an upstream/gateway stall and verify CLAUDE_STREAM_IDLE_TIMEOUT_MS and the non-streaming fallback.',
84
+ });
85
+
86
+ function isObject(value) {
87
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
88
+ }
89
+
90
+ function isPositiveNumber(value) {
91
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
92
+ }
93
+
94
+ function resultFor(cls, confidence, evidenceIds, recommendedNextCheck) {
95
+ return { class: cls, confidence, evidenceIds, recommendedNextCheck };
96
+ }
97
+
98
+ function unknown(evidenceIds = []) {
99
+ return resultFor(UNKNOWN_CLASS, 'none', evidenceIds, UNKNOWN_CHECK);
100
+ }
101
+
102
+ // ─── per-lane evidence evaluators ────────────────────────────────────────────
103
+ // Each returns { evidenceIds, confidence } when the evidence PROVES the lane, else null.
104
+
105
+ function detectProcessTreeLeak(bundle) {
106
+ const processResult = bundle.processResult;
107
+ if (!isObject(processResult)) return null;
108
+ const survivors = processResult.survivors;
109
+ // `survivors` must be an explicit, non-empty array: a missing field is unknown
110
+ // evidence (the caller never probed), and an empty array is a clean reap.
111
+ if (!Array.isArray(survivors) || survivors.length === 0) return null;
112
+ // Survivors alone proves nothing — the runner must have escalated. `signal` is an
113
+ // external kill, so it stays `unknown` rather than claiming a leak.
114
+ if (!RUNNER_ESCALATION_KINDS.has(processResult.failureKind)) return null;
115
+ return { evidenceIds: ['process-result'], confidence: 'high' };
116
+ }
117
+
118
+ function detectHookOverrun(bundle) {
119
+ const rows = bundle.hookTelemetry;
120
+ if (!Array.isArray(rows)) return null;
121
+ for (let i = 0; i < rows.length; i += 1) {
122
+ const row = rows[i];
123
+ if (isObject(row) && KILL_OUTCOMES.has(row.outcome)) {
124
+ // Cite only the failing row — the row index is the evidence locator.
125
+ return { evidenceIds: [`hook-telemetry[${i}]`], confidence: 'high' };
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function detectIndexDeadline(bundle) {
132
+ const indexError = bundle.indexError;
133
+ if (!isObject(indexError)) return null;
134
+ // Only the typed liveness failure is proof; a message alone could be anything.
135
+ const typed = indexError.name === 'IndexDiscoveryTimeoutError'
136
+ || indexError.code === 'INDEX_DISCOVERY_TIMEOUT';
137
+ if (!typed) return null;
138
+ return { evidenceIds: ['index-error'], confidence: 'high' };
139
+ }
140
+
141
+ function detectLockContention(bundle) {
142
+ const lockOutcome = bundle.lockOutcome;
143
+ if (!isObject(lockOutcome) || lockOutcome.ok !== false) return null;
144
+ // TASK-028's typed envelope: the callback never ran, so no mutation was lost.
145
+ if (lockOutcome.reason !== 'busy' && lockOutcome.reason !== 'aborted') return null;
146
+ return { evidenceIds: ['lock-outcome'], confidence: 'high' };
147
+ }
148
+
149
+ function detectContextCapacity(bundle) {
150
+ const context = bundle.context;
151
+ if (!isObject(context)) return null;
152
+ const capTokens = context.capTokens;
153
+ const estimatedTokens = context.estimatedTokens;
154
+ if (!isPositiveNumber(capTokens) || !isPositiveNumber(estimatedTokens)) return null;
155
+ if (estimatedTokens < capTokens) return null;
156
+ return { evidenceIds: ['context-capacity'], confidence: 'high' };
157
+ }
158
+
159
+ function detectGatewayStreamIdle(bundle) {
160
+ const stream = bundle.stream;
161
+ if (!isObject(stream)) return null;
162
+ const idleMs = stream.idleMs;
163
+ const idleTimeoutMs = stream.idleTimeoutMs;
164
+ // Both numbers are required: without the negotiated timeout there is nothing to
165
+ // compare against, and an idle duration alone proves no stall.
166
+ if (!isPositiveNumber(idleMs) || !isPositiveNumber(idleTimeoutMs)) return null;
167
+ if (idleMs < idleTimeoutMs) return null;
168
+ // A hook was active while the stream was silent → the silence may be UKit's, so the
169
+ // upstream lane is removed entirely. Never blame the gateway over hook evidence.
170
+ // `null`/`undefined` both mean "no hook was active" — only a real value withholds it.
171
+ if (bundle.hookActive !== null && bundle.hookActive !== undefined) return null;
172
+ return { evidenceIds: ['stream-idle'], confidence: 'high' };
173
+ }
174
+
175
+ // Precedence: the most specific, evidence-backed lane wins. The upstream lane is LAST —
176
+ // it is the only lane whose proof is the ABSENCE of other evidence, so it may only be
177
+ // reached once every hook/index/lock/context claim had its chance.
178
+ const LANE_PRECEDENCE = Object.freeze([
179
+ ['process-tree-leak', detectProcessTreeLeak],
180
+ ['hook-overrun', detectHookOverrun],
181
+ ['index-deadline', detectIndexDeadline],
182
+ ['lock-contention', detectLockContention],
183
+ ['context-capacity', detectContextCapacity],
184
+ ['gateway-stream-idle', detectGatewayStreamIdle],
185
+ ]);
186
+
187
+ // Evidence that was considered but is NOT sufficient to name a lane. Reported on the
188
+ // `unknown` result so a caller sees what was looked at, never just "nothing".
189
+ function inconclusiveEvidenceIds(bundle) {
190
+ const ids = [];
191
+ if (Array.isArray(bundle.hookTelemetry) && bundle.hookTelemetry.length > 0) {
192
+ ids.push('hook-telemetry');
193
+ }
194
+ if (isObject(bundle.hookActive)) ids.push('hook-active');
195
+ if (isObject(bundle.stream)) ids.push('stream-idle');
196
+ if (isObject(bundle.processResult)) ids.push('process-result');
197
+ if (isObject(bundle.indexError)) ids.push('index-error');
198
+ if (isObject(bundle.lockOutcome)) ids.push('lock-outcome');
199
+ if (isObject(bundle.context)) ids.push('context-capacity');
200
+ return ids;
201
+ }
202
+
203
+ /**
204
+ * Classify ONE stall from an evidence bundle.
205
+ *
206
+ * @param {object} [bundle] evidence collected by a scenario or a live diagnostic:
207
+ * `hookTelemetry` — redacted telemetry rows (TASK-019 schema; `outcome` is the
208
+ * runner's taxonomy, `hook` names the emitter).
209
+ * `hookActive` — `{hook, activeMs}` when a hook held the hot path. Presence alone
210
+ * removes the upstream lane.
211
+ * `processResult` — `{failureKind, signal, code, elapsedMs, survivors}` from the
212
+ * process-tree runner. `survivors` is the escalation probe.
213
+ * `indexError` — the thrown error (or its shape) from index discovery.
214
+ * `lockOutcome` — the typed `{ok:false, reason:'busy'|'aborted', waitedMs}` envelope.
215
+ * `context` — `{capTokens, estimatedTokens, ...}` from capacity negotiation.
216
+ * `stream` — `{idleMs, idleTimeoutMs, ...}` from stream timing.
217
+ * @returns {{ class: string, confidence: 'high'|'none', evidenceIds: string[],
218
+ * recommendedNextCheck: string }} exactly four fields; `class` is either a
219
+ * member of HANG_CLASSES or UNKNOWN_CLASS.
220
+ */
221
+ export function classifyHang(bundle = {}) {
222
+ if (!isObject(bundle)) return unknown();
223
+
224
+ for (const [lane, detect] of LANE_PRECEDENCE) {
225
+ const evidence = detect(bundle);
226
+ if (evidence) return resultFor(lane, evidence.confidence, evidence.evidenceIds, NEXT_CHECK[lane]);
227
+ }
228
+
229
+ return unknown(inconclusiveEvidenceIds(bundle));
230
+ }
231
+
232
+ /**
233
+ * C19 finding → lane map. Every finding this cycle addressed has a lane whose evidence a
234
+ * scenario in `tests/liveness/hangScenarios.test.js` produces, so no finding is left
235
+ * without a classifier answer. Exposed as data (not prose) so the liveness suite can fail
236
+ * when coverage regresses.
237
+ */
238
+ export const C19_LANE_MAP = Object.freeze([
239
+ Object.freeze({ findings: Object.freeze(['H01', 'H02', 'H05', 'H08', 'H21']), lane: 'hook-overrun' }),
240
+ Object.freeze({ findings: Object.freeze(['H03']), lane: 'process-tree-leak' }),
241
+ Object.freeze({ findings: Object.freeze(['H09', 'H10', 'H11', 'H12', 'H13', 'H14', 'H15']), lane: 'index-deadline' }),
242
+ Object.freeze({ findings: Object.freeze(['H16', 'H17', 'H18', 'H19', 'H20']), lane: 'lock-contention' }),
243
+ Object.freeze({ findings: Object.freeze(['H04', 'H22']), lane: 'context-capacity' }),
244
+ Object.freeze({ findings: Object.freeze(['H23', 'H24']), lane: 'hook-overrun' }),
245
+ Object.freeze({ findings: Object.freeze(['H25']), lane: 'gateway-stream-idle' }),
246
+ ]);