@ngockhoale/ukit 2.3.17 → 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 CHANGED
@@ -2,6 +2,45 @@
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
+
29
+ ## 2.3.19 - 2026-09-12
30
+
31
+ C14 bug-fix release record — one correctness fix is shipped; this release adds no new features.
32
+
33
+ - **Completion gate no longer silently releases a reentrant stop.** `templates/.claude/ukit/runtime/execution-ledger.mjs` (`--evaluate-stop`) had a `stop_hook_active` valve: when Claude Code re-fired `Stop` after the gate had already blocked once, the valve emitted only a `systemMessage` with no `decision` while completion evidence was still missing, so the turn ended and the session idled until the human typed "tiếp". The valve is removed — a reentrant stop is now evaluated like any other stop, blocking again with the actionable missing-evidence reason (produce the evidence or report a blocker), and the loop stays bounded by the existing visible continuation cap and the verification-loop blocker, so a finished task is never trapped.
34
+
35
+ ## 2.3.18 - 2026-09-12
36
+
37
+ C13 bug-sweep release record — two wave-1 correctness fixes are shipped; this release adds no new features.
38
+
39
+ - **Task-budget validator heading and target-count fix.** `src/core/taskBudgetValidator.js` and the shipped twin `templates/.claude/ukit/index/task-budget-validator.mjs` now accept the template's `## Test Cases (REQUIRED — TDD)` heading without a false `missing-field` verdict and count only top-level `## Target Files` bullets, avoiding indented sub-bullet miscounts.
40
+ - **Task-progress guard multi-section fix.** `src/core/taskProgressGuard.js` now evaluates milestone entries across every `## Progress` section, so a fresh appended entry is not masked by an older first-section entry while malformed and drift checks remain active.
41
+
42
+ Verification: version pin and targeted regression suites pass; `yarn release:verify` is the closing release gate.
43
+
5
44
  ## 2.3.17 - 2026-09-12
6
45
 
7
46
  Long-task resilience — a handoff run is now measured before planning, checkpointed during
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.17",
3
+ "version": "2.3.20",
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",
@@ -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
+ }
@@ -2,17 +2,22 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { writeFileAtomic } from './fileOps.js';
4
4
 
5
+ // Root-anchored (leading `/`) so the block only ignores project-root entries.
6
+ // Unanchored dir entries (`.claude/`) match same-named dirs at ANY depth and
7
+ // hid templates/.claude from git — the C12 trap `ukit install` kept re-adding.
8
+ // Entries with an inner slash (`.claude/ukit/...`) are already root-anchored
9
+ // by gitignore semantics.
5
10
  const UKIT_ENTRIES = [
6
- '.cache/',
11
+ '/.cache/',
7
12
  // legacy: Antigravity adapter removed in v2.2.0; leftovers must stay ignored
8
- '.antigravity/',
9
- '.claude/',
10
- '.codex/',
11
- '.omp/',
12
- '.ukit/',
13
- 'opencode.json',
14
- 'AGENTS.md',
15
- 'CLAUDE.md',
13
+ '/.antigravity/',
14
+ '/.claude/',
15
+ '/.codex/',
16
+ '/.omp/',
17
+ '/.ukit/',
18
+ '/opencode.json',
19
+ '/AGENTS.md',
20
+ '/CLAUDE.md',
16
21
  '.claude/ukit/.ukit/',
17
22
  '.claude/ukit/permission-usage.json',
18
23
  '.claude/ukit/permission-audit.log',
@@ -88,17 +88,23 @@ function splitSections(markdown) {
88
88
  }
89
89
 
90
90
  function getSectionBody(sections, name) {
91
- if (!sections.has(name)) return null;
92
- return sections.get(name).join('\n');
91
+ if (sections.has(name)) return sections.get(name).join('\n');
92
+ const normalizedName = name.replace(/\s*\([^)]*\)\s*$/, '');
93
+ for (const [heading, body] of sections) {
94
+ if (heading.replace(/\s*\([^)]*\)\s*$/, '') === normalizedName) {
95
+ return body.join('\n');
96
+ }
97
+ }
98
+ return null;
93
99
  }
94
100
 
95
- // Count bullets under `## Target Files` — lines whose first non-whitespace char is `- `
101
+ // Count bullets under `## Target Files` — only column-0 bullet lines.
96
102
  function countTargetFiles(body) {
97
103
  if (body == null) return 0;
98
104
  const lines = body.split('\n');
99
105
  let count = 0;
100
106
  for (const line of lines) {
101
- if (/^\s*-\s+\S/.test(line)) count += 1;
107
+ if (/^-\s+\S/.test(line)) count += 1;
102
108
  }
103
109
  return count;
104
110
  }
@@ -28,6 +28,7 @@
28
28
  // future watchdog integration) can inject a fixed timestamp.
29
29
 
30
30
  const PROGRESS_HEADING = /^##\s+Progress\s*$/m;
31
+ const PROGRESS_HEADING_GLOBAL = /^##\s+Progress\s*$/gm;
31
32
  const REPORT_HEADING = /^##\s+Executor Report\s*$/m;
32
33
  const TARGET_HEADING = /^##\s+Target Files\s*$/m;
33
34
  const ENTRY_RE = /^-\s+(\S+)\s+·\s+milestone:\s*([^·]*)·\s+last-green:\s*([^·]*)·\s+files:\s*([^·]*)·\s+drift:\s*(.*)$/;
@@ -87,6 +88,16 @@ function collectProgressEntries(progressSection) {
87
88
  return entries;
88
89
  }
89
90
 
91
+ function collectAllProgressEntries(markdown) {
92
+ const headings = [...markdown.matchAll(PROGRESS_HEADING_GLOBAL)];
93
+ return headings.flatMap((heading, index) => {
94
+ const start = heading.index + heading[0].length;
95
+ const nextHeading = /\n##\s+/.exec(markdown.slice(start));
96
+ const end = nextHeading ? start + nextHeading.index : markdown.length;
97
+ return collectProgressEntries(markdown.slice(start, end));
98
+ });
99
+ }
100
+
90
101
  export function checkMilestoneProgress({
91
102
  markdown,
92
103
  now = Date.now(),
@@ -110,8 +121,7 @@ export function checkMilestoneProgress({
110
121
  };
111
122
  }
112
123
 
113
- const progressSection = sliceSection(markdown, PROGRESS_HEADING);
114
- const entries = collectProgressEntries(progressSection || '');
124
+ const entries = collectAllProgressEntries(markdown);
115
125
 
116
126
  if (entries.length === 0) {
117
127
  violations.push({ type: 'no-progress', detail: '## Progress section has no entries' });
@@ -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
@@ -70,15 +70,21 @@ function splitSections(markdown) {
70
70
  }
71
71
 
72
72
  function getSectionBody(sections, name) {
73
- if (!sections.has(name)) return null;
74
- return sections.get(name).join('\n');
73
+ if (sections.has(name)) return sections.get(name).join('\n');
74
+ const normalizedName = name.replace(/\s*\([^)]*\)\s*$/, '');
75
+ for (const [heading, body] of sections) {
76
+ if (heading.replace(/\s*\([^)]*\)\s*$/, '') === normalizedName) {
77
+ return body.join('\n');
78
+ }
79
+ }
80
+ return null;
75
81
  }
76
82
 
77
83
  function countTargetFiles(body) {
78
84
  if (body == null) return 0;
79
85
  let count = 0;
80
86
  for (const line of body.split('\n')) {
81
- if (/^\s*-\s+\S/.test(line)) count += 1;
87
+ if (/^-\s+\S/.test(line)) count += 1;
82
88
  }
83
89
  return count;
84
90
  }
@@ -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
- return { continue: false, notify: false, missingEvidence: [] };
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
- return { continue: false, notify: false, missingEvidence: [] };
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,55 +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
- // Claude Code invokes Stop again after a Stop hook blocks the first stop. Re-blocking
942
- // that recovery turn creates a self-sustaining loop, so let it end normally instead.
943
- // If work still lacks evidence, surface the recovery reason to the user rather than
944
- // silently ending after the automatic continuation.
945
- // Vibecode autonomy keeps pushing through the reentrant Stop as well: the recovery turn
946
- // after a block is another chance to finish the work, not a release valve. Block again
947
- // with the reason; the loop still ends the moment evidence lands or a blocker appears.
948
- if (payload.stop_hook_active === true && state?.routeSummary?.autonomyLevel !== 'vibecode') {
949
- if (result.continue || result.capped || result.notify) {
950
- const recoveryReason = result.reason
951
- || 'UKit completion gate reached its continuation limit; unfinished work was not retried again.';
952
- process.stdout.write(`${JSON.stringify({
953
- systemMessage: `UKit stopped automatic recovery after one continuation: ${recoveryReason}`,
954
- })}\n`);
955
- }
956
- return;
957
- }
958
-
959
- if (result.continue) {
960
- if (result.finalNotice) await markNotified(projectRoot, payload, ledger);
961
- else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null, evidencePromptKey(state));
962
- process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
963
- } else if (result.capped || result.notify) {
964
- // Non-blocking endings (non-gated modes, or cap reached after the final notice) must
965
- // still tell the user what is unfinished — a silent end is indistinguishable from a stall.
966
- process.stderr.write(`[ukit-completion] ${result.reason}\n`);
967
- process.stdout.write(`${JSON.stringify({ systemMessage: result.reason })}\n`);
968
- }
969
1076
  }
970
1077
  }
971
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));