@ngockhoale/ukit 2.6.10 → 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,151 @@
1
+ // patternProposals.js — promotion lane step 1 (FR-204 / TASK-230).
2
+ //
3
+ // Turns mined failure patterns (`.ukit/storage/cache/failure-patterns.json`,
4
+ // C31 artifact) into pending pattern-candidate records for human
5
+ // `ukit memory approve`. Dry-run by default; `dryRun: false` writes via
6
+ // `proposePatternCandidate` from `src/core/memory/store.js`.
7
+ //
8
+ // Contracts:
9
+ // * NEVER THROWS — missing artifact, unreadable ledgers, store failures →
10
+ // partial result with `error` field, no exception escapes.
11
+ // * Nothing auto-promotes: candidates stay `pending`.
12
+ // * Eligible: count >= minCount AND sessions >= 2.
13
+ // * Dedupe: normalized-text OR meta.signature match against existing pending
14
+ // candidates and active v2 records → status 'duplicate', no write.
15
+
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import {
19
+ listPendingPatternCandidates,
20
+ proposePatternCandidate,
21
+ } from '../core/memory/store.js';
22
+
23
+ const ARTIFACT_REL = path.join('.ukit', 'storage', 'cache', 'failure-patterns.json');
24
+ const MIN_SESSIONS = 2;
25
+
26
+ function normalizeText(text) {
27
+ return String(text ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
28
+ }
29
+
30
+ function candidateText(pattern) {
31
+ const firstFile = Array.isArray(pattern.files) && pattern.files.length > 0
32
+ ? pattern.files[0]
33
+ : 'the affected files';
34
+ return `Verification pattern "${pattern.signature}" failed ${pattern.count}× across ${pattern.sessions} sessions — check ${firstFile} before retrying.`;
35
+ }
36
+
37
+ async function loadPatterns(projectRoot) {
38
+ try {
39
+ const raw = await fs.readFile(path.join(projectRoot, ARTIFACT_REL), 'utf8');
40
+ const parsed = JSON.parse(raw);
41
+ if (parsed && Array.isArray(parsed.patterns)) return parsed.patterns;
42
+ } catch {
43
+ // Artifact absent/corrupt → lazy re-mine below.
44
+ }
45
+ try {
46
+ const mod = await import('../diagnostics/failurePatterns.js');
47
+ if (typeof mod?.mineFailurePatterns !== 'function') return [];
48
+ const mined = await mod.mineFailurePatterns(projectRoot, { minCount: 1 });
49
+ return Array.isArray(mined?.patterns) ? mined.patterns : [];
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ async function knownSignaturesAndTexts(projectRoot, projectId) {
56
+ const signatures = new Set();
57
+ const texts = new Set();
58
+ try {
59
+ const pending = await listPendingPatternCandidates(projectRoot, projectId);
60
+ for (const entry of pending) {
61
+ texts.add(normalizeText(entry.text));
62
+ }
63
+ } catch {
64
+ // listing failed → degrade, store dedupe is last line of defense
65
+ }
66
+ try {
67
+ const { queryRecords } = await import('../core/memory/storeV2.js');
68
+ const records = await queryRecords(projectRoot, { projectId });
69
+ for (const record of records) {
70
+ if (typeof record?.meta?.signature === 'string') {
71
+ signatures.add(record.meta.signature);
72
+ }
73
+ if (record?.status === 'active' || record?.meta?.legacyStatus === 'pending') {
74
+ texts.add(normalizeText(record.text));
75
+ }
76
+ }
77
+ } catch {
78
+ // v2 store unavailable → degrade
79
+ }
80
+ return { signatures, texts };
81
+ }
82
+
83
+ /**
84
+ * Propose pending pattern-candidates from mined failure patterns.
85
+ *
86
+ * @param {string} projectRoot
87
+ * @param {string} projectId
88
+ * @param {{ minCount?: number, dryRun?: boolean }} [options]
89
+ * @returns {Promise<{generatedAt: string, patternsScanned: number, eligible: number,
90
+ * proposed: Array<{signature: string, text: string,
91
+ * status: 'proposed'|'duplicate'|'skipped'}>, dryRun: boolean, error?: string}>}
92
+ */
93
+ export async function proposeFromPatterns(projectRoot, projectId, { minCount = 3, dryRun = true } = {}) {
94
+ const result = {
95
+ generatedAt: new Date().toISOString(),
96
+ patternsScanned: 0,
97
+ eligible: 0,
98
+ proposed: [],
99
+ dryRun,
100
+ };
101
+
102
+ try {
103
+ const patterns = await loadPatterns(projectRoot);
104
+ result.patternsScanned = patterns.length;
105
+ const known = await knownSignaturesAndTexts(projectRoot, projectId);
106
+
107
+ for (const pattern of patterns) {
108
+ const signature = typeof pattern?.signature === 'string' ? pattern.signature : null;
109
+ const count = Number(pattern?.count) || 0;
110
+ const sessions = Number(pattern?.sessions) || 0;
111
+ if (!signature) continue;
112
+
113
+ const text = candidateText({ signature, count, sessions, files: pattern.files });
114
+ const eligible = count >= minCount && sessions >= MIN_SESSIONS;
115
+ if (!eligible) {
116
+ result.proposed.push({ signature, text, status: 'skipped' });
117
+ continue;
118
+ }
119
+ result.eligible += 1;
120
+
121
+ if (known.signatures.has(signature) || known.texts.has(normalizeText(text))) {
122
+ result.proposed.push({ signature, text, status: 'duplicate' });
123
+ continue;
124
+ }
125
+
126
+ if (dryRun) {
127
+ result.proposed.push({ signature, text, status: 'proposed' });
128
+ continue;
129
+ }
130
+
131
+ try {
132
+ await proposePatternCandidate(projectRoot, projectId, {
133
+ text,
134
+ category: 'failure-pattern',
135
+ signature,
136
+ detectedFrom: 'memory-learn',
137
+ });
138
+ known.signatures.add(signature);
139
+ known.texts.add(normalizeText(text));
140
+ result.proposed.push({ signature, text, status: 'proposed' });
141
+ } catch (err) {
142
+ result.proposed.push({ signature, text, status: 'skipped' });
143
+ result.error = err?.message ?? String(err);
144
+ }
145
+ }
146
+ } catch (err) {
147
+ result.error = err?.message ?? String(err);
148
+ }
149
+
150
+ return result;
151
+ }
@@ -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
+ }
@@ -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,
@@ -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.",