@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.
- package/manifests/platform.full.yaml +11 -0
- package/package.json +1 -1
- package/src/cli/commands/feedback.js +97 -0
- package/src/cli/commands/memory.js +250 -1
- package/src/cli/commands/metrics.js +235 -0
- package/src/cli/index.js +14 -0
- package/src/core/codeintel/retriever.js +65 -0
- package/src/core/memory/store.js +7 -2
- package/src/core/runtimeConfig.js +53 -0
- package/src/diagnostics/failurePatterns.js +154 -0
- package/src/diagnostics/feedbackEvents.js +196 -0
- package/src/diagnostics/laneStats.js +111 -0
- package/src/diagnostics/ledgerFiles.js +47 -0
- package/src/diagnostics/routeOutcomes.js +146 -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 +29 -5
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/protect-files.sh +26 -3
- package/templates/.claude/hooks/session-episode.sh +84 -0
- package/templates/.claude/settings.json +11 -0
- package/templates/.claude/ukit/index/route-task.mjs +63 -6
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +9 -1
- package/templates/.claude/ukit/runtime/hook-input.sh +26 -3
- package/templates/docs/UKIT_INTERNALS.md +17 -0
- package/templates/ukit/storage/config.json +18 -0
|
@@ -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
|
+
}
|
|
@@ -52,9 +52,13 @@ else
|
|
|
52
52
|
# path's ukit_emit_input_degraded.
|
|
53
53
|
rm -f "$UKIT_INPUT_FILE"
|
|
54
54
|
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).
|
|
55
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."}}'
|
|
56
59
|
echo "BLOCKED: dangerous-command gate could not inspect a truncated/stalled payload; deferred to human." >&2
|
|
57
|
-
exit 2
|
|
60
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
|
|
61
|
+
exit 0
|
|
58
62
|
fi
|
|
59
63
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
60
64
|
fi
|
|
@@ -115,15 +119,35 @@ DANGEROUS_PATTERNS=(
|
|
|
115
119
|
"dd if=/dev/"
|
|
116
120
|
)
|
|
117
121
|
|
|
118
|
-
# Dangerous detections surface as a structured
|
|
119
|
-
#
|
|
122
|
+
# Dangerous detections surface as a structured decision (stdout JSON). TASK-223:
|
|
123
|
+
# emission is centralized in ukit_emit_permission_decision — exit 0 on the direct
|
|
124
|
+
# host (where a non-zero exit would discard the JSON as a bare "hook error"),
|
|
125
|
+
# exit 2 inside the omp chain (whose bridge only parses stdout on code 2).
|
|
126
|
+
# Probe verdict (2026-09-20, spec step 4): this repo runs
|
|
127
|
+
# permissions.defaultMode=bypassPermissions, where an exit-0 `ask` auto-approves —
|
|
128
|
+
# dead for refusals. So the direct host gets `deny` (a proper denied surface, still
|
|
129
|
+
# fail-closed); the omp chain keeps `ask` (the host boundary prompts the human).
|
|
120
130
|
# The raw command and the matched pattern are NEVER echoed — arguments and comments can
|
|
121
131
|
# carry secrets, and the pattern text itself restates the dangerous command.
|
|
122
132
|
emit_dangerous_decision() {
|
|
123
133
|
REASON="$1"
|
|
124
|
-
|
|
134
|
+
if command -v ukit_emit_permission_decision >/dev/null 2>&1; then
|
|
135
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
136
|
+
ukit_emit_permission_decision ask "$REASON"
|
|
137
|
+
else
|
|
138
|
+
ukit_emit_permission_decision deny "$REASON"
|
|
139
|
+
fi
|
|
140
|
+
fi
|
|
141
|
+
# Helper unavailable (pre-install tree): keep the safe inline parity — the
|
|
142
|
+
# direct host gets deny + exit 0, the chain gets ask + exit 2.
|
|
143
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
144
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
|
|
145
|
+
echo "BLOCKED: $REASON" >&2
|
|
146
|
+
exit 2
|
|
147
|
+
fi
|
|
148
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}"
|
|
125
149
|
echo "BLOCKED: $REASON" >&2
|
|
126
|
-
exit
|
|
150
|
+
exit 0
|
|
127
151
|
}
|
|
128
152
|
|
|
129
153
|
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
|
|
@@ -80,9 +80,12 @@ else
|
|
|
80
80
|
# path's ukit_emit_input_degraded.
|
|
81
81
|
rm -f "$UKIT_INPUT_FILE"
|
|
82
82
|
UKIT_INPUT_FILE=""
|
|
83
|
+
# TASK-223: same emit shape as ukit_emit_permission_decision — exit 0 on the
|
|
84
|
+
# direct host (non-zero discards stdout there), exit 2 inside the omp chain.
|
|
83
85
|
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 context hard-cap gate cannot prove it safe. UKit defers this to a human decision."}}'
|
|
84
86
|
echo "BLOCKED: context hard-cap gate could not inspect a truncated/stalled payload; deferred to human." >&2
|
|
85
|
-
exit 2
|
|
87
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
|
|
88
|
+
exit 0
|
|
86
89
|
fi
|
|
87
90
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
88
91
|
fi
|
|
@@ -52,9 +52,12 @@ else
|
|
|
52
52
|
# path's ukit_emit_input_degraded.
|
|
53
53
|
rm -f "$UKIT_INPUT_FILE"
|
|
54
54
|
UKIT_INPUT_FILE=""
|
|
55
|
+
# TASK-223: same emit shape as ukit_emit_permission_decision — exit 0 on the
|
|
56
|
+
# direct host (non-zero discards stdout there), exit 2 inside the omp chain.
|
|
55
57
|
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 protected-file gate cannot prove it safe. UKit defers this to a human decision."}}'
|
|
56
58
|
echo "BLOCKED: protected-file gate could not inspect a truncated/stalled payload; deferred to human." >&2
|
|
57
|
-
exit 2
|
|
59
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
|
|
60
|
+
exit 0
|
|
58
61
|
fi
|
|
59
62
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
60
63
|
fi
|
|
@@ -112,9 +115,29 @@ if [ -n "$PROTECTED_MATCH" ]; then
|
|
|
112
115
|
# A stderr-only refusal can look like a silent stall when the host does not render
|
|
113
116
|
# hook stderr. Do not expose the path/pattern on stdout: the generic explanation is
|
|
114
117
|
# enough for the user and avoids leaking a potentially sensitive filename.
|
|
115
|
-
|
|
118
|
+
# TASK-223: emit through the centralized helper — exit 0 on the direct host so the
|
|
119
|
+
# structured decision survives (non-zero discards stdout there), exit 2 in the omp
|
|
120
|
+
# chain whose bridge parses stdout only on code 2. Probe verdict (2026-09-20):
|
|
121
|
+
# under bypassPermissions an exit-0 `ask` auto-approves — dead for a protected-file
|
|
122
|
+
# refusal — so the direct host emits `deny`; the chain keeps `ask` for the human.
|
|
123
|
+
__ukit_protect_reason='UKit protected-file guard blocked this edit. Ask the user to modify the protected file manually.'
|
|
124
|
+
echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
|
|
125
|
+
if command -v ukit_emit_permission_decision >/dev/null 2>&1; then
|
|
126
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
127
|
+
ukit_emit_permission_decision ask "$__ukit_protect_reason"
|
|
128
|
+
else
|
|
129
|
+
ukit_emit_permission_decision deny "$__ukit_protect_reason"
|
|
130
|
+
fi
|
|
131
|
+
fi
|
|
132
|
+
# Helper unavailable (pre-install tree): same decision/exit matrix inline.
|
|
133
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
134
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$__ukit_protect_reason\"}}"
|
|
135
|
+
echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
|
|
136
|
+
exit 2
|
|
137
|
+
fi
|
|
138
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$__ukit_protect_reason\"}}"
|
|
116
139
|
echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
|
|
117
|
-
exit
|
|
140
|
+
exit 0
|
|
118
141
|
fi
|
|
119
142
|
|
|
120
143
|
exit 0
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# session-episode.sh — SessionEnd hook: append a session episode to memory v2.
|
|
3
|
+
#
|
|
4
|
+
# Contract (SPEC §7b, TASK-231):
|
|
5
|
+
# * ALWAYS exits 0 — a session must never be blocked from ending.
|
|
6
|
+
# * No decision JSON — UKIT_HOOK_CHAIN_RUNNER-safe.
|
|
7
|
+
# * Runs `ukit memory episode` ONLY when
|
|
8
|
+
# .ukit/storage/config.json → learning.episodes.autoWrite === true
|
|
9
|
+
# (read defensively — the namespace may not exist yet) AND `ukit` resolves
|
|
10
|
+
# on PATH. Episode writes dedupe on meta.ledgerKey, so double-invocation is
|
|
11
|
+
# harmless.
|
|
12
|
+
# * Exports UKIT_SESSION_ID from the stdin payload's session_id so the CLI
|
|
13
|
+
# resolves the same exec-ledger file the session wrote.
|
|
14
|
+
|
|
15
|
+
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
16
|
+
CONFIG_FILE="$PROJECT_ROOT/.ukit/storage/config.json"
|
|
17
|
+
|
|
18
|
+
# Bounded stdin read (existing hook style): cap +1 byte in background so a
|
|
19
|
+
# producer that never closes the pipe cannot park the session teardown.
|
|
20
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-episode-in.XXXXXX")" || exit 0
|
|
21
|
+
if [ -e /dev/fd/0 ]; then
|
|
22
|
+
exec 8<&0
|
|
23
|
+
head -c 65537 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
24
|
+
UKIT_HEAD_PID=$!
|
|
25
|
+
# UKIT_HOOK_STAGE_MS:-2000 — bounded wait for the staged payload.
|
|
26
|
+
( sleep 2; kill "$UKIT_HEAD_PID" 2>/dev/null ) &
|
|
27
|
+
UKIT_WATCH_PID=$!
|
|
28
|
+
wait "$UKIT_HEAD_PID" 2>/dev/null
|
|
29
|
+
kill "$UKIT_WATCH_PID" 2>/dev/null
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Gate + session extraction in one bounded node step; all failures → exit 0.
|
|
33
|
+
# UKIT_HOOK_DEADLINE_MS:-5000 — keeps the registered 8s timeout comfortably
|
|
34
|
+
# above stage (2s) + deadline (5s) + margin (1s).
|
|
35
|
+
node -e '
|
|
36
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 5000;
|
|
37
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
38
|
+
|
|
39
|
+
const fs = require("fs");
|
|
40
|
+
const { spawnSync } = require("child_process");
|
|
41
|
+
|
|
42
|
+
const inputFile = process.argv[1];
|
|
43
|
+
const configPath = process.argv[2];
|
|
44
|
+
const projectRoot = process.argv[3];
|
|
45
|
+
|
|
46
|
+
function readJson(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let payload = {};
|
|
55
|
+
try {
|
|
56
|
+
const raw = fs.readFileSync(inputFile, "utf8");
|
|
57
|
+
payload = JSON.parse(raw || "{}") || {};
|
|
58
|
+
} catch {}
|
|
59
|
+
|
|
60
|
+
const config = readJson(configPath) || {};
|
|
61
|
+
if (config?.learning?.episodes?.autoWrite !== true) process.exit(0);
|
|
62
|
+
|
|
63
|
+
const probe = spawnSync("ukit", ["--version"], {
|
|
64
|
+
shell: true, stdio: "ignore", timeout: 2000,
|
|
65
|
+
});
|
|
66
|
+
if (probe.error || probe.status === null || probe.status === undefined) process.exit(0);
|
|
67
|
+
|
|
68
|
+
const sessionId = typeof payload.session_id === "string" && payload.session_id.trim()
|
|
69
|
+
? payload.session_id.trim()
|
|
70
|
+
: null;
|
|
71
|
+
|
|
72
|
+
spawnSync("ukit", ["memory", "episode"], {
|
|
73
|
+
shell: true,
|
|
74
|
+
stdio: "ignore",
|
|
75
|
+
timeout: 4000,
|
|
76
|
+
cwd: projectRoot,
|
|
77
|
+
env: sessionId
|
|
78
|
+
? { ...process.env, UKIT_SESSION_ID: sessionId }
|
|
79
|
+
: process.env,
|
|
80
|
+
});
|
|
81
|
+
' "$UKIT_INPUT_FILE" "$CONFIG_FILE" "$PROJECT_ROOT" 2>/dev/null || true
|
|
82
|
+
|
|
83
|
+
rm -f "$UKIT_INPUT_FILE" 2>/dev/null || true
|
|
84
|
+
exit 0
|
|
@@ -3793,6 +3793,12 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
|
|
|
3793
3793
|
}),
|
|
3794
3794
|
targetFile: route?.routingContext?.targetFile ?? null,
|
|
3795
3795
|
taskType: routingContext.taskType ?? null,
|
|
3796
|
+
// FR-202a (TASK-228): active-skill ids for phase-2 skill-accuracy roll-ups.
|
|
3797
|
+
// Telemetry only — deliberately excluded from dedupeKey/fingerprint inputs.
|
|
3798
|
+
skillIds: (route?.activeSkills ?? state?.activeSkills ?? [])
|
|
3799
|
+
.map((s) => s?.id)
|
|
3800
|
+
.filter(Boolean)
|
|
3801
|
+
.slice(0, 8),
|
|
3796
3802
|
executionMode: routeSummary.executionMode ?? null,
|
|
3797
3803
|
competingMode: routeSummary?.approachSelector?.competingMode ?? null,
|
|
3798
3804
|
competingScoreGap: routeSummary?.approachSelector?.competingScoreGap ?? null,
|
|
@@ -3872,6 +3878,13 @@ function getMemoryTimestamp(item) {
|
|
|
3872
3878
|
|
|
3873
3879
|
function buildMemorySegments(item) {
|
|
3874
3880
|
const content = item.content ?? {};
|
|
3881
|
+
if (item.type === 'record') {
|
|
3882
|
+
return [
|
|
3883
|
+
{ text: content.recordType, weight: 1 },
|
|
3884
|
+
{ text: content.text, weight: 3 },
|
|
3885
|
+
];
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3875
3888
|
if (item.type === 'project') {
|
|
3876
3889
|
return [
|
|
3877
3890
|
{ text: content.name, weight: 2 },
|
|
@@ -3964,6 +3977,7 @@ async function listMemoryItems(rootDir) {
|
|
|
3964
3977
|
const userMemory = (await readJson(path.join(runtimeRoot, 'user.json'), null)) ?? { preferences: {}, rules: [] };
|
|
3965
3978
|
const projectMemories = await readDirectoryJsonItems(path.join(runtimeRoot, 'projects'));
|
|
3966
3979
|
const sessionMemories = await readDirectoryJsonItems(path.join(runtimeRoot, 'sessions'));
|
|
3980
|
+
const recordMemories = await listMemoryV2RecordItems(runtimeRoot);
|
|
3967
3981
|
|
|
3968
3982
|
return [
|
|
3969
3983
|
{
|
|
@@ -3981,11 +3995,47 @@ async function listMemoryItems(rootDir) {
|
|
|
3981
3995
|
type: 'session',
|
|
3982
3996
|
content: item.content,
|
|
3983
3997
|
})),
|
|
3998
|
+
...recordMemories,
|
|
3984
3999
|
];
|
|
3985
4000
|
}
|
|
3986
4001
|
|
|
4002
|
+
const MEMORY_V2_RECORD_POOL_LIMIT = 20;
|
|
4003
|
+
|
|
4004
|
+
// v2 lane (SI-101): approved records live in a single records.json document.
|
|
4005
|
+
// Missing/malformed store → [] (same never-throw discipline as
|
|
4006
|
+
// readDirectoryJsonItems). Candidates: status 'active' AND (valid_until == null
|
|
4007
|
+
// OR valid_until > now), capped at 20 newest by created_at.
|
|
4008
|
+
async function listMemoryV2RecordItems(runtimeRoot) {
|
|
4009
|
+
const doc = await readJson(path.join(runtimeRoot, 'v2', 'records.json'), null);
|
|
4010
|
+
const records = Array.isArray(doc?.records) ? doc.records : [];
|
|
4011
|
+
const now = Date.now();
|
|
4012
|
+
|
|
4013
|
+
return records
|
|
4014
|
+
.filter((record) => record && typeof record === 'object')
|
|
4015
|
+
.filter((record) => record.status === 'active')
|
|
4016
|
+
.filter((record) => record.valid_until == null || record.valid_until > now)
|
|
4017
|
+
.sort((left, right) => (right.created_at ?? 0) - (left.created_at ?? 0))
|
|
4018
|
+
.slice(0, MEMORY_V2_RECORD_POOL_LIMIT)
|
|
4019
|
+
.map((record) => ({
|
|
4020
|
+
id: `record:${record.id}`,
|
|
4021
|
+
type: 'record',
|
|
4022
|
+
content: {
|
|
4023
|
+
recordType: record.type,
|
|
4024
|
+
text: record.text,
|
|
4025
|
+
projectId: record.project_id,
|
|
4026
|
+
updatedAt: record.created_at,
|
|
4027
|
+
},
|
|
4028
|
+
}));
|
|
4029
|
+
}
|
|
4030
|
+
|
|
3987
4031
|
function buildPreviousContextSnippet(item) {
|
|
3988
4032
|
const content = item.content ?? {};
|
|
4033
|
+
if (item.type === 'record') {
|
|
4034
|
+
const text = String(content.text ?? '').trim();
|
|
4035
|
+
const truncated = text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
4036
|
+
return `[${content.recordType ?? 'record'}] ${truncated}`;
|
|
4037
|
+
}
|
|
4038
|
+
|
|
3989
4039
|
if (item.type === 'project') {
|
|
3990
4040
|
const decisions = compactPhraseList((content.decisions ?? []).map((decision) => decision.what), { limit: 1 });
|
|
3991
4041
|
const rules = compactPhraseList(content.activeRules ?? [], { limit: 1 });
|
|
@@ -4036,11 +4086,18 @@ async function buildPreviousContextSnapshot({ rootDir = process.cwd(), routingCo
|
|
|
4036
4086
|
const queryTokens = tokenize(taskQuery);
|
|
4037
4087
|
const rankedItems = items
|
|
4038
4088
|
.filter((item) => item.type !== 'user')
|
|
4039
|
-
.filter((item) =>
|
|
4040
|
-
item.type === 'project'
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4089
|
+
.filter((item) => {
|
|
4090
|
+
if (item.type === 'project') {
|
|
4091
|
+
return item.content?.id === projectId;
|
|
4092
|
+
}
|
|
4093
|
+
if (item.type === 'session') {
|
|
4094
|
+
return item.content?.projectId === projectId;
|
|
4095
|
+
}
|
|
4096
|
+
if (item.type === 'record') {
|
|
4097
|
+
return item.content?.projectId == null || item.content?.projectId === projectId;
|
|
4098
|
+
}
|
|
4099
|
+
return true;
|
|
4100
|
+
})
|
|
4044
4101
|
.map((item) => ({
|
|
4045
4102
|
item,
|
|
4046
4103
|
score: scoreMemoryItem(item, queryTokens),
|
|
@@ -4060,7 +4117,7 @@ async function buildPreviousContextSnapshot({ rootDir = process.cwd(), routingCo
|
|
|
4060
4117
|
return {
|
|
4061
4118
|
line: rankedItems.map((item) => buildPreviousContextSnippet(item)).join(' | '),
|
|
4062
4119
|
selectedIds: rankedItems.map((item) => item.id),
|
|
4063
|
-
fingerprint: buildCompactMachineKey('route-memory-
|
|
4120
|
+
fingerprint: buildCompactMachineKey('route-memory-v2', {
|
|
4064
4121
|
taskQuery: normalize(taskQuery),
|
|
4065
4122
|
projectId,
|
|
4066
4123
|
items: rankedItems.map((item) => ({
|
|
@@ -102,7 +102,15 @@ async function run(payloadText, scriptPaths) {
|
|
|
102
102
|
deadlineMs: Math.min(childBudgetMs, remainingMs),
|
|
103
103
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
104
104
|
cwd: projectRoot,
|
|
105
|
-
|
|
105
|
+
// TASK-223 (HK-401): mark chain-spawned children so their structured
|
|
106
|
+
// permission decisions keep the omp contract (stdout parsed on exit 2).
|
|
107
|
+
// Direct Claude Code invocations carry no marker and exit 0 instead —
|
|
108
|
+
// a non-zero exit there discards stdout, killing the decision JSON.
|
|
109
|
+
env: {
|
|
110
|
+
...process.env,
|
|
111
|
+
CLAUDE_PROJECT_DIR: projectRoot,
|
|
112
|
+
UKIT_HOOK_CHAIN_RUNNER: '1',
|
|
113
|
+
},
|
|
106
114
|
});
|
|
107
115
|
const failureKind = chainFailureKind(result);
|
|
108
116
|
const code = Number.isFinite(result.code) ? result.code : 1;
|
|
@@ -135,12 +135,35 @@ ukit_input_degraded() {
|
|
|
135
135
|
[ "${UKIT_INPUT_TRUNCATED:-0}" = "1" ] || [ "${UKIT_HOOK_INPUT_STALLED:-0}" = "1" ]
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
# TASK-223 (HK-401): ALL PreToolUse structured permission decisions emit through
|
|
139
|
+
# this one helper. The JSON shape is harness-agnostic, but the exit code is NOT:
|
|
140
|
+
# direct Claude Code (UKIT_HOOK_CHAIN_RUNNER unset) — a non-zero exit means the
|
|
141
|
+
# harness DISCARDS stdout, so a decision JSON + exit 2 is dead text rendered
|
|
142
|
+
# as a bare "hook error" with no human-approval path. Emit the JSON and exit 0
|
|
143
|
+
# so `deny`/`ask` actually reaches the permission pipeline. Under
|
|
144
|
+
# permissions.defaultMode=bypassPermissions `ask` is auto-approved (probe
|
|
145
|
+
# verdict 2026-09-20), so refusal call sites pass `deny` directly — the host
|
|
146
|
+
# surfaces a proper "denied" instead of a hook error, still fail-closed.
|
|
147
|
+
# omp chain (UKIT_HOOK_CHAIN_RUNNER=1, exported by hook-chain-runner.mjs) — the
|
|
148
|
+
# bridge parses hookSpecificOutput ONLY when the child exits 2; exit 0
|
|
149
|
+
# short-circuits to {block:false} and would turn every gate fail-open. Emit
|
|
150
|
+
# the JSON and exit 2 exactly as before.
|
|
151
|
+
# stderr still carries the BLOCKED line on every path — the model needs the
|
|
152
|
+
# block reason regardless of which stdout contract the harness honors.
|
|
153
|
+
ukit_emit_permission_decision() {
|
|
154
|
+
local decision="${1:-ask}" reason="${2:-UKit deferred this tool call to a human decision.}"
|
|
155
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"${decision}\",\"permissionDecisionReason\":\"${reason}\"}}"
|
|
156
|
+
echo "BLOCKED: ${reason}" >&2
|
|
157
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
158
|
+
exit 2
|
|
159
|
+
fi
|
|
160
|
+
exit 0
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
ukit_emit_input_degraded() {
|
|
139
164
|
local posture="${1:-advisory}" hook_name="${2:-hook}"
|
|
140
165
|
if [ "$posture" = "failclosed" ]; then
|
|
141
|
-
|
|
142
|
-
echo "BLOCKED: ${hook_name} could not inspect a truncated/stalled payload; deferred to human." >&2
|
|
143
|
-
exit 2
|
|
166
|
+
ukit_emit_permission_decision ask "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so ${hook_name} cannot prove it safe. UKit defers this to a human decision."
|
|
144
167
|
fi
|
|
145
168
|
printf '%s\n' "{\"systemMessage\":\"UKit ${hook_name}: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input.\"}"
|
|
146
169
|
exit 0
|
|
@@ -221,3 +221,20 @@ otherwise route to the specialist.
|
|
|
221
221
|
- `.claude/skills/duraone/references/sql.md`
|
|
222
222
|
- `.claude/skills/duraone/references/workflow.md`
|
|
223
223
|
- Khi không active: dùng generic coding standards + project-specific patterns từ index.
|
|
224
|
+
|
|
225
|
+
## Learning loop — detail
|
|
226
|
+
|
|
227
|
+
Phase-4 telemetry → advisory tuning loop (FR-206, TASK-232).
|
|
228
|
+
|
|
229
|
+
- **Config namespace `learning.*`** (optional-present; absent → defaults merge + valid):
|
|
230
|
+
- `learning.feedback.enabled` (default `true`) — gates `collectFeedbackEvents`.
|
|
231
|
+
- `learning.feedback.maxEvents` (default `200`) — cap on labeled feedback events.
|
|
232
|
+
- `learning.proposals.minCount` / `learning.proposals.minSessions` (defaults `3`/`2`) — pattern-proposal thresholds.
|
|
233
|
+
- `learning.episodes.autoWrite` (default `false`) — gates the SessionEnd episode hook.
|
|
234
|
+
- `learning.tuning.enabled` / `learning.tuning.applyMode` (defaults `true`/`'manual'`; `'off'` disables computation).
|
|
235
|
+
- **Artifacts** (all under `.ukit/storage/`, written tmp+rename):
|
|
236
|
+
- `learning/feedback-events.json` — labeled wrong-route events (`rescue`, `re-route`, `repeat-stall`, `user-correction`).
|
|
237
|
+
- `learning/skill-accuracy.json` — per-skill trigger/join/accuracy roll-up.
|
|
238
|
+
- `learning/suggestions.json` — result of `computeTuningSuggestions(projectRoot)` (`src/learning/tuning.js`).
|
|
239
|
+
- `cache/retriever-lanes.jsonl` — per-query lane hit/weight telemetry consumed by `collectLaneStats`.
|
|
240
|
+
- **Advisory-only contract**: `suggestions` carry `{target, current, suggested, evidence}`; `applied` is always `[]`. `applyMode` is restricted to `manual|off` — **no writer ever mutates `codeIntel.retriever.weights` or `orchestration.escalation.debugLoopThreshold`**. Rules: lane weight ±0.1 step (clamped `[0.1, 2.0]`) when events ≥ 50 with a >0.5 dominant lane and a <0.02 starved lane; `debugLoopThreshold − 1` (min 1) when repeat-stall events ≥ 10. `ukit metrics` prints a `learning` section (pending count + targets; `n/a` when the artifact is absent).
|
|
@@ -225,6 +225,12 @@
|
|
|
225
225
|
"maxRetries": 1,
|
|
226
226
|
"confidenceThreshold": 50
|
|
227
227
|
},
|
|
228
|
+
"learning": {
|
|
229
|
+
"feedback": { "enabled": true, "maxEvents": 200 },
|
|
230
|
+
"proposals": { "minCount": 3, "minSessions": 2 },
|
|
231
|
+
"episodes": { "autoWrite": false },
|
|
232
|
+
"tuning": { "enabled": true, "applyMode": "manual" }
|
|
233
|
+
},
|
|
228
234
|
"safePatch": {
|
|
229
235
|
"enabled": true,
|
|
230
236
|
"strictSharedRisk": true,
|
|
@@ -454,6 +460,18 @@
|
|
|
454
460
|
"mac_dinh": true,
|
|
455
461
|
"y_nghia": "Planner phải hoàn thành PLAN.md §4 (Test Plan) trước khi task chuyển ready. Tắt sẽ làm UKit cho phép skip TDD — kéo theo executor dễ làm sót.",
|
|
456
462
|
"khuyen_nghi": "Giữ true."
|
|
463
|
+
},
|
|
464
|
+
"tu_ghi_episode_ket_thuc_session": {
|
|
465
|
+
"field": "learning.episodes.autoWrite",
|
|
466
|
+
"mac_dinh": false,
|
|
467
|
+
"y_nghia": "Nếu true, hook SessionEnd tự ghi episode vào memory v2 khi kết thúc session (cần `ukit` có trên PATH). Mặc định tắt để tránh ghi memory khi user chưa muốn.",
|
|
468
|
+
"khi_nao_bat": "Chỉ bật khi anh muốn UKit tự lưu episode mỗi session; dữ liệu vẫn nằm local trong .ukit/storage."
|
|
469
|
+
},
|
|
470
|
+
"tat_goi_y_tuning": {
|
|
471
|
+
"field": "learning.tuning.applyMode",
|
|
472
|
+
"mac_dinh": "manual",
|
|
473
|
+
"y_nghia": "manual = UKit chỉ tính và lưu gợi ý tuning vào .ukit/storage/learning/suggestions.json, KHÔNG bao giờ tự sửa weights/threshold. off = tắt luôn việc tính gợi ý.",
|
|
474
|
+
"khuyen_nghi": "Giữ manual. Không có chế độ auto-apply — mọi thay đổi weights/threshold đều do người dùng tự sửa."
|
|
457
475
|
}
|
|
458
476
|
},
|
|
459
477
|
"version": "Phiên bản config runtime đi kèm package UKit.",
|