@ngockhoale/ukit 2.3.19 → 2.3.20
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 +24 -0
- package/package.json +1 -1
- package/scripts/release/verify-release.mjs +45 -0
- package/templates/.claude/hooks/completion-gate.sh +13 -1
- package/templates/.claude/hooks/record-execution.sh +13 -1
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +158 -40
- package/templates/.omp/hooks/pre/ukit-bridge.js +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.3.20 - 2026-09-13
|
|
6
|
+
|
|
7
|
+
C15 bug-fix release record — this release completes the silent-stop class on top of the
|
|
8
|
+
already-published 2.3.19 (published to npm 2026-09-13, registry verified, carrying the C14
|
|
9
|
+
valve removal); it adds no new features. npm is the distribution channel and `ukit update`
|
|
10
|
+
pulls from npm, so an install on 2.3.19 (or older) needs an update to receive these fixes.
|
|
11
|
+
|
|
12
|
+
- **`--evaluate-stop` never releases a routed stop silently.**
|
|
13
|
+
`templates/.claude/ukit/runtime/execution-ledger.mjs` had a final dispatch with no `else`, so any
|
|
14
|
+
evaluation result of `{continue:false, notify:false}` that was not `capped` wrote nothing at all
|
|
15
|
+
— the Stop was allowed and the session idled with no visible reason. That default path is now a
|
|
16
|
+
loud release: an invisible blocker and a route with empty/short completion evidence both emit a
|
|
17
|
+
`systemMessage` naming the cause and how to resume, instead of silence. Malformed stdin no longer
|
|
18
|
+
crashes the hook into a silent stderr exit — it blocks with the crash detail, bounded by a crash
|
|
19
|
+
counter that loudly releases after the threshold and resets on a clean evaluate.
|
|
20
|
+
- **Fail-loud completion-gate wrappers.**
|
|
21
|
+
`templates/.claude/hooks/completion-gate.sh` and `templates/.claude/hooks/record-execution.sh`
|
|
22
|
+
exited 0 silently when the runtime script was missing or failed; both now emit a
|
|
23
|
+
`systemMessage` naming the missing/failing runtime and the `ukit install` remedy.
|
|
24
|
+
- **omp reentrant stop gets the same never-silent contract as the CLI.**
|
|
25
|
+
`templates/.omp/hooks/pre/ukit-bridge.js` released a reentrant stop with a non-blocking notice
|
|
26
|
+
and no continuation; it now blocks with the actionable reason like any other stop, stays bounded
|
|
27
|
+
by the ledger's continuation cap, and releases loudly at the cap.
|
|
28
|
+
|
|
5
29
|
## 2.3.19 - 2026-09-12
|
|
6
30
|
|
|
7
31
|
C14 bug-fix release record — one correctness fix is shipped; this release adds no new features.
|
package/package.json
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
|
|
5
6
|
const rootDir = process.cwd();
|
|
6
7
|
const npmCacheDir = path.join(os.tmpdir(), 'ukit-npm-cache');
|
|
7
8
|
|
|
9
|
+
// Opt-in post-publish registry-parity check (Release Policy invariant: npm and git always hold
|
|
10
|
+
// the same latest version). Only meaningful AFTER `npm publish` — before publish the registry is
|
|
11
|
+
// behind by definition — so it is gated behind --post-publish and never runs by default.
|
|
12
|
+
const postPublish = process.argv.includes('--post-publish');
|
|
13
|
+
|
|
14
|
+
if (postPublish) {
|
|
15
|
+
const localVersion = JSON.parse(readFileSync(path.join(rootDir, 'package.json'), 'utf8')).version;
|
|
16
|
+
const registryVersion = await readRegistryVersion();
|
|
17
|
+
|
|
18
|
+
console.log(`[release:verify] Registry parity (post-publish)`);
|
|
19
|
+
console.log(`[release:verify] package.json = ${localVersion}`);
|
|
20
|
+
console.log(`[release:verify] npm registry = ${registryVersion}`);
|
|
21
|
+
|
|
22
|
+
if (registryVersion !== localVersion) {
|
|
23
|
+
console.error(
|
|
24
|
+
`[release:verify] FAILED: registry version ${registryVersion} !== package.json ${localVersion}`,
|
|
25
|
+
);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log('\n[release:verify] All release checks passed.');
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
8
33
|
const steps = [
|
|
9
34
|
{
|
|
10
35
|
label: 'Artifact smoke',
|
|
@@ -54,3 +79,23 @@ function runStep({ command, args, env }) {
|
|
|
54
79
|
child.on('error', () => resolve(1));
|
|
55
80
|
});
|
|
56
81
|
}
|
|
82
|
+
|
|
83
|
+
function readRegistryVersion() {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const child = spawn('npm', ['view', '@ngockhoale/ukit', 'version'], {
|
|
86
|
+
cwd: rootDir,
|
|
87
|
+
env: { ...process.env, npm_config_cache: npmCacheDir },
|
|
88
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
let stdout = '';
|
|
92
|
+
child.stdout.on('data', (chunk) => {
|
|
93
|
+
stdout += chunk;
|
|
94
|
+
});
|
|
95
|
+
child.on('close', () => {
|
|
96
|
+
const version = stdout.trim();
|
|
97
|
+
resolve(version.length > 0 ? version : '<unavailable>');
|
|
98
|
+
});
|
|
99
|
+
child.on('error', () => resolve('<unavailable>'));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Stop hook: block premature terminal stops while routed completion evidence is missing.
|
|
3
|
+
# ADVISORY ONLY — always exit 0. A missing or failing runtime must be loud, not a silent pass.
|
|
3
4
|
|
|
4
5
|
INPUT=$(cat)
|
|
5
6
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
6
7
|
SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
|
|
7
8
|
|
|
8
9
|
if [ ! -f "$SCRIPT" ]; then
|
|
10
|
+
printf '%s\n' '{"systemMessage":"UKit completion gate: runtime script missing — run: ukit install"}'
|
|
9
11
|
exit 0
|
|
10
12
|
fi
|
|
11
13
|
|
|
12
|
-
printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --evaluate-stop
|
|
14
|
+
OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --evaluate-stop)
|
|
15
|
+
STATUS=$?
|
|
16
|
+
|
|
17
|
+
if [ "$STATUS" -ne 0 ]; then
|
|
18
|
+
printf '%s\n' '{"systemMessage":"UKit completion gate: runtime script failed — run: ukit install"}'
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
if [ -n "$OUTPUT" ]; then
|
|
23
|
+
printf '%s\n' "$OUTPUT"
|
|
24
|
+
fi
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PostToolUse hook: persist session-scoped source/write/verification receipts.
|
|
3
|
+
# ADVISORY ONLY — always exit 0. A missing or failing runtime must be loud, not a silent pass.
|
|
3
4
|
|
|
4
5
|
INPUT=$(cat)
|
|
5
6
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
6
7
|
SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
|
|
7
8
|
|
|
8
9
|
if [ ! -f "$SCRIPT" ]; then
|
|
10
|
+
printf '%s\n' '{"systemMessage":"UKit record execution: runtime script missing — run: ukit install"}'
|
|
9
11
|
exit 0
|
|
10
12
|
fi
|
|
11
13
|
|
|
12
|
-
printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --record
|
|
14
|
+
OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --record)
|
|
15
|
+
STATUS=$?
|
|
16
|
+
|
|
17
|
+
if [ "$STATUS" -ne 0 ]; then
|
|
18
|
+
printf '%s\n' '{"systemMessage":"UKit record execution: runtime script failed — run: ukit install"}'
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
if [ -n "$OUTPUT" ]; then
|
|
23
|
+
printf '%s\n' "$OUTPUT"
|
|
24
|
+
fi
|
|
@@ -13,6 +13,12 @@ const RESUME_INTENT_TTL_MS = 30 * 60 * 1000;
|
|
|
13
13
|
const MAX_RECEIPTS = 24;
|
|
14
14
|
const MAX_SOURCE_FILES = 16;
|
|
15
15
|
const MAX_CONTINUATIONS = 6;
|
|
16
|
+
// A malformed stdin payload crashes `--evaluate-stop` before a route/session can be read, so
|
|
17
|
+
// the only identity available is the project root. Count consecutive crashes there and, once
|
|
18
|
+
// this bounded budget is exhausted, release LOUD (naming the crash + `ukit install`) instead of
|
|
19
|
+
// blocking forever on a corrupt hook input. A successful evaluate resets the count. Mirrors
|
|
20
|
+
// MAX_CONTINUATIONS style — a hard-coded module const, deliberately not a config knob.
|
|
21
|
+
const MAX_GATE_CRASHES = 3;
|
|
16
22
|
// Two adjacent identical failed verifications (no edit attempt between them) are a loop;
|
|
17
23
|
// vibecode routes — which bypass the cap and every reentrant valve — additionally get a
|
|
18
24
|
// cumulative escape after the same verification failed this many times across edits.
|
|
@@ -82,6 +88,17 @@ function ledgerPath(projectRoot, payload = {}) {
|
|
|
82
88
|
);
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
function crashCounterPath(projectRoot) {
|
|
92
|
+
return path.join(
|
|
93
|
+
projectRoot,
|
|
94
|
+
'.ukit',
|
|
95
|
+
'storage',
|
|
96
|
+
'cache',
|
|
97
|
+
'exec-ledger',
|
|
98
|
+
'gate-crash-counter.json',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
function resumeIntentPath(projectRoot, sessionId) {
|
|
86
103
|
return path.join(
|
|
87
104
|
projectRoot,
|
|
@@ -727,10 +744,32 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
727
744
|
reason: `UKit stopped automatic recovery: ${ledger.blocker.detail || 'a recorded blocker ended this run.'}`,
|
|
728
745
|
};
|
|
729
746
|
}
|
|
747
|
+
// An invisible blocker (e.g. a permission decision the model already named in its own
|
|
748
|
+
// reply) must not loop — but it must not end silent either. Release loud so the user can
|
|
749
|
+
// tell a deliberate stop from a stall.
|
|
750
|
+
return {
|
|
751
|
+
continue: false,
|
|
752
|
+
notify: true,
|
|
753
|
+
missingEvidence: [],
|
|
754
|
+
reason: `UKit stopped automatic recovery: ${ledger.blocker.detail || 'a recorded blocker ended this run.'}`,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
if (!state || !state.routeSummary) {
|
|
758
|
+
// No routable state at all: a never-routed session (or a foreign-owned one) is silent
|
|
759
|
+
// success — there is nothing to report. The CLI's lost-route path handles the case where
|
|
760
|
+
// THIS session has ledger activity but the state vanished; callers that pass no state
|
|
761
|
+
// (e.g. the omp bridge for a session it does not own) must not be force-notified.
|
|
730
762
|
return { continue: false, notify: false, missingEvidence: [] };
|
|
731
763
|
}
|
|
732
764
|
if (evidence.length === 0) {
|
|
733
|
-
|
|
765
|
+
// A route with no completion evidence has no actionable block reason — blocking would
|
|
766
|
+
// loop unboundedly. Release loud instead of ending indistinguishable from a stall.
|
|
767
|
+
return {
|
|
768
|
+
continue: false,
|
|
769
|
+
notify: true,
|
|
770
|
+
missingEvidence: [],
|
|
771
|
+
reason: 'UKit completion gate: this route carries no completion evidence to verify, so the stop released without checking unfinished work. Send the task again in a new message to re-route it with a completion contract.',
|
|
772
|
+
};
|
|
734
773
|
}
|
|
735
774
|
|
|
736
775
|
// Request identity is the prompt (see evidencePromptKey): the router re-keys requestKey on
|
|
@@ -742,7 +781,10 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
742
781
|
const effectiveLedger = sameRequest ? ledger : {};
|
|
743
782
|
const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, routeSummary));
|
|
744
783
|
if (missingEvidence.length === 0) {
|
|
745
|
-
|
|
784
|
+
// Silent success: the route is present and every required evidence is satisfied. Marked
|
|
785
|
+
// `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
|
|
786
|
+
// bare loud else would turn a clean stop into user-visible noise.
|
|
787
|
+
return { continue: false, notify: false, complete: true, missingEvidence: [] };
|
|
746
788
|
}
|
|
747
789
|
|
|
748
790
|
// `find-cause` can validly end clean: an investigation may establish that no actionable
|
|
@@ -907,7 +949,121 @@ async function readStdin() {
|
|
|
907
949
|
return chunks.join('');
|
|
908
950
|
}
|
|
909
951
|
|
|
952
|
+
// Malformed hook input crashes the evaluate BEFORE any route/session can be read, so the only
|
|
953
|
+
// stable identity is the project root. Count consecutive crashes there; past MAX_GATE_CRASHES,
|
|
954
|
+
// release LOUD naming the crash and the remedy instead of blocking a corrupt hook forever. Any
|
|
955
|
+
// counter I/O failure fails SAFE to block (still loud, never silent). A successful evaluate
|
|
956
|
+
// resets the count.
|
|
957
|
+
async function handleEvaluateCrash(projectRoot, error) {
|
|
958
|
+
const detail = `malformed hook input crashed the completion gate (${error?.message || error})`;
|
|
959
|
+
const blockReason = () => `${detail}. This is the UKit completion gate, not the task — re-send the task in a new message so the hook payload is regenerated.`;
|
|
960
|
+
let count = 0;
|
|
961
|
+
try {
|
|
962
|
+
const current = await readJson(crashCounterPath(projectRoot), null);
|
|
963
|
+
count = Number(current?.count) || 0;
|
|
964
|
+
} catch {
|
|
965
|
+
count = 0;
|
|
966
|
+
}
|
|
967
|
+
const next = count + 1;
|
|
968
|
+
try {
|
|
969
|
+
await writeJsonAtomic(crashCounterPath(projectRoot), { count: next, updatedAt: Date.now() });
|
|
970
|
+
} catch {
|
|
971
|
+
// Counter unwritable: we cannot bound the crash loop, but silence is never an option.
|
|
972
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: blockReason() })}\n`);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (next >= MAX_GATE_CRASHES) {
|
|
976
|
+
const message = `UKit completion gate: ${detail}. This recurred ${next} times and cannot be bounded, so the stop is releasing loudly instead of blocking again. The hook payload is corrupt — run: ukit install to refresh the runtime, then re-send the task in a new message.`;
|
|
977
|
+
process.stderr.write(`[ukit-completion] ${message}\n`);
|
|
978
|
+
process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: blockReason() })}\n`);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function resetGateCrashCounter(projectRoot) {
|
|
985
|
+
// Only touch disk when a crash was actually counted — a clean stop on a healthy project
|
|
986
|
+
// must not create/rewrite a crash-counter file.
|
|
987
|
+
try {
|
|
988
|
+
await fs.access(crashCounterPath(projectRoot));
|
|
989
|
+
} catch {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
await writeJsonAtomic(crashCounterPath(projectRoot), { count: 0, updatedAt: Date.now() });
|
|
994
|
+
} catch {
|
|
995
|
+
// Best-effort: an unwritable counter only means the next crash blocks again, which is safe.
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function runEvaluateStop() {
|
|
1000
|
+
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
1001
|
+
let payload;
|
|
1002
|
+
try {
|
|
1003
|
+
payload = JSON.parse((await readStdin()) || '{}');
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
await handleEvaluateCrash(projectRoot, error);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
// A parse that reached here is a functioning evaluate — reset the crash budget so the next
|
|
1009
|
+
// malformed payload starts blocking again.
|
|
1010
|
+
await resetGateCrashCounter(projectRoot);
|
|
1011
|
+
|
|
1012
|
+
const state = await readRouteState(projectRoot, payload);
|
|
1013
|
+
const ledger = await readExecutionLedger(projectRoot, payload) || {};
|
|
1014
|
+
|
|
1015
|
+
// A session ledger with activity proves this session was routed and accumulating evidence.
|
|
1016
|
+
// If the route state is now missing or unreadable (router crashed/timed out before
|
|
1017
|
+
// persisting it, or the session stamp was lost), the gate cannot evaluate completion — and
|
|
1018
|
+
// ending silently there is indistinguishable from a mid-run stall. Surface the loss so the
|
|
1019
|
+
// user sees WHY the run released. Sessions that were never routed (no ledger activity at
|
|
1020
|
+
// all) stay silent: there is nothing to report (silent-success carve-out).
|
|
1021
|
+
if (!state) {
|
|
1022
|
+
const hasActivity = ledger.sourceSucceeded || ledger.writeAttempted
|
|
1023
|
+
|| ledger.verificationAttempted || (ledger.receipts || []).length > 0;
|
|
1024
|
+
if (!hasActivity) return;
|
|
1025
|
+
process.stdout.write(`${JSON.stringify({
|
|
1026
|
+
systemMessage: 'UKit completion gate: route state for this session was lost before the stop was evaluated (router did not persist it), so unfinished work could not be verified. Send the task again in a new message to re-route and finish it.',
|
|
1027
|
+
})}\n`);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const result = evaluateCompletion({ state, ledger });
|
|
1032
|
+
|
|
1033
|
+
// A reentrant Stop (Claude Code re-fires Stop after this hook already blocked once) is
|
|
1034
|
+
// gated exactly like any other Stop: while evidence is still missing it blocks again with
|
|
1035
|
+
// the actionable reason instead of silently releasing the recovery turn. Loop termination
|
|
1036
|
+
// is guaranteed elsewhere — non-vibecode routes end at the continuation cap (final-notice
|
|
1037
|
+
// block -> capped visible release), and vibecode ends on the visible verification-loop
|
|
1038
|
+
// blocker.
|
|
1039
|
+
if (result.continue) {
|
|
1040
|
+
if (result.finalNotice) await markNotified(projectRoot, payload, ledger);
|
|
1041
|
+
else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null, evidencePromptKey(state));
|
|
1042
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (result.capped || result.notify) {
|
|
1046
|
+
// Non-blocking endings (non-gated modes, an invisible blocker, an evidence-less route, or
|
|
1047
|
+
// cap reached after the final notice) must still tell the user what is unfinished — a
|
|
1048
|
+
// silent end is indistinguishable from a stall.
|
|
1049
|
+
process.stderr.write(`[ukit-completion] ${result.reason}\n`);
|
|
1050
|
+
process.stdout.write(`${JSON.stringify({ systemMessage: result.reason })}\n`);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (result.complete) return; // silent success: route present, all evidence satisfied
|
|
1054
|
+
// Defensive final else: no evaluation result may ever end a routed stop silently.
|
|
1055
|
+
const fallback = `UKit completion gate: the stop was evaluated without a decision (missing evidence: ${(result.missingEvidence || []).join(', ') || 'none'}). If work is unfinished, re-send the task in a new message.`;
|
|
1056
|
+
process.stderr.write(`[ukit-completion] ${fallback}\n`);
|
|
1057
|
+
process.stdout.write(`${JSON.stringify({ systemMessage: fallback })}\n`);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
910
1060
|
async function main() {
|
|
1061
|
+
// Dispatch the stop flag BEFORE parsing stdin: a malformed payload used to throw out of
|
|
1062
|
+
// main(), hit the .catch (stderr + exit 1) with no decision, and release the stop silently.
|
|
1063
|
+
if (process.argv.includes('--evaluate-stop')) {
|
|
1064
|
+
await runEvaluateStop();
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
911
1067
|
const payload = JSON.parse((await readStdin()) || '{}');
|
|
912
1068
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
|
|
913
1069
|
if (process.argv.includes('--record')) {
|
|
@@ -917,44 +1073,6 @@ async function main() {
|
|
|
917
1073
|
toolName: payload.tool_name,
|
|
918
1074
|
harness: process.env.UKIT_HARNESS || 'claude-code',
|
|
919
1075
|
});
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
|
-
if (process.argv.includes('--evaluate-stop')) {
|
|
923
|
-
const state = await readRouteState(projectRoot, payload);
|
|
924
|
-
const ledger = await readExecutionLedger(projectRoot, payload) || {};
|
|
925
|
-
const result = evaluateCompletion({ state, ledger });
|
|
926
|
-
|
|
927
|
-
// A session ledger with activity proves this session was routed and accumulating
|
|
928
|
-
// evidence. If the route state is now missing or unreadable (router crashed/timed out
|
|
929
|
-
// before persisting it, or the session stamp was lost), the gate cannot evaluate
|
|
930
|
-
// completion — and ending silently there is indistinguishable from a mid-run stall.
|
|
931
|
-
// Surface the loss so the user sees WHY the run released. Sessions that were never
|
|
932
|
-
// routed (no ledger activity at all) stay silent: there is nothing to report.
|
|
933
|
-
if (!state && (ledger.sourceSucceeded || ledger.writeAttempted
|
|
934
|
-
|| ledger.verificationAttempted || (ledger.receipts || []).length > 0)) {
|
|
935
|
-
process.stdout.write(`${JSON.stringify({
|
|
936
|
-
systemMessage: 'UKit completion gate: route state for this session was lost before the stop was evaluated (router did not persist it), so unfinished work could not be verified. Send the task again in a new message to re-route and finish it.',
|
|
937
|
-
})}\n`);
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
// A reentrant Stop (Claude Code re-fires Stop after this hook already blocked once) is
|
|
942
|
-
// gated exactly like any other Stop: while evidence is still missing it blocks again
|
|
943
|
-
// with the actionable reason instead of silently releasing the recovery turn. Loop
|
|
944
|
-
// termination is guaranteed elsewhere — non-vibecode routes end at the continuation cap
|
|
945
|
-
// (final-notice block -> capped visible release), and vibecode ends on the visible
|
|
946
|
-
// verification-loop blocker.
|
|
947
|
-
|
|
948
|
-
if (result.continue) {
|
|
949
|
-
if (result.finalNotice) await markNotified(projectRoot, payload, ledger);
|
|
950
|
-
else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null, evidencePromptKey(state));
|
|
951
|
-
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
|
|
952
|
-
} else if (result.capped || result.notify) {
|
|
953
|
-
// Non-blocking endings (non-gated modes, or cap reached after the final notice) must
|
|
954
|
-
// still tell the user what is unfinished — a silent end is indistinguishable from a stall.
|
|
955
|
-
process.stderr.write(`[ukit-completion] ${result.reason}\n`);
|
|
956
|
-
process.stdout.write(`${JSON.stringify({ systemMessage: result.reason })}\n`);
|
|
957
|
-
}
|
|
958
1076
|
}
|
|
959
1077
|
}
|
|
960
1078
|
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
evidencePromptKey,
|
|
15
15
|
incrementContinuation,
|
|
16
16
|
markNotified,
|
|
17
|
-
noteStopProgress,
|
|
18
17
|
readExecutionLedger,
|
|
19
18
|
readRouteState,
|
|
20
19
|
recordExecutionReceipt,
|
|
@@ -634,23 +633,6 @@ export async function runSessionStop(
|
|
|
634
633
|
}
|
|
635
634
|
|
|
636
635
|
if (suppliedLedger === undefined) {
|
|
637
|
-
// Claude Code flags reentrant stops with stop_hook_active and the ledger CLI releases on
|
|
638
|
-
// them; omp's session_stop carries no such field. Detect the equivalent from the ledger:
|
|
639
|
-
// when the recovery turn produced no new receipts since the stop that blocked it,
|
|
640
|
-
// blocking again would only burn another turn in a self-sustaining loop — release with a
|
|
641
|
-
// visible reason instead. Vibecode autonomy keeps pushing (same as the CLI valve).
|
|
642
|
-
let reentrantStop = false;
|
|
643
|
-
try {
|
|
644
|
-
reentrantStop = (await noteStopProgress(projectRoot, payload)).reentrant === true;
|
|
645
|
-
} catch {
|
|
646
|
-
reentrantStop = false;
|
|
647
|
-
}
|
|
648
|
-
if (reentrantStop && state?.routeSummary?.autonomyLevel !== 'vibecode') {
|
|
649
|
-
const notice = `UKit stopped automatic recovery after one continuation: ${evaluation.reason}`;
|
|
650
|
-
pi.logger?.warn?.(`[UKit] ${notice}`);
|
|
651
|
-
sendContext(pi, [notice], 'nextTurn', { display: true });
|
|
652
|
-
return undefined;
|
|
653
|
-
}
|
|
654
636
|
try {
|
|
655
637
|
if (evaluation.finalNotice) await markNotified(projectRoot, payload, ledger);
|
|
656
638
|
else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null, evidencePromptKey(state));
|