@ngockhoale/ukit 2.4.2 → 2.5.0

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 (59) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -0
  3. package/manifests/platform.full.yaml +51 -112
  4. package/package.json +2 -1
  5. package/scripts/index/refresh-index.mjs +47 -22
  6. package/src/cli/commands/doctor.js +132 -2
  7. package/src/cli/commands/uninstall.js +18 -0
  8. package/src/core/applyPlan.js +17 -2
  9. package/src/core/compact/threshold.js +36 -6
  10. package/src/core/diffPlan.js +35 -0
  11. package/src/core/fileOps.js +26 -0
  12. package/src/core/projectImportant.js +430 -0
  13. package/src/core/sensitiveValueScanner.js +118 -0
  14. package/src/core/status.js +55 -1
  15. package/src/core/uninstall.js +183 -3
  16. package/src/diagnostics/classifyHang.js +246 -0
  17. package/src/index/buildIndex.js +1033 -62
  18. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  19. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  20. package/templates/.claude/hooks/completion-gate.sh +51 -10
  21. package/templates/.claude/hooks/compress-output.sh +38 -6
  22. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  23. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  24. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  25. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  26. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  27. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  28. package/templates/.claude/hooks/project-important.sh +67 -0
  29. package/templates/.claude/hooks/protect-files.sh +31 -5
  30. package/templates/.claude/hooks/record-execution.sh +31 -5
  31. package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
  32. package/templates/.claude/hooks/skill-router.sh +31 -5
  33. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  34. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  35. package/templates/.claude/hooks/verification-guard.sh +107 -112
  36. package/templates/.claude/hooks/vision-router.sh +49 -13
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  39. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  40. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  41. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  42. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  43. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  44. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  45. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  46. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  47. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  48. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  49. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  51. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  52. package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
  53. package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
  54. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  55. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  56. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  57. package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
  58. package/templates/AGENTS.md +8 -0
  59. package/templates/PROJECT_IMPORTANT.md +9 -0
@@ -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: {
@@ -45,7 +56,7 @@ export const HOOK_EVENT_MAP = {
45
56
  },
46
57
  before_agent_start: ['sensitive-data-guard.sh', 'skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
47
58
  'session.compacting': ['reinject-context.sh'],
48
- session_start: ['auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
59
+ session_start: ['project-important.sh', 'auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
49
60
  };
50
61
 
51
62
  const TOOL_NAME_MAP = {
@@ -100,6 +111,7 @@ export const ADVISORY_SCRIPTS = new Set([
100
111
  'task-watchdog.sh',
101
112
  'compress-output.sh',
102
113
  'reinject-context.sh',
114
+ 'project-important.sh',
103
115
  'auto-prune-bash.sh',
104
116
  'reset-compact-pressure.sh',
105
117
  'handoff-resume.sh',
@@ -118,6 +130,48 @@ function classifyFailure(scriptName) {
118
130
  // whose dangerous-command check timed out stays blocked with the honest reason.
119
131
  const TIMEOUT_STAYS_CLOSED = new Set(['block-dangerous.sh']);
120
132
 
133
+ // TASK-018: the hook-chain-runner's failure taxonomy. Infrastructure outcomes
134
+ // (overflow / timeout / signal / budget-exhausted) produced NO verdict, so their
135
+ // captured output is untrustworthy and never reaches the model context, a block
136
+ // reason, or telemetry. Legacy runner entries (killed without a kind) keep the
137
+ // pre-TASK-018 timeout behavior.
138
+ const INFRASTRUCTURE_FAILURE_KINDS = new Set([
139
+ 'output-overflow',
140
+ 'timeout',
141
+ 'signal',
142
+ 'budget-exhausted',
143
+ ]);
144
+
145
+ // Telemetry redaction (TASK-018): hook stdout content never enters diagnostics by
146
+ // construction (the runner drops it for infrastructure kinds, and only lengths are
147
+ // recorded); stderr excerpts are bounded and high-signal secret shapes are masked
148
+ // before anything is written to .ukit/storage/cache/hook-errors/ or logged.
149
+ const SECRET_SHAPE_PATTERNS = [
150
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
151
+ /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g,
152
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g,
153
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
154
+ /\bAKIA[0-9A-Z]{16}\b/g,
155
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g,
156
+ /\b[0-9a-f]{40,}\b/gi,
157
+ ];
158
+
159
+ // TASK-018 review fix round 1 (minor): mask BEFORE truncating. Truncating first
160
+ // cut a secret that straddled the boundary, leaving a fragment whose pattern no
161
+ // longer matched (the truncated head leaked). The raw string is bounded by the
162
+ // runner's maxBuffer, so masking the full text stays cheap.
163
+ function redactDiagnosticText(text, maxChars = 500) {
164
+ const raw = String(text ?? '');
165
+ if (!raw) return '';
166
+ let redacted = raw;
167
+ for (const pattern of SECRET_SHAPE_PATTERNS) {
168
+ redacted = redacted.replace(pattern, '<redacted>');
169
+ }
170
+ return raw.length > maxChars
171
+ ? `${redacted.slice(0, maxChars)}…[+${raw.length - maxChars} chars]`
172
+ : redacted;
173
+ }
174
+
121
175
  function runtimeMetadata(event = {}, context = {}) {
122
176
  const sessionManager = context?.sessionManager;
123
177
  return {
@@ -170,24 +224,61 @@ function parseStructuredDecision(stdout) {
170
224
  }
171
225
 
172
226
  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`)
227
+ const failureKind = typeof execResult?.failureKind === 'string' ? execResult.failureKind : null;
228
+ // Infrastructure outcomes produce no verdict: no captured content travels with
229
+ // them (overflowed stdout is truncated mid-stream and may embed secrets).
230
+ const infrastructure = failureKind
231
+ ? INFRASTRUCTURE_FAILURE_KINDS.has(failureKind)
232
+ : Boolean(execResult?.killed);
233
+ const stdout = infrastructure ? '' : (execResult?.stdout ?? '');
234
+ const stderr = infrastructure
235
+ ? redactDiagnosticText(execResult?.stderr)
177
236
  : (execResult?.stderr ?? '');
178
237
 
179
- if (killed) {
238
+ if (infrastructure) {
239
+ if (failureKind === 'output-overflow') {
240
+ if (FAIL_CLOSED_SCRIPTS.has(scriptName)) {
241
+ return {
242
+ block: true,
243
+ 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.`,
244
+ stdout,
245
+ stderr: '',
246
+ };
247
+ }
248
+ return {
249
+ block: false,
250
+ warning: `${scriptName} overflowed its output capture buffer and was stopped; its output was discarded — an infrastructure event, not a verdict.`,
251
+ stdout,
252
+ stderr: '',
253
+ };
254
+ }
180
255
  if (TIMEOUT_STAYS_CLOSED.has(scriptName)) {
181
256
  return {
182
257
  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.`,
258
+ 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
259
  stdout,
185
260
  stderr,
186
261
  };
187
262
  }
263
+ const why = failureKind === 'signal'
264
+ ? 'was terminated by a signal'
265
+ : failureKind === 'budget-exhausted'
266
+ ? 'did not run — the hook chain total budget was exhausted before it could'
267
+ : 'exceeded its hook budget and was killed';
268
+ // TASK-018 review finding 3: the 16s ceiling makes a budget-exhausted skip of a
269
+ // LATE fail-closed guard (handoff-model-guard.sh / context-hardcap-gate.sh)
270
+ // realistic, and that guard fails open. This is deliberate anti-freeze policy:
271
+ // a guard that produced NO verdict is an infrastructure event, and blocking on
272
+ // it froze every Edit|Write whenever the machine was slow. Only
273
+ // block-dangerous.sh stays closed (TIMEOUT_STAYS_CLOSED) because destructive-
274
+ // command protection was explicitly required to never fail open. Stated in the
275
+ // warning so a skipped guard is never a silent one.
276
+ const failOpenTradeOff = FAIL_CLOSED_SCRIPTS.has(scriptName) && !TIMEOUT_STAYS_CLOSED.has(scriptName)
277
+ ? ` ${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.`
278
+ : '';
188
279
  return {
189
280
  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}`,
281
+ warning: `${scriptName} ${why} — treated as "could not verify", not as a block (an infrastructure event, not a verdict): ${stderr || 'no stderr'}.${failOpenTradeOff}`,
191
282
  stdout,
192
283
  stderr,
193
284
  };
@@ -224,7 +315,7 @@ function translateExecResult(scriptName, execResult) {
224
315
  }
225
316
  return {
226
317
  block: false,
227
- warning: `${scriptName} exited ${code} (failing open): ${stderr || 'no stderr'}`,
318
+ warning: `${scriptName} exited ${code} (failing open): ${redactDiagnosticText(stderr) || 'no stderr'}`,
228
319
  stdout,
229
320
  stderr,
230
321
  };
@@ -232,15 +323,13 @@ function translateExecResult(scriptName, execResult) {
232
323
 
233
324
  export { translateExecResult };
234
325
 
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
- }
326
+ // The exec timeout must exceed the runner's own chain budget (resolved from the
327
+ // SAME UKIT_HOOK_CHAIN_* knobs by hook-chain-budget.mjs) plus enough margin to
328
+ // cover the runner's TERM→KILL grace and hard settle slack, or the bridge orphans
329
+ // the runner mid-chain. TASK-018: the ceiling caps what used to be linear growth.
330
+ // Review fix round 1: this used to be a separate hardcoded formula under a fixed
331
+ // 30s cap that silently disagreed with the runner whenever the env knobs were set.
332
+ const chainExecTimeoutMs = resolveChainExecTimeoutMs;
244
333
 
245
334
  function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic) {
246
335
  try {
@@ -277,37 +366,25 @@ function resolveNodeExecutable() {
277
366
  return cachedNodeExecutable;
278
367
  }
279
368
 
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 */ }
369
+ // TASK-031: payload staging moved to hook-payload-store.mjs. Small payloads stay inline
370
+ // (zero filesystem work); over-cap payloads stage atomically into an owner-only file
371
+ // (macOS allows ~256KB per argument (E2BIG), and PostToolUse Bash payloads embed whole
372
+ // tool outputs — routinely past that limit, so the exec would fail before ANY hook runs
373
+ // and the chain verdict would be lost). Stale sweeping is sampled, bounded, and deferred
374
+ // off the request path; inline argv remains the fallback for read-only roots, unambiguous
375
+ // because raw JSON never starts with '@'.
376
+ function payloadsDirFor(projectRoot) {
377
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-payloads');
295
378
  }
296
379
 
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
- }
380
+ function schedulePayloadSweep(projectRoot) {
381
+ // Deferred: runs after the current turn settles, never inside the chain request.
382
+ const timer = setTimeout(() => {
383
+ try {
384
+ maybeSweepStalePayloads(payloadsDirFor(projectRoot));
385
+ } catch { /* deferred sweeping is best effort */ }
386
+ }, 0);
387
+ timer.unref?.();
311
388
  }
312
389
 
313
390
  export async function runScriptChain(
@@ -326,9 +403,13 @@ export async function runScriptChain(
326
403
  const scriptPaths = scripts.map((scriptName) => path.join(projectRoot, '.claude', 'hooks', scriptName));
327
404
  const nodeExecutable = resolveNodeExecutable();
328
405
  const startedAt = Date.now();
329
- const payloadFile = writePayloadFile(projectRoot, payload);
330
- const payloadArg = payloadFile ? `@${payloadFile}` : JSON.stringify(payload);
406
+ const payloadReference = createPayloadReference(JSON.stringify(payload), {
407
+ maxBytes: PAYLOAD_INLINE_MAX_BYTES,
408
+ dir: payloadsDirFor(projectRoot),
409
+ });
410
+ const payloadArg = payloadReference.arg;
331
411
  let execResult;
412
+ let payloadProbe = null;
332
413
  try {
333
414
  execResult = await pi.exec(
334
415
  nodeExecutable,
@@ -338,9 +419,12 @@ export async function runScriptChain(
338
419
  } catch (error) {
339
420
  execResult = { code: 1, stdout: '', stderr: error?.message ?? String(error), killed: false };
340
421
  } finally {
341
- if (payloadFile) {
342
- try { fs.rmSync(payloadFile, { force: true }); } catch { /* best effort cleanup */ }
343
- }
422
+ // TASK-031: verify the staged payload survived the chain intact BEFORE removing it —
423
+ // a file that vanished or was truncated mid-flight means the scripts ran against a
424
+ // different payload than the host captured, so their verdicts are void.
425
+ payloadProbe = probePayloadIntegrity(payloadReference);
426
+ payloadReference.cleanup();
427
+ if (payloadReference.mode === 'file') schedulePayloadSweep(projectRoot);
344
428
  }
345
429
  const elapsedMs = Date.now() - startedAt;
346
430
 
@@ -355,7 +439,11 @@ export async function runScriptChain(
355
439
  }
356
440
 
357
441
  const hasUsableResults = Boolean(chainResult) && Array.isArray(chainResult.results) && chainResult.results.length > 0;
358
- const transportFailed = Boolean(execResult?.killed) || Boolean(parseError) || Boolean(chainResult?.wrapperError) || !hasUsableResults;
442
+ const transportFailed = payloadProbe !== null
443
+ || Boolean(execResult?.killed)
444
+ || Boolean(parseError)
445
+ || Boolean(chainResult?.wrapperError)
446
+ || !hasUsableResults;
359
447
 
360
448
  if (transportFailed) {
361
449
  // The aggregate runner produced no verifiable per-script verdict. Never relabel this as a
@@ -366,7 +454,13 @@ export async function runScriptChain(
366
454
  killed: Boolean(execResult?.killed),
367
455
  code: execResult?.code ?? null,
368
456
  stdoutLength: (execResult?.stdout || '').length,
369
- stderr: execResult?.stderr || '',
457
+ // TASK-018: bounded + secret-masked excerpt only — raw hook/runner output
458
+ // never reaches the hook-errors telemetry file.
459
+ stderrExcerpt: redactDiagnosticText(execResult?.stderr),
460
+ // TASK-031: payload integrity is recorded as a classification + byte count,
461
+ // never as content.
462
+ payloadProbe,
463
+ payloadBytes: payloadReference.bytes,
370
464
  elapsedMs,
371
465
  nodeExecutable,
372
466
  nodeVersion: process.version,
@@ -375,15 +469,36 @@ export async function runScriptChain(
375
469
  wrapperError: chainResult?.wrapperError || null,
376
470
  };
377
471
  recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
472
+ // TASK-031: a staged payload that was lost or corrupted mid-chain voids every
473
+ // verdict below it. Chains that must not fail open on an unverifiable verdict
474
+ // (Edit|Write transport policy, and block-dangerous.sh's never-fail-open rule)
475
+ // stay closed; everything else fails open loudly. Neither reason carries any
476
+ // payload content — only the classification and byte count.
477
+ const payloadStaysClosed = payloadProbe !== null
478
+ && (failClosedOnTransportError || scripts.includes('block-dangerous.sh'));
479
+ if (payloadStaysClosed) {
480
+ const integrityReason = `UKit hook payload transport failed: the staged payload file was `
481
+ + `${payloadProbe === 'missing' ? 'removed' : 'truncated'} before the chain could read it `
482
+ + `(expected ${payloadReference.bytes} bytes), so the scripts may have run against an empty `
483
+ + `or partial payload and their verdicts were discarded — the call could not be verified. `
484
+ + `See .ukit/storage/cache/hook-errors/.`;
485
+ return { block: true, reason: integrityReason, context, invoked };
486
+ }
378
487
  const nodePathHint = process.env.UKIT_NODE_PATH
379
488
  ? ''
380
489
  : ` UKit already tried process.execPath and a "node" PATH lookup; neither resolved to a `
381
490
  + `working Node.js binary (runtime="${diagnostic.nodeExecutable}"). Set UKIT_NODE_PATH to `
382
491
  + `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}`;
492
+ const reason = payloadProbe !== null
493
+ ? `UKit hook payload transport failed: the staged payload file was `
494
+ + `${payloadProbe === 'missing' ? 'removed' : 'truncated'} before the chain could read it `
495
+ + `(expected ${payloadReference.bytes} bytes), so the scripts may have run against an empty `
496
+ + `or partial payload and their verdicts were discarded — treated as "could not verify", `
497
+ + `not as a block. See .ukit/storage/cache/hook-errors/.`
498
+ : `UKit OMP hook runner failed before producing a valid result `
499
+ + `(killed=${diagnostic.killed}, code=${diagnostic.code}, elapsedMs=${diagnostic.elapsedMs}, `
500
+ + `runtime=${diagnostic.nodeExecutable}). No safety-gate verdict was available for [${scripts.join(', ')}]. `
501
+ + `See .ukit/storage/cache/hook-errors/.${nodePathHint}`;
387
502
  if (failClosedOnTransportError) {
388
503
  return { block: true, reason, context, invoked };
389
504
  }
@@ -589,10 +704,12 @@ export async function runSessionCompact(pi, event, { projectRoot, context: exten
589
704
  // session_compact fires only after agent.replaceMessages()/rebaseAfterCompaction() already
590
705
  // ran (session-maintenance.ts), i.e. the session is idle here, and agent-session.ts's
591
706
  // sendCustomMessage() non-streaming branch appends the message to the live context
592
- // regardless of deliverAs — so 'steer' is accepted right after a compaction. Not a live
593
- // repro, so the 'nextTurn' copy below stays as defense in depth.
707
+ // regardless of deliverAs — so 'steer' is accepted right after a compaction.
708
+ // TASK-042: delivery is exact-once. The former defense-in-depth 'nextTurn' copy produced
709
+ // a second model-visible duplicate of the same context; per spec §10/§17 Phase 5 keep
710
+ // 'steer' only (switch to 'nextTurn' ONLY if a live omp smoke proves 'steer' is not
711
+ // retained after compact — never both).
594
712
  sendContext(pi, result.context, 'steer');
595
- sendContext(pi, result.context, 'nextTurn');
596
713
  return undefined;
597
714
  }
598
715
 
@@ -238,6 +238,14 @@ At the start of every OpenCode session, before working on the first task:
238
238
  4. If the route result points to a skill, read that SKILL.md before acting — do not skip this step.
239
239
  5. If `.ukit/storage/config.json` has `router.enabled: true`, prefer the router output over ad-hoc guessing.
240
240
 
241
+ ## Project Owner Instructions — Codex and OpenCode
242
+
243
+ When running in Codex or OpenCode, read and follow the root
244
+ `PROJECT_IMPORTANT.md` before doing project work. It is the canonical
245
+ project-owner instruction source. Do not copy its contents into this file.
246
+ If it is missing or unreadable, state that limitation and continue with the
247
+ remaining project instructions.
248
+
241
249
  ## Skills
242
250
 
243
251
  - Canonical skills live in `.claude/skills/`.
@@ -0,0 +1,9 @@
1
+ # Project Important Instructions
2
+
3
+ <!--
4
+ Add project-specific, non-negotiable AI instructions here.
5
+
6
+ UKit creates this file only when it is missing. After creation, UKit never rewrites,
7
+ merges, formats, chmods, or deletes it. Keep it at or below 6,000 Unicode code
8
+ points for complete runtime injection. Do not put credentials or secrets here.
9
+ -->