@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.
- package/CHANGELOG.md +70 -0
- package/manifests/documentation.yaml +24 -2
- package/manifests/instructionRules.yaml +62 -0
- package/manifests/platform.full.yaml +11 -0
- package/package.json +1 -1
- package/scripts/perf/audit-perf.mjs +920 -0
- package/src/cli/commands/doctor.js +23 -4
- package/src/cli/commands/feedback.js +97 -0
- package/src/cli/commands/memory.js +250 -1
- package/src/cli/commands/metrics.js +109 -1
- package/src/cli/index.js +7 -0
- package/src/core/codeintel/retriever.js +65 -0
- package/src/core/diffPlan.js +8 -0
- package/src/core/memory/store.js +7 -2
- package/src/core/ompConfigMerge.js +222 -0
- package/src/core/runInstallPipeline.js +11 -0
- package/src/core/runtimeConfig.js +64 -0
- package/src/core/unattendedDoctor.js +227 -0
- package/src/diagnostics/failurePatterns.js +1 -34
- package/src/diagnostics/feedbackEvents.js +196 -0
- package/src/diagnostics/laneStats.js +111 -0
- package/src/diagnostics/ledgerFiles.js +47 -0
- package/src/diagnostics/skillAccuracy.js +158 -0
- package/src/learning/patternProposals.js +151 -0
- package/src/learning/tuning.js +213 -0
- package/templates/.claude/hooks/block-dangerous.sh +76 -9
- package/templates/.claude/hooks/context-hardcap-gate.sh +26 -8
- package/templates/.claude/hooks/project-important.sh +70 -9
- package/templates/.claude/hooks/protect-files.sh +24 -7
- package/templates/.claude/hooks/sensitive-data-guard.sh +57 -5
- package/templates/.claude/hooks/session-episode.sh +84 -0
- package/templates/.claude/settings.json +29 -113
- package/templates/.claude/ukit/index/route-task.mjs +6 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +217 -10
- package/templates/.claude/ukit/runtime/hook-input.sh +119 -0
- package/templates/.omp/config.yml +32 -4
- package/templates/.omp/hooks/pre/ukit-bridge.js +12 -1
- package/templates/AGENTS.md +22 -10
- package/templates/CLAUDE.md +22 -10
- package/templates/adapter-presets/opencode/opencode.template.json +1 -1
- package/templates/docs/UKIT_INTERNALS.md +17 -0
- package/templates/instructions/core.md +22 -10
- package/templates/instructions/layout.yaml +12 -12
- package/templates/ukit/storage/config.json +20 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -16,10 +16,22 @@ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
|
16
16
|
if [ "$__ukit_main_stage_rc" -ne 0 ]; then
|
|
17
17
|
# Infra failure during staging (mktemp/truncate): payload was never
|
|
18
18
|
# inspected — announce the degrade (SPEC §8), never a silent pass.
|
|
19
|
-
|
|
19
|
+
# TASK-003: `deny`, not `ask` — ask auto-approves under bypassPermissions.
|
|
20
|
+
ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so dangerous-command gate cannot prove it safe."
|
|
20
21
|
fi
|
|
21
|
-
# BUG-C21-01: a truncated/stalled staged payload
|
|
22
|
-
|
|
22
|
+
# BUG-C21-01 / TASK-003: a truncated/stalled staged payload used to be announced
|
|
23
|
+
# as a blanket `ask` — but `ask` auto-approves under bypassPermissions (direct
|
|
24
|
+
# host) = the gate silently skipped. Now the degraded path first attempts a
|
|
25
|
+
# bounded salvage of tool_input.command: a COMPLETE recovered command still gets
|
|
26
|
+
# the normal verdict below; an unrecoverable/incomplete one is refused with
|
|
27
|
+
# `deny` (the only verdict both hosts treat as closed — review R1-1).
|
|
28
|
+
if ukit_input_degraded; then
|
|
29
|
+
if UKIT_SALVAGED_COMMAND="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_input.command")"; then
|
|
30
|
+
UKIT_SALVAGE_ACTIVE=1
|
|
31
|
+
else
|
|
32
|
+
ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound) and the command could not be recovered, so dangerous-command gate cannot prove it safe."
|
|
33
|
+
fi
|
|
34
|
+
fi
|
|
23
35
|
else
|
|
24
36
|
# Runtime helper missing (pre-install tree): the SAME bounded staging,
|
|
25
37
|
# inline - `cat >/dev/null` used to block forever on a producer that never
|
|
@@ -52,16 +64,22 @@ else
|
|
|
52
64
|
# path's ukit_emit_input_degraded.
|
|
53
65
|
rm -f "$UKIT_INPUT_FILE"
|
|
54
66
|
UKIT_INPUT_FILE=""
|
|
55
|
-
# TASK-223: same emit shape as ukit_emit_permission_decision —
|
|
56
|
-
# direct host the JSON only reaches the permission pipeline on exit
|
|
57
|
-
# omp chain needs exit 2 (marker exported by hook-chain-runner.mjs).
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
# TASK-223 + TASK-003: same emit shape as ukit_emit_permission_decision —
|
|
68
|
+
# under a direct host the JSON only reaches the permission pipeline on exit
|
|
69
|
+
# 0; the omp chain needs exit 2 (marker exported by hook-chain-runner.mjs).
|
|
70
|
+
# The decision is `deny`, never `ask`: bypassPermissions auto-approves ask.
|
|
71
|
+
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so dangerous-command gate cannot prove it safe."}}'
|
|
72
|
+
echo "BLOCKED: dangerous-command gate could not inspect a truncated/stalled payload; refused fail-closed." >&2
|
|
60
73
|
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
|
|
61
74
|
exit 0
|
|
62
75
|
fi
|
|
63
76
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
64
77
|
fi
|
|
78
|
+
if [ "${UKIT_SALVAGE_ACTIVE:-0}" = "1" ]; then
|
|
79
|
+
# TASK-003: degraded path already recovered a COMPLETE command — skip the
|
|
80
|
+
# normal extraction (the staged prefix would not parse anyway).
|
|
81
|
+
COMMAND="$UKIT_SALVAGED_COMMAND"
|
|
82
|
+
else
|
|
65
83
|
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
66
84
|
# jq is not installed on stock macOS. Keep jq as the low-latency normal path, but use
|
|
67
85
|
# UKit's required Node runtime as a fallback so its absence cannot silently disable the gate.
|
|
@@ -81,6 +99,7 @@ process.stdin.on("end", () => {
|
|
|
81
99
|
});
|
|
82
100
|
' 2>/dev/null)
|
|
83
101
|
fi
|
|
102
|
+
fi
|
|
84
103
|
|
|
85
104
|
if [ -z "$COMMAND" ]; then
|
|
86
105
|
exit 0
|
|
@@ -168,6 +187,37 @@ SAFE_ONE_TARGET_REGEX='^(\./)?(dist|build|coverage|\.next|\.nuxt|\.turbo|tmp|tem
|
|
|
168
187
|
UNSAFE_ONE_TARGET_REGEX='^(/.*|~|~/.*|\.\.|\.\./.*|\.)$'
|
|
169
188
|
RM_WORD_REGEX=$'(^|[;&|[:space:]"\x27])rm([[:space:]"\x27]|$)'
|
|
170
189
|
|
|
190
|
+
# TASK-013 / SPEC §10: canonical-path containment for the name allowlist. A
|
|
191
|
+
# target that passes SAFE_ONE_TARGET_REGEX must ALSO resolve inside
|
|
192
|
+
# realpath(projectRoot) — a `dist` symlinked outside the project (or a `..`
|
|
193
|
+
# segment) downgrades the segment to the generic verdict, never allow.
|
|
194
|
+
#
|
|
195
|
+
# ukit_canonical_path mirrors GNU `realpath -m` semantics portably:
|
|
196
|
+
# 1. `realpath -m` (GNU/coreutils) when supported;
|
|
197
|
+
# 2. ONE documented fallback — stock macOS `realpath` cannot resolve
|
|
198
|
+
# nonexistent paths, so resolve the deepest existing ancestor and append
|
|
199
|
+
# the lexical remainder (equivalent for containment);
|
|
200
|
+
# 3. `realpath` itself absent → empty output → fail closed (unresolved-
|
|
201
|
+
# generic), never silently allow.
|
|
202
|
+
ukit_canonical_path() {
|
|
203
|
+
__u_p="$1"
|
|
204
|
+
__u_out=$(realpath -m -- "$__u_p" 2>/dev/null) && [ -n "$__u_out" ] && { printf '%s' "$__u_out"; return 0; }
|
|
205
|
+
__u_rest=""
|
|
206
|
+
while [ -n "$__u_p" ] && [ "$__u_p" != "/" ] && [ ! -e "$__u_p" ] && [ ! -L "$__u_p" ]; do
|
|
207
|
+
__u_rest="/${__u_p##*/}${__u_rest}"
|
|
208
|
+
__u_p="${__u_p%/*}"
|
|
209
|
+
done
|
|
210
|
+
[ -z "$__u_p" ] && __u_p="/"
|
|
211
|
+
__u_out=$(realpath -- "$__u_p" 2>/dev/null) || return 1
|
|
212
|
+
printf '%s%s' "$__u_out" "$__u_rest"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# Project-root canonical prefix, resolved once before the segment loop. Targets
|
|
216
|
+
# are interpreted relative to the project root (the host's rm cwd), not the
|
|
217
|
+
# hook's own cwd. An unresolvable root fails closed: no allowlist target can
|
|
218
|
+
# prove containment against an unknown root.
|
|
219
|
+
UKIT_PROJECT_ROOT_CANON=$(ukit_canonical_path "${CLAUDE_PROJECT_DIR:-$PWD}")
|
|
220
|
+
|
|
171
221
|
RM_VERDICT_UNSAFE=0
|
|
172
222
|
RM_VERDICT_GENERIC=0
|
|
173
223
|
RM_DIRECT_SEEN=0
|
|
@@ -201,7 +251,24 @@ while IFS= read -r segment; do
|
|
|
201
251
|
seg_any_unsafe=1
|
|
202
252
|
seg_all_safe=0
|
|
203
253
|
elif printf '%s' "$target" | grep -qE "$SAFE_ONE_TARGET_REGEX"; then
|
|
204
|
-
:
|
|
254
|
+
# Containment gate (TASK-013): the allowlisted NAME is not enough — the
|
|
255
|
+
# canonical target must live inside the canonical project root. `..`
|
|
256
|
+
# segments and unresolvable/missing realpath fail closed to generic.
|
|
257
|
+
case "$target" in
|
|
258
|
+
*..*) seg_all_safe=0 ;;
|
|
259
|
+
*)
|
|
260
|
+
case "$target" in /*) __t_abs="$target" ;; *) __t_abs="$UKIT_PROJECT_ROOT_CANON/$target" ;; esac
|
|
261
|
+
__t_canon=$(ukit_canonical_path "$__t_abs")
|
|
262
|
+
if [ -z "$UKIT_PROJECT_ROOT_CANON" ] || [ -z "$__t_canon" ]; then
|
|
263
|
+
seg_all_safe=0
|
|
264
|
+
else
|
|
265
|
+
case "$__t_canon" in
|
|
266
|
+
"$UKIT_PROJECT_ROOT_CANON"/*) : ;;
|
|
267
|
+
*) seg_all_safe=0 ;;
|
|
268
|
+
esac
|
|
269
|
+
fi
|
|
270
|
+
;;
|
|
271
|
+
esac
|
|
205
272
|
else
|
|
206
273
|
seg_all_safe=0
|
|
207
274
|
fi
|