@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 +39 -0
- package/package.json +1 -1
- package/scripts/release/verify-release.mjs +45 -0
- package/src/core/ensureGitignore.js +14 -9
- package/src/core/taskBudgetValidator.js +10 -4
- package/src/core/taskProgressGuard.js +12 -2
- package/templates/.claude/hooks/completion-gate.sh +13 -1
- package/templates/.claude/hooks/record-execution.sh +13 -1
- package/templates/.claude/ukit/index/task-budget-validator.mjs +9 -3
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +158 -51
- package/templates/.omp/hooks/pre/ukit-bridge.js +0 -18
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,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
|
-
'
|
|
11
|
+
'/.cache/',
|
|
7
12
|
// legacy: Antigravity adapter removed in v2.2.0; leftovers must stay ignored
|
|
8
|
-
'
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
12
|
-
'
|
|
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 (
|
|
92
|
-
|
|
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` —
|
|
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 (
|
|
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
|
|
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 (
|
|
74
|
-
|
|
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 (
|
|
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
|
-
|
|
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,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));
|