@ngockhoale/ukit 2.4.2 → 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 (43) hide show
  1. package/manifests/platform.full.yaml +19 -111
  2. package/package.json +2 -1
  3. package/scripts/index/refresh-index.mjs +47 -22
  4. package/src/core/compact/threshold.js +36 -6
  5. package/src/diagnostics/classifyHang.js +246 -0
  6. package/src/index/buildIndex.js +1033 -62
  7. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  8. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  9. package/templates/.claude/hooks/completion-gate.sh +51 -10
  10. package/templates/.claude/hooks/compress-output.sh +38 -6
  11. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  12. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  13. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  14. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  15. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  16. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  17. package/templates/.claude/hooks/protect-files.sh +31 -5
  18. package/templates/.claude/hooks/record-execution.sh +31 -5
  19. package/templates/.claude/hooks/sensitive-data-guard.sh +44 -8
  20. package/templates/.claude/hooks/skill-router.sh +31 -5
  21. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  22. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  23. package/templates/.claude/hooks/verification-guard.sh +107 -112
  24. package/templates/.claude/hooks/vision-router.sh +49 -13
  25. package/templates/.claude/settings.json +0 -5
  26. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  27. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  28. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  29. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  30. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  31. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  32. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  33. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  34. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  35. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  36. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  37. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  38. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  39. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  40. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  41. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  42. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  43. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -2,10 +2,14 @@
2
2
  /**
3
3
  * task-watchdog.mjs — wall-clock watchdog runtime for handoff tasks.
4
4
  *
5
- * Self-contained runtime module. No imports from `src/`, no sibling imports
6
- * beyond `node:` builtins (the `token-utils` file lock is intentionally NOT
7
- * used — file-locking watchdog state under a Stop hook with a 4s budget is a
8
- * recipe for getting killed mid-write by its own chain).
5
+ * Self-contained runtime module. No imports from `src/`; the one allowed sibling
6
+ * import is the shared hook async-lock runtime (TASK-028, `./async-lock.mjs`) —
7
+ * since TASK-024, every state mutation is a lock-scoped read-modify-write through
8
+ * it, because atomic temp+rename alone still loses updates when two concurrent
9
+ * Stop hooks read-modify-write the same counters (last-writer-wins). The lock's
10
+ * budget-capped, never-callback-unlocked design keeps the mutation inside the
11
+ * hook deadline: an expired acquisition budget is a typed fail-open outcome, and
12
+ * nothing is ever written unlocked.
9
13
  *
10
14
  * Exports:
11
15
  * DEFAULT_CONFIG — fail-open defaults; identical to the
@@ -23,7 +27,19 @@
23
27
  * readState(statePath) / writeState(statePath, value) —
24
28
  * `.ukit/storage/cache/task-watchdog/state.json`,
25
29
  * shape { firstSeen, hardBlocks }; any I/O error
26
- * → treat as empty, never throw.
30
+ * → treat as empty, never throw. writeState is
31
+ * the low-level persist helper — call it only
32
+ * from inside mutateWatchdogState (or tests).
33
+ * mutateWatchdogState(statePath, updater, {signal, deadlineMs}) —
34
+ * the ONLY way to mutate state: one lock-scoped
35
+ * read → updater → write. Committed state, or a
36
+ * typed `lock-timeout` outcome with no mutation.
37
+ * evaluateStopWatchdog({...}) — TASK-025: the Stop-branch evaluation extracted
38
+ * from task-watchdog.sh's heredoc. Returns a
39
+ * normalized { kind, reason?, systemMessage? }
40
+ * result for the stop coordinator, which merges
41
+ * it with the completion gate's decision and
42
+ * emits at most one Stop decision.
27
43
  *
28
44
  * The hard clock does not reset on Progress appends on purpose: trickling entries
29
45
  * every 4 minutes must not buy an unlimited task. The newest Progress timestamp is
@@ -33,6 +49,8 @@
33
49
  import fs from 'node:fs/promises';
34
50
  import path from 'node:path';
35
51
 
52
+ import { withAsyncLock } from './async-lock.mjs';
53
+
36
54
  // ─── Defaults ─────────────────────────────────────────────────────────────
37
55
  export const DEFAULT_CONFIG = Object.freeze({
38
56
  taskBudgets: {
@@ -126,15 +144,65 @@ export async function readState(statePath) {
126
144
 
127
145
  export async function writeState(statePath, value) {
128
146
  if (!statePath) return;
147
+ let tempPath = null;
129
148
  try {
130
149
  await fs.mkdir(path.dirname(statePath), { recursive: true });
131
150
  // temp + rename to avoid leaving a half-written state file if the hook is killed.
132
- const tempPath = `${statePath}.${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`;
151
+ tempPath = `${statePath}.${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`;
133
152
  await fs.writeFile(tempPath, `${JSON.stringify(value, null, 1)}\n`, 'utf8');
134
153
  await fs.rename(tempPath, statePath);
154
+ tempPath = null; // renamed into place — nothing left to clean up
135
155
  } catch {
136
156
  // Fail-open: never throw from state writes.
157
+ } finally {
158
+ if (tempPath) {
159
+ // TASK-024: a failed write/rename must not leak its temp file.
160
+ try {
161
+ await fs.rm(tempPath, { force: true });
162
+ } catch {}
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Lock-scoped read-modify-write of the watchdog state file (TASK-024). The ONLY
169
+ * sanctioned way to mutate state: read → updater → write all happen inside one
170
+ * `withAsyncLock` acquisition, so concurrent Stop hooks can never lose each
171
+ * other's firstSeen anchors or hard-block counts to last-writer-wins.
172
+ *
173
+ * The updater receives the freshly read state and mutates it in place (matching
174
+ * the existing ensureFirstSeen / bumpHardBlocks helpers); its return value is
175
+ * ignored and the mutated state is what gets persisted. An expired acquisition
176
+ * budget or an aborted signal is a typed fail-open outcome: the updater NEVER
177
+ * runs unlocked and nothing is written — the caller picks the policy.
178
+ *
179
+ * @param {string} statePath - state file to mutate (lock lives beside it)
180
+ * @param {(state: { firstSeen: Record<string, number>, hardBlocks: Record<string, number> }) => (void | Promise<void>)} updater
181
+ * @param {{ signal?: AbortSignal, deadlineMs?: number }} [options]
182
+ * @returns {Promise<{ ok: true, state: * } | { ok: false, reason: 'lock-timeout' | 'aborted', waitedMs: number }>}
183
+ * committed state, or the typed no-mutation outcome
184
+ */
185
+ export async function mutateWatchdogState(statePath, updater, { signal, deadlineMs } = {}) {
186
+ if (typeof statePath !== 'string' || !statePath) {
187
+ throw new TypeError('mutateWatchdogState(statePath, updater, options) requires a state path');
188
+ }
189
+ if (typeof updater !== 'function') {
190
+ throw new TypeError('mutateWatchdogState(statePath, updater, options) requires an updater function');
137
191
  }
192
+ const outcome = await withAsyncLock(statePath, { signal, deadlineMs }, async () => {
193
+ const state = await readState(statePath);
194
+ await updater(state);
195
+ await writeState(statePath, state);
196
+ return state;
197
+ });
198
+ if (outcome.ok) {
199
+ return { ok: true, state: outcome.value };
200
+ }
201
+ // 'busy' (acquisition budget expired) is what the watchdog interface names a
202
+ // lock-timeout; 'aborted' passes through unchanged. Either way nothing ran.
203
+ return outcome.reason === 'aborted'
204
+ ? { ok: false, reason: 'aborted', waitedMs: outcome.waitedMs }
205
+ : { ok: false, reason: 'lock-timeout', waitedMs: outcome.waitedMs };
138
206
  }
139
207
 
140
208
  // ─── Task parser ──────────────────────────────────────────────────────────
@@ -297,3 +365,109 @@ export function pickLastGreen(task) {
297
365
  if (!task?.lastProgressIso) return null;
298
366
  return { iso: task.lastProgressIso, label: 'milestone' };
299
367
  }
368
+
369
+ // ─── Stop evaluation (TASK-025) ───────────────────────────────────────────
370
+ /**
371
+ * Evaluate the watchdog policy for ONE Stop event and return a normalized result
372
+ * the stop coordinator (stop-coordinator.mjs) can merge with the completion gate's
373
+ * decision:
374
+ * { kind: 'block', reason }
375
+ * { kind: 'advisory', systemMessage }
376
+ * { kind: 'none' }
377
+ *
378
+ * Semantics are the extracted Stop branch of task-watchdog.sh, unchanged:
379
+ * anchors firstSeen once per task in one lock-scoped read-modify-write, then
380
+ * evaluates budgets; split policy + hard trip bumps the task's hardBlocks counter
381
+ * exactly once (cap-checked inside the same critical section); pause policy and
382
+ * degraded/cap-exhausted paths are advisory only and never emit a decision.
383
+ *
384
+ * Throws on infrastructure failure — the coordinator owns the failure policy
385
+ * (advisory lane: reported verbatim, never blocking the completion decision).
386
+ *
387
+ * @param {object} [options]
388
+ * @param {string} options.projectRoot - installed project root
389
+ * @param {number} [options.now] - evaluation clock (epochMs)
390
+ * @param {object} [options.config] - pre-loaded config (defaults to loadConfig of
391
+ * `<root>/.ukit/storage/config.json`, fail-open defaults)
392
+ * @param {Array} [options.tasks] - pre-discovered in_progress tasks (defaults to
393
+ * listInProgressTasks(projectRoot))
394
+ * @param {number} [options.lockBudgetMs] - per-acquisition lock budget; the
395
+ * coordinator passes a short slice so two acquisitions fit the hook deadline
396
+ */
397
+ export async function evaluateStopWatchdog({ projectRoot, now = Date.now(), config = null, tasks = null, lockBudgetMs } = {}) {
398
+ if (!config) config = await loadConfig(path.join(projectRoot, '.ukit', 'storage', 'config.json'));
399
+ if (!tasks) tasks = await listInProgressTasks(projectRoot);
400
+
401
+ const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'task-watchdog', 'state.json');
402
+ const state = await readState(statePath);
403
+ const lockOptions = Number.isFinite(lockBudgetMs) ? { deadlineMs: lockBudgetMs } : {};
404
+
405
+ // Anchor firstSeen via one lock-scoped read-modify-write (TASK-024 semantics).
406
+ try {
407
+ await mutateWatchdogState(statePath, (fresh) => {
408
+ for (const task of tasks) {
409
+ try {
410
+ ensureFirstSeen(fresh, task.id, now);
411
+ } catch {}
412
+ }
413
+ }, lockOptions);
414
+ } catch {}
415
+
416
+ const evals = evaluateBudgets({ tasks, state, now, config });
417
+ const hardResults = evals.filter((r) => r.phase === 'hard');
418
+ const softResults = evals.filter((r) => r.phase === 'soft');
419
+
420
+ if (hardResults.length === 0) {
421
+ if (softResults.length > 0) {
422
+ return {
423
+ kind: 'advisory',
424
+ systemMessage: softResults.map((r) => describeSoft(r, r.id)).join('\n'),
425
+ };
426
+ }
427
+ return { kind: 'none' };
428
+ }
429
+
430
+ const hardPolicy = String(config.taskBudgets?.hardPolicy || 'split');
431
+ const firstHard = hardResults[0];
432
+
433
+ if (hardPolicy !== 'split') {
434
+ // pause (or anything non-split) → advisory only, never a decision.
435
+ return {
436
+ kind: 'advisory',
437
+ systemMessage: hardResults.map((r) => describePause({ id: r.id, result: r })).join('\n'),
438
+ };
439
+ }
440
+
441
+ // split policy + hard trip: the cap check and the bump are ONE lock-scoped
442
+ // read-modify-write — one invocation bumps at most once (TASK-024).
443
+ let bumped = false;
444
+ const bumpOutcome = await mutateWatchdogState(statePath, (fresh) => {
445
+ const current = Number(fresh.hardBlocks[firstHard.id] || 0);
446
+ if (current >= (HARD_BLOCK_CAP || 2)) return;
447
+ try {
448
+ bumpHardBlocks(fresh, firstHard.id);
449
+ bumped = true;
450
+ } catch {}
451
+ }, lockOptions);
452
+ const persistedBlocks = Number(
453
+ (bumpOutcome.ok ? bumpOutcome.state : state)?.hardBlocks?.[firstHard.id] || 0,
454
+ );
455
+
456
+ if (!bumpOutcome.ok || !bumped) {
457
+ return {
458
+ kind: 'advisory',
459
+ systemMessage: describeDegraded({ id: firstHard.id, hardBlocks: persistedBlocks }),
460
+ };
461
+ }
462
+
463
+ const task = tasks.find((t) => t.id === firstHard.id) || { id: firstHard.id };
464
+ return {
465
+ kind: 'block',
466
+ reason: describeSplitReason({
467
+ id: firstHard.id,
468
+ result: firstHard,
469
+ hardBlocks: persistedBlocks,
470
+ lastGreen: pickLastGreen(task),
471
+ }),
472
+ };
473
+ }
@@ -30,7 +30,7 @@ function positiveInteger(value, fallback) {
30
30
  }
31
31
 
32
32
  async function scanOnce(filePath, maxBytes) {
33
- const cap = positiveInteger(maxBytes, DEFAULT_TAIL_MAX_BYTES);
33
+ const cap = Math.min(positiveInteger(maxBytes, DEFAULT_TAIL_MAX_BYTES), DEFAULT_TAIL_MAX_BYTES);
34
34
  const stat = await fsp.stat(filePath);
35
35
  if (!stat.isFile() || stat.size <= 0) {
36
36
  return EMPTY_RESULT();
@@ -18,6 +18,17 @@ import {
18
18
  readRouteState,
19
19
  recordExecutionReceipt,
20
20
  } from '../../../.claude/ukit/runtime/execution-ledger.mjs';
21
+ import {
22
+ PAYLOAD_INLINE_MAX_BYTES,
23
+ createPayloadReference,
24
+ maybeSweepStalePayloads,
25
+ probePayloadIntegrity,
26
+ } from '../../../.claude/ukit/runtime/hook-payload-store.mjs';
27
+ // TASK-018 review fix round 1: the chain budget is resolved by ONE shared module,
28
+ // so the runner's inner deadline and this bridge's outer pi.exec timeout can never
29
+ // drift apart (a configured 60-120s chain used to be killed here at 14-20s, which
30
+ // for Edit|Write is a fail-closed transport failure = every edit blocked).
31
+ import { resolveChainExecTimeoutMs } from '../../../.claude/ukit/runtime/hook-chain-budget.mjs';
21
32
 
22
33
  export const HOOK_EVENT_MAP = {
23
34
  tool_call: {
@@ -118,6 +129,48 @@ function classifyFailure(scriptName) {
118
129
  // whose dangerous-command check timed out stays blocked with the honest reason.
119
130
  const TIMEOUT_STAYS_CLOSED = new Set(['block-dangerous.sh']);
120
131
 
132
+ // TASK-018: the hook-chain-runner's failure taxonomy. Infrastructure outcomes
133
+ // (overflow / timeout / signal / budget-exhausted) produced NO verdict, so their
134
+ // captured output is untrustworthy and never reaches the model context, a block
135
+ // reason, or telemetry. Legacy runner entries (killed without a kind) keep the
136
+ // pre-TASK-018 timeout behavior.
137
+ const INFRASTRUCTURE_FAILURE_KINDS = new Set([
138
+ 'output-overflow',
139
+ 'timeout',
140
+ 'signal',
141
+ 'budget-exhausted',
142
+ ]);
143
+
144
+ // Telemetry redaction (TASK-018): hook stdout content never enters diagnostics by
145
+ // construction (the runner drops it for infrastructure kinds, and only lengths are
146
+ // recorded); stderr excerpts are bounded and high-signal secret shapes are masked
147
+ // before anything is written to .ukit/storage/cache/hook-errors/ or logged.
148
+ const SECRET_SHAPE_PATTERNS = [
149
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
150
+ /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g,
151
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g,
152
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
153
+ /\bAKIA[0-9A-Z]{16}\b/g,
154
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g,
155
+ /\b[0-9a-f]{40,}\b/gi,
156
+ ];
157
+
158
+ // TASK-018 review fix round 1 (minor): mask BEFORE truncating. Truncating first
159
+ // cut a secret that straddled the boundary, leaving a fragment whose pattern no
160
+ // longer matched (the truncated head leaked). The raw string is bounded by the
161
+ // runner's maxBuffer, so masking the full text stays cheap.
162
+ function redactDiagnosticText(text, maxChars = 500) {
163
+ const raw = String(text ?? '');
164
+ if (!raw) return '';
165
+ let redacted = raw;
166
+ for (const pattern of SECRET_SHAPE_PATTERNS) {
167
+ redacted = redacted.replace(pattern, '<redacted>');
168
+ }
169
+ return raw.length > maxChars
170
+ ? `${redacted.slice(0, maxChars)}…[+${raw.length - maxChars} chars]`
171
+ : redacted;
172
+ }
173
+
121
174
  function runtimeMetadata(event = {}, context = {}) {
122
175
  const sessionManager = context?.sessionManager;
123
176
  return {
@@ -170,24 +223,61 @@ function parseStructuredDecision(stdout) {
170
223
  }
171
224
 
172
225
  function translateExecResult(scriptName, execResult) {
173
- const killed = Boolean(execResult?.killed);
174
- const stdout = execResult?.stdout ?? '';
175
- const stderr = killed
176
- ? (execResult?.stderr || `${scriptName} was killed before it completed`)
226
+ const failureKind = typeof execResult?.failureKind === 'string' ? execResult.failureKind : null;
227
+ // Infrastructure outcomes produce no verdict: no captured content travels with
228
+ // them (overflowed stdout is truncated mid-stream and may embed secrets).
229
+ const infrastructure = failureKind
230
+ ? INFRASTRUCTURE_FAILURE_KINDS.has(failureKind)
231
+ : Boolean(execResult?.killed);
232
+ const stdout = infrastructure ? '' : (execResult?.stdout ?? '');
233
+ const stderr = infrastructure
234
+ ? redactDiagnosticText(execResult?.stderr)
177
235
  : (execResult?.stderr ?? '');
178
236
 
179
- if (killed) {
237
+ if (infrastructure) {
238
+ if (failureKind === 'output-overflow') {
239
+ if (FAIL_CLOSED_SCRIPTS.has(scriptName)) {
240
+ return {
241
+ block: true,
242
+ reason: `${scriptName} overflowed its output capture buffer and was stopped before it produced a verdict, so UKit could not verify the call — the call stays blocked and no overflowed hook output was kept.`,
243
+ stdout,
244
+ stderr: '',
245
+ };
246
+ }
247
+ return {
248
+ block: false,
249
+ warning: `${scriptName} overflowed its output capture buffer and was stopped; its output was discarded — an infrastructure event, not a verdict.`,
250
+ stdout,
251
+ stderr: '',
252
+ };
253
+ }
180
254
  if (TIMEOUT_STAYS_CLOSED.has(scriptName)) {
181
255
  return {
182
256
  block: true,
183
- reason: `${scriptName} exceeded its hook budget and was killed, so UKit could not verify the command is safe — the call stays blocked. Retry once; if this repeats, the machine is too slow for the guard to finish.`,
257
+ reason: `${scriptName} ${failureKind === 'budget-exhausted' ? 'was never reached — the hook chain total budget was exhausted' : 'exceeded its hook budget and was killed'}, so UKit could not verify the command is safe — the call stays blocked. Retry once; if this repeats, the machine is too slow for the guard to finish.`,
184
258
  stdout,
185
259
  stderr,
186
260
  };
187
261
  }
262
+ const why = failureKind === 'signal'
263
+ ? 'was terminated by a signal'
264
+ : failureKind === 'budget-exhausted'
265
+ ? 'did not run — the hook chain total budget was exhausted before it could'
266
+ : 'exceeded its hook budget and was killed';
267
+ // TASK-018 review finding 3: the 16s ceiling makes a budget-exhausted skip of a
268
+ // LATE fail-closed guard (handoff-model-guard.sh / context-hardcap-gate.sh)
269
+ // realistic, and that guard fails open. This is deliberate anti-freeze policy:
270
+ // a guard that produced NO verdict is an infrastructure event, and blocking on
271
+ // it froze every Edit|Write whenever the machine was slow. Only
272
+ // block-dangerous.sh stays closed (TIMEOUT_STAYS_CLOSED) because destructive-
273
+ // command protection was explicitly required to never fail open. Stated in the
274
+ // warning so a skipped guard is never a silent one.
275
+ const failOpenTradeOff = FAIL_CLOSED_SCRIPTS.has(scriptName) && !TIMEOUT_STAYS_CLOSED.has(scriptName)
276
+ ? ` ${scriptName} is a fail-closed guard, but a guard that never produced a verdict is treated as "could not verify" rather than a block — the deliberate anti-freeze trade-off for infrastructure events; only block-dangerous.sh stays closed.`
277
+ : '';
188
278
  return {
189
279
  block: false,
190
- warning: `${scriptName} exceeded its hook budget and was killed — treated as "could not verify", not as a block (a timeout is an infrastructure event, not a verdict): ${stderr}`,
280
+ warning: `${scriptName} ${why} — treated as "could not verify", not as a block (an infrastructure event, not a verdict): ${stderr || 'no stderr'}.${failOpenTradeOff}`,
191
281
  stdout,
192
282
  stderr,
193
283
  };
@@ -224,7 +314,7 @@ function translateExecResult(scriptName, execResult) {
224
314
  }
225
315
  return {
226
316
  block: false,
227
- warning: `${scriptName} exited ${code} (failing open): ${stderr || 'no stderr'}`,
317
+ warning: `${scriptName} exited ${code} (failing open): ${redactDiagnosticText(stderr) || 'no stderr'}`,
228
318
  stdout,
229
319
  stderr,
230
320
  };
@@ -232,15 +322,13 @@ function translateExecResult(scriptName, execResult) {
232
322
 
233
323
  export { translateExecResult };
234
324
 
235
- // Mirrors hook-chain-runner.mjs's TOTAL/CHILD budget constants: the exec timeout must
236
- // exceed the runner's own chain budget (max(10s, scripts×4s)) plus parse margin, or the
237
- // bridge orphans the runner mid-chain once a chain grows past 2 scripts.
238
- const HOOK_CHAIN_BASE_BUDGET_MS = 10000;
239
- const HOOK_CHAIN_CHILD_BUDGET_MS = 4000;
240
- function chainExecTimeoutMs(scriptCount) {
241
- const budget = Math.max(HOOK_CHAIN_BASE_BUDGET_MS, scriptCount * HOOK_CHAIN_CHILD_BUDGET_MS);
242
- return Math.min(30000, budget + 2000);
243
- }
325
+ // The exec timeout must exceed the runner's own chain budget (resolved from the
326
+ // SAME UKIT_HOOK_CHAIN_* knobs by hook-chain-budget.mjs) plus enough margin to
327
+ // cover the runner's TERM→KILL grace and hard settle slack, or the bridge orphans
328
+ // the runner mid-chain. TASK-018: the ceiling caps what used to be linear growth.
329
+ // Review fix round 1: this used to be a separate hardcoded formula under a fixed
330
+ // 30s cap that silently disagreed with the runner whenever the env knobs were set.
331
+ const chainExecTimeoutMs = resolveChainExecTimeoutMs;
244
332
 
245
333
  function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic) {
246
334
  try {
@@ -277,37 +365,25 @@ function resolveNodeExecutable() {
277
365
  return cachedNodeExecutable;
278
366
  }
279
367
 
280
- // argv is bounded: macOS allows ~256KB per argument (E2BIG), and PostToolUse Bash payloads
281
- // embed the whole tool output — routinely past that limit — so the exec fails before ANY
282
- // hook runs and the chain verdict is lost. Pass the payload through a temp file instead
283
- // (runner reads `@path` markers); inline argv stays as the fallback for read-only project
284
- // roots, and remains unambiguous because raw JSON never starts with '@'.
285
- function sweepStalePayloadFiles(dir) {
286
- try {
287
- const cutoff = Date.now() - 60 * 60 * 1000;
288
- for (const name of fs.readdirSync(dir)) {
289
- const filePath = path.join(dir, name);
290
- try {
291
- if (fs.statSync(filePath).mtimeMs < cutoff) fs.rmSync(filePath, { force: true });
292
- } catch { /* raced away — fine */ }
293
- }
294
- } catch { /* sweeping is best effort */ }
368
+ // TASK-031: payload staging moved to hook-payload-store.mjs. Small payloads stay inline
369
+ // (zero filesystem work); over-cap payloads stage atomically into an owner-only file
370
+ // (macOS allows ~256KB per argument (E2BIG), and PostToolUse Bash payloads embed whole
371
+ // tool outputs — routinely past that limit, so the exec would fail before ANY hook runs
372
+ // and the chain verdict would be lost). Stale sweeping is sampled, bounded, and deferred
373
+ // off the request path; inline argv remains the fallback for read-only roots, unambiguous
374
+ // because raw JSON never starts with '@'.
375
+ function payloadsDirFor(projectRoot) {
376
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-payloads');
295
377
  }
296
378
 
297
- function writePayloadFile(projectRoot, payload) {
298
- try {
299
- const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-payloads');
300
- fs.mkdirSync(dir, { recursive: true });
301
- sweepStalePayloadFiles(dir);
302
- const filePath = path.join(
303
- dir,
304
- `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}.json`,
305
- );
306
- fs.writeFileSync(filePath, JSON.stringify(payload), 'utf8');
307
- return filePath;
308
- } catch {
309
- return null;
310
- }
379
+ function schedulePayloadSweep(projectRoot) {
380
+ // Deferred: runs after the current turn settles, never inside the chain request.
381
+ const timer = setTimeout(() => {
382
+ try {
383
+ maybeSweepStalePayloads(payloadsDirFor(projectRoot));
384
+ } catch { /* deferred sweeping is best effort */ }
385
+ }, 0);
386
+ timer.unref?.();
311
387
  }
312
388
 
313
389
  export async function runScriptChain(
@@ -326,9 +402,13 @@ export async function runScriptChain(
326
402
  const scriptPaths = scripts.map((scriptName) => path.join(projectRoot, '.claude', 'hooks', scriptName));
327
403
  const nodeExecutable = resolveNodeExecutable();
328
404
  const startedAt = Date.now();
329
- const payloadFile = writePayloadFile(projectRoot, payload);
330
- const payloadArg = payloadFile ? `@${payloadFile}` : JSON.stringify(payload);
405
+ const payloadReference = createPayloadReference(JSON.stringify(payload), {
406
+ maxBytes: PAYLOAD_INLINE_MAX_BYTES,
407
+ dir: payloadsDirFor(projectRoot),
408
+ });
409
+ const payloadArg = payloadReference.arg;
331
410
  let execResult;
411
+ let payloadProbe = null;
332
412
  try {
333
413
  execResult = await pi.exec(
334
414
  nodeExecutable,
@@ -338,9 +418,12 @@ export async function runScriptChain(
338
418
  } catch (error) {
339
419
  execResult = { code: 1, stdout: '', stderr: error?.message ?? String(error), killed: false };
340
420
  } finally {
341
- if (payloadFile) {
342
- try { fs.rmSync(payloadFile, { force: true }); } catch { /* best effort cleanup */ }
343
- }
421
+ // TASK-031: verify the staged payload survived the chain intact BEFORE removing it —
422
+ // a file that vanished or was truncated mid-flight means the scripts ran against a
423
+ // different payload than the host captured, so their verdicts are void.
424
+ payloadProbe = probePayloadIntegrity(payloadReference);
425
+ payloadReference.cleanup();
426
+ if (payloadReference.mode === 'file') schedulePayloadSweep(projectRoot);
344
427
  }
345
428
  const elapsedMs = Date.now() - startedAt;
346
429
 
@@ -355,7 +438,11 @@ export async function runScriptChain(
355
438
  }
356
439
 
357
440
  const hasUsableResults = Boolean(chainResult) && Array.isArray(chainResult.results) && chainResult.results.length > 0;
358
- const transportFailed = Boolean(execResult?.killed) || Boolean(parseError) || Boolean(chainResult?.wrapperError) || !hasUsableResults;
441
+ const transportFailed = payloadProbe !== null
442
+ || Boolean(execResult?.killed)
443
+ || Boolean(parseError)
444
+ || Boolean(chainResult?.wrapperError)
445
+ || !hasUsableResults;
359
446
 
360
447
  if (transportFailed) {
361
448
  // The aggregate runner produced no verifiable per-script verdict. Never relabel this as a
@@ -366,7 +453,13 @@ export async function runScriptChain(
366
453
  killed: Boolean(execResult?.killed),
367
454
  code: execResult?.code ?? null,
368
455
  stdoutLength: (execResult?.stdout || '').length,
369
- stderr: execResult?.stderr || '',
456
+ // TASK-018: bounded + secret-masked excerpt only — raw hook/runner output
457
+ // never reaches the hook-errors telemetry file.
458
+ stderrExcerpt: redactDiagnosticText(execResult?.stderr),
459
+ // TASK-031: payload integrity is recorded as a classification + byte count,
460
+ // never as content.
461
+ payloadProbe,
462
+ payloadBytes: payloadReference.bytes,
370
463
  elapsedMs,
371
464
  nodeExecutable,
372
465
  nodeVersion: process.version,
@@ -375,15 +468,36 @@ export async function runScriptChain(
375
468
  wrapperError: chainResult?.wrapperError || null,
376
469
  };
377
470
  recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
471
+ // TASK-031: a staged payload that was lost or corrupted mid-chain voids every
472
+ // verdict below it. Chains that must not fail open on an unverifiable verdict
473
+ // (Edit|Write transport policy, and block-dangerous.sh's never-fail-open rule)
474
+ // stay closed; everything else fails open loudly. Neither reason carries any
475
+ // payload content — only the classification and byte count.
476
+ const payloadStaysClosed = payloadProbe !== null
477
+ && (failClosedOnTransportError || scripts.includes('block-dangerous.sh'));
478
+ if (payloadStaysClosed) {
479
+ const integrityReason = `UKit hook payload transport failed: the staged payload file was `
480
+ + `${payloadProbe === 'missing' ? 'removed' : 'truncated'} before the chain could read it `
481
+ + `(expected ${payloadReference.bytes} bytes), so the scripts may have run against an empty `
482
+ + `or partial payload and their verdicts were discarded — the call could not be verified. `
483
+ + `See .ukit/storage/cache/hook-errors/.`;
484
+ return { block: true, reason: integrityReason, context, invoked };
485
+ }
378
486
  const nodePathHint = process.env.UKIT_NODE_PATH
379
487
  ? ''
380
488
  : ` UKit already tried process.execPath and a "node" PATH lookup; neither resolved to a `
381
489
  + `working Node.js binary (runtime="${diagnostic.nodeExecutable}"). Set UKIT_NODE_PATH to `
382
490
  + `an explicit Node.js binary path to override, e.g.: export UKIT_NODE_PATH="$(command -v node)".`;
383
- const reason = `UKit OMP hook runner failed before producing a valid result `
384
- + `(killed=${diagnostic.killed}, code=${diagnostic.code}, elapsedMs=${diagnostic.elapsedMs}, `
385
- + `runtime=${diagnostic.nodeExecutable}). No safety-gate verdict was available for [${scripts.join(', ')}]. `
386
- + `See .ukit/storage/cache/hook-errors/.${nodePathHint}`;
491
+ const reason = payloadProbe !== null
492
+ ? `UKit hook payload transport failed: the staged payload file was `
493
+ + `${payloadProbe === 'missing' ? 'removed' : 'truncated'} before the chain could read it `
494
+ + `(expected ${payloadReference.bytes} bytes), so the scripts may have run against an empty `
495
+ + `or partial payload and their verdicts were discarded — treated as "could not verify", `
496
+ + `not as a block. See .ukit/storage/cache/hook-errors/.`
497
+ : `UKit OMP hook runner failed before producing a valid result `
498
+ + `(killed=${diagnostic.killed}, code=${diagnostic.code}, elapsedMs=${diagnostic.elapsedMs}, `
499
+ + `runtime=${diagnostic.nodeExecutable}). No safety-gate verdict was available for [${scripts.join(', ')}]. `
500
+ + `See .ukit/storage/cache/hook-errors/.${nodePathHint}`;
387
501
  if (failClosedOnTransportError) {
388
502
  return { block: true, reason, context, invoked };
389
503
  }