@ngockhoale/ukit 2.6.9 → 2.6.11

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.
@@ -0,0 +1,213 @@
1
+ // tuning.js — advisory tuning suggestions for the Phase-4 learning loop
2
+ // (SPEC C32 §8b).
3
+ //
4
+ // Reads the learning artifacts already on disk:
5
+ // * lane stats — via lazy collectLaneStats() over
6
+ // `.ukit/storage/cache/retriever-lanes.jsonl`
7
+ // * feedback events — `.ukit/storage/learning/feedback-events.json`
8
+ // * skill accuracy — `.ukit/storage/learning/skill-accuracy.json`
9
+ //
10
+ // Contracts:
11
+ // * NEVER THROWS. Missing/malformed inputs → `skipped` entries with a
12
+ // reason, never an exception. Artifact write failure is non-fatal.
13
+ // * ADVISORY ONLY. `learning.tuning.applyMode` is restricted to
14
+ // 'manual'|'off'; suggestions are computed and persisted but NOTHING is
15
+ // ever written back to config weights/thresholds (`applied` stays []).
16
+ // * Gated by config `learning?.tuning?.enabled !== false` and
17
+ // `learning?.tuning?.applyMode !== 'off'`.
18
+ //
19
+ // Output shape (persisted to `.ukit/storage/learning/suggestions.json`,
20
+ // tmp+rename):
21
+ // { generatedAt, applied: [], suggestions: [{target, current, suggested,
22
+ // evidence}], skipped: [{target, reason}] }
23
+
24
+ import fs from 'node:fs/promises';
25
+ import path from 'node:path';
26
+
27
+ const LEARNING_DIR_REL = path.join('.ukit', 'storage', 'learning');
28
+ const SUGGESTIONS_REL = path.join(LEARNING_DIR_REL, 'suggestions.json');
29
+ const FEEDBACK_EVENTS_REL = path.join(LEARNING_DIR_REL, 'feedback-events.json');
30
+ const SKILL_ACCURACY_REL = path.join(LEARNING_DIR_REL, 'skill-accuracy.json');
31
+ const CONFIG_REL = path.join('.ukit', 'storage', 'config.json');
32
+
33
+ const MIN_LANE_EVENTS = 50;
34
+ const DOMINANT_CONTRIBUTION = 0.5;
35
+ const STARVED_CONTRIBUTION = 0.02;
36
+ const WEIGHT_STEP = 0.1;
37
+ const WEIGHT_MIN = 0.1;
38
+ const WEIGHT_MAX = 2.0;
39
+ const MIN_REPEAT_STALLS = 10;
40
+
41
+ const DEFAULT_WEIGHTS = { exact: 1.0, symbol: 1.2, bm25: 0.8, semantic: 1.0, vector: 0.6 };
42
+ const DEFAULT_DEBUG_LOOP_THRESHOLD = 2;
43
+
44
+ function isObject(value) {
45
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
46
+ }
47
+
48
+ function clamp(value, min, max) {
49
+ return Math.min(max, Math.max(min, value));
50
+ }
51
+
52
+ function round1(value) {
53
+ return Math.round(value * 10) / 10;
54
+ }
55
+
56
+ async function readJson(filePath) {
57
+ try {
58
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ async function writeArtifact(filePath, result) {
65
+ const dir = path.dirname(filePath);
66
+ const tmp = path.join(dir, `.suggestions-${process.pid}.tmp`);
67
+ try {
68
+ await fs.mkdir(dir, { recursive: true });
69
+ await fs.writeFile(tmp, JSON.stringify(result, null, 2));
70
+ await fs.rename(tmp, filePath);
71
+ } catch {
72
+ await fs.rm(tmp, { force: true }).catch(() => {});
73
+ }
74
+ }
75
+
76
+ async function tuningEnabled(config) {
77
+ const tuning = config?.learning?.tuning;
78
+ if (tuning?.enabled === false) return 'learning.tuning.enabled=false';
79
+ if (tuning?.applyMode === 'off') return "learning.tuning.applyMode='off'";
80
+ return null;
81
+ }
82
+
83
+ function laneWeightSuggestions(laneStats, weights, suggestions, skipped) {
84
+ if (!laneStats || laneStats.empty || !isObject(laneStats.lanes)) {
85
+ skipped.push({ target: 'codeIntel.retriever.weights.*', reason: 'no retriever-lanes.jsonl data' });
86
+ return;
87
+ }
88
+ if ((laneStats.events ?? 0) < MIN_LANE_EVENTS) {
89
+ skipped.push({
90
+ target: 'codeIntel.retriever.weights.*',
91
+ reason: `insufficient lane events (${laneStats.events ?? 0} < ${MIN_LANE_EVENTS})`,
92
+ });
93
+ return;
94
+ }
95
+ const lanes = Object.entries(laneStats.lanes);
96
+ const dominant = lanes.filter(([, b]) => (b?.contribution ?? 0) > DOMINANT_CONTRIBUTION);
97
+ const starved = lanes.filter(([, b]) => (b?.contribution ?? 0) < STARVED_CONTRIBUTION);
98
+ if (dominant.length === 0 || starved.length === 0) {
99
+ skipped.push({
100
+ target: 'codeIntel.retriever.weights.*',
101
+ reason: 'no dominant+starved lane pair',
102
+ });
103
+ return;
104
+ }
105
+ for (const [lane, bucket] of starved) {
106
+ const current = typeof weights[lane] === 'number' ? weights[lane] : (DEFAULT_WEIGHTS[lane] ?? 1.0);
107
+ suggestions.push({
108
+ target: `codeIntel.retriever.weights.${lane}`,
109
+ current,
110
+ suggested: round1(clamp(current - WEIGHT_STEP, WEIGHT_MIN, WEIGHT_MAX)),
111
+ evidence: {
112
+ events: laneStats.events,
113
+ contribution: bucket.contribution,
114
+ reason: `lane contribution ${bucket.contribution.toFixed(3)} < ${STARVED_CONTRIBUTION} while another lane dominates — step weight down`,
115
+ },
116
+ });
117
+ }
118
+ for (const [lane, bucket] of dominant) {
119
+ const current = typeof weights[lane] === 'number' ? weights[lane] : (DEFAULT_WEIGHTS[lane] ?? 1.0);
120
+ suggestions.push({
121
+ target: `codeIntel.retriever.weights.${lane}`,
122
+ current,
123
+ suggested: round1(clamp(current + WEIGHT_STEP, WEIGHT_MIN, WEIGHT_MAX)),
124
+ evidence: {
125
+ events: laneStats.events,
126
+ contribution: bucket.contribution,
127
+ reason: `lane contribution ${bucket.contribution.toFixed(3)} > ${DOMINANT_CONTRIBUTION} — step weight up`,
128
+ },
129
+ });
130
+ }
131
+ }
132
+
133
+ function escalationSuggestion(feedbackEvents, debugLoopThreshold, suggestions, skipped) {
134
+ const target = 'orchestration.escalation.debugLoopThreshold';
135
+ if (!isObject(feedbackEvents)) {
136
+ skipped.push({ target, reason: 'no feedback-events.json artifact' });
137
+ return;
138
+ }
139
+ const stalls = feedbackEvents.byKind?.['repeat-stall']
140
+ ?? (Array.isArray(feedbackEvents.events)
141
+ ? feedbackEvents.events.filter((e) => e?.kind === 'repeat-stall').length
142
+ : 0);
143
+ if (stalls < MIN_REPEAT_STALLS) {
144
+ skipped.push({ target, reason: `repeat-stall events ${stalls} < ${MIN_REPEAT_STALLS}` });
145
+ return;
146
+ }
147
+ const current = typeof debugLoopThreshold === 'number' ? debugLoopThreshold : DEFAULT_DEBUG_LOOP_THRESHOLD;
148
+ suggestions.push({
149
+ target,
150
+ current,
151
+ suggested: Math.max(1, current - 1),
152
+ evidence: {
153
+ repeatStalls: stalls,
154
+ reason: `${stalls} repeat-stall events >= ${MIN_REPEAT_STALLS} — lower escalation threshold one step (min 1)`,
155
+ },
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Compute advisory tuning suggestions and persist them to
161
+ * `.ukit/storage/learning/suggestions.json` (tmp+rename). Never applies
162
+ * anything; never throws.
163
+ *
164
+ * @param {string} projectRoot repository root containing `.ukit/storage/`.
165
+ * @returns {Promise<{generatedAt: string, applied: Array,
166
+ * suggestions: Array<object>, skipped: Array<{target: string, reason: string}>}>}
167
+ */
168
+ export async function computeTuningSuggestions(projectRoot) {
169
+ const result = {
170
+ generatedAt: new Date().toISOString(),
171
+ applied: [],
172
+ suggestions: [],
173
+ skipped: [],
174
+ };
175
+ try {
176
+ const config = await readJson(path.join(projectRoot, CONFIG_REL));
177
+ const disabledReason = await tuningEnabled(config);
178
+ if (disabledReason) {
179
+ result.skipped.push({ target: 'learning.tuning', reason: `tuning disabled (${disabledReason})` });
180
+ await writeArtifact(path.join(projectRoot, SUGGESTIONS_REL), result);
181
+ return result;
182
+ }
183
+
184
+ let laneStats = null;
185
+ try {
186
+ const mod = await import('../diagnostics/laneStats.js');
187
+ if (typeof mod?.collectLaneStats === 'function') {
188
+ laneStats = await mod.collectLaneStats(projectRoot);
189
+ }
190
+ } catch {
191
+ laneStats = null;
192
+ }
193
+
194
+ const feedbackEvents = await readJson(path.join(projectRoot, FEEDBACK_EVENTS_REL));
195
+ const skillAccuracy = await readJson(path.join(projectRoot, SKILL_ACCURACY_REL));
196
+ if (!isObject(skillAccuracy)) {
197
+ result.skipped.push({ target: 'skills.*', reason: 'no skill-accuracy.json artifact' });
198
+ }
199
+
200
+ const weights = isObject(config?.codeIntel?.retriever?.weights)
201
+ ? config.codeIntel.retriever.weights
202
+ : DEFAULT_WEIGHTS;
203
+ const debugLoopThreshold = config?.orchestration?.escalation?.debugLoopThreshold;
204
+
205
+ laneWeightSuggestions(laneStats, weights, result.suggestions, result.skipped);
206
+ escalationSuggestion(feedbackEvents, debugLoopThreshold, result.suggestions, result.skipped);
207
+ } catch (error) {
208
+ result.skipped.push({ target: 'learning.tuning', reason: error?.message ?? String(error) });
209
+ }
210
+
211
+ await writeArtifact(path.join(projectRoot, SUGGESTIONS_REL), result);
212
+ return result;
213
+ }
@@ -52,9 +52,13 @@ else
52
52
  # path's ukit_emit_input_degraded.
53
53
  rm -f "$UKIT_INPUT_FILE"
54
54
  UKIT_INPUT_FILE=""
55
+ # TASK-223: same emit shape as ukit_emit_permission_decision — under a
56
+ # direct host the JSON only reaches the permission pipeline on exit 0; the
57
+ # omp chain needs exit 2 (marker exported by hook-chain-runner.mjs).
55
58
  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 dangerous-command gate cannot prove it safe. UKit defers this to a human decision."}}'
56
59
  echo "BLOCKED: dangerous-command gate could not inspect a truncated/stalled payload; deferred to human." >&2
57
- exit 2
60
+ if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
61
+ exit 0
58
62
  fi
59
63
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
60
64
  fi
@@ -115,15 +119,35 @@ DANGEROUS_PATTERNS=(
115
119
  "dd if=/dev/"
116
120
  )
117
121
 
118
- # Dangerous detections surface as a structured `ask` decision (stdout JSON) so a human
119
- # decides; exit 2 still refuses the call for harnesses that ignore structured output.
122
+ # Dangerous detections surface as a structured decision (stdout JSON). TASK-223:
123
+ # emission is centralized in ukit_emit_permission_decision — exit 0 on the direct
124
+ # host (where a non-zero exit would discard the JSON as a bare "hook error"),
125
+ # exit 2 inside the omp chain (whose bridge only parses stdout on code 2).
126
+ # Probe verdict (2026-09-20, spec step 4): this repo runs
127
+ # permissions.defaultMode=bypassPermissions, where an exit-0 `ask` auto-approves —
128
+ # dead for refusals. So the direct host gets `deny` (a proper denied surface, still
129
+ # fail-closed); the omp chain keeps `ask` (the host boundary prompts the human).
120
130
  # The raw command and the matched pattern are NEVER echoed — arguments and comments can
121
131
  # carry secrets, and the pattern text itself restates the dangerous command.
122
132
  emit_dangerous_decision() {
123
133
  REASON="$1"
124
- printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
134
+ if command -v ukit_emit_permission_decision >/dev/null 2>&1; then
135
+ if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
136
+ ukit_emit_permission_decision ask "$REASON"
137
+ else
138
+ ukit_emit_permission_decision deny "$REASON"
139
+ fi
140
+ fi
141
+ # Helper unavailable (pre-install tree): keep the safe inline parity — the
142
+ # direct host gets deny + exit 0, the chain gets ask + exit 2.
143
+ if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
144
+ printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
145
+ echo "BLOCKED: $REASON" >&2
146
+ exit 2
147
+ fi
148
+ printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}"
125
149
  echo "BLOCKED: $REASON" >&2
126
- exit 2
150
+ exit 0
127
151
  }
128
152
 
129
153
  for pattern in "${DANGEROUS_PATTERNS[@]}"; do
@@ -80,9 +80,12 @@ else
80
80
  # path's ukit_emit_input_degraded.
81
81
  rm -f "$UKIT_INPUT_FILE"
82
82
  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.
83
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."}}'
84
86
  echo "BLOCKED: context hard-cap gate could not inspect a truncated/stalled payload; deferred to human." >&2
85
- exit 2
87
+ if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
88
+ exit 0
86
89
  fi
87
90
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
88
91
  fi
@@ -52,9 +52,12 @@ else
52
52
  # path's ukit_emit_input_degraded.
53
53
  rm -f "$UKIT_INPUT_FILE"
54
54
  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.
55
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."}}'
56
58
  echo "BLOCKED: protected-file gate could not inspect a truncated/stalled payload; deferred to human." >&2
57
- exit 2
59
+ if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
60
+ exit 0
58
61
  fi
59
62
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
60
63
  fi
@@ -112,9 +115,29 @@ if [ -n "$PROTECTED_MATCH" ]; then
112
115
  # A stderr-only refusal can look like a silent stall when the host does not render
113
116
  # hook stderr. Do not expose the path/pattern on stdout: the generic explanation is
114
117
  # enough for the user and avoids leaking a potentially sensitive filename.
115
- printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit protected-file guard blocked this edit. Ask the user to modify the protected file manually."}}'
118
+ # TASK-223: emit through the centralized helper — exit 0 on the direct host so the
119
+ # structured decision survives (non-zero discards stdout there), exit 2 in the omp
120
+ # chain whose bridge parses stdout only on code 2. Probe verdict (2026-09-20):
121
+ # under bypassPermissions an exit-0 `ask` auto-approves — dead for a protected-file
122
+ # refusal — so the direct host emits `deny`; the chain keeps `ask` for the human.
123
+ __ukit_protect_reason='UKit protected-file guard blocked this edit. Ask the user to modify the protected file manually.'
124
+ echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
125
+ if command -v ukit_emit_permission_decision >/dev/null 2>&1; then
126
+ if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
127
+ ukit_emit_permission_decision ask "$__ukit_protect_reason"
128
+ else
129
+ ukit_emit_permission_decision deny "$__ukit_protect_reason"
130
+ fi
131
+ fi
132
+ # Helper unavailable (pre-install tree): same decision/exit matrix inline.
133
+ if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
134
+ printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$__ukit_protect_reason\"}}"
135
+ echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
136
+ exit 2
137
+ fi
138
+ printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$__ukit_protect_reason\"}}"
116
139
  echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
117
- exit 2
140
+ exit 0
118
141
  fi
119
142
 
120
143
  exit 0
@@ -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
@@ -253,6 +253,17 @@
253
253
  }
254
254
  ]
255
255
  }
256
+ ],
257
+ "SessionEnd": [
258
+ {
259
+ "hooks": [
260
+ {
261
+ "type": "command",
262
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-episode.sh\"",
263
+ "timeout": 8
264
+ }
265
+ ]
266
+ }
256
267
  ]
257
268
  }
258
269
  }
@@ -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,
@@ -3872,6 +3878,13 @@ function getMemoryTimestamp(item) {
3872
3878
 
3873
3879
  function buildMemorySegments(item) {
3874
3880
  const content = item.content ?? {};
3881
+ if (item.type === 'record') {
3882
+ return [
3883
+ { text: content.recordType, weight: 1 },
3884
+ { text: content.text, weight: 3 },
3885
+ ];
3886
+ }
3887
+
3875
3888
  if (item.type === 'project') {
3876
3889
  return [
3877
3890
  { text: content.name, weight: 2 },
@@ -3964,6 +3977,7 @@ async function listMemoryItems(rootDir) {
3964
3977
  const userMemory = (await readJson(path.join(runtimeRoot, 'user.json'), null)) ?? { preferences: {}, rules: [] };
3965
3978
  const projectMemories = await readDirectoryJsonItems(path.join(runtimeRoot, 'projects'));
3966
3979
  const sessionMemories = await readDirectoryJsonItems(path.join(runtimeRoot, 'sessions'));
3980
+ const recordMemories = await listMemoryV2RecordItems(runtimeRoot);
3967
3981
 
3968
3982
  return [
3969
3983
  {
@@ -3981,11 +3995,47 @@ async function listMemoryItems(rootDir) {
3981
3995
  type: 'session',
3982
3996
  content: item.content,
3983
3997
  })),
3998
+ ...recordMemories,
3984
3999
  ];
3985
4000
  }
3986
4001
 
4002
+ const MEMORY_V2_RECORD_POOL_LIMIT = 20;
4003
+
4004
+ // v2 lane (SI-101): approved records live in a single records.json document.
4005
+ // Missing/malformed store → [] (same never-throw discipline as
4006
+ // readDirectoryJsonItems). Candidates: status 'active' AND (valid_until == null
4007
+ // OR valid_until > now), capped at 20 newest by created_at.
4008
+ async function listMemoryV2RecordItems(runtimeRoot) {
4009
+ const doc = await readJson(path.join(runtimeRoot, 'v2', 'records.json'), null);
4010
+ const records = Array.isArray(doc?.records) ? doc.records : [];
4011
+ const now = Date.now();
4012
+
4013
+ return records
4014
+ .filter((record) => record && typeof record === 'object')
4015
+ .filter((record) => record.status === 'active')
4016
+ .filter((record) => record.valid_until == null || record.valid_until > now)
4017
+ .sort((left, right) => (right.created_at ?? 0) - (left.created_at ?? 0))
4018
+ .slice(0, MEMORY_V2_RECORD_POOL_LIMIT)
4019
+ .map((record) => ({
4020
+ id: `record:${record.id}`,
4021
+ type: 'record',
4022
+ content: {
4023
+ recordType: record.type,
4024
+ text: record.text,
4025
+ projectId: record.project_id,
4026
+ updatedAt: record.created_at,
4027
+ },
4028
+ }));
4029
+ }
4030
+
3987
4031
  function buildPreviousContextSnippet(item) {
3988
4032
  const content = item.content ?? {};
4033
+ if (item.type === 'record') {
4034
+ const text = String(content.text ?? '').trim();
4035
+ const truncated = text.length > 120 ? `${text.slice(0, 117)}...` : text;
4036
+ return `[${content.recordType ?? 'record'}] ${truncated}`;
4037
+ }
4038
+
3989
4039
  if (item.type === 'project') {
3990
4040
  const decisions = compactPhraseList((content.decisions ?? []).map((decision) => decision.what), { limit: 1 });
3991
4041
  const rules = compactPhraseList(content.activeRules ?? [], { limit: 1 });
@@ -4036,11 +4086,18 @@ async function buildPreviousContextSnapshot({ rootDir = process.cwd(), routingCo
4036
4086
  const queryTokens = tokenize(taskQuery);
4037
4087
  const rankedItems = items
4038
4088
  .filter((item) => item.type !== 'user')
4039
- .filter((item) => (
4040
- item.type === 'project'
4041
- ? (item.content?.id === projectId)
4042
- : (item.type === 'session' ? item.content?.projectId === projectId : true)
4043
- ))
4089
+ .filter((item) => {
4090
+ if (item.type === 'project') {
4091
+ return item.content?.id === projectId;
4092
+ }
4093
+ if (item.type === 'session') {
4094
+ return item.content?.projectId === projectId;
4095
+ }
4096
+ if (item.type === 'record') {
4097
+ return item.content?.projectId == null || item.content?.projectId === projectId;
4098
+ }
4099
+ return true;
4100
+ })
4044
4101
  .map((item) => ({
4045
4102
  item,
4046
4103
  score: scoreMemoryItem(item, queryTokens),
@@ -4060,7 +4117,7 @@ async function buildPreviousContextSnapshot({ rootDir = process.cwd(), routingCo
4060
4117
  return {
4061
4118
  line: rankedItems.map((item) => buildPreviousContextSnippet(item)).join(' | '),
4062
4119
  selectedIds: rankedItems.map((item) => item.id),
4063
- fingerprint: buildCompactMachineKey('route-memory-v1', {
4120
+ fingerprint: buildCompactMachineKey('route-memory-v2', {
4064
4121
  taskQuery: normalize(taskQuery),
4065
4122
  projectId,
4066
4123
  items: rankedItems.map((item) => ({
@@ -102,7 +102,15 @@ async function run(payloadText, scriptPaths) {
102
102
  deadlineMs: Math.min(childBudgetMs, remainingMs),
103
103
  maxBuffer: MAX_BUFFER_BYTES,
104
104
  cwd: projectRoot,
105
- env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
105
+ // TASK-223 (HK-401): mark chain-spawned children so their structured
106
+ // permission decisions keep the omp contract (stdout parsed on exit 2).
107
+ // Direct Claude Code invocations carry no marker and exit 0 instead —
108
+ // a non-zero exit there discards stdout, killing the decision JSON.
109
+ env: {
110
+ ...process.env,
111
+ CLAUDE_PROJECT_DIR: projectRoot,
112
+ UKIT_HOOK_CHAIN_RUNNER: '1',
113
+ },
106
114
  });
107
115
  const failureKind = chainFailureKind(result);
108
116
  const code = Number.isFinite(result.code) ? result.code : 1;
@@ -135,12 +135,35 @@ ukit_input_degraded() {
135
135
  [ "${UKIT_INPUT_TRUNCATED:-0}" = "1" ] || [ "${UKIT_HOOK_INPUT_STALLED:-0}" = "1" ]
136
136
  }
137
137
 
138
+ # TASK-223 (HK-401): ALL PreToolUse structured permission decisions emit through
139
+ # this one helper. The JSON shape is harness-agnostic, but the exit code is NOT:
140
+ # direct Claude Code (UKIT_HOOK_CHAIN_RUNNER unset) — a non-zero exit means the
141
+ # harness DISCARDS stdout, so a decision JSON + exit 2 is dead text rendered
142
+ # as a bare "hook error" with no human-approval path. Emit the JSON and exit 0
143
+ # so `deny`/`ask` actually reaches the permission pipeline. Under
144
+ # permissions.defaultMode=bypassPermissions `ask` is auto-approved (probe
145
+ # verdict 2026-09-20), so refusal call sites pass `deny` directly — the host
146
+ # surfaces a proper "denied" instead of a hook error, still fail-closed.
147
+ # omp chain (UKIT_HOOK_CHAIN_RUNNER=1, exported by hook-chain-runner.mjs) — the
148
+ # bridge parses hookSpecificOutput ONLY when the child exits 2; exit 0
149
+ # short-circuits to {block:false} and would turn every gate fail-open. Emit
150
+ # the JSON and exit 2 exactly as before.
151
+ # stderr still carries the BLOCKED line on every path — the model needs the
152
+ # block reason regardless of which stdout contract the harness honors.
153
+ ukit_emit_permission_decision() {
154
+ local decision="${1:-ask}" reason="${2:-UKit deferred this tool call to a human decision.}"
155
+ printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"${decision}\",\"permissionDecisionReason\":\"${reason}\"}}"
156
+ echo "BLOCKED: ${reason}" >&2
157
+ if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
158
+ exit 2
159
+ fi
160
+ exit 0
161
+ }
162
+
138
163
  ukit_emit_input_degraded() {
139
164
  local posture="${1:-advisory}" hook_name="${2:-hook}"
140
165
  if [ "$posture" = "failclosed" ]; then
141
- 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 ${hook_name} cannot prove it safe. UKit defers this to a human decision.\"}}"
142
- echo "BLOCKED: ${hook_name} could not inspect a truncated/stalled payload; deferred to human." >&2
143
- exit 2
166
+ ukit_emit_permission_decision ask "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so ${hook_name} cannot prove it safe. UKit defers this to a human decision."
144
167
  fi
145
168
  printf '%s\n' "{\"systemMessage\":\"UKit ${hook_name}: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input.\"}"
146
169
  exit 0
@@ -221,3 +221,20 @@ otherwise route to the specialist.
221
221
  - `.claude/skills/duraone/references/sql.md`
222
222
  - `.claude/skills/duraone/references/workflow.md`
223
223
  - Khi không active: dùng generic coding standards + project-specific patterns từ index.
224
+
225
+ ## Learning loop — detail
226
+
227
+ Phase-4 telemetry → advisory tuning loop (FR-206, TASK-232).
228
+
229
+ - **Config namespace `learning.*`** (optional-present; absent → defaults merge + valid):
230
+ - `learning.feedback.enabled` (default `true`) — gates `collectFeedbackEvents`.
231
+ - `learning.feedback.maxEvents` (default `200`) — cap on labeled feedback events.
232
+ - `learning.proposals.minCount` / `learning.proposals.minSessions` (defaults `3`/`2`) — pattern-proposal thresholds.
233
+ - `learning.episodes.autoWrite` (default `false`) — gates the SessionEnd episode hook.
234
+ - `learning.tuning.enabled` / `learning.tuning.applyMode` (defaults `true`/`'manual'`; `'off'` disables computation).
235
+ - **Artifacts** (all under `.ukit/storage/`, written tmp+rename):
236
+ - `learning/feedback-events.json` — labeled wrong-route events (`rescue`, `re-route`, `repeat-stall`, `user-correction`).
237
+ - `learning/skill-accuracy.json` — per-skill trigger/join/accuracy roll-up.
238
+ - `learning/suggestions.json` — result of `computeTuningSuggestions(projectRoot)` (`src/learning/tuning.js`).
239
+ - `cache/retriever-lanes.jsonl` — per-query lane hit/weight telemetry consumed by `collectLaneStats`.
240
+ - **Advisory-only contract**: `suggestions` carry `{target, current, suggested, evidence}`; `applied` is always `[]`. `applyMode` is restricted to `manual|off` — **no writer ever mutates `codeIntel.retriever.weights` or `orchestration.escalation.debugLoopThreshold`**. Rules: lane weight ±0.1 step (clamped `[0.1, 2.0]`) when events ≥ 50 with a >0.5 dominant lane and a <0.02 starved lane; `debugLoopThreshold − 1` (min 1) when repeat-stall events ≥ 10. `ukit metrics` prints a `learning` section (pending count + targets; `n/a` when the artifact is absent).
@@ -225,6 +225,12 @@
225
225
  "maxRetries": 1,
226
226
  "confidenceThreshold": 50
227
227
  },
228
+ "learning": {
229
+ "feedback": { "enabled": true, "maxEvents": 200 },
230
+ "proposals": { "minCount": 3, "minSessions": 2 },
231
+ "episodes": { "autoWrite": false },
232
+ "tuning": { "enabled": true, "applyMode": "manual" }
233
+ },
228
234
  "safePatch": {
229
235
  "enabled": true,
230
236
  "strictSharedRisk": true,
@@ -454,6 +460,18 @@
454
460
  "mac_dinh": true,
455
461
  "y_nghia": "Planner phải hoàn thành PLAN.md §4 (Test Plan) trước khi task chuyển ready. Tắt sẽ làm UKit cho phép skip TDD — kéo theo executor dễ làm sót.",
456
462
  "khuyen_nghi": "Giữ true."
463
+ },
464
+ "tu_ghi_episode_ket_thuc_session": {
465
+ "field": "learning.episodes.autoWrite",
466
+ "mac_dinh": false,
467
+ "y_nghia": "Nếu true, hook SessionEnd tự ghi episode vào memory v2 khi kết thúc session (cần `ukit` có trên PATH). Mặc định tắt để tránh ghi memory khi user chưa muốn.",
468
+ "khi_nao_bat": "Chỉ bật khi anh muốn UKit tự lưu episode mỗi session; dữ liệu vẫn nằm local trong .ukit/storage."
469
+ },
470
+ "tat_goi_y_tuning": {
471
+ "field": "learning.tuning.applyMode",
472
+ "mac_dinh": "manual",
473
+ "y_nghia": "manual = UKit chỉ tính và lưu gợi ý tuning vào .ukit/storage/learning/suggestions.json, KHÔNG bao giờ tự sửa weights/threshold. off = tắt luôn việc tính gợi ý.",
474
+ "khuyen_nghi": "Giữ manual. Không có chế độ auto-apply — mọi thay đổi weights/threshold đều do người dùng tự sửa."
457
475
  }
458
476
  },
459
477
  "version": "Phiên bản config runtime đi kèm package UKit.",