@ngockhoale/ukit 2.6.10 → 2.7.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 (44) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/manifests/documentation.yaml +24 -2
  3. package/manifests/instructionRules.yaml +62 -0
  4. package/manifests/platform.full.yaml +11 -0
  5. package/package.json +1 -1
  6. package/scripts/perf/audit-perf.mjs +920 -0
  7. package/src/cli/commands/doctor.js +23 -4
  8. package/src/cli/commands/feedback.js +97 -0
  9. package/src/cli/commands/memory.js +250 -1
  10. package/src/cli/commands/metrics.js +109 -1
  11. package/src/cli/index.js +7 -0
  12. package/src/core/codeintel/retriever.js +65 -0
  13. package/src/core/diffPlan.js +8 -0
  14. package/src/core/memory/store.js +7 -2
  15. package/src/core/ompConfigMerge.js +222 -0
  16. package/src/core/runInstallPipeline.js +11 -0
  17. package/src/core/runtimeConfig.js +64 -0
  18. package/src/core/unattendedDoctor.js +227 -0
  19. package/src/diagnostics/failurePatterns.js +1 -34
  20. package/src/diagnostics/feedbackEvents.js +196 -0
  21. package/src/diagnostics/laneStats.js +111 -0
  22. package/src/diagnostics/ledgerFiles.js +47 -0
  23. package/src/diagnostics/skillAccuracy.js +158 -0
  24. package/src/learning/patternProposals.js +151 -0
  25. package/src/learning/tuning.js +213 -0
  26. package/templates/.claude/hooks/block-dangerous.sh +76 -9
  27. package/templates/.claude/hooks/context-hardcap-gate.sh +26 -8
  28. package/templates/.claude/hooks/project-important.sh +70 -9
  29. package/templates/.claude/hooks/protect-files.sh +24 -7
  30. package/templates/.claude/hooks/sensitive-data-guard.sh +57 -5
  31. package/templates/.claude/hooks/session-episode.sh +84 -0
  32. package/templates/.claude/settings.json +29 -113
  33. package/templates/.claude/ukit/index/route-task.mjs +6 -0
  34. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +217 -10
  35. package/templates/.claude/ukit/runtime/hook-input.sh +119 -0
  36. package/templates/.omp/config.yml +32 -4
  37. package/templates/.omp/hooks/pre/ukit-bridge.js +12 -1
  38. package/templates/AGENTS.md +22 -10
  39. package/templates/CLAUDE.md +22 -10
  40. package/templates/adapter-presets/opencode/opencode.template.json +1 -1
  41. package/templates/docs/UKIT_INTERNALS.md +17 -0
  42. package/templates/instructions/core.md +22 -10
  43. package/templates/instructions/layout.yaml +12 -12
  44. package/templates/ukit/storage/config.json +20 -0
@@ -44,10 +44,19 @@ source "$HOOK_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
44
44
  if [ "$__ukit_main_stage_rc" -ne 0 ]; then
45
45
  # Infra failure during staging (mktemp/truncate): payload was never
46
46
  # inspected — announce the degrade (SPEC §8), never a silent pass.
47
- ukit_emit_input_degraded failclosed "context hard-cap gate"
47
+ # TASK-003: `deny`, not `ask` — ask auto-approves under bypassPermissions.
48
+ ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so context hard-cap gate cannot prove it safe."
49
+ fi
50
+ # BUG-C21-01 / TASK-003: degraded input first salvages tool_name — a COMPLETE
51
+ # recovered name takes the normal verdict path below; an unrecoverable one is
52
+ # refused with `deny` (never `ask`: bypassPermissions auto-approves ask).
53
+ if ukit_input_degraded; then
54
+ if UKIT_SALVAGED_TOOL_NAME="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_name")"; then
55
+ UKIT_SALVAGE_ACTIVE=1
56
+ else
57
+ ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound) and the tool name could not be recovered, so context hard-cap gate cannot prove it safe."
58
+ fi
48
59
  fi
49
- # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
50
- ukit_input_degraded && ukit_emit_input_degraded failclosed "context hard-cap gate"
51
60
  else
52
61
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
53
62
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -80,17 +89,18 @@ else
80
89
  # path's ukit_emit_input_degraded.
81
90
  rm -f "$UKIT_INPUT_FILE"
82
91
  UKIT_INPUT_FILE=""
83
- # TASK-223: same emit shape as ukit_emit_permission_decision — exit 0 on the
84
- # direct host (non-zero discards stdout there), exit 2 inside the omp chain.
85
- printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so context hard-cap gate cannot prove it safe. UKit defers this to a human decision."}}'
86
- echo "BLOCKED: context hard-cap gate could not inspect a truncated/stalled payload; deferred to human." >&2
92
+ # TASK-223 + TASK-003: same emit shape as ukit_emit_permission_decision —
93
+ # exit 0 on the direct host (non-zero discards stdout there), exit 2 inside
94
+ # the omp chain. `deny`, never `ask`: bypassPermissions auto-approves ask.
95
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so context hard-cap gate cannot prove it safe."}}'
96
+ echo "BLOCKED: context hard-cap gate could not inspect a truncated/stalled payload; refused fail-closed." >&2
87
97
  if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
88
98
  exit 0
89
99
  fi
90
100
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
91
101
  fi
92
102
 
93
- INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" node <<'NODE'
103
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" UKIT_SALVAGE_ACTIVE="${UKIT_SALVAGE_ACTIVE:-0}" UKIT_SALVAGED_TOOL_NAME="${UKIT_SALVAGED_TOOL_NAME:-}" node <<'NODE'
94
104
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
95
105
  // SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
96
106
  // degrade (fail-open posture kept: still exit 0).
@@ -164,6 +174,14 @@ async function readRunCursor() {
164
174
  return {};
165
175
  }
166
176
  })();
177
+ // TASK-003: on the degraded path the staged prefix cannot parse, so the shell
178
+ // handed us the salvaged tool_name — reconstruct just enough payload for the
179
+ // gate's normal verdict.
180
+ if (process.env.UKIT_SALVAGE_ACTIVE === '1'
181
+ && typeof process.env.UKIT_SALVAGED_TOOL_NAME === 'string'
182
+ && process.env.UKIT_SALVAGED_TOOL_NAME) {
183
+ payload.tool_name = process.env.UKIT_SALVAGED_TOOL_NAME;
184
+ }
167
185
  const toolName = payload?.tool_name || '';
168
186
  if (!GATED_TOOLS.has(toolName)) {
169
187
  process.exit(0);
@@ -6,6 +6,58 @@
6
6
  # session. No renderer logic lives here — the shared runtime module renders.
7
7
 
8
8
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
10
+ SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/project-important.mjs"
11
+ UNREADABLE_WARNING='[UKit] PROJECT_IMPORTANT.md was not injected: the file could not be read. Run `ukit doctor`.'
12
+ PROJECT_IMPORTANT_FILE="$PROJECT_ROOT/PROJECT_IMPORTANT.md"
13
+ PROJECT_IMPORTANT_CACHE_DIR="$PROJECT_ROOT/.ukit/storage/cache"
14
+ PROJECT_IMPORTANT_CACHE="$PROJECT_IMPORTANT_CACHE_DIR/project-important.cache"
15
+
16
+ # Cache invalidation uses both metadata and a cheap content checksum. Metadata
17
+ # avoids reading a stable file twice in the common case, while the checksum
18
+ # catches in-place edits that preserve size and timestamp.
19
+ ukit_pi_fingerprint() {
20
+ local metadata checksum
21
+ # GNU coreutils first: it rejects '%m:%z:%i' outright, while BSD stat would
22
+ # accept the GNU format string and silently print a filesystem summary.
23
+ metadata="$(/usr/bin/stat -c '%Y:%s:%i' "$PROJECT_IMPORTANT_FILE" 2>/dev/null)"
24
+ if [ -z "$metadata" ]; then
25
+ metadata="$(/usr/bin/stat -f '%m:%z:%i' "$PROJECT_IMPORTANT_FILE" 2>/dev/null)"
26
+ fi
27
+ [ -n "$metadata" ] || return 1
28
+ checksum="$(cksum < "$PROJECT_IMPORTANT_FILE" 2>/dev/null)" || return 1
29
+ [ -n "$checksum" ] || return 1
30
+ printf '%s|%s' "$metadata" "$checksum"
31
+ }
32
+
33
+ # Resolve the runtime before consulting the cache. A stale cache must never
34
+ # hide a missing install or renderer, while a valid hit intentionally skips
35
+ # stdin staging: this advisory hook does not inspect its SessionStart payload,
36
+ # and waiting on a producer that holds the pipe open is the stall this hook is
37
+ # being fixed for (auto-prune-bash.sh, wired beside it, never reads stdin either).
38
+ if [ ! -f "$SCRIPT" ]; then
39
+ printf '%s\n' "$UNREADABLE_WARNING"
40
+ exit 0
41
+ fi
42
+
43
+ if [ -f "$PROJECT_IMPORTANT_FILE" ] && [ -f "$PROJECT_IMPORTANT_CACHE" ]; then
44
+ __ukit_pi_key="$(ukit_pi_fingerprint 2>/dev/null)"
45
+ if [ -n "$__ukit_pi_key" ]; then
46
+ __ukit_pi_cache="$(cat "$PROJECT_IMPORTANT_CACHE" 2>/dev/null)"
47
+ # Key on the first line; the rest is the renderer's exact stdout bytes.
48
+ # Anything malformed (empty, key-only, wrong key) falls through to the
49
+ # cold path, so a corrupt cache is a re-render, never a wrong answer.
50
+ if [ "${__ukit_pi_cache%%$'\n'*}" = "$__ukit_pi_key" ] \
51
+ && [ "$__ukit_pi_cache" != "$__ukit_pi_key" ]; then
52
+ __ukit_pi_hit="${__ukit_pi_cache#"$__ukit_pi_key"$'\n'}"
53
+ if [ -n "$__ukit_pi_hit" ]; then
54
+ printf '%s\n' "$__ukit_pi_hit"
55
+ exit 0
56
+ fi
57
+ fi
58
+ fi
59
+ fi
60
+
9
61
  # shellcheck source=/dev/null
10
62
  if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
11
63
  # Arm advisory latency telemetry when available; a missing runtime (pre-install
@@ -60,15 +112,6 @@ else
60
112
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
61
113
  fi
62
114
 
63
- PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
64
- SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/project-important.mjs"
65
- UNREADABLE_WARNING='[UKit] PROJECT_IMPORTANT.md was not injected: the file could not be read. Run `ukit doctor`.'
66
-
67
- if [ ! -f "$SCRIPT" ]; then
68
- printf '%s\n' "$UNREADABLE_WARNING"
69
- exit 0
70
- fi
71
-
72
115
  # The wrapped runtime self-arms on UKIT_HOOK_DEADLINE_MS (SCRIPT_ARM/EXIT_ARM),
73
116
  # so a wedged read exits 0 inside the deadline instead of orphaning a node child.
74
117
  INPUT="$(cat "$UKIT_INPUT_FILE" 2>/dev/null)"
@@ -80,5 +123,23 @@ if [ "$STATUS" -ne 0 ] || [ -z "$OUTPUT" ]; then
80
123
  exit 0
81
124
  fi
82
125
 
126
+ # Cache writes are advisory and atomic. Recompute the key after rendering so a
127
+ # file edit racing the renderer cannot publish bytes under an old fingerprint.
128
+ __ukit_pi_render_key=""
129
+ if [ -f "$PROJECT_IMPORTANT_FILE" ]; then
130
+ __ukit_pi_render_key="$(ukit_pi_fingerprint 2>/dev/null)"
131
+ fi
132
+ if [ -n "$__ukit_pi_render_key" ]; then
133
+ if mkdir -p "$PROJECT_IMPORTANT_CACHE_DIR" 2>/dev/null; then
134
+ __ukit_pi_tmp="$PROJECT_IMPORTANT_CACHE.tmp.$$"
135
+ if printf '%s\n%s' "$__ukit_pi_render_key" "$OUTPUT" > "$__ukit_pi_tmp" 2>/dev/null \
136
+ && mv -f "$__ukit_pi_tmp" "$PROJECT_IMPORTANT_CACHE" 2>/dev/null; then
137
+ :
138
+ else
139
+ rm -f "$__ukit_pi_tmp" 2>/dev/null || true
140
+ fi
141
+ fi
142
+ fi
143
+
83
144
  printf '%s\n' "$OUTPUT"
84
145
  exit 0
@@ -16,10 +16,20 @@ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
16
16
  if [ "$__ukit_main_stage_rc" -ne 0 ]; then
17
17
  # Infra failure during staging (mktemp/truncate): payload was never
18
18
  # inspected — announce the degrade (SPEC §8), never a silent pass.
19
- ukit_emit_input_degraded failclosed "protected-file gate"
19
+ # TASK-003: `deny`, not `ask` — ask auto-approves under bypassPermissions.
20
+ ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so protected-file gate cannot prove it safe."
20
21
  fi
21
- # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
22
- ukit_input_degraded && ukit_emit_input_degraded failclosed "protected-file gate"
22
+ # BUG-C21-01 / TASK-003: degraded input first attempts a bounded salvage of
23
+ # tool_input.file_path — a COMPLETE recovered path still gets the normal verdict
24
+ # below; an unrecoverable/incomplete one is refused with `deny` (never `ask`:
25
+ # bypassPermissions auto-approves ask on the direct host — review R1-1).
26
+ if ukit_input_degraded; then
27
+ if UKIT_SALVAGED_FILE_PATH="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_input.file_path")"; then
28
+ UKIT_SALVAGE_ACTIVE=1
29
+ else
30
+ ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound) and the file path could not be recovered, so protected-file gate cannot prove it safe."
31
+ fi
32
+ fi
23
33
  else
24
34
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
25
35
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -52,15 +62,21 @@ else
52
62
  # path's ukit_emit_input_degraded.
53
63
  rm -f "$UKIT_INPUT_FILE"
54
64
  UKIT_INPUT_FILE=""
55
- # TASK-223: same emit shape as ukit_emit_permission_decision — exit 0 on the
56
- # direct host (non-zero discards stdout there), exit 2 inside the omp chain.
57
- printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so protected-file gate cannot prove it safe. UKit defers this to a human decision."}}'
58
- echo "BLOCKED: protected-file gate could not inspect a truncated/stalled payload; deferred to human." >&2
65
+ # TASK-223 + TASK-003: same emit shape as ukit_emit_permission_decision —
66
+ # exit 0 on the direct host (non-zero discards stdout there), exit 2 inside
67
+ # the omp chain. `deny`, never `ask`: bypassPermissions auto-approves ask.
68
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so protected-file gate cannot prove it safe."}}'
69
+ echo "BLOCKED: protected-file gate could not inspect a truncated/stalled payload; refused fail-closed." >&2
59
70
  if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
60
71
  exit 0
61
72
  fi
62
73
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
63
74
  fi
75
+ if [ "${UKIT_SALVAGE_ACTIVE:-0}" = "1" ]; then
76
+ # TASK-003: degraded path already recovered a COMPLETE file_path — skip the
77
+ # normal extraction (the staged prefix would not parse anyway).
78
+ FILE_PATH="$UKIT_SALVAGED_FILE_PATH"
79
+ else
64
80
  INPUT="$(cat "$UKIT_INPUT_FILE")"
65
81
  # jq is absent on stock macOS. Keep jq as the low-latency normal path, but fall back to
66
82
  # UKit's required Node runtime so a missing optional binary cannot disable this gate.
@@ -80,6 +96,7 @@ process.stdin.on("end", () => {
80
96
  });
81
97
  ' 2>/dev/null)
82
98
  fi
99
+ fi
83
100
 
84
101
  if [ -z "$FILE_PATH" ]; then
85
102
  exit 0
@@ -30,9 +30,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
30
  HOOK_DIR="$SCRIPT_DIR"
31
31
  SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/sensitive-value-scanner.mjs"
32
32
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
33
- # Bounded stdin (H01) — fail-closed overflow: an oversized payload is REFUSED
34
- # (silent exit 2, nothing staged, nothing echoed). The gate never truncates
35
- # unknown overflow and never materializes it in a shell variable.
33
+ # Bounded stdin (H01). TASK-003: staging uses `truncate` so a cut payload can be
34
+ # SALVAGED — when the decision-relevant field is provably complete the guard
35
+ # runs the real scan on it; when it cannot be recovered the gate refuses
36
+ # fail-closed (exit 2 + §8 systemMessage, never `ask`: bypassPermissions
37
+ # auto-approves `ask` on the direct host — review R1-1).
36
38
  # shellcheck source=/dev/null
37
39
  if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
38
40
  # TASK-019: arm advisory latency telemetry; the finish row is written by the
@@ -40,7 +42,7 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
40
42
  # runtime (pre-install tree) simply leaves telemetry off.
41
43
  source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
42
44
  trap ukit_cleanup_hook_input EXIT
43
- ukit_stage_hook_input 2097152 refuse
45
+ ukit_stage_hook_input 2097152 truncate
44
46
  STAGE_RC=$?
45
47
  if [ "$STAGE_RC" -ne 0 ]; then
46
48
  # BUG-C21-02: a fail-closed refusal must never be a SILENT block — emit the
@@ -51,6 +53,40 @@ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
51
53
  echo 'BLOCKED (sensitive data gate): payload could not be staged within bounds; never scanned (fail-closed).' >&2
52
54
  exit 2
53
55
  fi
56
+ # TASK-003 degraded-path salvage: recover hook_event_name + the channel's
57
+ # scanned field (prompt for UserPromptSubmit; tool_name + the decision field
58
+ # for PreToolUse Read/Grep/Bash). Every salvage step must prove its field
59
+ # COMPLETE; any failure refuses fail-closed — a partial field is never
60
+ # scanned (a truncated dangerous shape could parse benign = fail-open).
61
+ if ukit_input_degraded; then
62
+ __ukit_salvage_fail() {
63
+ printf '%s\n' '{"systemMessage":"UKit sensitive-data gate blocked this call: the tool-call payload was truncated or stalled during stdin staging and its scanned field could not be recovered intact, so it was never scanned. Fail-closed — reduce the payload or check for a stalled producer."}'
64
+ echo 'BLOCKED (sensitive data gate): truncated/stalled payload; scanned field unrecoverable — refused fail-closed.' >&2
65
+ exit 2
66
+ }
67
+ UKIT_SALVAGED_EVENT="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "hook_event_name")" || __ukit_salvage_fail
68
+ UKIT_SALVAGED_TOOL_NAME=""
69
+ UKIT_SALVAGED_FIELD_NAME=""
70
+ UKIT_SALVAGED_FIELD_VALUE=""
71
+ case "$UKIT_SALVAGED_EVENT" in
72
+ UserPromptSubmit)
73
+ UKIT_SALVAGED_FIELD_NAME="prompt"
74
+ UKIT_SALVAGED_FIELD_VALUE="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "prompt")" || __ukit_salvage_fail
75
+ ;;
76
+ PreToolUse)
77
+ UKIT_SALVAGED_TOOL_NAME="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_name")" || __ukit_salvage_fail
78
+ case "$UKIT_SALVAGED_TOOL_NAME" in
79
+ Read) UKIT_SALVAGED_FIELD_NAME="file_path" ;;
80
+ Grep) UKIT_SALVAGED_FIELD_NAME="path" ;;
81
+ Bash) UKIT_SALVAGED_FIELD_NAME="command" ;;
82
+ *) __ukit_salvage_fail ;;
83
+ esac
84
+ UKIT_SALVAGED_FIELD_VALUE="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_input.$UKIT_SALVAGED_FIELD_NAME")" || __ukit_salvage_fail
85
+ ;;
86
+ *) __ukit_salvage_fail ;;
87
+ esac
88
+ UKIT_SALVAGE_ACTIVE=1
89
+ fi
54
90
  else
55
91
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
56
92
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -90,7 +126,7 @@ else
90
126
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
91
127
  fi
92
128
 
93
- INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_PATH="$SCRIPT_PATH" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node <<'NODE'
129
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_PATH="$SCRIPT_PATH" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" UKIT_SALVAGE_ACTIVE="${UKIT_SALVAGE_ACTIVE:-0}" UKIT_SALVAGED_EVENT="${UKIT_SALVAGED_EVENT:-}" UKIT_SALVAGED_TOOL_NAME="${UKIT_SALVAGED_TOOL_NAME:-}" UKIT_SALVAGED_FIELD_NAME="${UKIT_SALVAGED_FIELD_NAME:-}" UKIT_SALVAGED_FIELD_VALUE="${UKIT_SALVAGED_FIELD_VALUE:-}" node <<'NODE'
94
130
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
95
131
  // BUG-C21-03 (chosen posture: FAIL-CLOSED, parity with the staging refusal):
96
132
  // a wedged scan must never pass an uninspected payload. The deadline emits a
@@ -127,6 +163,22 @@ const payload = (() => {
127
163
  }
128
164
  })();
129
165
 
166
+ // TASK-003: on the degraded path the staged prefix cannot parse, so the shell
167
+ // handed us the provably-COMPLETE salvaged fields — reconstruct just enough
168
+ // payload for the normal channel scan below.
169
+ if (process.env.UKIT_SALVAGE_ACTIVE === '1' && process.env.UKIT_SALVAGED_EVENT) {
170
+ payload.hook_event_name = process.env.UKIT_SALVAGED_EVENT;
171
+ if (process.env.UKIT_SALVAGED_EVENT === 'PreToolUse' && process.env.UKIT_SALVAGED_TOOL_NAME) {
172
+ payload.tool_name = process.env.UKIT_SALVAGED_TOOL_NAME;
173
+ payload.tool_input = {};
174
+ if (process.env.UKIT_SALVAGED_FIELD_NAME) {
175
+ payload.tool_input[process.env.UKIT_SALVAGED_FIELD_NAME] = process.env.UKIT_SALVAGED_FIELD_VALUE;
176
+ }
177
+ } else if (process.env.UKIT_SALVAGED_EVENT === 'UserPromptSubmit') {
178
+ payload.prompt = process.env.UKIT_SALVAGED_FIELD_VALUE;
179
+ }
180
+ }
181
+
130
182
  const projectRoot = process.env.PROJECT_ROOT || process.cwd();
131
183
 
132
184
  async function readJsonSafe(filePath, fallback) {
@@ -0,0 +1,84 @@
1
+ #!/bin/bash
2
+ # session-episode.sh — SessionEnd hook: append a session episode to memory v2.
3
+ #
4
+ # Contract (SPEC §7b, TASK-231):
5
+ # * ALWAYS exits 0 — a session must never be blocked from ending.
6
+ # * No decision JSON — UKIT_HOOK_CHAIN_RUNNER-safe.
7
+ # * Runs `ukit memory episode` ONLY when
8
+ # .ukit/storage/config.json → learning.episodes.autoWrite === true
9
+ # (read defensively — the namespace may not exist yet) AND `ukit` resolves
10
+ # on PATH. Episode writes dedupe on meta.ledgerKey, so double-invocation is
11
+ # harmless.
12
+ # * Exports UKIT_SESSION_ID from the stdin payload's session_id so the CLI
13
+ # resolves the same exec-ledger file the session wrote.
14
+
15
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
16
+ CONFIG_FILE="$PROJECT_ROOT/.ukit/storage/config.json"
17
+
18
+ # Bounded stdin read (existing hook style): cap +1 byte in background so a
19
+ # producer that never closes the pipe cannot park the session teardown.
20
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-episode-in.XXXXXX")" || exit 0
21
+ if [ -e /dev/fd/0 ]; then
22
+ exec 8<&0
23
+ head -c 65537 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
24
+ UKIT_HEAD_PID=$!
25
+ # UKIT_HOOK_STAGE_MS:-2000 — bounded wait for the staged payload.
26
+ ( sleep 2; kill "$UKIT_HEAD_PID" 2>/dev/null ) &
27
+ UKIT_WATCH_PID=$!
28
+ wait "$UKIT_HEAD_PID" 2>/dev/null
29
+ kill "$UKIT_WATCH_PID" 2>/dev/null
30
+ fi
31
+
32
+ # Gate + session extraction in one bounded node step; all failures → exit 0.
33
+ # UKIT_HOOK_DEADLINE_MS:-5000 — keeps the registered 8s timeout comfortably
34
+ # above stage (2s) + deadline (5s) + margin (1s).
35
+ node -e '
36
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 5000;
37
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
38
+
39
+ const fs = require("fs");
40
+ const { spawnSync } = require("child_process");
41
+
42
+ const inputFile = process.argv[1];
43
+ const configPath = process.argv[2];
44
+ const projectRoot = process.argv[3];
45
+
46
+ function readJson(filePath) {
47
+ try {
48
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ let payload = {};
55
+ try {
56
+ const raw = fs.readFileSync(inputFile, "utf8");
57
+ payload = JSON.parse(raw || "{}") || {};
58
+ } catch {}
59
+
60
+ const config = readJson(configPath) || {};
61
+ if (config?.learning?.episodes?.autoWrite !== true) process.exit(0);
62
+
63
+ const probe = spawnSync("ukit", ["--version"], {
64
+ shell: true, stdio: "ignore", timeout: 2000,
65
+ });
66
+ if (probe.error || probe.status === null || probe.status === undefined) process.exit(0);
67
+
68
+ const sessionId = typeof payload.session_id === "string" && payload.session_id.trim()
69
+ ? payload.session_id.trim()
70
+ : null;
71
+
72
+ spawnSync("ukit", ["memory", "episode"], {
73
+ shell: true,
74
+ stdio: "ignore",
75
+ timeout: 4000,
76
+ cwd: projectRoot,
77
+ env: sessionId
78
+ ? { ...process.env, UKIT_SESSION_ID: sessionId }
79
+ : process.env,
80
+ });
81
+ ' "$UKIT_INPUT_FILE" "$CONFIG_FILE" "$PROJECT_ROOT" 2>/dev/null || true
82
+
83
+ rm -f "$UKIT_INPUT_FILE" 2>/dev/null || true
84
+ exit 0
@@ -57,8 +57,8 @@
57
57
  "hooks": [
58
58
  {
59
59
  "type": "command",
60
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
61
- "timeout": 8
60
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\":8",
61
+ "timeout": 18
62
62
  }
63
63
  ]
64
64
  },
@@ -67,33 +67,8 @@
67
67
  "hooks": [
68
68
  {
69
69
  "type": "command",
70
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh\"",
71
- "timeout": 8
72
- },
73
- {
74
- "type": "command",
75
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\"",
76
- "timeout": 11
77
- },
78
- {
79
- "type": "command",
80
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-edit-backup.sh\"",
81
- "timeout": 8
82
- },
83
- {
84
- "type": "command",
85
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
86
- "timeout": 20
87
- },
88
- {
89
- "type": "command",
90
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
91
- "timeout": 8
92
- },
93
- {
94
- "type": "command",
95
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\"",
96
- "timeout": 8
70
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\":11 \"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-edit-backup.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8",
71
+ "timeout": 73
97
72
  }
98
73
  ]
99
74
  },
@@ -102,33 +77,8 @@
102
77
  "hooks": [
103
78
  {
104
79
  "type": "command",
105
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\"",
106
- "timeout": 15
107
- },
108
- {
109
- "type": "command",
110
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh\"",
111
- "timeout": 8
112
- },
113
- {
114
- "type": "command",
115
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
116
- "timeout": 8
117
- },
118
- {
119
- "type": "command",
120
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
121
- "timeout": 8
122
- },
123
- {
124
- "type": "command",
125
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\"",
126
- "timeout": 8
127
- },
128
- {
129
- "type": "command",
130
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\"",
131
- "timeout": 8
80
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\":15 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\":8",
81
+ "timeout": 65
132
82
  }
133
83
  ]
134
84
  }
@@ -139,8 +89,8 @@
139
89
  "hooks": [
140
90
  {
141
91
  "type": "command",
142
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
143
- "timeout": 8
92
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\":8",
93
+ "timeout": 18
144
94
  }
145
95
  ]
146
96
  },
@@ -149,18 +99,8 @@
149
99
  "hooks": [
150
100
  {
151
101
  "type": "command",
152
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-verify.sh\"",
153
- "timeout": 8
154
- },
155
- {
156
- "type": "command",
157
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
158
- "timeout": 8
159
- },
160
- {
161
- "type": "command",
162
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\"",
163
- "timeout": 8
102
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-verify.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\":8",
103
+ "timeout": 34
164
104
  }
165
105
  ]
166
106
  },
@@ -169,13 +109,8 @@
169
109
  "hooks": [
170
110
  {
171
111
  "type": "command",
172
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/compress-output.sh\"",
173
- "timeout": 12
174
- },
175
- {
176
- "type": "command",
177
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
178
- "timeout": 8
112
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/compress-output.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\":8",
113
+ "timeout": 30
179
114
  }
180
115
  ]
181
116
  }
@@ -185,23 +120,8 @@
185
120
  "hooks": [
186
121
  {
187
122
  "type": "command",
188
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
189
- "timeout": 8
190
- },
191
- {
192
- "type": "command",
193
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
194
- "timeout": 20
195
- },
196
- {
197
- "type": "command",
198
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\"",
199
- "timeout": 12
200
- },
201
- {
202
- "type": "command",
203
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-window-guard.sh\"",
204
- "timeout": 10
123
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-window-guard.sh\":10",
124
+ "timeout": 60
205
125
  }
206
126
  ]
207
127
  }
@@ -211,8 +131,8 @@
211
131
  "hooks": [
212
132
  {
213
133
  "type": "command",
214
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\"",
215
- "timeout": 8
134
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\":8",
135
+ "timeout": 18
216
136
  }
217
137
  ]
218
138
  }
@@ -222,8 +142,8 @@
222
142
  "hooks": [
223
143
  {
224
144
  "type": "command",
225
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/reinject-context.sh\"",
226
- "timeout": 8
145
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/reinject-context.sh\":8",
146
+ "timeout": 18
227
147
  }
228
148
  ]
229
149
  }
@@ -233,23 +153,19 @@
233
153
  "hooks": [
234
154
  {
235
155
  "type": "command",
236
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/project-important.sh\"",
237
- "timeout": 8
238
- },
239
- {
240
- "type": "command",
241
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-prune-bash.sh\"",
242
- "timeout": 12
243
- },
244
- {
245
- "type": "command",
246
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/reset-compact-pressure.sh\"",
247
- "timeout": 12
248
- },
156
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/project-important.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-prune-bash.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/reset-compact-pressure.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-resume.sh\":8",
157
+ "timeout": 50
158
+ }
159
+ ]
160
+ }
161
+ ],
162
+ "SessionEnd": [
163
+ {
164
+ "hooks": [
249
165
  {
250
166
  "type": "command",
251
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-resume.sh\"",
252
- "timeout": 8
167
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-episode.sh\":8",
168
+ "timeout": 18
253
169
  }
254
170
  ]
255
171
  }
@@ -3793,6 +3793,12 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
3793
3793
  }),
3794
3794
  targetFile: route?.routingContext?.targetFile ?? null,
3795
3795
  taskType: routingContext.taskType ?? null,
3796
+ // FR-202a (TASK-228): active-skill ids for phase-2 skill-accuracy roll-ups.
3797
+ // Telemetry only — deliberately excluded from dedupeKey/fingerprint inputs.
3798
+ skillIds: (route?.activeSkills ?? state?.activeSkills ?? [])
3799
+ .map((s) => s?.id)
3800
+ .filter(Boolean)
3801
+ .slice(0, 8),
3796
3802
  executionMode: routeSummary.executionMode ?? null,
3797
3803
  competingMode: routeSummary?.approachSelector?.competingMode ?? null,
3798
3804
  competingScoreGap: routeSummary?.approachSelector?.competingScoreGap ?? null,