@ngockhoale/ukit 2.3.1 → 2.3.3
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 +67 -0
- package/package.json +1 -1
- package/src/core/runtimeConfig.js +1 -1
- package/src/index/taskRouting.js +16 -0
- package/templates/.claude/hooks/block-dangerous.sh +14 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +39 -17
- package/templates/.claude/hooks/context-window-guard.sh +1 -1
- package/templates/.claude/hooks/handoff-resume.sh +71 -40
- package/templates/.claude/hooks/skill-router.sh +53 -4
- package/templates/.claude/ukit/index/route-task.mjs +77 -20
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +130 -11
- package/templates/.omp/hooks/pre/ukit-bridge.js +43 -4
- package/templates/AGENTS.md +2 -1
- package/templates/CLAUDE.md +2 -1
- package/templates/docs/AI_HANDOFF/RULES.md +2 -2
- package/templates/ukit/storage/config.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.3.3 - 2026-09-10
|
|
6
|
+
|
|
7
|
+
False-stop and long-context release. Four fixes that share one theme: UKit must run a prompt
|
|
8
|
+
to a finished result, and any stop must say why.
|
|
9
|
+
|
|
10
|
+
Session-bound completion gate. Project-scoped route state could outlive the session-scoped
|
|
11
|
+
execution ledger, so a new session could inherit an older session's completion contract and
|
|
12
|
+
stop with a false `missing write-evidence` message. `skill-router.sh` now binds fresh, cached,
|
|
13
|
+
and route-less state to `sessionId`; `execution-ledger.mjs` accepts route state only when its
|
|
14
|
+
session matches the hook payload and ignores unbound legacy state for session-aware payloads.
|
|
15
|
+
Same-session gating and prompt-scoped evidence carry remain intact. Regression coverage now
|
|
16
|
+
covers mismatched sessions, unbound legacy state, and prior-session route isolation.
|
|
17
|
+
|
|
18
|
+
Orchestration envelopes no longer corrupt the active route. `<task-notification>`,
|
|
19
|
+
`<tool-result>`, `<system-reminder>`, and `<local-command-caveat>…</local-command-stdout>`
|
|
20
|
+
payloads delivered through the prompt boundary were treated as explicit user prompts,
|
|
21
|
+
rewriting route/cache/audit state mid-run. `skill-router.sh` now ignores complete envelopes
|
|
22
|
+
before routing (fail-open, opening-tag attributes accepted); router exceptions surface on
|
|
23
|
+
stderr instead of disappearing. Related ownership fixes: Stop evaluation loads route state
|
|
24
|
+
with the current session payload, execution-ledger/route-task reject incompatible explicit
|
|
25
|
+
session ownership without inventing identity, subagent tool calls cannot rewrite the main
|
|
26
|
+
route, and cache entries are not touched before ownership is established.
|
|
27
|
+
|
|
28
|
+
Ordinary long-context tasks preserve and resume. A natural-language routed task with no
|
|
29
|
+
`docs/AI_HANDOFF/RUN.md` previously hit the hard-cap gate with no landing window and no
|
|
30
|
+
resume cursor after compaction. Ordinary ledger state now gets the existing finite
|
|
31
|
+
`hardCapGraceCalls` landing budget (only with explicit session ownership, matching
|
|
32
|
+
session/prompt/request identity, unfinished evidence, and no blocker); the first grace call
|
|
33
|
+
writes a 30-minute hash-only resume intent that `handoff-resume.sh` consumes once and
|
|
34
|
+
emits as a continuation instruction with no raw prompt/command data. The
|
|
35
|
+
`context-window-guard.sh` fallback moved from 220k to the shipped 500k default.
|
|
36
|
+
|
|
37
|
+
New `vibecode` autonomy level. One typed prompt must run to a finished result: with
|
|
38
|
+
`autonomy.level: vibecode`, the completion gate opts out of the continuation cap entirely and
|
|
39
|
+
keeps blocking (including the reentrant `stop_hook_active` Stop) until completion evidence
|
|
40
|
+
arrives or a genuine blocker is recorded — every block carries its reason. `deriveTaskRoute`
|
|
41
|
+
now propagates `autonomyLevel` and a `continuousExecution` policy
|
|
42
|
+
(`{ enabled, stopOn, maxContinuations }`) into `routeSummary` for every level, and
|
|
43
|
+
`validateRuntimeConfig` accepts the fourth level.
|
|
44
|
+
|
|
45
|
+
Dangerous commands are a human decision, not a silent block. `block-dangerous.sh` now emits a
|
|
46
|
+
structured `permissionDecision: "ask"` (PreToolUse JSON) with the raw command scrubbed from
|
|
47
|
+
all output — arguments and comments can carry secrets — while exit 2 still refuses the call
|
|
48
|
+
for harnesses that ignore structured output. The omp bridge chain layer translates the same
|
|
49
|
+
structured decision into an `ask` verdict; because omp has no native ask, its host boundary
|
|
50
|
+
surfaces it as a block carrying the decision reason.
|
|
51
|
+
|
|
52
|
+
## 2.3.2 - 2026-09-09
|
|
53
|
+
|
|
54
|
+
Docs-contract release. "Every stop says why — no silent idle" is now a shipping Execution
|
|
55
|
+
Contract rule, born from a real incident: a turn ended waiting for the user's `npm login`,
|
|
56
|
+
the request sat at the bottom of a long report, the user did their part (created
|
|
57
|
+
`~/.npmrc`), and the idle session looked exactly like a stall for 79 minutes.
|
|
58
|
+
|
|
59
|
+
The new contract (identical in `CLAUDE.md` and `AGENTS.md`, parity-test enforced): when a
|
|
60
|
+
turn ends only because the user must act (login, approval, protected-file edit), the reply
|
|
61
|
+
opens with one line naming the exact action — `WAITING ON YOU: <command/action>` — and a
|
|
62
|
+
one-shot wakeup (~20-30 min) is scheduled when the harness provides one, so the session
|
|
63
|
+
re-checks and auto-continues once the user has acted. An ended turn cannot observe
|
|
64
|
+
external/auth changes by itself; without that line the idle session is indistinguishable
|
|
65
|
+
from a stall. Companion rule: any error (failed command, hook, test, publish) is reported
|
|
66
|
+
verbatim in the same turn, never silently retried past the user.
|
|
67
|
+
|
|
68
|
+
Decision recorded in `docs/MEMORY.md` with the incident as the reason. Verified: all
|
|
69
|
+
doc-referencing suites 298/298 (incl. the CLAUDE↔AGENTS parity test, which caught the
|
|
70
|
+
first one-sided edit before it could ship), consistency 194/194.
|
|
71
|
+
|
|
5
72
|
## 2.3.1 - 2026-09-09
|
|
6
73
|
|
|
7
74
|
"1 prompt mà đứng 10 lần" is fixed at the root. Four stall vectors in the completion
|
package/package.json
CHANGED
|
@@ -257,7 +257,7 @@ export function validateRuntimeConfig(config) {
|
|
|
257
257
|
errors.push('version must be a non-empty string.');
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
const VALID_AUTONOMY_LEVELS = new Set(['conservative', 'balanced', 'free-run']);
|
|
260
|
+
const VALID_AUTONOMY_LEVELS = new Set(['conservative', 'balanced', 'free-run', 'vibecode']);
|
|
261
261
|
if (!isPlainObject(config.autonomy)) {
|
|
262
262
|
errors.push('autonomy must be an object.');
|
|
263
263
|
} else {
|
package/src/index/taskRouting.js
CHANGED
|
@@ -240,6 +240,7 @@ export function buildRouteSummary({
|
|
|
240
240
|
executionCandidates,
|
|
241
241
|
});
|
|
242
242
|
const executionContract = buildExecutionContract(executionMode);
|
|
243
|
+
const continuousExecution = buildContinuousExecutionPolicy(autonomyLevel);
|
|
243
244
|
const postEditReview = executionContract?.postEditReviewPolicy
|
|
244
245
|
? { policy: executionContract.postEditReviewPolicy, agent: 'code-reviewer', reviewTargetType: 'diff' }
|
|
245
246
|
: null;
|
|
@@ -290,6 +291,8 @@ export function buildRouteSummary({
|
|
|
290
291
|
completionState,
|
|
291
292
|
postEditReview,
|
|
292
293
|
continuationState,
|
|
294
|
+
autonomyLevel,
|
|
295
|
+
continuousExecution,
|
|
293
296
|
intentMode: routingContext.intentMode ?? null,
|
|
294
297
|
handoffFile,
|
|
295
298
|
handoffBudget,
|
|
@@ -303,6 +306,19 @@ export function buildRouteSummary({
|
|
|
303
306
|
};
|
|
304
307
|
}
|
|
305
308
|
|
|
309
|
+
// Execution-ledger.mjs mirrors this policy for its Stop gate; the two must stay aligned.
|
|
310
|
+
// MAX_CONTINUATIONS (6) below is the same constant the ledger enforces for bounded modes.
|
|
311
|
+
function buildContinuousExecutionPolicy(autonomyLevel) {
|
|
312
|
+
if (autonomyLevel === 'vibecode') {
|
|
313
|
+
return {
|
|
314
|
+
enabled: true,
|
|
315
|
+
stopOn: ['completion-evidence', 'genuine-blocker', 'dangerous-command-decision'],
|
|
316
|
+
maxContinuations: null,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return { enabled: false, maxContinuations: 6 };
|
|
320
|
+
}
|
|
321
|
+
|
|
306
322
|
function deriveContextMode(taskType) {
|
|
307
323
|
if (taskType === 'trivial' || taskType === 'simple') return 'LITE';
|
|
308
324
|
if (taskType === 'non-trivial' || taskType === 'shared-simple') return 'FULL';
|
|
@@ -42,10 +42,20 @@ DANGEROUS_PATTERNS=(
|
|
|
42
42
|
"dd if=/dev/"
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
+
# Dangerous detections surface as a structured `ask` decision (stdout JSON) so a human
|
|
46
|
+
# decides; exit 2 still refuses the call for harnesses that ignore structured output.
|
|
47
|
+
# The raw command and the matched pattern are NEVER echoed — arguments and comments can
|
|
48
|
+
# carry secrets, and the pattern text itself restates the dangerous command.
|
|
49
|
+
emit_dangerous_decision() {
|
|
50
|
+
REASON="$1"
|
|
51
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
|
|
52
|
+
echo "BLOCKED: $REASON" >&2
|
|
53
|
+
exit 2
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
|
|
46
57
|
if echo "$SCAN_COMMAND" | grep -qE "$pattern"; then
|
|
47
|
-
|
|
48
|
-
exit 2
|
|
58
|
+
emit_dangerous_decision "Dangerous command pattern detected; it could cause irreversible damage. UKit defers this to a human decision."
|
|
49
59
|
fi
|
|
50
60
|
done
|
|
51
61
|
|
|
@@ -60,11 +70,9 @@ if echo "$SCAN_COMMAND" | grep -qE "(^|[;&|[:space:]])rm([[:space:]]|$)"; then
|
|
|
60
70
|
if echo "$SCAN_COMMAND" | grep -qE "$SAFE_DELETE_REGEX"; then
|
|
61
71
|
:
|
|
62
72
|
elif echo "$SCAN_COMMAND" | grep -qE "$UNSAFE_TARGET_REGEX"; then
|
|
63
|
-
|
|
64
|
-
exit 2
|
|
73
|
+
emit_dangerous_decision "Unsafe delete target detected; recursive force-delete could cause irreversible damage. UKit defers this to a human decision."
|
|
65
74
|
else
|
|
66
|
-
|
|
67
|
-
exit 2
|
|
75
|
+
emit_dangerous_decision "Recursive force-delete outside safe cleanup targets (dist/build/coverage/.next/.nuxt/.turbo/tmp/.cache/node_modules) is destructive. UKit defers this to a human decision."
|
|
68
76
|
fi
|
|
69
77
|
fi
|
|
70
78
|
fi
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: hard-enforce an absolute context token cap (compact.hardCapTokens,
|
|
3
|
-
# default
|
|
4
|
-
# window, so lower it on a 200k model), separate from the
|
|
3
|
+
# default 500000, sized for a 1M window — must stay below the model's real context
|
|
4
|
+
# window, so lower it on a 200k/256k model), separate from the
|
|
5
5
|
# soft/hard advisory pressure phases in
|
|
6
|
-
# compact-threshold.mjs (default soft=
|
|
6
|
+
# compact-threshold.mjs (default soft=150000/hard=240000, which only print a suggestion).
|
|
7
7
|
#
|
|
8
8
|
# Those advisory phases are just injected text — nothing stops the agent from ignoring
|
|
9
9
|
# them and letting a session run to hundreds of thousands of tokens with no compaction.
|
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
# real compaction.
|
|
19
19
|
#
|
|
20
20
|
# Grace window (compact.hardCapGraceCalls, default 10): blocking the very first tool call
|
|
21
|
-
# past the cap strands a handoff run mid-edit — files half-written,
|
|
22
|
-
#
|
|
23
|
-
# an unfinished run, the
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
21
|
+
# past the cap strands a handoff run or ordinary routed task mid-edit — files half-written,
|
|
22
|
+
# no durable completion state — which is strictly worse than letting it land. When RUN.md
|
|
23
|
+
# shows an unfinished handoff run, or the shared ledger proves an unfinished session-bound
|
|
24
|
+
# ordinary task, the gate allows a BOUNDED number of further calls so it can land the current
|
|
25
|
+
# edit/verification and persist resume state; after that it blocks exactly as before. The
|
|
26
|
+
# budget is per over-cap episode, not per wave: it only resets when the estimate actually
|
|
27
|
+
# drops (i.e. a real compaction happened), so a task cannot mint itself fresh grace forever.
|
|
27
28
|
#
|
|
28
29
|
# Config toggle: compact.hardCapBlock (default true). Set to false only to debug this
|
|
29
30
|
# gate itself; it must not become a normal escape hatch.
|
|
@@ -98,6 +99,10 @@ function readRunCursor() {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
const mod = await import(pathToFileURL(thresholdModulePath).href);
|
|
102
|
+
const ledgerModulePath = path.join(hookDir, '..', 'ukit', 'runtime', 'execution-ledger.mjs');
|
|
103
|
+
const ledgerMod = fs.existsSync(ledgerModulePath)
|
|
104
|
+
? await import(pathToFileURL(ledgerModulePath).href)
|
|
105
|
+
: null;
|
|
101
106
|
const pressurePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'compact-pressure.json');
|
|
102
107
|
const rawState = readJsonSafe(pressurePath, null);
|
|
103
108
|
|
|
@@ -127,7 +132,16 @@ function readRunCursor() {
|
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
const run = readRunCursor();
|
|
130
|
-
|
|
135
|
+
let ordinaryTask = null;
|
|
136
|
+
if (!run && ledgerMod) {
|
|
137
|
+
try {
|
|
138
|
+
ordinaryTask = await ledgerMod.readResumableExecution(projectRoot, payload);
|
|
139
|
+
} catch {
|
|
140
|
+
ordinaryTask = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const resumable = run || ordinaryTask;
|
|
144
|
+
if (resumable) {
|
|
131
145
|
const graceCalls = Number.isFinite(config?.compact?.hardCapGraceCalls)
|
|
132
146
|
? config.compact.hardCapGraceCalls
|
|
133
147
|
: 10;
|
|
@@ -145,17 +159,25 @@ function readRunCursor() {
|
|
|
145
159
|
try {
|
|
146
160
|
fs.mkdirSync(path.dirname(gracePath), { recursive: true });
|
|
147
161
|
fs.writeFileSync(gracePath, JSON.stringify({ ...carried, used }));
|
|
162
|
+
if (ordinaryTask && ledgerMod && used === 1) {
|
|
163
|
+
await ledgerMod.writeResumeIntent(projectRoot, payload);
|
|
164
|
+
}
|
|
148
165
|
} catch {
|
|
149
|
-
// Losing the counter must not block the run
|
|
166
|
+
// Losing the counter or advisory resume intent must not block the run.
|
|
150
167
|
}
|
|
168
|
+
const landing = run
|
|
169
|
+
? `An unfinished handoff run is in flight (Phase: ${run.phase}${run.cursor ? `, Cursor: ${run.cursor}` : ''}), so this call is allowed instead of stranding it mid-edit.`
|
|
170
|
+
: 'An unfinished ordinary routed task is in flight, so this call is allowed instead of stranding it before compaction.';
|
|
151
171
|
process.stderr.write(
|
|
152
172
|
[
|
|
153
173
|
`CONTEXT OVER CAP — grace ${used}/${graceCalls} (~${state.estimatedTotalTokens} tokens >= ${thresholds.hardCapTokens}).`,
|
|
154
|
-
|
|
155
|
-
|
|
174
|
+
landing,
|
|
175
|
+
run
|
|
176
|
+
? 'Spend the remaining grace on LANDING, not on new work: finish the current edit, commit, update docs/AI_HANDOFF/RUN.md, push.'
|
|
177
|
+
: 'Spend the remaining grace on LANDING, not on new work: finish the current mutation or verification, then compact as soon as the host allows it.',
|
|
156
178
|
'Do NOT start a new task, open new files, or spawn agents. When grace runs out the gate blocks hard.',
|
|
157
|
-
'Tell the user in your reply that context is over the cap and they should run /compact as soon as this
|
|
158
|
-
'After
|
|
179
|
+
'Tell the user in your reply that context is over the cap and they should run /compact as soon as this landing step is complete.',
|
|
180
|
+
'After compaction, the SessionStart resume hook replays the safe cursor and the task continues automatically.',
|
|
159
181
|
].join('\n') + '\n',
|
|
160
182
|
);
|
|
161
183
|
process.exit(0);
|
|
@@ -169,9 +191,9 @@ function readRunCursor() {
|
|
|
169
191
|
'Typing "continue" alone will hit this same block; only /compact (or a new session) resets the counter. After compacting, resume the interrupted task.',
|
|
170
192
|
'This is an absolute ceiling (compact.hardCapTokens), separate from the soft/hard advisory phases — those were apparently not followed.',
|
|
171
193
|
`Edit/Write/Bash refused (tool_name=${toolName}) until real compaction happens.`,
|
|
172
|
-
|
|
194
|
+
resumable
|
|
173
195
|
? 'The unfinished-run grace window (compact.hardCapGraceCalls) is already exhausted — progress should be committed and the cursor written by now.'
|
|
174
|
-
: 'No unfinished
|
|
196
|
+
: 'No resumable unfinished routed task was found, so there is no grace window to spend.',
|
|
175
197
|
'Do not work around this by summarizing inline and continuing, and do not reach for a non-gated write tool.',
|
|
176
198
|
];
|
|
177
199
|
process.stderr.write(`${lines.join('\n')}\n`);
|
|
@@ -25,6 +25,7 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
|
25
25
|
INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
|
|
26
26
|
const fs = require('fs');
|
|
27
27
|
const path = require('path');
|
|
28
|
+
const { pathToFileURL } = require('url');
|
|
28
29
|
|
|
29
30
|
const payload = (() => {
|
|
30
31
|
try {
|
|
@@ -37,54 +38,84 @@ const payload = (() => {
|
|
|
37
38
|
|
|
38
39
|
const projectRoot = process.env.PROJECT_ROOT;
|
|
39
40
|
const runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
|
|
41
|
+
const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'execution-ledger.mjs');
|
|
42
|
+
const source = typeof payload.source === 'string' ? payload.source : 'startup';
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
async function emitOrdinaryResume() {
|
|
45
|
+
if (!fs.existsSync(runtimePath)) return false;
|
|
46
|
+
try {
|
|
47
|
+
const runtime = await import(pathToFileURL(runtimePath).href);
|
|
48
|
+
const intent = await runtime.readResumeIntent(projectRoot, payload, { consume: true });
|
|
49
|
+
if (!intent) return false;
|
|
50
|
+
const out = [
|
|
51
|
+
'UKIT ROUTED TASK RESUME — an unfinished ordinary routed task was preserved before context compaction.',
|
|
52
|
+
'',
|
|
53
|
+
'This is a CONTINUATION, not a new request. Do not re-plan or ask whether to continue.',
|
|
54
|
+
'Resume the current routed task from its existing route state and execution ledger.',
|
|
55
|
+
'Use one bounded source/context slice if needed, then execute the next missing edit or verification milestone.',
|
|
56
|
+
];
|
|
57
|
+
if (source === 'compact') {
|
|
58
|
+
out.push('');
|
|
59
|
+
out.push('The session was just compacted, so the window is clean — resume immediately rather than reporting status and waiting.');
|
|
60
|
+
}
|
|
61
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
48
66
|
}
|
|
49
67
|
|
|
50
|
-
|
|
68
|
+
(async () => {
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = fs.readFileSync(runPath, 'utf8');
|
|
72
|
+
} catch {
|
|
73
|
+
// No handoff cursor: an ordinary routed-task intent may still be resumable.
|
|
74
|
+
await emitOrdinaryResume();
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
51
77
|
|
|
52
|
-
const
|
|
53
|
-
// `done` means the last cycle finished cleanly. Anything else means a step was in flight.
|
|
54
|
-
if (!phase || /^done$/i.test(phase)) process.exit(0);
|
|
78
|
+
const field = (name) => (text.match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1] || '').trim();
|
|
55
79
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
80
|
+
const phase = field('Phase');
|
|
81
|
+
// `done` means the last cycle finished cleanly. Anything else means a step was in flight.
|
|
82
|
+
if (!phase || /^done$/i.test(phase)) {
|
|
83
|
+
await emitOrdinaryResume();
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
61
86
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
` Phase: ${phase}`,
|
|
67
|
-
` Cursor: ${cursor || '(not recorded)'}`,
|
|
68
|
-
` Next: ${next || '(not recorded)'}`,
|
|
69
|
-
'',
|
|
70
|
-
'This is a CONTINUATION, not a new cycle. Do not re-plan, do not overwrite PLAN.md or the',
|
|
71
|
-
'task files, and do not ask the user whether to continue — they already asked for a',
|
|
72
|
-
'one-shot run and the pipeline was interrupted, not cancelled.',
|
|
73
|
-
'',
|
|
74
|
-
'Read docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md, then execute the `Next:` step',
|
|
75
|
-
'above using .claude/commands/ukit/handoff-fullstack.md as the procedure. Skip tasks',
|
|
76
|
-
'already `done` or `pending_review`; pick up `ready`, `in_progress`, `changes_requested`',
|
|
77
|
-
'and `blocked` ones. Keep writing the cursor after every step.',
|
|
78
|
-
];
|
|
87
|
+
const goal = field('Goal');
|
|
88
|
+
const base = field('Base');
|
|
89
|
+
const cursor = field('Cursor');
|
|
90
|
+
const next = field('Next');
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
92
|
+
const out = [
|
|
93
|
+
'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run was found on disk.',
|
|
94
|
+
` Goal: ${goal || '(not recorded)'}`,
|
|
95
|
+
` Base: ${base || '(not recorded)'}`,
|
|
96
|
+
` Phase: ${phase}`,
|
|
97
|
+
` Cursor: ${cursor || '(not recorded)'}`,
|
|
98
|
+
` Next: ${next || '(not recorded)'}`,
|
|
99
|
+
'',
|
|
100
|
+
'This is a CONTINUATION, not a new cycle. Do not re-plan, do not overwrite PLAN.md or the',
|
|
101
|
+
'task files, and do not ask the user whether to continue — they already asked for a',
|
|
102
|
+
'one-shot run and the pipeline was interrupted, not cancelled.',
|
|
103
|
+
'',
|
|
104
|
+
'Read docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md, then execute the `Next:` step',
|
|
105
|
+
'above using .claude/commands/ukit/handoff-fullstack.md as the procedure. Skip tasks',
|
|
106
|
+
'already `done` or `pending_review`; pick up `ready`, `in_progress`, `changes_requested`',
|
|
107
|
+
'and `blocked` ones. Keep writing the cursor after every step.',
|
|
108
|
+
];
|
|
85
109
|
|
|
86
|
-
|
|
87
|
-
|
|
110
|
+
if (source === 'compact') {
|
|
111
|
+
out.push('');
|
|
112
|
+
out.push('The session was just compacted, so the window is clean — this is the ideal moment');
|
|
113
|
+
out.push('to continue. Resume immediately rather than reporting status and waiting.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
})();
|
|
88
119
|
NODE
|
|
89
120
|
|
|
90
121
|
exit 0
|
|
@@ -5,6 +5,34 @@
|
|
|
5
5
|
INPUT=$(cat)
|
|
6
6
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
7
7
|
|
|
8
|
+
# Internal orchestration envelopes are not user requests. Ignore them before routing or
|
|
9
|
+
# compact-pressure bookkeeping so task results cannot replace the active completion contract.
|
|
10
|
+
case "$INPUT" in
|
|
11
|
+
*"<task-notification"*|*"<tool-result"*|*"<system-reminder"*|*"<local-command-caveat"*)
|
|
12
|
+
INTERNAL_ORCHESTRATION=$(INPUT="$INPUT" node -e '
|
|
13
|
+
try {
|
|
14
|
+
const payload = JSON.parse(process.env.INPUT || "{}");
|
|
15
|
+
const candidates = [payload?.prompt, payload?.user_prompt, payload?.text, payload?.message, payload?.input]
|
|
16
|
+
.filter((value) => typeof value === "string")
|
|
17
|
+
.map((value) => value.trim());
|
|
18
|
+
const block = (name) => new RegExp(`^<${name}(?:\\s[^>]*)?>[\\s\\S]*<\\/${name}>$`, "i");
|
|
19
|
+
const internal = candidates.some((value) => (
|
|
20
|
+
block("task-notification").test(value)
|
|
21
|
+
|| block("tool-result").test(value)
|
|
22
|
+
|| block("system-reminder").test(value)
|
|
23
|
+
|| /^<local-command-caveat(?:\s[^>]*)?>[\s\S]*<\/local-command-stdout>$/i.test(value)
|
|
24
|
+
));
|
|
25
|
+
process.stdout.write(internal ? "1" : "0");
|
|
26
|
+
} catch {
|
|
27
|
+
process.stdout.write("0");
|
|
28
|
+
}
|
|
29
|
+
' 2>/dev/null)
|
|
30
|
+
if [ "$INTERNAL_ORCHESTRATION" = "1" ]; then
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
;;
|
|
34
|
+
esac
|
|
35
|
+
|
|
8
36
|
# Worktree early-exit: when the tool call is scoped to a disposable .worktrees/task-*
|
|
9
37
|
# tree, there is nothing to route — exit before spawning the router. Reads
|
|
10
38
|
# tool_input.file_path / tool_input.path / tool_input.command (never tool_input.pattern).
|
|
@@ -2367,8 +2395,6 @@ const { pathToFileURL } = require('url');
|
|
|
2367
2395
|
const routeCachePath = path.join(projectRoot, '.claude', 'ukit', 'route-cache.json');
|
|
2368
2396
|
const routeAuditPath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-audit.json');
|
|
2369
2397
|
const cacheUtilsPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'cache-utils.mjs');
|
|
2370
|
-
const previous = readJson(statePath, {});
|
|
2371
|
-
const runtimeConfig = loadRuntimeConfig(projectRoot);
|
|
2372
2398
|
|
|
2373
2399
|
let payload = {};
|
|
2374
2400
|
try {
|
|
@@ -2377,6 +2403,20 @@ const { pathToFileURL } = require('url');
|
|
|
2377
2403
|
payload = {};
|
|
2378
2404
|
}
|
|
2379
2405
|
|
|
2406
|
+
const sessionCandidate = payload.session_id ?? payload.sessionId;
|
|
2407
|
+
const sessionId = sessionCandidate === undefined || sessionCandidate === null
|
|
2408
|
+
? null
|
|
2409
|
+
: String(sessionCandidate).trim() || null;
|
|
2410
|
+
const storedPrevious = readJson(statePath, {});
|
|
2411
|
+
// Route state is project-local, while hook ledgers are session-local. Do not let a
|
|
2412
|
+
// differently-bound snapshot become the contract for a new session.
|
|
2413
|
+
const previous = sessionId
|
|
2414
|
+
? (storedPrevious?.sessionId && String(storedPrevious.sessionId).trim() === sessionId
|
|
2415
|
+
? storedPrevious
|
|
2416
|
+
: {})
|
|
2417
|
+
: storedPrevious;
|
|
2418
|
+
const runtimeConfig = loadRuntimeConfig(projectRoot);
|
|
2419
|
+
|
|
2380
2420
|
const promptText = extractPrompt(payload);
|
|
2381
2421
|
const commandText = payload.tool_input?.command || payload.command || '';
|
|
2382
2422
|
const filePath = normalizeRelativeFile(projectRoot, payload.tool_input?.file_path || payload.file_path || '');
|
|
@@ -2447,6 +2487,7 @@ const { pathToFileURL } = require('url');
|
|
|
2447
2487
|
// whether a Stop is premature. Downgrading to a route-less state here silently disarms
|
|
2448
2488
|
// the gate mid-task, so carry the previous route forward until a real route replaces it.
|
|
2449
2489
|
fs.writeFileSync(statePath, JSON.stringify({
|
|
2490
|
+
...(sessionId ? { sessionId } : {}),
|
|
2450
2491
|
fingerprint,
|
|
2451
2492
|
ts: now,
|
|
2452
2493
|
source: 'skill-router',
|
|
@@ -2562,8 +2603,12 @@ const { pathToFileURL } = require('url');
|
|
|
2562
2603
|
touch: true,
|
|
2563
2604
|
})
|
|
2564
2605
|
: null;
|
|
2606
|
+
const sessionCompatibleCachedRoute = !sessionId
|
|
2607
|
+
|| Boolean(cachedRouteState?.sessionId)
|
|
2608
|
+
&& String(cachedRouteState.sessionId).trim() === sessionId;
|
|
2565
2609
|
if (
|
|
2566
|
-
|
|
2610
|
+
sessionCompatibleCachedRoute
|
|
2611
|
+
&& cachedRouteState?.requestKey === requestKey
|
|
2567
2612
|
&& cachedRouteState?.routeSummary
|
|
2568
2613
|
&& Array.isArray(cachedRouteState?.activeSkills)
|
|
2569
2614
|
) {
|
|
@@ -2581,6 +2626,7 @@ const { pathToFileURL } = require('url');
|
|
|
2581
2626
|
}
|
|
2582
2627
|
const reusedState = {
|
|
2583
2628
|
...cachedRouteState,
|
|
2629
|
+
...(sessionId ? { sessionId } : {}),
|
|
2584
2630
|
source: 'skill-router',
|
|
2585
2631
|
ts: now,
|
|
2586
2632
|
requestKey,
|
|
@@ -2671,6 +2717,7 @@ const { pathToFileURL } = require('url');
|
|
|
2671
2717
|
routeSummary,
|
|
2672
2718
|
});
|
|
2673
2719
|
const sharedState = {
|
|
2720
|
+
...(sessionId ? { sessionId } : {}),
|
|
2674
2721
|
requestKey,
|
|
2675
2722
|
fingerprint,
|
|
2676
2723
|
ts: now,
|
|
@@ -2709,7 +2756,9 @@ const { pathToFileURL } = require('url');
|
|
|
2709
2756
|
) {
|
|
2710
2757
|
process.stdout.write(`[ukit-skill-router] Helper: ${routeSummary.helperHint}\n`);
|
|
2711
2758
|
}
|
|
2712
|
-
})().catch(() => {
|
|
2759
|
+
})().catch((error) => {
|
|
2760
|
+
const detail = error?.message || String(error);
|
|
2761
|
+
process.stderr.write(`[ukit-skill-router] ${detail}\n`);
|
|
2713
2762
|
process.exit(0);
|
|
2714
2763
|
});
|
|
2715
2764
|
NODE
|
|
@@ -166,7 +166,11 @@ async function main() {
|
|
|
166
166
|
const sharedStatePath = path.join(rootDir, '.claude', 'ukit', 'skill-router-state.json');
|
|
167
167
|
const routeCachePath = path.join(rootDir, '.claude', 'ukit', 'route-cache.json');
|
|
168
168
|
const routeAuditPath = path.join(rootDir, '.ukit', 'storage', 'cache', 'route-audit.json');
|
|
169
|
-
const
|
|
169
|
+
const persistedPreviousState = await readJson(sharedStatePath, {});
|
|
170
|
+
const sessionId = normalizeSessionId(readFlagValue(args, '--session-id'));
|
|
171
|
+
const previousStateOwnershipBlocked = hasRouteStateOwner(persistedPreviousState)
|
|
172
|
+
&& !isRouteStateCompatible(persistedPreviousState, sessionId);
|
|
173
|
+
const previousState = previousStateOwnershipBlocked ? {} : persistedPreviousState;
|
|
170
174
|
const escalationConfig = await readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
|
|
171
175
|
const targetFile = readFlagValue(args, '--target');
|
|
172
176
|
const taskType = readFlagValue(args, '--type');
|
|
@@ -185,7 +189,7 @@ async function main() {
|
|
|
185
189
|
.trim();
|
|
186
190
|
|
|
187
191
|
if (!promptText && !commandText && !targetFile) {
|
|
188
|
-
console.error('Usage: node .codex/ukit/index/route-task.mjs "<prompt>" [--tool-command <cmd>] [--target <file>] [--type trivial|simple|non-trivial] [--adapter codex|claude|antigravity|opencode]');
|
|
192
|
+
console.error('Usage: node .codex/ukit/index/route-task.mjs "<prompt>" [--tool-command <cmd>] [--target <file>] [--type trivial|simple|non-trivial] [--session-id <id>] [--adapter codex|claude|antigravity|opencode]');
|
|
189
193
|
process.exitCode = 1;
|
|
190
194
|
return;
|
|
191
195
|
}
|
|
@@ -245,18 +249,33 @@ async function main() {
|
|
|
245
249
|
previousState,
|
|
246
250
|
requestKey,
|
|
247
251
|
});
|
|
252
|
+
const routeCacheEntry = previousStateOwnershipBlocked
|
|
253
|
+
? null
|
|
254
|
+
: await readRecentCacheEntry(routeCachePath, requestKey, {
|
|
255
|
+
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
256
|
+
touch: false,
|
|
257
|
+
});
|
|
258
|
+
const cachedRouteState = reuseSharedRouteState({
|
|
259
|
+
previousState: routeCacheEntry,
|
|
260
|
+
requestKey,
|
|
261
|
+
});
|
|
262
|
+
const cachedRouteStateOwnershipBlocked = hasRouteStateOwner(routeCacheEntry)
|
|
263
|
+
&& !isRouteStateCompatible(routeCacheEntry, sessionId);
|
|
264
|
+
const canPersistRouteState = !previousStateOwnershipBlocked;
|
|
265
|
+
const canPersistCachedRouteState = !cachedRouteStateOwnershipBlocked;
|
|
266
|
+
|
|
248
267
|
if (reusableState) {
|
|
249
268
|
printRouteState(reusableState);
|
|
250
269
|
return;
|
|
251
270
|
}
|
|
252
271
|
|
|
253
|
-
|
|
254
|
-
|
|
272
|
+
if (cachedRouteState && canPersistCachedRouteState) {
|
|
273
|
+
await readRecentCacheEntry(routeCachePath, requestKey, {
|
|
255
274
|
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
256
275
|
touch: true,
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
260
279
|
if (cachedRouteState) {
|
|
261
280
|
const rescuedRouteSummary = cachedRouteState.routeSummary
|
|
262
281
|
? {
|
|
@@ -277,16 +296,19 @@ async function main() {
|
|
|
277
296
|
}
|
|
278
297
|
const sharedState = {
|
|
279
298
|
...cachedRouteState,
|
|
299
|
+
...(sessionId ? { sessionId } : {}),
|
|
280
300
|
source: 'task-route',
|
|
281
301
|
ts: Date.now(),
|
|
282
302
|
requestKey,
|
|
283
303
|
routeSummary: rescuedRouteSummary,
|
|
284
304
|
};
|
|
285
305
|
sharedState.fingerprint = buildRouteStateFingerprint(sharedState);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
306
|
+
if (canPersistCachedRouteState) {
|
|
307
|
+
await writeJson(sharedStatePath, sharedState);
|
|
308
|
+
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
309
|
+
state: sharedState,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
290
312
|
printRouteState(sharedState);
|
|
291
313
|
return;
|
|
292
314
|
}
|
|
@@ -324,15 +346,18 @@ async function main() {
|
|
|
324
346
|
source: 'task-route',
|
|
325
347
|
requestKey,
|
|
326
348
|
indexGeneratedAtMs,
|
|
349
|
+
sessionId,
|
|
327
350
|
});
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
351
|
+
if (canPersistRouteState) {
|
|
352
|
+
await writeJson(sharedStatePath, sharedState);
|
|
353
|
+
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
354
|
+
route,
|
|
355
|
+
state: sharedState,
|
|
356
|
+
}));
|
|
357
|
+
await writeRecentCacheEntry(routeCachePath, compactRouteCacheState(sharedState), {
|
|
358
|
+
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
336
361
|
|
|
337
362
|
printRouteState(sharedState);
|
|
338
363
|
}
|
|
@@ -1509,6 +1534,7 @@ function buildRouteSummary({
|
|
|
1509
1534
|
executionCandidates,
|
|
1510
1535
|
});
|
|
1511
1536
|
const executionContract = buildExecutionContract(executionMode);
|
|
1537
|
+
const continuousExecution = buildContinuousExecutionPolicy(autonomyLevel);
|
|
1512
1538
|
const tierLane = buildTierLane({ modelTier: executionContract?.modelTier ?? null });
|
|
1513
1539
|
const completionState = buildCompletionState({
|
|
1514
1540
|
executionMode,
|
|
@@ -1555,6 +1581,8 @@ function buildRouteSummary({
|
|
|
1555
1581
|
tierLane,
|
|
1556
1582
|
completionState,
|
|
1557
1583
|
continuationState,
|
|
1584
|
+
autonomyLevel,
|
|
1585
|
+
continuousExecution,
|
|
1558
1586
|
...(expectedSourceFiles.length > 0 ? { expectedSourceFiles } : {}),
|
|
1559
1587
|
intentMode: routingContext.intentMode ?? null,
|
|
1560
1588
|
handoffFile,
|
|
@@ -1573,6 +1601,19 @@ function buildRouteSummary({
|
|
|
1573
1601
|
};
|
|
1574
1602
|
}
|
|
1575
1603
|
|
|
1604
|
+
// Execution-ledger.mjs mirrors this policy for its Stop gate; the two must stay aligned.
|
|
1605
|
+
// MAX_CONTINUATIONS (6) below is the same constant the ledger enforces for bounded modes.
|
|
1606
|
+
function buildContinuousExecutionPolicy(autonomyLevel) {
|
|
1607
|
+
if (autonomyLevel === 'vibecode') {
|
|
1608
|
+
return {
|
|
1609
|
+
enabled: true,
|
|
1610
|
+
stopOn: ['completion-evidence', 'genuine-blocker', 'dangerous-command-decision'],
|
|
1611
|
+
maxContinuations: null,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
return { enabled: false, maxContinuations: 6 };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1576
1617
|
function deriveContextMode(taskType) {
|
|
1577
1618
|
if (taskType === 'trivial' || taskType === 'simple') return 'LITE';
|
|
1578
1619
|
if (taskType === 'non-trivial' || taskType === 'shared-simple') return 'FULL';
|
|
@@ -2665,11 +2706,25 @@ function readFlagValue(argv, flag) {
|
|
|
2665
2706
|
return withEquals ? withEquals.slice(flag.length + 1) : null;
|
|
2666
2707
|
}
|
|
2667
2708
|
|
|
2709
|
+
function normalizeSessionId(value) {
|
|
2710
|
+
const normalized = value === undefined || value === null ? '' : String(value).trim();
|
|
2711
|
+
return normalized || null;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
function hasRouteStateOwner(state) {
|
|
2715
|
+
return Boolean(normalizeSessionId(state?.sessionId));
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
function isRouteStateCompatible(state, sessionId) {
|
|
2719
|
+
const owner = normalizeSessionId(state?.sessionId);
|
|
2720
|
+
return Boolean(owner && sessionId && owner === sessionId);
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2668
2723
|
function isFlagOrValue(argv, index) {
|
|
2669
2724
|
const arg = argv[index];
|
|
2670
2725
|
if (!arg.startsWith('--')) {
|
|
2671
2726
|
const prev = argv[index - 1];
|
|
2672
|
-
return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--adapter'].includes(prev));
|
|
2727
|
+
return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--session-id', '--adapter'].includes(prev));
|
|
2673
2728
|
}
|
|
2674
2729
|
return true;
|
|
2675
2730
|
}
|
|
@@ -2805,6 +2860,7 @@ function createSharedRouteState({
|
|
|
2805
2860
|
route,
|
|
2806
2861
|
source = 'task-route',
|
|
2807
2862
|
requestKey = null,
|
|
2863
|
+
sessionId = null,
|
|
2808
2864
|
}) {
|
|
2809
2865
|
const helpers = compactHelpers({
|
|
2810
2866
|
verificationRecommendation: route.verificationRecommendation ?? null,
|
|
@@ -2812,6 +2868,7 @@ function createSharedRouteState({
|
|
|
2812
2868
|
});
|
|
2813
2869
|
|
|
2814
2870
|
return {
|
|
2871
|
+
...(sessionId ? { sessionId } : {}),
|
|
2815
2872
|
requestKey,
|
|
2816
2873
|
fingerprint: buildRouteStateFingerprint(route),
|
|
2817
2874
|
ts: Date.now(),
|
|
@@ -7,6 +7,8 @@ import path from 'node:path';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
9
|
const LEDGER_VERSION = 1;
|
|
10
|
+
const RESUME_INTENT_VERSION = 1;
|
|
11
|
+
const RESUME_INTENT_TTL_MS = 30 * 60 * 1000;
|
|
10
12
|
const MAX_RECEIPTS = 24;
|
|
11
13
|
const MAX_SOURCE_FILES = 16;
|
|
12
14
|
const MAX_CONTINUATIONS = 6;
|
|
@@ -30,11 +32,17 @@ function firstDefined(values) {
|
|
|
30
32
|
return values.find((value) => value !== undefined && value !== null);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
function
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
function explicitSessionId(value = {}) {
|
|
36
|
+
const candidate = firstDefined([
|
|
37
|
+
value.session_id,
|
|
38
|
+
value.sessionId,
|
|
37
39
|
]);
|
|
40
|
+
const normalized = candidate === undefined || candidate === null ? '' : String(candidate).trim();
|
|
41
|
+
return normalized || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sessionIdentity(payload = {}) {
|
|
45
|
+
const sessionId = explicitSessionId(payload);
|
|
38
46
|
if (sessionId) return safeSegment(sessionId);
|
|
39
47
|
|
|
40
48
|
const transcriptPath = firstDefined([
|
|
@@ -58,6 +66,17 @@ function ledgerPath(projectRoot, payload = {}) {
|
|
|
58
66
|
);
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
function resumeIntentPath(projectRoot, sessionId) {
|
|
70
|
+
return path.join(
|
|
71
|
+
projectRoot,
|
|
72
|
+
'.ukit',
|
|
73
|
+
'storage',
|
|
74
|
+
'cache',
|
|
75
|
+
'resume-intents',
|
|
76
|
+
`${safeSegment(sessionId)}.json`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
async function readJson(filePath, fallback = null) {
|
|
62
81
|
try {
|
|
63
82
|
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
@@ -73,14 +92,107 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
73
92
|
await fs.rename(tempPath, filePath);
|
|
74
93
|
}
|
|
75
94
|
|
|
76
|
-
export async function readRouteState(projectRoot) {
|
|
77
|
-
|
|
95
|
+
export async function readRouteState(projectRoot, payload = {}) {
|
|
96
|
+
const state = await readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
|
|
97
|
+
const sessionId = explicitSessionId(payload);
|
|
98
|
+
const stateSessionId = explicitSessionId(state || {});
|
|
99
|
+
if (!state || !sessionId) return state;
|
|
100
|
+
if (!stateSessionId) return null;
|
|
101
|
+
return stateSessionId === sessionId ? state : null;
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
export async function readExecutionLedger(projectRoot, payload = {}) {
|
|
81
105
|
return readJson(ledgerPath(projectRoot, payload), null);
|
|
82
106
|
}
|
|
83
107
|
|
|
108
|
+
function promptKeyFromText(promptText) {
|
|
109
|
+
const normalized = String(promptText || '').trim();
|
|
110
|
+
if (!normalized) return null;
|
|
111
|
+
return `prompt-${crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 20)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function explicitPromptKey(routeState = {}) {
|
|
115
|
+
return promptKeyFromText(routeState?.routingContext?.lastExplicitUserPromptText);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hasUnfinishedCompletion(state = {}, ledger = {}) {
|
|
119
|
+
if (ledger?.blocker) return false;
|
|
120
|
+
const mode = state?.routeSummary?.executionMode || state?.routeSummary?.approachSelector?.executionMode;
|
|
121
|
+
if (!IMPLEMENT_MODES.has(mode)) return false;
|
|
122
|
+
const required = requiredEvidence(state);
|
|
123
|
+
if (required.length === 0) return false;
|
|
124
|
+
return required.some((item) => !evidenceSatisfied(item, ledger, state?.routeSummary || {}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resumeSessionHash(sessionId) {
|
|
128
|
+
return crypto.createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 32);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function readResumableExecution(projectRoot, payload = {}) {
|
|
132
|
+
const sessionId = explicitSessionId(payload);
|
|
133
|
+
if (!sessionId) return null;
|
|
134
|
+
const state = await readRouteState(projectRoot, payload);
|
|
135
|
+
const ledger = await readExecutionLedger(projectRoot, payload);
|
|
136
|
+
const promptKey = explicitPromptKey(state || {});
|
|
137
|
+
if (!state || !ledger || !ledger.sessionId || ledger.sessionId !== sessionId
|
|
138
|
+
|| ledger.sessionKey !== safeSegment(sessionId) || !ledger.requestKey
|
|
139
|
+
|| !ledger.promptKey || !promptKey || ledger.promptKey !== promptKey
|
|
140
|
+
|| !hasUnfinishedCompletion(state, ledger)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return { state, ledger, sessionId };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function readResumeIntent(projectRoot, payload = {}, { consume = false, now = Date.now() } = {}) {
|
|
147
|
+
const sessionId = explicitSessionId(payload);
|
|
148
|
+
if (!sessionId) return null;
|
|
149
|
+
const intent = await readJson(resumeIntentPath(projectRoot, sessionId), null);
|
|
150
|
+
if (!intent || intent.version !== RESUME_INTENT_VERSION) return null;
|
|
151
|
+
if (intent.sessionKey !== safeSegment(sessionId) || intent.sessionHash !== resumeSessionHash(sessionId)) return null;
|
|
152
|
+
if (!intent.promptKey || !intent.requestKey || !Number.isFinite(intent.expiresAt) || intent.expiresAt <= now) return null;
|
|
153
|
+
|
|
154
|
+
const state = await readRouteState(projectRoot, payload);
|
|
155
|
+
const ledger = await readExecutionLedger(projectRoot, payload);
|
|
156
|
+
const promptKey = explicitPromptKey(state || {});
|
|
157
|
+
if (!state || !ledger || ledger.sessionId !== sessionId
|
|
158
|
+
|| ledger.sessionKey !== safeSegment(sessionId)
|
|
159
|
+
|| state.requestKey !== intent.requestKey
|
|
160
|
+
|| ledger.requestKey !== intent.requestKey
|
|
161
|
+
|| promptKey !== intent.promptKey
|
|
162
|
+
|| ledger.promptKey !== intent.promptKey
|
|
163
|
+
|| !hasUnfinishedCompletion(state, ledger)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (consume) {
|
|
167
|
+
await fs.rm(resumeIntentPath(projectRoot, sessionId), { force: true });
|
|
168
|
+
}
|
|
169
|
+
return intent;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function writeResumeIntent(projectRoot, payload = {}, { now = Date.now() } = {}) {
|
|
173
|
+
const sessionId = explicitSessionId(payload);
|
|
174
|
+
if (!sessionId) return null;
|
|
175
|
+
const state = await readRouteState(projectRoot, payload);
|
|
176
|
+
const ledger = await readExecutionLedger(projectRoot, payload);
|
|
177
|
+
const promptKey = explicitPromptKey(state || {});
|
|
178
|
+
if (!state || !ledger || ledger.sessionId !== sessionId || !promptKey
|
|
179
|
+
|| ledger.promptKey !== promptKey || !ledger.requestKey || !hasUnfinishedCompletion(state, ledger)) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const intent = {
|
|
183
|
+
version: RESUME_INTENT_VERSION,
|
|
184
|
+
sessionKey: safeSegment(sessionId),
|
|
185
|
+
sessionHash: resumeSessionHash(sessionId),
|
|
186
|
+
promptKey,
|
|
187
|
+
requestKey: ledger.requestKey,
|
|
188
|
+
routeFingerprint: state.fingerprint || null,
|
|
189
|
+
createdAt: now,
|
|
190
|
+
expiresAt: now + RESUME_INTENT_TTL_MS,
|
|
191
|
+
};
|
|
192
|
+
await writeJsonAtomic(resumeIntentPath(projectRoot, sessionId), intent);
|
|
193
|
+
return intent;
|
|
194
|
+
}
|
|
195
|
+
|
|
84
196
|
function explicitError(payload = {}) {
|
|
85
197
|
return [
|
|
86
198
|
payload.isError,
|
|
@@ -246,7 +358,7 @@ export async function recordExecutionReceipt({
|
|
|
246
358
|
harness = 'unknown',
|
|
247
359
|
} = {}) {
|
|
248
360
|
if (!projectRoot || !toolName) return null;
|
|
249
|
-
const routeState = await readRouteState(projectRoot);
|
|
361
|
+
const routeState = await readRouteState(projectRoot, payload);
|
|
250
362
|
const current = await readExecutionLedger(projectRoot, payload);
|
|
251
363
|
const ledger = !current || current.requestKey !== (routeState?.requestKey || null)
|
|
252
364
|
? carriedEvidenceLedger(freshLedger(payload, routeState, harness), current)
|
|
@@ -376,6 +488,9 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}) {
|
|
|
376
488
|
|
|
377
489
|
export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
378
490
|
const routeSummary = state?.routeSummary || {};
|
|
491
|
+
// Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
|
|
492
|
+
// so the completion gate keeps pushing instead of releasing at the continuation cap.
|
|
493
|
+
const vibecode = routeSummary.autonomyLevel === 'vibecode';
|
|
379
494
|
const mode = routeSummary.executionMode || routeSummary.approachSelector?.executionMode || null;
|
|
380
495
|
const evidence = requiredEvidence(state);
|
|
381
496
|
if (ledger?.blocker) {
|
|
@@ -411,7 +526,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
411
526
|
const continuationCount = staleContinuations
|
|
412
527
|
? 0
|
|
413
528
|
: Number(effectiveLedger?.continuationCount || 0);
|
|
414
|
-
if (continuationCount >= MAX_CONTINUATIONS) {
|
|
529
|
+
if (!vibecode && continuationCount >= MAX_CONTINUATIONS) {
|
|
415
530
|
if (effectiveLedger?.notified === true) {
|
|
416
531
|
return {
|
|
417
532
|
continue: false,
|
|
@@ -431,7 +546,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
431
546
|
};
|
|
432
547
|
}
|
|
433
548
|
|
|
434
|
-
const finalAttempt = continuationCount === MAX_CONTINUATIONS - 1;
|
|
549
|
+
const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
|
|
435
550
|
const instruction = recoveryInstruction(missingEvidence, effectiveLedger, routeSummary);
|
|
436
551
|
return {
|
|
437
552
|
continue: true,
|
|
@@ -440,6 +555,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
440
555
|
`UKit completion gate: missing ${missingEvidence.join(', ')}.`,
|
|
441
556
|
instruction,
|
|
442
557
|
finalAttempt ? 'Final automatic recovery attempt: finish now or report a concrete blocker with its evidence.' : null,
|
|
558
|
+
vibecode ? 'Continuous (vibecode) execution is active: keep working until the requested result is complete; UKit stops only on completion evidence, a genuine blocker, or a dangerous-command decision.' : null,
|
|
443
559
|
].filter(Boolean).join(' '),
|
|
444
560
|
};
|
|
445
561
|
}
|
|
@@ -490,7 +606,7 @@ async function main() {
|
|
|
490
606
|
return;
|
|
491
607
|
}
|
|
492
608
|
if (process.argv.includes('--evaluate-stop')) {
|
|
493
|
-
const state = await readRouteState(projectRoot);
|
|
609
|
+
const state = await readRouteState(projectRoot, payload);
|
|
494
610
|
const ledger = await readExecutionLedger(projectRoot, payload) || {};
|
|
495
611
|
const result = evaluateCompletion({ state, ledger });
|
|
496
612
|
|
|
@@ -498,7 +614,10 @@ async function main() {
|
|
|
498
614
|
// that recovery turn creates a self-sustaining loop, so let it end normally instead.
|
|
499
615
|
// If work still lacks evidence, surface the recovery reason to the user rather than
|
|
500
616
|
// silently ending after the automatic continuation.
|
|
501
|
-
|
|
617
|
+
// Vibecode autonomy keeps pushing through the reentrant Stop as well: the recovery turn
|
|
618
|
+
// after a block is another chance to finish the work, not a release valve. Block again
|
|
619
|
+
// with the reason; the loop still ends the moment evidence lands or a blocker appears.
|
|
620
|
+
if (payload.stop_hook_active === true && state?.routeSummary?.autonomyLevel !== 'vibecode') {
|
|
502
621
|
if (result.continue || result.capped || result.notify) {
|
|
503
622
|
const recoveryReason = result.reason
|
|
504
623
|
|| 'UKit completion gate reached its continuation limit; unfinished work was not retried again.';
|
|
@@ -138,6 +138,26 @@ function buildHookPayload(hookEventName, fields = {}) {
|
|
|
138
138
|
return payload;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// A chain script may emit a Claude-Code-shaped structured decision on stdout. `ask` means
|
|
142
|
+
// the human must decide — the chain layer passes it through as a non-block `ask` verdict and
|
|
143
|
+
// the host boundary in hook() converts it to what the host supports. `deny` is a block that
|
|
144
|
+
// carries the structured reason. Anything unparseable falls through to exit-code semantics.
|
|
145
|
+
function parseStructuredDecision(stdout) {
|
|
146
|
+
if (typeof stdout !== 'string' || !stdout.trim()) return null;
|
|
147
|
+
try {
|
|
148
|
+
const decision = JSON.parse(stdout)?.hookSpecificOutput;
|
|
149
|
+
if (decision?.hookEventName !== 'PreToolUse') return null;
|
|
150
|
+
const permissionDecision = decision.permissionDecision;
|
|
151
|
+
if (permissionDecision !== 'ask' && permissionDecision !== 'deny') return null;
|
|
152
|
+
return {
|
|
153
|
+
permissionDecision,
|
|
154
|
+
reason: typeof decision.permissionDecisionReason === 'string' ? decision.permissionDecisionReason : '',
|
|
155
|
+
};
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
141
161
|
function translateExecResult(scriptName, execResult) {
|
|
142
162
|
const killed = Boolean(execResult?.killed);
|
|
143
163
|
const code = killed ? 1 : (execResult?.code ?? 0);
|
|
@@ -150,6 +170,19 @@ function translateExecResult(scriptName, execResult) {
|
|
|
150
170
|
return { block: false, stdout, stderr };
|
|
151
171
|
}
|
|
152
172
|
if (code === 2) {
|
|
173
|
+
const structured = parseStructuredDecision(stdout);
|
|
174
|
+
if (structured?.permissionDecision === 'ask') {
|
|
175
|
+
return {
|
|
176
|
+
block: false,
|
|
177
|
+
ask: true,
|
|
178
|
+
reason: structured.reason || stderr || `${scriptName} requires a human decision`,
|
|
179
|
+
stdout,
|
|
180
|
+
stderr,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (structured?.permissionDecision === 'deny') {
|
|
184
|
+
return { block: true, reason: structured.reason || stderr || `${scriptName} denied the call`, stdout, stderr };
|
|
185
|
+
}
|
|
153
186
|
return { block: true, reason: stderr || `${scriptName} exited 2 (blocked)`, stdout, stderr };
|
|
154
187
|
}
|
|
155
188
|
if (classifyFailure(scriptName) === 'closed') {
|
|
@@ -300,6 +333,9 @@ export async function runScriptChain(
|
|
|
300
333
|
if (verdict.block) {
|
|
301
334
|
return { block: true, reason: verdict.reason, context, invoked };
|
|
302
335
|
}
|
|
336
|
+
if (verdict.ask) {
|
|
337
|
+
return { block: false, ask: true, reason: verdict.reason, context, invoked };
|
|
338
|
+
}
|
|
303
339
|
}
|
|
304
340
|
|
|
305
341
|
return { block: false, context, invoked };
|
|
@@ -501,7 +537,7 @@ export async function runSessionStop(
|
|
|
501
537
|
) {
|
|
502
538
|
const metadata = runtimeMetadata(event, extensionContext);
|
|
503
539
|
const payload = buildHookPayload('Stop', metadata);
|
|
504
|
-
const state = suppliedState ?? await readRouteState(projectRoot);
|
|
540
|
+
const state = suppliedState ?? await readRouteState(projectRoot, payload);
|
|
505
541
|
const ledger = suppliedLedger ?? await readExecutionLedger(projectRoot, payload) ?? {};
|
|
506
542
|
const evaluation = evaluateCompletion({ state, ledger });
|
|
507
543
|
if (!evaluation.continue) {
|
|
@@ -545,9 +581,12 @@ export default function hook(pi) {
|
|
|
545
581
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || installedProjectRoot();
|
|
546
582
|
|
|
547
583
|
pi.on('tool_call', (event, context) => {
|
|
548
|
-
return runToolCall(pi, event, { projectRoot, context }).then((result) =>
|
|
549
|
-
|
|
550
|
-
|
|
584
|
+
return runToolCall(pi, event, { projectRoot, context }).then((result) => {
|
|
585
|
+
// omp's hook surface has no native `ask`; a human-decision verdict is surfaced by
|
|
586
|
+
// blocking with the decision reason, so a dangerous command never runs silently.
|
|
587
|
+
if (result.block || result.ask) return { block: true, reason: result.reason };
|
|
588
|
+
return undefined;
|
|
589
|
+
});
|
|
551
590
|
});
|
|
552
591
|
pi.on('tool_result', (event, context) => runToolResult(pi, event, { projectRoot, context }));
|
|
553
592
|
pi.on('before_agent_start', (event, context) => runBeforeAgentStart(pi, event, { projectRoot, context }));
|
package/templates/AGENTS.md
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
- If routed state says `pull-indexed-context`, treat it as internal continuation — after the bounded read, **continue to edit/verify in the same turn** when safe.
|
|
25
25
|
- If routed state shows `continuation required` or a stuck-lane rescue mode, finish the named milestone before widening reads or repeating analysis.
|
|
26
26
|
- **Do NOT say "done", "applied", or "fixed" after Read/Grep/analysis alone.** Completion wording requires concrete Edit/Write evidence in the current turn, and verification when the scope is risky.
|
|
27
|
+
- **Every stop says why — no silent idle.** When a turn ends because only the user can act (login, approval, protected-file edit), open the reply with one line naming the exact action: `WAITING ON YOU: <command/action>`, and schedule a one-shot wakeup (~20-30 min) when the harness provides one so the session re-checks and auto-continues once the user has acted. An ended turn cannot observe external/auth changes by itself, so without that line (and the wakeup) the idle session looks identical to a stall. Any error — failed command, hook, test, publish — is reported verbatim in the same turn, never silently retried past the user.
|
|
27
28
|
|
|
28
29
|
## Index-First Loop
|
|
29
30
|
|
|
@@ -161,7 +162,7 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
|
|
|
161
162
|
|
|
162
163
|
## Adaptive Autonomy
|
|
163
164
|
|
|
164
|
-
- `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking first: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more).
|
|
165
|
+
- `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking first: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more), `vibecode` (run one prompt to a finished result; the completion gate stops only on completion evidence, a genuine blocker, or a dangerous-command decision).
|
|
165
166
|
- End users should not need to change this; maintainers may tune it per-project.
|
|
166
167
|
|
|
167
168
|
## 3-Tier Model Routing
|
package/templates/CLAUDE.md
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
- If routed state says `pull-indexed-context`, treat it as internal continuation — after the bounded read, **continue to edit/verify in the same turn** when safe.
|
|
25
25
|
- If routed state shows `continuation required` or a stuck-lane rescue mode, finish the named milestone before widening reads or repeating analysis.
|
|
26
26
|
- **Do NOT say "done", "applied", or "fixed" after Read/Grep/analysis alone.** Completion wording requires concrete Edit/Write evidence in the current turn, and verification when the scope is risky.
|
|
27
|
+
- **Every stop says why — no silent idle.** When a turn ends because only the user can act (login, approval, protected-file edit), open the reply with one line naming the exact action: `WAITING ON YOU: <command/action>`, and schedule a one-shot wakeup (~20-30 min) when the harness provides one so the session re-checks and auto-continues once the user has acted. An ended turn cannot observe external/auth changes by itself, so without that line (and the wakeup) the idle session looks identical to a stall. Any error — failed command, hook, test, publish — is reported verbatim in the same turn, never silently retried past the user.
|
|
27
28
|
|
|
28
29
|
## Index-First Loop
|
|
29
30
|
|
|
@@ -161,7 +162,7 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
|
|
|
161
162
|
|
|
162
163
|
## Adaptive Autonomy
|
|
163
164
|
|
|
164
|
-
- `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking first: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more).
|
|
165
|
+
- `autonomy.level` in `.ukit/storage/config.json` controls how much UKit acts without asking first: `conservative` (ask more), `balanced` (default), `free-run` (auto-run more), `vibecode` (run one prompt to a finished result; the completion gate stops only on completion evidence, a genuine blocker, or a dangerous-command decision).
|
|
165
166
|
- End users should not need to change this; maintainers may tune it per-project.
|
|
166
167
|
|
|
167
168
|
## 3-Tier Model Routing
|
|
@@ -79,8 +79,8 @@ Next: <bước kế tiếp chính xác>
|
|
|
79
79
|
- Subagent ghi **full log vào task file trên đĩa**, chỉ trả về orchestrator ≤10 dòng (executor) / ≤6 dòng (reviewer). Paste log ngược lại orchestrator là nguyên nhân số 1 làm run chết vì hết context.
|
|
80
80
|
- Hết mỗi wave: commit, ghi cursor, **collapse** wave đó còn 1 dòng/task trong bộ nhớ làm việc, rồi chạy tiếp.
|
|
81
81
|
- Yêu cầu `/compact` **chỉ** được đặt ở cuối command, giữa 2 cycle. Giữa cycle thì tuyệt đối không — state đã nằm hết ở git + `INDEX.md` + `RUN.md` nên compact ở ranh giới cycle không mất gì.
|
|
82
|
-
- Vượt `compact.hardCapTokens` (mặc định 500k = 50% của context window 1M) mà `RUN.md` còn run
|
|
83
|
-
- Không hook nào gọi được `/compact` — đó là lệnh client-only. Nhưng từ 2.1.3, settings mặc định đặt `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW` = 70% của `hardCapTokens` (mặc định 350000 < 500k), render lúc install, nên **client tự auto-compact trước khi gate chặn**. Đường thường: auto-compact chạy → `handoff-resume.sh` replay cursor → chạy tiếp, không cần người gõ gì. Grace window ở trên chỉ còn là lưới an toàn.
|
|
82
|
+
- Vượt `compact.hardCapTokens` (mặc định 500k = 50% của context window 1M) mà `RUN.md` còn run dở **hoặc ordinary routed task có session execution ledger còn thiếu completion evidence**: `context-hardcap-gate` cho thêm `compact.hardCapGraceCalls` (mặc định 10) tool call rồi mới chặn cứng. **Grace đó chỉ để hạ cánh** — hoàn tất edit/verification đang dở và ghi state resume; không mở task mới, không đọc thêm file, không spawn agent. Hết grace là chặn thật; budget chỉ reset khi ước lượng token thực sự giảm (có compact thật), không reset theo wave.
|
|
83
|
+
- Không hook nào gọi được `/compact` — đó là lệnh client-only. Nhưng từ 2.1.3, settings mặc định đặt `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW` = 70% của `hardCapTokens` (mặc định 350000 < 500k), render lúc install, nên **client tự auto-compact trước khi gate chặn**. Đường thường: auto-compact chạy → `handoff-resume.sh` replay cursor cho handoff run hoặc tiêu thụ resume intent cho ordinary task → chạy tiếp, không cần người gõ gì. Grace window ở trên chỉ còn là lưới an toàn.
|
|
84
84
|
- Sửa một trong hai số đó thì phải giữ `autoCompactWindow < hardCapTokens`. Đảo thứ tự là deadlock: gate chặn tool trước → transcript ngừng lớn → ngưỡng auto-compact không bao giờ tới. `tests/core/autoCompactWindow.test.js` khóa bất biến này.
|
|
85
85
|
|
|
86
86
|
### Git
|
|
@@ -385,7 +385,7 @@
|
|
|
385
385
|
"version": "Phiên bản config runtime đi kèm package UKit.",
|
|
386
386
|
"agent": "Adapter mặc định của workspace. Thường giữ nguyên theo lúc install.",
|
|
387
387
|
"autonomy": {
|
|
388
|
-
"level": "Mức tự chủ của UKit. conservative: hỏi trước khi fallback/escalate, không tự delegate. balanced: mặc định, hành vi hiện tại. free-run: tự chạy fallback, delegate tự nhiên hơn, bớt xác nhận.",
|
|
388
|
+
"level": "Mức tự chủ của UKit. conservative: hỏi trước khi fallback/escalate, không tự delegate. balanced: mặc định, hành vi hiện tại. free-run: tự chạy fallback, delegate tự nhiên hơn, bớt xác nhận. vibecode: chạy một prompt tới khi có kết quả — completion gate không nhả ở continuation cap, chỉ dừng khi đủ completion evidence, gặp blocker thật, hoặc quyết định lệnh nguy hiểm; mọi cú dừng đều nêu lý do.",
|
|
389
389
|
"affectVerification": "Nếu true, autonomy.level ảnh hưởng hành vi verification plan.",
|
|
390
390
|
"affectDelegation": "Nếu true, autonomy.level ảnh hưởng ngưỡng delegation."
|
|
391
391
|
},
|
|
@@ -399,7 +399,7 @@
|
|
|
399
399
|
"hardCapTokens": "Ngưỡng cứng tuyệt đối (mặc định 500000 token ước lượng = 50% của context window 1M). Chạm/vượt ngưỡng này thì context coi như quá dài — không phải gợi ý nữa, là bắt buộc. PHẢI thấp hơn context window thật của model, nếu không API sẽ báo lỗi vượt context trước khi gate kịp chặn — model 200k thì hạ xuống 100000, model 256k thì 128000. Cả env.CLAUDE_CODE_AUTO_COMPACT_WINDOW (.claude/settings.json) lẫn compaction.thresholdTokens (.omp/config.yml) đều được SUY RA từ số này khi chạy ukit install, nên chỉ cần sửa ở đây rồi cài lại là auto-compact của cả Claude Code và omp đổi theo.",
|
|
400
400
|
"autoCompactWindowRatio": "Auto-compact chạy ở bao nhiêu phần của hardCapTokens (mặc định 0.7 → 350000). Phải < 1 để client tự compact TRƯỚC khi context-hardcap-gate chặn tool; số càng nhỏ thì compact càng sớm và càng nhiều đệm an toàn.",
|
|
401
401
|
"hardCapBlock": "Nếu true, hook context-hardcap-gate chặn cứng Edit/Write/Bash (exit 2) khi vượt hardCapTokens, cho tới khi có compact thật (PreCompact) reset lại bộ đếm.",
|
|
402
|
-
"hardCapGraceCalls": "Số tool call được phép chạy tiếp sau khi vượt hardCapTokens
|
|
402
|
+
"hardCapGraceCalls": "Số tool call được phép chạy tiếp sau khi vượt hardCapTokens khi handoff run trong docs/AI_HANDOFF/RUN.md hoặc ordinary routed task có session execution ledger còn dang dở (mặc định 10). Đây chỉ là landing window: hoàn tất edit/verification đang dở và ghi state resume, không mở task mới. Hết grace là chặn cứng như cũ. Budget tính theo mỗi đợt vượt cap, chỉ reset khi ước lượng token thật sự giảm (có compact thật) — không reset theo wave.",
|
|
403
403
|
"contextRotDetection": "Phát hiện context quá dài/dễ mục để giữ lại state quan trọng trước khi AI nhớ sai.",
|
|
404
404
|
"askBeforeDrop": "Giữ thái độ thận trọng trước khi bỏ context quan trọng. Nếu rủi ro thì hand back cho main model.",
|
|
405
405
|
"agentContext": {
|