@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
@@ -0,0 +1,227 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { execFile } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parse } from 'yaml';
6
+ import { pathExists, readJsonIfExists } from './fileOps.js';
7
+ import { VALID_PERMISSION_MODES } from './runtimeConfig.js';
8
+
9
+ // TASK-011 / SPEC §8 — unattended-mode doctor checks (FR-007).
10
+ // Seven checks, each returning the projectChecks remediation-class shape:
11
+ // { label, passed, applicable?, failed, remediationClass, remedy, detail? }
12
+ // remediationClass ∈ install-repairable | owner-action | advisory.
13
+
14
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
15
+ const INSTALL_REPAIR = 'Run ukit install';
16
+ const OMP_CONFIG_TIMEOUT_MS = 5000;
17
+ const COMPLETION_LOOP_HEADER = '## Unattended Completion Loop';
18
+
19
+ async function readOmpConfig(projectRoot) {
20
+ const configPath = path.join(projectRoot, '.omp', 'config.yml');
21
+ try {
22
+ const text = await fs.readFile(configPath, 'utf8');
23
+ const parsed = parse(text);
24
+ return { exists: true, parsed: parsed && typeof parsed === 'object' ? parsed : {}, parseError: null };
25
+ } catch (error) {
26
+ if (error?.code === 'ENOENT') return { exists: false, parsed: null, parseError: null };
27
+ return { exists: true, parsed: null, parseError: error?.message ?? String(error) };
28
+ }
29
+ }
30
+
31
+ const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
32
+
33
+ function execOmpConfigGet(projectRoot, ompPath) {
34
+ return new Promise((resolve) => {
35
+ execFile(
36
+ ompPath,
37
+ ['config', 'get', 'tools.approvalMode'],
38
+ { cwd: projectRoot, timeout: OMP_CONFIG_TIMEOUT_MS },
39
+ (error, stdout) => {
40
+ if (error) {
41
+ resolve({ ok: false, value: null });
42
+ return;
43
+ }
44
+ const value = String(stdout ?? '').replace(ANSI_PATTERN, '').trim();
45
+ resolve({ ok: value.length > 0, value: value || null });
46
+ },
47
+ );
48
+ });
49
+ }
50
+
51
+ async function readTemplateDenyMatches() {
52
+ try {
53
+ const text = await fs.readFile(path.join(PACKAGE_ROOT, 'templates', '.omp', 'config.yml'), 'utf8');
54
+ const parsed = parse(text);
55
+ const patterns = parsed?.bash?.patterns;
56
+ if (!Array.isArray(patterns)) return [];
57
+ return patterns
58
+ .filter((entry) => entry && entry.approval === 'deny' && typeof entry.match === 'string')
59
+ .map((entry) => entry.match);
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ export async function inspectUnattendedMode({ projectRoot, ompPath = 'omp' }) {
66
+ const runtimeConfig = await readJsonIfExists(
67
+ path.join(projectRoot, '.ukit', 'storage', 'config.json'),
68
+ );
69
+ const permissionModeRaw = runtimeConfig?.orchestration?.permissionMode;
70
+ const permissionModeValid = permissionModeRaw === undefined
71
+ || VALID_PERMISSION_MODES.has(permissionModeRaw);
72
+ const permissionMode = permissionModeRaw === undefined ? 'unattended' : permissionModeRaw;
73
+
74
+ const omp = await readOmpConfig(projectRoot);
75
+ const tools = omp.parsed?.tools ?? {};
76
+ const approvalMap = tools.approval && typeof tools.approval === 'object' ? tools.approval : {};
77
+ const bashPatterns = Array.isArray(omp.parsed?.bash?.patterns) ? omp.parsed.bash.patterns : [];
78
+
79
+ // Check 2 — effective approvalMode: `omp config get` first (5s timeout), YAML fallback.
80
+ let effectiveApprovalMode = null;
81
+ let approvalSource = null;
82
+ if (omp.exists) {
83
+ const ompResult = await execOmpConfigGet(projectRoot, ompPath);
84
+ if (ompResult.ok) {
85
+ effectiveApprovalMode = ompResult.value;
86
+ approvalSource = 'omp config get';
87
+ } else if (tools.approvalMode !== undefined) {
88
+ effectiveApprovalMode = tools.approvalMode ?? null;
89
+ approvalSource = '.omp/config.yml (omp binary unavailable)';
90
+ }
91
+ }
92
+
93
+ const promptValues = [tools.approvalMode, ...Object.values(approvalMap)];
94
+ for (const entry of bashPatterns) {
95
+ if (entry && typeof entry === 'object' && 'approval' in entry) promptValues.push(entry.approval);
96
+ }
97
+ const promptPolicyCount = promptValues.filter((v) => v === 'prompt').length;
98
+
99
+ const bridgePath = path.join(projectRoot, '.omp', 'hooks', 'pre', 'ukit-bridge.js');
100
+ const bridgeExists = await pathExists(bridgePath);
101
+ const requiredDenyMatches = await readTemplateDenyMatches();
102
+ const installedDenyMatches = new Set(
103
+ bashPatterns
104
+ .filter((entry) => entry && entry.approval === 'deny' && typeof entry.match === 'string')
105
+ .map((entry) => entry.match),
106
+ );
107
+ const missingDenyMatches = requiredDenyMatches.filter((m) => !installedDenyMatches.has(m));
108
+ const guardState = !bridgeExists
109
+ ? 'degraded'
110
+ : (missingDenyMatches.length > 0 ? 'partial' : 'enabled');
111
+
112
+ let loopGuidanceInstalled = false;
113
+ let instructionDocExists = false;
114
+ for (const docName of ['CLAUDE.md', 'AGENTS.md']) {
115
+ try {
116
+ const text = await fs.readFile(path.join(projectRoot, docName), 'utf8');
117
+ instructionDocExists = true;
118
+ if (text.includes(COMPLETION_LOOP_HEADER)) {
119
+ loopGuidanceInstalled = true;
120
+ break;
121
+ }
122
+ } catch {
123
+ // file may not exist
124
+ }
125
+ }
126
+
127
+ const check = (label, passed, remediationClass, remedy, extra = {}) => ({
128
+ label,
129
+ passed,
130
+ failed: !passed,
131
+ remediationClass,
132
+ remedy,
133
+ ...extra,
134
+ });
135
+
136
+ const checks = [
137
+ // 1 — permissionMode declared
138
+ check(
139
+ '`permissionMode` declared',
140
+ permissionModeValid,
141
+ 'owner-action',
142
+ `Set orchestration.permissionMode in .ukit/storage/config.json to one of: ${[...VALID_PERMISSION_MODES].join(', ')}.`,
143
+ permissionModeRaw === undefined ? { detail: 'unattended (default)' } : {},
144
+ ),
145
+ // 2 — effective approvalMode is yolo
146
+ effectiveApprovalMode === null
147
+ ? check(
148
+ 'effective approvalMode is `yolo`',
149
+ false,
150
+ 'install-repairable',
151
+ INSTALL_REPAIR,
152
+ {
153
+ applicable: false,
154
+ detail: omp.exists
155
+ ? 'no tools.approvalMode readable (omp unavailable and YAML value absent)'
156
+ : 'no .omp/config.yml project override',
157
+ },
158
+ )
159
+ : check(
160
+ 'effective approvalMode is `yolo`',
161
+ effectiveApprovalMode === 'yolo',
162
+ 'install-repairable',
163
+ 'Run ukit install to pin tools.approvalMode: yolo in .omp/config.yml.',
164
+ { detail: `value: ${effectiveApprovalMode} (source: ${approvalSource})` },
165
+ ),
166
+ // 3 — eval/bash policy allow
167
+ check(
168
+ "eval/bash policy 'allow'",
169
+ omp.exists && approvalMap.eval === 'allow' && approvalMap.bash === 'allow',
170
+ 'install-repairable',
171
+ 'Run ukit install to restore tools.approval.eval/bash: allow.',
172
+ omp.exists
173
+ ? {}
174
+ : { applicable: false, detail: '.omp/config.yml absent' },
175
+ ),
176
+ // 4 — zero prompt policies
177
+ check(
178
+ 'zero `prompt` policies',
179
+ promptPolicyCount === 0,
180
+ 'install-repairable',
181
+ 'Run ukit install to strip `prompt` approvals (unattended mode cannot surface prompts).',
182
+ {
183
+ ...(omp.exists ? {} : { applicable: false, detail: '.omp/config.yml absent' }),
184
+ detail: `${promptPolicyCount} prompt policy(ies)`,
185
+ },
186
+ ),
187
+ // 5 — project override active
188
+ check(
189
+ 'project override active',
190
+ omp.exists,
191
+ 'advisory',
192
+ 'Run ukit install to materialize the project .omp/config.yml override.',
193
+ omp.exists ? {} : { applicable: false, detail: '.omp/config.yml absent' },
194
+ ),
195
+ // 6 — completion loop guidance installed
196
+ check(
197
+ 'completion loop guidance installed',
198
+ loopGuidanceInstalled,
199
+ 'install-repairable',
200
+ `Run ukit install to add the '${COMPLETION_LOOP_HEADER}' section to CLAUDE.md/AGENTS.md.`,
201
+ instructionDocExists ? {} : { applicable: false, detail: 'no CLAUDE.md/AGENTS.md' },
202
+ ),
203
+ // 7 — safety guard enabled
204
+ check(
205
+ 'safety guard enabled',
206
+ guardState === 'enabled',
207
+ 'advisory',
208
+ !bridgeExists
209
+ ? 'Restore .omp/hooks/pre/ukit-bridge.js (run ukit install); guard is degraded.'
210
+ : `Add the missing deny patterns to .omp/config.yml bash.patterns (${missingDenyMatches.length} missing).`,
211
+ {
212
+ ...(omp.exists || bridgeExists ? {} : { applicable: false }),
213
+ detail: `guard state: ${guardState}${missingDenyMatches.length > 0 ? ` (${missingDenyMatches.length} deny pattern(s) missing)` : ''}`,
214
+ },
215
+ ),
216
+ ];
217
+
218
+ return {
219
+ checks,
220
+ summary: {
221
+ permissionMode,
222
+ effectiveApprovalMode,
223
+ promptPolicyCount,
224
+ guardState,
225
+ },
226
+ };
227
+ }
@@ -19,10 +19,9 @@
19
19
 
20
20
  import fs from 'node:fs/promises';
21
21
  import path from 'node:path';
22
+ import { listLedgerFiles, LEDGER_DIR_REL } from './ledgerFiles.js';
22
23
 
23
- const LEDGER_DIR_REL = path.join('.ukit', 'storage', 'cache', 'exec-ledger');
24
24
  const ARTIFACT_REL = path.join('.ukit', 'storage', 'cache', 'failure-patterns.json');
25
- const NON_LEDGER_FILES = new Set(['gate-crash-counter.json']);
26
25
  const MAX_PATTERNS = 10;
27
26
  const MAX_EXAMPLES = 3;
28
27
 
@@ -33,14 +32,6 @@ function isObject(value) {
33
32
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
34
33
  }
35
34
 
36
- function isLedgerFilename(name) {
37
- if (typeof name !== 'string' || !name.endsWith('.json')) return false;
38
- if (NON_LEDGER_FILES.has(name)) return false;
39
- if (name.includes('.journal')) return false;
40
- if (name.includes('.quarantine')) return false;
41
- return true;
42
- }
43
-
44
35
  // First two whitespace tokens, lowercased; path-like or numeric tokens become `<arg>`.
45
36
  function normalizeSignature(command) {
46
37
  if (typeof command !== 'string') return null;
@@ -74,30 +65,6 @@ function failureCommands(ledger) {
74
65
  return out;
75
66
  }
76
67
 
77
- async function listLedgerFiles(dir, limit) {
78
- let entries;
79
- try {
80
- entries = await fs.readdir(dir, { withFileTypes: true });
81
- } catch {
82
- return [];
83
- }
84
- const files = [];
85
- for (const entry of entries) {
86
- if (!entry.isFile() || !isLedgerFilename(entry.name)) continue;
87
- files.push(entry.name);
88
- }
89
- const withMtime = await Promise.all(files.map(async (name) => {
90
- try {
91
- const stat = await fs.stat(path.join(dir, name));
92
- return { name, mtimeMs: stat.mtimeMs };
93
- } catch {
94
- return { name, mtimeMs: 0 };
95
- }
96
- }));
97
- withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
98
- return withMtime.slice(0, limit).map((f) => f.name);
99
- }
100
-
101
68
  async function writeArtifact(filePath, result) {
102
69
  const dir = path.dirname(filePath);
103
70
  const tmp = path.join(dir, `.failure-patterns-${process.pid}.tmp`);
@@ -0,0 +1,196 @@
1
+ // feedbackEvents.js — labeled wrong-route feedback events collector (SPEC C32 §3b).
2
+ //
3
+ // Derives labeled events from telemetry already on disk:
4
+ // * rescue — route-audit entry with `rescueMode != null` → detail = rescueMode
5
+ // * re-route — consecutive same-fingerprint audit entries where the later
6
+ // entry changed executionMode (detail "<prev>→<new>")
7
+ // * repeat-stall — audit `repeatCount >= minRepeat` with no matching
8
+ // ledger `writeSucceeded` (join on requestKey)
9
+ // * user-correction — manual labels appended by `ukit feedback "<text>"` to
10
+ // `.ukit/storage/cache/feedback-manual.jsonl`
11
+ //
12
+ // Contracts:
13
+ // * NEVER THROWS. Missing/malformed telemetry → zeroed result, `error` field
14
+ // on failure. Artifact write failure is non-fatal.
15
+ // * EVIDENCE-ONLY. The only side effect is the advisory artifact
16
+ // `.ukit/storage/learning/feedback-events.json` (tmp+rename).
17
+ // * Gated by config `learning?.feedback?.enabled !== false` (default true;
18
+ // the `learning` namespace lands in TASK-232 so the read is defensive).
19
+
20
+ import fs from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import { listLedgerFiles, LEDGER_DIR_REL } from './ledgerFiles.js';
23
+
24
+ const CACHE_DIR_REL = path.join('.ukit', 'storage', 'cache');
25
+ const AUDIT_REL = path.join(CACHE_DIR_REL, 'route-audit.json');
26
+ const MANUAL_REL = path.join(CACHE_DIR_REL, 'feedback-manual.jsonl');
27
+ const ARTIFACT_REL = path.join('.ukit', 'storage', 'learning', 'feedback-events.json');
28
+ const CONFIG_REL = path.join('.ukit', 'storage', 'config.json');
29
+ const MAX_EVENTS = 200;
30
+
31
+ function isObject(value) {
32
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+
35
+ function zeroedResult(extra = {}) {
36
+ return {
37
+ generatedAt: new Date().toISOString(),
38
+ auditRowsScanned: 0,
39
+ ledgersScanned: 0,
40
+ byKind: { rescue: 0, 're-route': 0, 'repeat-stall': 0, 'user-correction': 0 },
41
+ events: [],
42
+ ...extra,
43
+ };
44
+ }
45
+
46
+ async function readJson(filePath) {
47
+ try {
48
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ // Default true when `learning`/`feedback` is absent (namespace lands in TASK-232).
55
+ async function feedbackEnabled(projectRoot) {
56
+ const config = await readJson(path.join(projectRoot, CONFIG_REL));
57
+ return config?.learning?.feedback?.enabled !== false;
58
+ }
59
+
60
+ // Manual jsonl is field-tolerant: unknown keys ignored, a legacy `context`
61
+ // key is treated as an optional extra. Bad lines are skipped, not fatal.
62
+ async function readManualEvents(projectRoot, byKind) {
63
+ const events = [];
64
+ let raw;
65
+ try {
66
+ raw = await fs.readFile(path.join(projectRoot, MANUAL_REL), 'utf8');
67
+ } catch {
68
+ return events;
69
+ }
70
+ for (const line of raw.split('\n')) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed) continue;
73
+ let record;
74
+ try {
75
+ record = JSON.parse(trimmed);
76
+ } catch {
77
+ continue;
78
+ }
79
+ if (!isObject(record) || typeof record.text !== 'string') continue;
80
+ const event = {
81
+ ts: typeof record.ts === 'number' ? record.ts : 0,
82
+ kind: 'user-correction',
83
+ detail: record.text,
84
+ source: 'manual',
85
+ };
86
+ if (typeof record.targetFile === 'string') event.targetFile = record.targetFile;
87
+ events.push(event);
88
+ byKind['user-correction'] += 1;
89
+ }
90
+ return events;
91
+ }
92
+
93
+ async function writeArtifact(filePath, result) {
94
+ const dir = path.dirname(filePath);
95
+ const tmp = path.join(dir, `.feedback-events-${process.pid}.tmp`);
96
+ try {
97
+ await fs.mkdir(dir, { recursive: true });
98
+ await fs.writeFile(tmp, JSON.stringify(result, null, 2));
99
+ await fs.rename(tmp, filePath);
100
+ } catch {
101
+ await fs.rm(tmp, { force: true }).catch(() => {});
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Collect labeled wrong-route feedback events.
107
+ *
108
+ * @param {string} projectRoot repository root containing `.ukit/storage/`.
109
+ * @param {{ limitLedgers?: number, minRepeat?: number }} [options]
110
+ * @returns {Promise<{generatedAt: string, auditRowsScanned: number,
111
+ * ledgersScanned: number, byKind: Record<string, number>,
112
+ * events: Array<object>, error?: string}>} never throws.
113
+ */
114
+ export async function collectFeedbackEvents(projectRoot, { limitLedgers = 500, minRepeat = 2 } = {}) {
115
+ const result = zeroedResult();
116
+ try {
117
+ if (!(await feedbackEnabled(projectRoot))) {
118
+ return result;
119
+ }
120
+
121
+ const auditDoc = await readJson(path.join(projectRoot, AUDIT_REL));
122
+ const auditEntries = Array.isArray(auditDoc?.entries)
123
+ ? auditDoc.entries.filter(isObject)
124
+ : [];
125
+ result.auditRowsScanned = auditEntries.length;
126
+
127
+ const ledgerDir = path.join(projectRoot, LEDGER_DIR_REL);
128
+ const ledgerNames = await listLedgerFiles(ledgerDir, limitLedgers);
129
+ const writeOkKeys = new Set();
130
+ for (const name of ledgerNames) {
131
+ const ledger = await readJson(path.join(ledgerDir, name));
132
+ if (!isObject(ledger)) continue;
133
+ result.ledgersScanned += 1;
134
+ if (ledger.writeSucceeded === true && typeof ledger.requestKey === 'string') {
135
+ writeOkKeys.add(ledger.requestKey);
136
+ }
137
+ }
138
+
139
+ const events = [];
140
+ for (const entry of auditEntries) {
141
+ const base = {
142
+ ts: typeof entry.ts === 'number' ? entry.ts : 0,
143
+ requestKey: typeof entry.requestKey === 'string' ? entry.requestKey : undefined,
144
+ targetFile: typeof entry.targetFile === 'string' ? entry.targetFile : undefined,
145
+ taskType: typeof entry.taskType === 'string' ? entry.taskType : undefined,
146
+ executionMode: typeof entry.executionMode === 'string' ? entry.executionMode : undefined,
147
+ source: 'derived',
148
+ };
149
+
150
+ if (entry.rescueMode != null) {
151
+ events.push({ ...base, kind: 'rescue', detail: String(entry.rescueMode) });
152
+ result.byKind.rescue += 1;
153
+ }
154
+
155
+ if ((entry.repeatCount ?? 0) >= minRepeat && !writeOkKeys.has(entry.requestKey)) {
156
+ events.push({ ...base, kind: 'repeat-stall', detail: `repeatCount=${entry.repeatCount}` });
157
+ result.byKind['repeat-stall'] += 1;
158
+ }
159
+ }
160
+
161
+ // re-route: audit is newest-first — entry i is the later entry, i+1 the
162
+ // previous one for the same promptFingerprint + targetFile.
163
+ for (let i = 0; i < auditEntries.length - 1; i += 1) {
164
+ const later = auditEntries[i];
165
+ const prev = auditEntries[i + 1];
166
+ if (typeof later.promptFingerprint !== 'string' || !later.promptFingerprint) continue;
167
+ if (later.promptFingerprint !== prev.promptFingerprint) continue;
168
+ if (later.targetFile !== prev.targetFile) continue;
169
+ if (later.wideningBlocked === true) continue;
170
+ const prevMode = prev.executionMode;
171
+ const newMode = later.executionMode;
172
+ if (prevMode == null || newMode == null || prevMode === newMode) continue;
173
+ events.push({
174
+ ts: typeof later.ts === 'number' ? later.ts : 0,
175
+ kind: 're-route',
176
+ requestKey: typeof later.requestKey === 'string' ? later.requestKey : undefined,
177
+ targetFile: typeof later.targetFile === 'string' ? later.targetFile : undefined,
178
+ taskType: typeof later.taskType === 'string' ? later.taskType : undefined,
179
+ executionMode: typeof later.executionMode === 'string' ? later.executionMode : undefined,
180
+ detail: `${prevMode}→${newMode}`,
181
+ source: 'derived',
182
+ });
183
+ result.byKind['re-route'] += 1;
184
+ }
185
+
186
+ events.push(...await readManualEvents(projectRoot, result.byKind));
187
+
188
+ events.sort((a, b) => b.ts - a.ts);
189
+ result.events = events.slice(0, MAX_EVENTS);
190
+ } catch (error) {
191
+ result.error = error?.message ?? String(error);
192
+ }
193
+
194
+ await writeArtifact(path.join(projectRoot, ARTIFACT_REL), result);
195
+ return result;
196
+ }
@@ -0,0 +1,111 @@
1
+ // laneStats.js — retriever lane contribution stats (SPEC C32 §5b).
2
+ //
3
+ // Reads `.ukit/storage/cache/retriever-lanes.jsonl` emitted by the retriever
4
+ // (FR-202b) and rolls up per-lane counters:
5
+ // retrievals = events where the lane had hits > 0
6
+ // totalHits = sum of lane hits
7
+ // exclusive = events where this lane was the SOLE lane with hits > 0
8
+ // (emitters record per-lane hit counts only, so exclusivity
9
+ // is approximated rather than per-path recomputed)
10
+ // avgWeight = mean weightUsed over events that recorded one
11
+ // contribution = retrievals / max(1, events)
12
+ //
13
+ // Contracts: NEVER THROWS — missing/empty file → `empty: true` + zeroed lanes.
14
+
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+
18
+ const LANES_REL = path.join('.ukit', 'storage', 'cache', 'retriever-lanes.jsonl');
19
+
20
+ function isObject(value) {
21
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
22
+ }
23
+
24
+ function zeroedResult(extra = {}) {
25
+ return {
26
+ generatedAt: new Date().toISOString(),
27
+ events: 0,
28
+ lanes: {},
29
+ empty: false,
30
+ ...extra,
31
+ };
32
+ }
33
+
34
+ function emptyLaneBucket() {
35
+ return {
36
+ retrievals: 0,
37
+ totalHits: 0,
38
+ exclusive: 0,
39
+ avgWeight: 0,
40
+ contribution: 0,
41
+ _weightSum: 0,
42
+ _weightCount: 0,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Collect retriever lane contribution stats.
48
+ *
49
+ * @param {string} projectRoot repository root containing `.ukit/storage/`.
50
+ * @param {{ maxEvents?: number }} [options]
51
+ * @returns {Promise<object>} never throws.
52
+ */
53
+ export async function collectLaneStats(projectRoot, { maxEvents = 2000 } = {}) {
54
+ const result = zeroedResult();
55
+ try {
56
+ let raw;
57
+ try {
58
+ raw = await fs.readFile(path.join(projectRoot, LANES_REL), 'utf8');
59
+ } catch {
60
+ return { ...result, empty: true };
61
+ }
62
+ const lines = raw.split('\n');
63
+ const events = [];
64
+ for (const line of lines) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed) continue;
67
+ try {
68
+ const record = JSON.parse(trimmed);
69
+ if (isObject(record) && isObject(record.lanes)) events.push(record);
70
+ } catch {
71
+ // skip malformed line
72
+ }
73
+ if (events.length >= maxEvents) break;
74
+ }
75
+ if (events.length === 0) {
76
+ return { ...result, empty: true };
77
+ }
78
+ result.events = events.length;
79
+
80
+ for (const event of events) {
81
+ const positive = [];
82
+ for (const [lane, stats] of Object.entries(event.lanes)) {
83
+ if (!isObject(stats)) continue;
84
+ const bucket = result.lanes[lane] ??= emptyLaneBucket();
85
+ const hits = typeof stats.hits === 'number' ? stats.hits : 0;
86
+ if (hits > 0) {
87
+ bucket.retrievals += 1;
88
+ bucket.totalHits += hits;
89
+ positive.push(lane);
90
+ }
91
+ if (typeof stats.weightUsed === 'number') {
92
+ bucket._weightSum += stats.weightUsed;
93
+ bucket._weightCount += 1;
94
+ }
95
+ }
96
+ if (positive.length === 1) {
97
+ result.lanes[positive[0]].exclusive += 1;
98
+ }
99
+ }
100
+
101
+ for (const bucket of Object.values(result.lanes)) {
102
+ bucket.avgWeight = bucket._weightCount === 0 ? 0 : bucket._weightSum / bucket._weightCount;
103
+ bucket.contribution = bucket.retrievals / Math.max(1, result.events);
104
+ delete bucket._weightSum;
105
+ delete bucket._weightCount;
106
+ }
107
+ } catch (error) {
108
+ result.error = error?.message ?? String(error);
109
+ }
110
+ return result;
111
+ }
@@ -0,0 +1,47 @@
1
+ // ledgerFiles.js — shared exec-ledger directory helpers (SPEC C32 §3a).
2
+ //
3
+ // Extracted from failurePatterns.js so feedbackEvents (and any future
4
+ // ledger-scanning diagnostics) share one glob/mtime discipline:
5
+ // * glob `*.json`, skip `gate-crash-counter.json`, `*.journal*`,
6
+ // `*.quarantine*`, bound results by mtime-desc order.
7
+
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ export const LEDGER_DIR_REL = path.join('.ukit', 'storage', 'cache', 'exec-ledger');
12
+
13
+ const NON_LEDGER_FILES = new Set(['gate-crash-counter.json']);
14
+
15
+ export function isLedgerFilename(name) {
16
+ if (typeof name !== 'string' || !name.endsWith('.json')) return false;
17
+ if (NON_LEDGER_FILES.has(name)) return false;
18
+ if (name.includes('.journal')) return false;
19
+ if (name.includes('.quarantine')) return false;
20
+ return true;
21
+ }
22
+
23
+ // Returns up to `limit` ledger filenames sorted by mtime descending.
24
+ // Missing/unreadable dir → [] (never throws).
25
+ export async function listLedgerFiles(dir, limit) {
26
+ let entries;
27
+ try {
28
+ entries = await fs.readdir(dir, { withFileTypes: true });
29
+ } catch {
30
+ return [];
31
+ }
32
+ const files = [];
33
+ for (const entry of entries) {
34
+ if (!entry.isFile() || !isLedgerFilename(entry.name)) continue;
35
+ files.push(entry.name);
36
+ }
37
+ const withMtime = await Promise.all(files.map(async (name) => {
38
+ try {
39
+ const stat = await fs.stat(path.join(dir, name));
40
+ return { name, mtimeMs: stat.mtimeMs };
41
+ } catch {
42
+ return { name, mtimeMs: 0 };
43
+ }
44
+ }));
45
+ withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
46
+ return withMtime.slice(0, Math.max(0, limit)).map((f) => f.name);
47
+ }