@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,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
+ }
@@ -0,0 +1,146 @@
1
+ // Route-outcome join (SPEC §4, SI-102): joins route-audit.json entries to
2
+ // per-request exec-ledger ledgers on requestKey and aggregates outcome stats
3
+ // per executionMode / taskType. Read-only; never throws.
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ const UNCLASSIFIED = '(unclassified)';
9
+ const LEDGER_EXCLUDE = new Set(['gate-crash-counter.json']);
10
+
11
+ function zeroedResult(extra = {}) {
12
+ return {
13
+ generatedAt: new Date().toISOString(),
14
+ ledgersScanned: 0,
15
+ auditRowsScanned: 0,
16
+ joined: 0,
17
+ unmatchedAudit: 0,
18
+ unmatchedLedger: 0,
19
+ byMode: {},
20
+ byTaskType: {},
21
+ joinCoverage: 0,
22
+ ...extra,
23
+ };
24
+ }
25
+
26
+ function emptyBucket() {
27
+ return {
28
+ routes: 0,
29
+ joined: 0,
30
+ writeOk: 0,
31
+ writeFail: 0,
32
+ verifyAttempted: 0,
33
+ verifyOk: 0,
34
+ verifyFail: 0,
35
+ rescue: 0,
36
+ stalled: 0,
37
+ };
38
+ }
39
+
40
+ async function listLedgerFiles(ledgerDir, limitLedgers) {
41
+ let dirents;
42
+ try {
43
+ dirents = await fs.readdir(ledgerDir, { withFileTypes: true });
44
+ } catch {
45
+ return [];
46
+ }
47
+ const candidates = [];
48
+ for (const dirent of dirents) {
49
+ if (!dirent.isFile()) continue;
50
+ const name = dirent.name;
51
+ if (!name.endsWith('.json')) continue;
52
+ if (LEDGER_EXCLUDE.has(name)) continue;
53
+ if (name.includes('.journal') || name.includes('.quarantine')) continue;
54
+ candidates.push(name);
55
+ }
56
+ const withMtime = await Promise.all(candidates.map(async (name) => {
57
+ try {
58
+ const stat = await fs.stat(path.join(ledgerDir, name));
59
+ return { name, mtimeMs: stat.mtimeMs };
60
+ } catch {
61
+ return { name, mtimeMs: 0 };
62
+ }
63
+ }));
64
+ withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
65
+ return withMtime.slice(0, Math.max(0, limitLedgers)).map((entry) => entry.name);
66
+ }
67
+
68
+ async function readJson(filePath) {
69
+ try {
70
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function bump(bucket, ledger, auditEntry) {
77
+ bucket.joined += 1;
78
+ if (ledger.writeSucceeded === true) bucket.writeOk += 1;
79
+ else if (ledger.writeAttempted === true) bucket.writeFail += 1;
80
+ if (ledger.verificationAttempted === true) bucket.verifyAttempted += 1;
81
+ if (ledger.verificationSucceeded === true) bucket.verifyOk += 1;
82
+ if (ledger.verificationFailed === true) bucket.verifyFail += 1;
83
+ if (auditEntry.rescueMode != null) bucket.rescue += 1;
84
+ if ((auditEntry.repeatCount ?? 0) >= 2 && ledger.writeSucceeded !== true) {
85
+ bucket.stalled += 1;
86
+ }
87
+ }
88
+
89
+ export async function collectRouteOutcomes(projectRoot, { limitLedgers = 500 } = {}) {
90
+ try {
91
+ const cacheDir = path.join(projectRoot, '.ukit', 'storage', 'cache');
92
+ const auditDoc = await readJson(path.join(cacheDir, 'route-audit.json'));
93
+ const auditEntries = Array.isArray(auditDoc?.entries) ? auditDoc.entries : [];
94
+
95
+ const ledgerDir = path.join(cacheDir, 'exec-ledger');
96
+ const ledgerFiles = await listLedgerFiles(ledgerDir, limitLedgers);
97
+ const ledgers = [];
98
+ for (const name of ledgerFiles) {
99
+ const doc = await readJson(path.join(ledgerDir, name));
100
+ if (doc && typeof doc === 'object') ledgers.push(doc);
101
+ }
102
+
103
+ const result = zeroedResult();
104
+ result.ledgersScanned = ledgers.length;
105
+ result.auditRowsScanned = auditEntries.length;
106
+
107
+ const auditByKey = new Map();
108
+ for (const entry of auditEntries) {
109
+ if (entry && typeof entry.requestKey === 'string' && entry.requestKey) {
110
+ auditByKey.set(entry.requestKey, entry);
111
+ }
112
+ }
113
+ const ledgerKeys = new Set();
114
+ for (const ledger of ledgers) {
115
+ if (ledger && typeof ledger.requestKey === 'string' && ledger.requestKey) {
116
+ ledgerKeys.add(ledger.requestKey);
117
+ }
118
+ }
119
+
120
+ for (const entry of auditByKey.values()) {
121
+ const modeKey = entry.executionMode ?? UNCLASSIFIED;
122
+ const taskKey = entry.taskType ?? UNCLASSIFIED;
123
+ const modeBucket = result.byMode[modeKey] ??= emptyBucket();
124
+ const taskBucket = result.byTaskType[taskKey] ??= emptyBucket();
125
+ modeBucket.routes += 1;
126
+ taskBucket.routes += 1;
127
+ const ledger = ledgers.find((candidate) => candidate.requestKey === entry.requestKey);
128
+ if (!ledger) continue;
129
+ result.joined += 1;
130
+ bump(modeBucket, ledger, entry);
131
+ bump(taskBucket, ledger, entry);
132
+ }
133
+
134
+ result.unmatchedAudit = auditByKey.size - result.joined;
135
+ const matchedLedgerKeys = new Set(
136
+ ledgers
137
+ .filter((ledger) => auditByKey.has(ledger?.requestKey))
138
+ .map((ledger) => ledger.requestKey),
139
+ );
140
+ result.unmatchedLedger = ledgerKeys.size - matchedLedgerKeys.size;
141
+ result.joinCoverage = result.joined / Math.max(1, auditByKey.size);
142
+ return result;
143
+ } catch (error) {
144
+ return zeroedResult({ error: error?.message ?? String(error) });
145
+ }
146
+ }
@@ -0,0 +1,158 @@
1
+ // skillAccuracy.js — per-skill trigger accuracy collector (SPEC C32 §5a).
2
+ //
3
+ // Joins route-audit entries carrying `skillIds` (FR-202) to exec-ledger
4
+ // ledgers via `requestKey` (same join discipline as routeOutcomes.js) and
5
+ // aggregates per-skill counters:
6
+ // triggers = audit rows listing the skill
7
+ // joined = those matched to a ledger
8
+ // accuracy = joined === 0 ? null : writeOk / joined
9
+ // verifyOk/verifyFail/rescue reported separately for nuance.
10
+ // Skills with triggers >= 2 && accuracy !== null && accuracy < 0.5 are also
11
+ // copied into `lowAccuracy` for quick scanning.
12
+ //
13
+ // Contracts: NEVER THROWS — missing/malformed telemetry → zeroed result with
14
+ // `error` field. Only side effect is the advisory artifact
15
+ // `.ukit/storage/learning/skill-accuracy.json` (tmp+rename, non-fatal).
16
+ // Gated by config `learning?.feedback?.enabled !== false` (default true).
17
+
18
+ import fs from 'node:fs/promises';
19
+ import path from 'node:path';
20
+ import { listLedgerFiles, LEDGER_DIR_REL } from './ledgerFiles.js';
21
+
22
+ const CACHE_DIR_REL = path.join('.ukit', 'storage', 'cache');
23
+ const AUDIT_REL = path.join(CACHE_DIR_REL, 'route-audit.json');
24
+ const ARTIFACT_REL = path.join('.ukit', 'storage', 'learning', 'skill-accuracy.json');
25
+ const CONFIG_REL = path.join('.ukit', 'storage', 'config.json');
26
+ const MAX_SKILL_IDS = 8;
27
+
28
+ function isObject(value) {
29
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
30
+ }
31
+
32
+ function zeroedResult(extra = {}) {
33
+ return {
34
+ generatedAt: new Date().toISOString(),
35
+ auditRowsScanned: 0,
36
+ ledgersScanned: 0,
37
+ joined: 0,
38
+ skills: {},
39
+ lowAccuracy: [],
40
+ ...extra,
41
+ };
42
+ }
43
+
44
+ function emptySkillBucket() {
45
+ return {
46
+ triggers: 0,
47
+ joined: 0,
48
+ writeOk: 0,
49
+ writeFail: 0,
50
+ verifyOk: 0,
51
+ verifyFail: 0,
52
+ rescue: 0,
53
+ accuracy: null,
54
+ };
55
+ }
56
+
57
+ async function readJson(filePath) {
58
+ try {
59
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ // Default true when `learning`/`feedback` is absent (namespace lands in TASK-232).
66
+ async function feedbackEnabled(projectRoot) {
67
+ const config = await readJson(path.join(projectRoot, CONFIG_REL));
68
+ return config?.learning?.feedback?.enabled !== false;
69
+ }
70
+
71
+ function skillIdsOf(entry) {
72
+ if (!Array.isArray(entry?.skillIds)) return [];
73
+ return entry.skillIds
74
+ .filter((id) => typeof id === 'string' && id)
75
+ .slice(0, MAX_SKILL_IDS);
76
+ }
77
+
78
+ async function writeArtifact(filePath, result) {
79
+ const dir = path.dirname(filePath);
80
+ const tmp = path.join(dir, `.skill-accuracy-${process.pid}.tmp`);
81
+ try {
82
+ await fs.mkdir(dir, { recursive: true });
83
+ await fs.writeFile(tmp, JSON.stringify(result, null, 2));
84
+ await fs.rename(tmp, filePath);
85
+ } catch {
86
+ await fs.rm(tmp, { force: true }).catch(() => {});
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Collect per-skill trigger accuracy by joining route-audit `skillIds` to
92
+ * exec-ledger ledgers on `requestKey`.
93
+ *
94
+ * @param {string} projectRoot repository root containing `.ukit/storage/`.
95
+ * @param {{ limitLedgers?: number }} [options]
96
+ * @returns {Promise<object>} never throws.
97
+ */
98
+ export async function collectSkillAccuracy(projectRoot, { limitLedgers = 500 } = {}) {
99
+ const result = zeroedResult();
100
+ try {
101
+ if (!(await feedbackEnabled(projectRoot))) {
102
+ return result;
103
+ }
104
+
105
+ const auditDoc = await readJson(path.join(projectRoot, AUDIT_REL));
106
+ const auditEntries = Array.isArray(auditDoc?.entries)
107
+ ? auditDoc.entries.filter(isObject)
108
+ : [];
109
+ result.auditRowsScanned = auditEntries.length;
110
+
111
+ const ledgerDir = path.join(projectRoot, LEDGER_DIR_REL);
112
+ const ledgerNames = await listLedgerFiles(ledgerDir, limitLedgers);
113
+ const ledgerByKey = new Map();
114
+ for (const name of ledgerNames) {
115
+ const ledger = await readJson(path.join(ledgerDir, name));
116
+ if (!isObject(ledger)) continue;
117
+ result.ledgersScanned += 1;
118
+ if (typeof ledger.requestKey === 'string' && ledger.requestKey) {
119
+ ledgerByKey.set(ledger.requestKey, ledger);
120
+ }
121
+ }
122
+
123
+ for (const entry of auditEntries) {
124
+ const ids = skillIdsOf(entry);
125
+ if (ids.length === 0) continue;
126
+ const ledger = typeof entry.requestKey === 'string'
127
+ ? ledgerByKey.get(entry.requestKey)
128
+ : undefined;
129
+ for (const id of ids) {
130
+ const bucket = result.skills[id] ??= emptySkillBucket();
131
+ bucket.triggers += 1;
132
+ if (!ledger) continue;
133
+ bucket.joined += 1;
134
+ if (ledger.writeSucceeded === true) bucket.writeOk += 1;
135
+ else if (ledger.writeAttempted === true || ledger.writeSucceeded === false) {
136
+ bucket.writeFail += 1;
137
+ }
138
+ if (ledger.verificationSucceeded === true) bucket.verifyOk += 1;
139
+ if (ledger.verificationFailed === true) bucket.verifyFail += 1;
140
+ if (entry.rescueMode != null) bucket.rescue += 1;
141
+ }
142
+ if (ledger) result.joined += 1;
143
+ }
144
+
145
+ for (const [id, bucket] of Object.entries(result.skills)) {
146
+ bucket.accuracy = bucket.joined === 0 ? null : bucket.writeOk / bucket.joined;
147
+ if (bucket.triggers >= 2 && bucket.accuracy !== null && bucket.accuracy < 0.5) {
148
+ result.lowAccuracy.push({ id, accuracy: bucket.accuracy });
149
+ }
150
+ }
151
+ result.lowAccuracy.sort((a, b) => a.accuracy - b.accuracy);
152
+ } catch (error) {
153
+ result.error = error?.message ?? String(error);
154
+ }
155
+
156
+ await writeArtifact(path.join(projectRoot, ARTIFACT_REL), result);
157
+ return result;
158
+ }
@@ -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
+ }