@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.
@@ -1325,6 +1325,17 @@ items:
1325
1325
  packs:
1326
1326
  - core
1327
1327
 
1328
+ - id: hook-session-episode
1329
+ type: hook
1330
+ sourceTemplate: .claude/hooks/session-episode.sh
1331
+ targetPath: .claude/hooks/session-episode.sh
1332
+ requires: []
1333
+ mergeStrategy: overwrite_with_backup
1334
+ variables: []
1335
+ enabledByDefault: true
1336
+ packs:
1337
+ - core
1338
+
1328
1339
  - id: ukit-index-anchor-search-script
1329
1340
  type: config
1330
1341
  sourceTemplate: .claude/ukit/index/anchor-search.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.6.10",
3
+ "version": "2.6.11",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,97 @@
1
+ // `ukit feedback` — manual wrong-route label capture (SPEC C32 §3c).
2
+ //
3
+ // `ukit feedback "<text>" [--target <file>]` appends
4
+ // `{ts, text, targetFile?, sessionId?}` to
5
+ // `.ukit/storage/cache/feedback-manual.jsonl` and prints `feedback recorded`.
6
+ // `sessionId` = UKIT_SESSION_ID env only (SessionEnd hook exports it) — omitted
7
+ // when unset. `--list` prints a byKind roll-up via collectFeedbackEvents and
8
+ // never writes. Missing/empty text or unknown flag → usage on stderr, exit 1.
9
+
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { collectFeedbackEvents } from '../../diagnostics/feedbackEvents.js';
13
+
14
+ const HELP_FLAGS = new Set(['--help', '-h', 'help']);
15
+
16
+ function printUsage(stream = console.log) {
17
+ stream('Usage: ukit feedback "<text>" [--target <file>]');
18
+ stream(' ukit feedback --list');
19
+ stream('');
20
+ stream('Record a manual wrong-route correction label, or inspect collected');
21
+ stream('feedback events (rescue / re-route / repeat-stall / user-correction).');
22
+ }
23
+
24
+ async function appendManualLine(projectRoot, record) {
25
+ const filePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'feedback-manual.jsonl');
26
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
27
+ await fs.appendFile(filePath, JSON.stringify(record) + '\n');
28
+ }
29
+
30
+ export async function runFeedback({ projectRoot, argv = [] }) {
31
+ const args = argv ?? [];
32
+
33
+ if (args.some((a) => HELP_FLAGS.has(a))) {
34
+ printUsage();
35
+ return;
36
+ }
37
+
38
+ if (args.includes('--list')) {
39
+ const result = await collectFeedbackEvents(projectRoot);
40
+ console.log('feedback-events');
41
+ console.log(` audit rows: ${result.auditRowsScanned} ledgers: ${result.ledgersScanned}`);
42
+ for (const [kind, count] of Object.entries(result.byKind)) {
43
+ console.log(` ${kind}: ${count}`);
44
+ }
45
+ for (const event of result.events.slice(0, 5)) {
46
+ console.log(` - ${event.kind} · ${event.detail}`);
47
+ }
48
+ return;
49
+ }
50
+
51
+ let text = null;
52
+ let targetFile;
53
+ for (let i = 0; i < args.length; i += 1) {
54
+ const arg = args[i];
55
+ if (arg === '--target') {
56
+ targetFile = args[i + 1];
57
+ i += 1;
58
+ if (typeof targetFile !== 'string' || targetFile.startsWith('--')) {
59
+ console.error('Missing value for --target.');
60
+ printUsage(console.error);
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ continue;
65
+ }
66
+ if (arg.startsWith('--')) {
67
+ console.error(`Unknown flag: ${arg}`);
68
+ printUsage(console.error);
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+ if (text == null) {
73
+ text = arg;
74
+ }
75
+ }
76
+
77
+ if (!text || !text.trim()) {
78
+ console.error('Missing feedback text.');
79
+ printUsage(console.error);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+
84
+ const record = { ts: Date.now(), text: text.trim() };
85
+ if (typeof targetFile === 'string' && targetFile) record.targetFile = targetFile;
86
+ const sessionId = process.env.UKIT_SESSION_ID;
87
+ if (sessionId) record.sessionId = sessionId;
88
+
89
+ try {
90
+ await appendManualLine(projectRoot, record);
91
+ } catch (error) {
92
+ console.error(`Failed to record feedback: ${error?.message ?? error}`);
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ console.log('feedback recorded');
97
+ }
@@ -8,6 +8,7 @@ import {
8
8
  runProjectHygiene,
9
9
  } from '../../core/memory/store.js';
10
10
  import {
11
+ addRecord,
11
12
  getRecord,
12
13
  loadRecords,
13
14
  queryRecords,
@@ -20,7 +21,9 @@ import { inspectRuntimeConfig } from '../../core/runtimeConfig.js';
20
21
  import { buildRuntimePaths } from '../../core/runtimePaths.js';
21
22
  import { pathExists } from '../../core/fileOps.js';
22
23
  import { detectProjectContext } from '../../context/detectProjectContext.js';
24
+ import { listLedgerFiles, LEDGER_DIR_REL } from '../../diagnostics/ledgerFiles.js';
23
25
  import fs from 'node:fs/promises';
26
+ import path from 'node:path';
24
27
 
25
28
  const HELP_FLAGS = new Set(['--help', '-h', 'help']);
26
29
 
@@ -177,6 +180,192 @@ async function runMemoryV2(projectRoot, args) {
177
180
  throw new Error(`Unknown memory v2 op: ${op}. Expected list|show|promote|stale|migrate.`);
178
181
  }
179
182
 
183
+ // ---- `ukit memory promote` — MEMORY.md `## ukit-learned` lane (SPEC §7a) ----
184
+
185
+ const LEARNED_START = '<!-- ukit-learned:start -->';
186
+ const LEARNED_END = '<!-- ukit-learned:end -->';
187
+ const MEM_REF_RE = /<!--\s*mem:(mem_[0-9a-f]{12})\s*-->/g;
188
+
189
+ // Approved promotable records: active project_rule/procedure belonging to the
190
+ // project, not bulk-migrated v1 leftovers (created_by 'migration'), and not
191
+ // still-pending candidates (meta.legacyStatus 'pending').
192
+ async function collectPromotableRecords(projectRoot, projectId) {
193
+ const records = await loadRecords(projectRoot);
194
+ return records.filter((r) =>
195
+ (r.type === 'project_rule' || r.type === 'procedure')
196
+ && r.status === 'active'
197
+ && r.created_by !== 'migration'
198
+ && r.meta?.legacyStatus !== 'pending'
199
+ && (r.project_id == null || r.project_id === projectId));
200
+ }
201
+
202
+ function renderLearnedBlock(records) {
203
+ const lines = [LEARNED_START, '## ukit-learned'];
204
+ for (const r of records) {
205
+ lines.push(`- [${r.type}] ${r.text} <!-- mem:${r.id} -->`);
206
+ }
207
+ lines.push(LEARNED_END);
208
+ return lines.join('\n');
209
+ }
210
+
211
+ // Ids already referenced in mem:<id> comments OUTSIDE the managed block are
212
+ // user-promoted manually — promote must not duplicate them inside the block.
213
+ function externallyReferencedIds(content) {
214
+ const withoutBlock = content.replace(
215
+ new RegExp(`${escapeRe(LEARNED_START)}[\\s\\S]*?${escapeRe(LEARNED_END)}`, 'g'),
216
+ '',
217
+ );
218
+ const ids = new Set();
219
+ for (const m of withoutBlock.matchAll(MEM_REF_RE)) ids.add(m[1]);
220
+ return ids;
221
+ }
222
+
223
+ function escapeRe(s) {
224
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
225
+ }
226
+
227
+ async function writeTextAtomic(filePath, text) {
228
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
229
+ const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
230
+ await fs.writeFile(tmp, text);
231
+ await fs.rename(tmp, filePath);
232
+ }
233
+
234
+ async function runMemoryPromote(projectRoot, args) {
235
+ const dryRun = args.includes('--dry-run');
236
+ const projectId = (await detectProjectContext(projectRoot)).project.name;
237
+ const memoryMdPath = path.join(projectRoot, 'docs', 'MEMORY.md');
238
+
239
+ let existing = '';
240
+ try {
241
+ existing = await fs.readFile(memoryMdPath, 'utf8');
242
+ } catch {
243
+ existing = '';
244
+ }
245
+
246
+ const outsideIds = externallyReferencedIds(existing);
247
+ const promotable = (await collectPromotableRecords(projectRoot, projectId))
248
+ .filter((r) => !outsideIds.has(r.id));
249
+
250
+ const block = renderLearnedBlock(promotable);
251
+
252
+ if (dryRun) {
253
+ console.log('[UKit] memory promote (dry-run) — rendered block:');
254
+ console.log(block);
255
+ console.log(`[UKit] promoted: ${promotable.length} records → docs/MEMORY.md (dry-run)`);
256
+ return;
257
+ }
258
+
259
+ let next;
260
+ const startIdx = existing.indexOf(LEARNED_START);
261
+ const endIdx = existing.indexOf(LEARNED_END);
262
+ if (startIdx >= 0 && endIdx > startIdx) {
263
+ next = `${existing.slice(0, startIdx)}${block}${existing.slice(endIdx + LEARNED_END.length)}`;
264
+ } else if (existing.length > 0) {
265
+ next = `${existing.replace(/\s*$/, '')}\n\n${block}\n`;
266
+ } else {
267
+ next = `${block}\n`;
268
+ }
269
+
270
+ await writeTextAtomic(memoryMdPath, next);
271
+ console.log(`[UKit] promoted: ${promotable.length} records → docs/MEMORY.md`);
272
+ }
273
+
274
+ // ---- `ukit memory episode` — session episode from exec-ledger (SPEC §7b) ----
275
+
276
+ // Mirrors execution-ledger.mjs safeSegment — src/ cannot import the runtime
277
+ // module, so the segment rule is duplicated deliberately.
278
+ function safeSegment(value) {
279
+ return String(value || 'default')
280
+ .trim()
281
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
282
+ .slice(0, 96) || 'default';
283
+ }
284
+
285
+ async function readJsonIfExists(filePath) {
286
+ try {
287
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ // Resolution order: --session <id> → UKIT_SESSION_ID env → most-recent ledger.
294
+ async function resolveLedger(projectRoot, sessionFlag) {
295
+ const dir = path.join(projectRoot, LEDGER_DIR_REL);
296
+ const candidate = sessionFlag ?? process.env.UKIT_SESSION_ID;
297
+ if (candidate) {
298
+ const file = `${safeSegment(candidate)}.json`;
299
+ const ledger = await readJsonIfExists(path.join(dir, file));
300
+ return ledger ? { ledger, ledgerKey: file } : { ledger: null, ledgerKey: file };
301
+ }
302
+ const [name] = await listLedgerFiles(dir, 1);
303
+ if (!name) return { ledger: null, ledgerKey: null };
304
+ const ledger = await readJsonIfExists(path.join(dir, name));
305
+ return ledger ? { ledger, ledgerKey: name } : { ledger: null, ledgerKey: name };
306
+ }
307
+
308
+ function episodeText(ledger, sessionId) {
309
+ const write = ledger.writeSucceeded === true ? 'writeOk' : 'writeFail';
310
+ const verify = ledger.verificationAttempted !== true
311
+ ? 'not-run'
312
+ : ledger.verificationSucceeded === true ? 'ok' : 'fail';
313
+ const receipts = Array.isArray(ledger.receipts) ? ledger.receipts : [];
314
+ const lastCommand = receipts.length > 0
315
+ ? String(receipts[receipts.length - 1].command ?? '').split('\n')[0].trim() || 'n/a'
316
+ : 'n/a';
317
+ const text = `Session ${sessionId}: ${write}, verify=${verify}, `
318
+ + `${receipts.length} receipts, last command ${lastCommand}`;
319
+ return text.slice(0, 300);
320
+ }
321
+
322
+ async function runMemoryEpisode(projectRoot, args) {
323
+ const dryRun = args.includes('--dry-run');
324
+ const sessionFlag = extractFlag(args, '--session').value;
325
+
326
+ const { config } = await inspectRuntimeConfig(projectRoot);
327
+ if (config?.memoryV2?.enabled === false) {
328
+ console.log('[UKit] episode: skipped (memoryV2 disabled)');
329
+ return;
330
+ }
331
+
332
+ const { ledger, ledgerKey } = await resolveLedger(projectRoot, sessionFlag);
333
+ if (!ledger) {
334
+ console.log('[UKit] episode: nothing to record');
335
+ return;
336
+ }
337
+
338
+ const sessionId = ledger.sessionId ?? ledger.sessionKey
339
+ ?? (ledgerKey ? ledgerKey.replace(/\.json$/, '') : 'unknown');
340
+ const text = episodeText(ledger, sessionId);
341
+
342
+ if (dryRun) {
343
+ console.log(`[UKit] episode (dry-run): ${text}`);
344
+ return;
345
+ }
346
+
347
+ const existing = await loadRecords(projectRoot);
348
+ if (existing.some((r) => r.meta?.ledgerKey === ledgerKey)) {
349
+ console.log('[UKit] episode: already recorded');
350
+ return;
351
+ }
352
+
353
+ const ttlDays = Number(config?.memoryV2?.episodeTtlDays) || 90;
354
+ const projectId = (await detectProjectContext(projectRoot)).project.name;
355
+ const record = await addRecord(projectRoot, {
356
+ type: 'episode',
357
+ scope: 'session',
358
+ text,
359
+ provenance: 'exec-ledger',
360
+ confidence: 0.6,
361
+ createdBy: 'episode-hook',
362
+ projectId,
363
+ validUntil: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
364
+ meta: { sessionId, ledgerKey },
365
+ });
366
+ console.log(`[UKit] episode: recorded ${record.id} for session ${sessionId}`);
367
+ }
368
+
180
369
  export async function runMemory({ projectRoot, argv = [] }) {
181
370
  const runtimePaths = buildRuntimePaths(projectRoot);
182
371
  if (!(await pathExists(runtimePaths.runtimeRoot))) {
@@ -242,6 +431,53 @@ export async function runMemory({ projectRoot, argv = [] }) {
242
431
  return;
243
432
  }
244
433
 
434
+ if (subcommand === 'learn') {
435
+ const applyFlag = rest.includes('--apply');
436
+ const knownFlags = new Set(['--apply']);
437
+ const minFlag = extractFlag(rest, '--min');
438
+ const projectFlag = extractFlag(minFlag.rest, '--project');
439
+ for (const arg of projectFlag.rest) {
440
+ if (arg.startsWith('--') && !knownFlags.has(arg)) {
441
+ throw new Error(`Unknown flag for memory learn: ${arg}`);
442
+ }
443
+ }
444
+ const minCount = minFlag.value != null ? Number(minFlag.value) : undefined;
445
+ if (minFlag.value != null && (!Number.isFinite(minCount) || minCount < 1)) {
446
+ throw new Error(`Invalid --min value: ${minFlag.value}`);
447
+ }
448
+ const projectId = projectFlag.value ?? (await detectProjectContext(projectRoot)).project.name;
449
+
450
+ const { proposeFromPatterns } = await import('../../learning/patternProposals.js');
451
+ const result = await proposeFromPatterns(projectRoot, projectId, {
452
+ minCount,
453
+ dryRun: !applyFlag,
454
+ });
455
+ if (result.error) {
456
+ console.log(`[UKit] memory learn: ${result.error}`);
457
+ }
458
+
459
+ if (!applyFlag) {
460
+ console.log(`[UKit] memory learn (dry-run) — ${result.patternsScanned} pattern(s) scanned, ${result.eligible} eligible.`);
461
+ for (const entry of result.proposed) {
462
+ console.log(` [${entry.status}] ${entry.signature} — ${entry.text}`);
463
+ }
464
+ console.log('[UKit] Run `ukit memory learn --apply` to write pending candidates.');
465
+ return;
466
+ }
467
+
468
+ const proposed = result.proposed.filter((p) => p.status === 'proposed').length;
469
+ const duplicates = result.proposed.filter((p) => p.status === 'duplicate').length;
470
+ const skipped = result.proposed.filter((p) => p.status === 'skipped').length;
471
+ for (const entry of result.proposed) {
472
+ console.log(` [${entry.status}] ${entry.signature} — ${entry.text}`);
473
+ }
474
+ console.log(`[UKit] memory learn: proposed ${proposed}, duplicate ${duplicates}, skipped ${skipped} for project ${projectId}.`);
475
+ if (proposed > 0) {
476
+ console.log('[UKit] Review with `ukit memory list --pending`, then `ukit memory approve <id>`.');
477
+ }
478
+ return;
479
+ }
480
+
245
481
  if (subcommand === 'approve' || subcommand === 'reject') {
246
482
  const projectFlagIndex = rest.indexOf('--project');
247
483
  const explicitProjectId = projectFlagIndex >= 0 ? rest[projectFlagIndex + 1] : null;
@@ -327,6 +563,16 @@ export async function runMemory({ projectRoot, argv = [] }) {
327
563
  return;
328
564
  }
329
565
 
566
+ if (subcommand === 'promote') {
567
+ await runMemoryPromote(projectRoot, rest);
568
+ return;
569
+ }
570
+
571
+ if (subcommand === 'episode') {
572
+ await runMemoryEpisode(projectRoot, rest);
573
+ return;
574
+ }
575
+
330
576
  if (subcommand === 'forget') {
331
577
  const memoryId = rest.join(' ').trim();
332
578
  if (!memoryId) {
@@ -353,7 +599,7 @@ export async function runMemory({ projectRoot, argv = [] }) {
353
599
 
354
600
  export function printMemoryHelp() {
355
601
  console.log('UKit Memory Commands');
356
- console.log('Usage: ukit memory <list|search|recall|forget|export|propose|approve|reject|hygiene> [args]');
602
+ console.log('Usage: ukit memory <list|search|recall|forget|export|propose|approve|reject|learn|promote|episode|hygiene> [args]');
357
603
  console.log('');
358
604
  console.log('Subcommands:');
359
605
  console.log(' list List memory items in shared .ukit/storage/memory');
@@ -365,5 +611,8 @@ export function printMemoryHelp() {
365
611
  console.log(' propose "<text>" [--category <name>] [--project <id>] Propose a project convention for human approval');
366
612
  console.log(' approve <id> [--project <id>] Approve a pending pattern candidate into project conventions');
367
613
  console.log(' reject <id> [--project <id>] Reject a pending pattern candidate');
614
+ console.log(' learn [--apply] [--min <n>] [--project <id>] Propose pending candidates from mined failure patterns (dry-run by default)');
615
+ console.log(' promote [--dry-run] Render approved rules/procedures into the ## ukit-learned block in docs/MEMORY.md');
616
+ console.log(' episode [--dry-run] [--session <id>] Write a session episode record from the exec-ledger');
368
617
  console.log(' hygiene [--project <id>] Run decision-conflict resolution + session archiving now');
369
618
  }
@@ -51,6 +51,16 @@ async function collectFailurePatterns(projectRoot) {
51
51
  }
52
52
  }
53
53
 
54
+ async function lazyCollect(modulePath, exportName, projectRoot) {
55
+ try {
56
+ const mod = await import(modulePath);
57
+ if (typeof mod?.[exportName] !== 'function') return null;
58
+ return await mod[exportName](projectRoot);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
54
64
  function printRouteOutcomes(routeOutcomes) {
55
65
  console.log('route-outcomes');
56
66
  console.log(` ledgers: ${routeOutcomes.ledgersScanned} audit rows: ${routeOutcomes.auditRowsScanned} joined: ${routeOutcomes.joined} coverage: ${(routeOutcomes.joinCoverage * 100).toFixed(1)}% unmatched audit: ${routeOutcomes.unmatchedAudit} unmatched ledger: ${routeOutcomes.unmatchedLedger}`);
@@ -85,6 +95,92 @@ function printMemory(memory) {
85
95
  console.log(`memory: v2 records=${memory.total}${statusPairs ? ` (${statusPairs})` : ''}`);
86
96
  }
87
97
 
98
+ function printFeedback(feedback) {
99
+ console.log('feedback');
100
+ if (!feedback) {
101
+ console.log(' n/a');
102
+ return;
103
+ }
104
+ const kinds = Object.entries(feedback.byKind ?? {})
105
+ .map(([kind, count]) => `${kind}=${count}`).join(' ');
106
+ console.log(` ${kinds || '(none)'}`);
107
+ const events = Array.isArray(feedback.events) ? feedback.events.slice(0, 5) : [];
108
+ for (const event of events) {
109
+ console.log(` ${event.kind}: ${event.detail ?? ''}`);
110
+ }
111
+ }
112
+
113
+ function printSkills(skillAccuracy) {
114
+ console.log('skills');
115
+ if (!skillAccuracy) {
116
+ console.log(' n/a');
117
+ return;
118
+ }
119
+ const skills = Object.entries(skillAccuracy.skills ?? {});
120
+ if (skills.length === 0) {
121
+ console.log(' (none)');
122
+ } else {
123
+ console.log(' skill\ttriggers\tjoined\taccuracy');
124
+ for (const [id, bucket] of skills) {
125
+ const accuracy = bucket.accuracy === null ? 'n/a' : bucket.accuracy.toFixed(2);
126
+ console.log(` ${id}\t${bucket.triggers}\t${bucket.joined}\t${accuracy}`);
127
+ }
128
+ }
129
+ const low = Array.isArray(skillAccuracy.lowAccuracy) ? skillAccuracy.lowAccuracy : [];
130
+ if (low.length > 0) {
131
+ console.log(` lowAccuracy: ${low.map((e) => `${e.id}=${e.accuracy.toFixed(2)}`).join(' ')}`);
132
+ }
133
+ }
134
+
135
+ async function collectLearningSuggestions(projectRoot) {
136
+ try {
137
+ const raw = await fs.readFile(
138
+ path.join(projectRoot, '.ukit', 'storage', 'learning', 'suggestions.json'), 'utf8');
139
+ const doc = JSON.parse(raw);
140
+ const suggestions = Array.isArray(doc?.suggestions) ? doc.suggestions : [];
141
+ return {
142
+ pending: suggestions.length,
143
+ targets: suggestions.map((s) => s?.target).filter(Boolean),
144
+ generatedAt: doc?.generatedAt ?? null,
145
+ };
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function printLearning(learning) {
152
+ console.log('learning');
153
+ if (!learning) {
154
+ console.log(' n/a');
155
+ return;
156
+ }
157
+ console.log(` pending=${learning.pending}${learning.generatedAt ? ` generatedAt=${learning.generatedAt}` : ''}`);
158
+ for (const target of learning.targets.slice(0, 10)) {
159
+ console.log(` - ${target}`);
160
+ }
161
+ }
162
+
163
+ function printLaneStats(laneStats) {
164
+ console.log('retriever-lanes');
165
+ if (!laneStats) {
166
+ console.log(' n/a');
167
+ return;
168
+ }
169
+ if (laneStats.empty) {
170
+ console.log(' (no retriever-lanes.jsonl data)');
171
+ return;
172
+ }
173
+ const lanes = Object.entries(laneStats.lanes ?? {});
174
+ if (lanes.length === 0) {
175
+ console.log(' (none)');
176
+ return;
177
+ }
178
+ console.log(' lane\tretrievals\ttotalHits\texclusive\tavgWeight\tcontribution');
179
+ for (const [lane, bucket] of lanes) {
180
+ console.log(` ${lane}\t${bucket.retrievals}\t${bucket.totalHits}\t${bucket.exclusive}\t${bucket.avgWeight.toFixed(2)}\t${bucket.contribution.toFixed(2)}`);
181
+ }
182
+ }
183
+
88
184
  export async function runMetrics({ projectRoot, argv = [] }) {
89
185
  if (argv.some((flag) => HELP_FLAGS.has(flag))) {
90
186
  printUsage();
@@ -111,8 +207,12 @@ export async function runMetrics({ projectRoot, argv = [] }) {
111
207
  }));
112
208
  const failurePatterns = await collectFailurePatterns(projectRoot);
113
209
  const memory = await collectMemorySummary(projectRoot);
210
+ const feedback = await lazyCollect('../../diagnostics/feedbackEvents.js', 'collectFeedbackEvents', projectRoot);
211
+ const skillAccuracy = await lazyCollect('../../diagnostics/skillAccuracy.js', 'collectSkillAccuracy', projectRoot);
212
+ const laneStats = await lazyCollect('../../diagnostics/laneStats.js', 'collectLaneStats', projectRoot);
213
+ const learning = await collectLearningSuggestions(projectRoot);
114
214
 
115
- const rollup = { routeOutcomes, failurePatterns, memory };
215
+ const rollup = { routeOutcomes, failurePatterns, memory, feedback, skillAccuracy, laneStats, learning };
116
216
 
117
217
  if (argv.includes('--json')) {
118
218
  console.log(JSON.stringify(rollup, null, 2));
@@ -123,5 +223,13 @@ export async function runMetrics({ projectRoot, argv = [] }) {
123
223
  console.log('');
124
224
  printFailurePatterns(failurePatterns);
125
225
  console.log('');
226
+ printFeedback(feedback);
227
+ console.log('');
228
+ printSkills(skillAccuracy);
229
+ console.log('');
230
+ printLaneStats(laneStats);
231
+ console.log('');
232
+ printLearning(learning);
233
+ console.log('');
126
234
  printMemory(memory);
127
235
  }
package/src/cli/index.js CHANGED
@@ -8,6 +8,7 @@ import { runMemory } from './commands/memory.js';
8
8
  import { runUpdate } from './commands/update.js';
9
9
  import { runCode } from './commands/code.js';
10
10
  import { runMetrics } from './commands/metrics.js';
11
+ import { runFeedback } from './commands/feedback.js';
11
12
 
12
13
  const GLOBAL_FLAGS = new Set(['--help', '-h', '--version', '-v']);
13
14
 
@@ -71,6 +72,11 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
71
72
  return;
72
73
  }
73
74
 
75
+ if (command === 'feedback') {
76
+ await runFeedback({ projectRoot, argv: commandArgv });
77
+ return;
78
+ }
79
+
74
80
  if (command === 'update') {
75
81
  await runUpdate({ packageVersion, argv: commandArgv });
76
82
  return;
@@ -105,6 +111,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
105
111
  console.log(' status Show UKit runtime status');
106
112
  console.log(' memory Inspect shared UKit memory');
107
113
  console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
114
+ console.log(' feedback Record or list wrong-route feedback labels');
108
115
  console.log(' update Upgrade the global UKit CLI to the latest version');
109
116
  console.log(' version Show UKit version');
110
117
  console.log('');
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
3
5
 
4
6
  import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, normalizeRelative } from '../../index/paths.js';
5
7
  import { loadRuntimeConfig } from '../runtimeConfig.js';
@@ -260,6 +262,55 @@ function concatMerge(lanes) {
260
262
  return merged;
261
263
  }
262
264
 
265
+ // FR-202b (TASK-228): fire-and-forget lane telemetry. One JSONL line per
266
+ // retrieval at .ukit/storage/cache/retriever-lanes.jsonl; query persisted as
267
+ // sha256 only (no raw prompts on disk). Failures are swallowed — telemetry
268
+ // must never break retrieve(). Sync writes keep the fire-and-forget contract
269
+ // deterministic for callers/tests.
270
+ const RETRIEVER_LANES_MAX_BYTES = 256 * 1024;
271
+ const RETRIEVER_LANES_KEEP_LINES = 2000;
272
+
273
+ function emitRetrieverLaneTelemetry(rootDir, { query, mode, lanes, omitted, mergedPaths, merge, weights }) {
274
+ try {
275
+ const laneStats = {};
276
+ for (const laneName of LANE_ORDER) {
277
+ const lane = lanes.get(laneName);
278
+ const stat = {
279
+ hits: lane ? lane.length : 0,
280
+ weightUsed: weights?.[laneName] ?? 0,
281
+ };
282
+ const why = omitted?.find((o) => o?.what === `${laneName}-lane`)?.why;
283
+ if (!lane && why) stat.omitted = why;
284
+ if (lane) stat.paths = lane.map((entry) => entry.path).slice(0, 100);
285
+ laneStats[laneName] = stat;
286
+ }
287
+ const event = {
288
+ ts: Date.now(),
289
+ query: createHash('sha256').update(String(query ?? '')).digest('hex'),
290
+ mode,
291
+ lanes: laneStats,
292
+ mergedPaths,
293
+ merge,
294
+ };
295
+ const filePath = path.join(rootDir, '.ukit', 'storage', 'cache', 'retriever-lanes.jsonl');
296
+ fsSync.mkdirSync(path.dirname(filePath), { recursive: true });
297
+ // Best-effort cap: past 256 KiB keep only the newest ~2000 lines.
298
+ try {
299
+ const st = fsSync.statSync(filePath);
300
+ if (st.size > RETRIEVER_LANES_MAX_BYTES) {
301
+ const kept = fsSync.readFileSync(filePath, 'utf8').split('\n').filter(Boolean)
302
+ .slice(-RETRIEVER_LANES_KEEP_LINES);
303
+ fsSync.writeFileSync(filePath, kept.length ? `${kept.join('\n')}\n` : '');
304
+ }
305
+ } catch {
306
+ // Missing/unreadable file is fine — append below recreates it.
307
+ }
308
+ fsSync.appendFileSync(filePath, `${JSON.stringify(event)}\n`);
309
+ } catch {
310
+ // Telemetry is advisory; never propagate.
311
+ }
312
+ }
313
+
263
314
  /**
264
315
  * retrieve(projectRoot, query, { mode, limit, snapshot, merge, weights }) →
265
316
  * { anchors, evidence, relations, omitted }
@@ -384,6 +435,20 @@ export async function retrieve(projectRoot, query, { mode = 'search', limit, sna
384
435
  omitted.push({ what: 'anchors', why: 'no-match' });
385
436
  }
386
437
 
438
+ // FR-202b: emit lane telemetry only when a merge result exists — skipped
439
+ // entirely on the early no-index return and on empty merges.
440
+ if (merged.length > 0) {
441
+ emitRetrieverLaneTelemetry(rootDir, {
442
+ query: normalizedQuery,
443
+ mode,
444
+ lanes,
445
+ omitted,
446
+ mergedPaths: anchors.map((a) => a.path),
447
+ merge: effectiveMerge,
448
+ weights: effectiveWeights,
449
+ });
450
+ }
451
+
387
452
  const relations = [];
388
453
 
389
454
  // Impact mode — 1-hop reverse imports: who imports each anchor file.