@ngockhoale/ukit 2.3.2 → 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 CHANGED
@@ -2,6 +2,53 @@
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
+
5
52
  ## 2.3.2 - 2026-09-09
6
53
 
7
54
  Docs-contract release. "Every stop says why — no silent idle" is now a shipping Execution
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 {
@@ -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
- echo "BLOCKED: Dangerous command detected matching pattern '$pattern'. This command could cause irreversible damage. Ask the user to run it manually if truly needed." >&2
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
- echo "BLOCKED: Unsafe delete target detected. Refusing recursive force-delete." >&2
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
- echo "BLOCKED: 'rm -rf' is only auto-allowed for safe cleanup targets (dist/build/coverage/.next/.nuxt/.turbo/tmp/.cache/node_modules)." >&2
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 220000, sized for a 256k window — must stay below the model's real context
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=50000/hard=80000, which only print a suggestion).
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, nothing committed, no
22
- # cursor — which is strictly worse than letting it land. When docs/AI_HANDOFF/RUN.md shows
23
- # an unfinished run, the gate therefore allows a BOUNDED number of further calls so the run
24
- # can commit, write its cursor and push; after that it blocks exactly as before. The budget
25
- # is per over-cap episode, not per wave: it only resets when the estimate actually drops
26
- # (i.e. a real compaction happened), so a run cannot mint itself fresh grace forever.
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
- if (run) {
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; worst case grace restarts.
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
- `An unfinished run is in flight (Phase: ${run.phase}${run.cursor ? `, Cursor: ${run.cursor}` : ''}), so this call is allowed instead of stranding it mid-edit.`,
155
- 'Spend the remaining grace on LANDING, not on new work: finish the current edit, commit, update docs/AI_HANDOFF/RUN.md, push.',
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 run lands.',
158
- 'After the user runs /compact, the SessionStart resume hook replays the cursor and the run continues automatically.',
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
- run
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 run in docs/AI_HANDOFF/RUN.md, so there is no grace window to spend.',
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`);
@@ -75,7 +75,7 @@ function loadHardCap() {
75
75
  const value = JSON.parse(raw)?.compact?.hardCapTokens;
76
76
  if (Number.isFinite(value) && value > 0) return value;
77
77
  } catch { /* fall through to the default */ }
78
- return 220_000;
78
+ return 500_000;
79
79
  }
80
80
 
81
81
  let text;
@@ -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
- let text;
42
- try {
43
- text = fs.readFileSync(runPath, 'utf8');
44
- } catch {
45
- // No cursor file: nothing was running, or the last cycle was cleared. Stay silent —
46
- // a resume banner on every ordinary session start would be pure noise.
47
- process.exit(0);
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
- const field = (name) => (text.match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1] || '').trim();
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 phase = field('Phase');
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 goal = field('Goal');
57
- const base = field('Base');
58
- const cursor = field('Cursor');
59
- const next = field('Next');
60
- const source = typeof payload.source === 'string' ? payload.source : 'startup';
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 out = [
63
- 'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run was found on disk.',
64
- ` Goal: ${goal || '(not recorded)'}`,
65
- ` Base: ${base || '(not recorded)'}`,
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
- if (source === 'compact') {
81
- out.push('');
82
- out.push('The session was just compacted, so the window is clean — this is the ideal moment');
83
- out.push('to continue. Resume immediately rather than reporting status and waiting.');
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
- process.stdout.write(`${out.join('\n')}\n`);
87
- process.exit(0);
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
- cachedRouteState?.requestKey === requestKey
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 previousState = await readJson(sharedStatePath, {});
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
- const cachedRouteState = reuseSharedRouteState({
254
- previousState: await readRecentCacheEntry(routeCachePath, requestKey, {
272
+ if (cachedRouteState && canPersistCachedRouteState) {
273
+ await readRecentCacheEntry(routeCachePath, requestKey, {
255
274
  maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
256
275
  touch: true,
257
- }),
258
- requestKey,
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
- await writeJson(sharedStatePath, sharedState);
287
- await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
288
- state: sharedState,
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
- await writeJson(sharedStatePath, sharedState);
329
- await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
330
- route,
331
- state: sharedState,
332
- }));
333
- await writeRecentCacheEntry(routeCachePath, compactRouteCacheState(sharedState), {
334
- maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
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 sessionIdentity(payload = {}) {
34
- const sessionId = firstDefined([
35
- payload.session_id,
36
- payload.sessionId,
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
- return readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
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
- if (payload.stop_hook_active === true) {
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
- result.block ? { block: true, reason: result.reason } : undefined
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 }));
@@ -162,7 +162,7 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
162
162
 
163
163
  ## Adaptive Autonomy
164
164
 
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).
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).
166
166
  - End users should not need to change this; maintainers may tune it per-project.
167
167
 
168
168
  ## 3-Tier Model Routing
@@ -162,7 +162,7 @@ Khi Handoff mode: đọc `docs/AI_HANDOFF/RULES.md` để biết 4 phase (Idea+P
162
162
 
163
163
  ## Adaptive Autonomy
164
164
 
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).
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).
166
166
  - End users should not need to change this; maintainers may tune it per-project.
167
167
 
168
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 dở: `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 đang dở, commit, ghi cursor, push. 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 → 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 KHI docs/AI_HANDOFF/RUN.md còn run dở (mặc định 10). Dùng để run kịp commit + ghi cursor + push rồi mới bị chặn, thay vì chết giữa lúc đang Edit. 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.",
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": {