@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,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
- ukit_emit_input_degraded failclosed "dangerous-command gate"
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 must be announced (SPEC §8), never silently passed.
22
- ukit_input_degraded && ukit_emit_input_degraded failclosed "dangerous-command gate"
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 — under a
56
- # direct host the JSON only reaches the permission pipeline on exit 0; the
57
- # omp chain needs exit 2 (marker exported by hook-chain-runner.mjs).
58
- printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","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. UKit defers this to a human decision."}}'
59
- echo "BLOCKED: dangerous-command gate could not inspect a truncated/stalled payload; deferred to human." >&2
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