@ngockhoale/ukit 2.2.2 → 2.2.4
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 +32 -0
- package/manifests/platform.full.yaml +46 -0
- package/package.json +1 -1
- package/templates/.claude/hooks/completion-gate.sh +12 -0
- package/templates/.claude/hooks/record-execution.sh +12 -0
- package/templates/.claude/settings.json +31 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +354 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +115 -0
- package/templates/.omp/config.yml +3 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +346 -212
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.2.4 - 2026-08-27
|
|
6
|
+
|
|
7
|
+
Follow-up to 2.2.3's hook-chain collapse: a real omp session on a CloudMounter/FUSE-mounted project reported every tool call — including harmless `echo`, `pwd`, `ls`, and `Edit` — failing with `invalid hook-chain output`, and in one case `protect-files.sh hook chain was killed before safety gates completed`, despite `protect-files.sh` itself passing trivially. The bridge was misattributing any aggregate-runner transport failure to the first fail-closed script in the chain, whether or not that script ever ran.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Outer transport failures no longer impersonate a specific safety gate.** `runScriptChain` in `ukit-bridge.js` treated a killed/timed-out `pi.exec`, empty stdout, or malformed JSON from `hook-chain-runner.mjs` as proof that the chain's first fail-closed script (e.g. `protect-files.sh`) had failed — without verifying that script ever ran. It now distinguishes "the runner returned a verifiable per-script result" from "the runner itself failed before producing one," and on the latter reports the real failure (killed/code/elapsedMs/runtime) instead of a fabricated gate name. Diagnostics are appended to `.ukit/storage/cache/hook-errors/<session>.jsonl`.
|
|
12
|
+
- **Bash and Read/Grep/Glob no longer fail closed on a bridge/runner outage.** Only `Edit`/`Write` chains still block when the runner can't produce a verdict, since those guard file mutation. Ordinary shell commands and read-only tools now fail open (with a logged warning), so a runner-level hiccup can no longer brick command execution or diagnosis tools.
|
|
13
|
+
- **`process.execPath` is no longer trusted blindly for the runner subprocess.** The bridge resolves `process.env.UKIT_NODE_PATH || process.execPath` and records the resolved runtime in the failure diagnostic, so a Bun-hosted omp launching the Node-only runner under the wrong binary is now visible instead of silently assumed.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **6 regression tests** in `tests/hooks/ompHookBridge.test.js` (`case 13`) covering a killed outer exec, malformed stdout, a non-zero exit with empty stdout before any script ran, fail-open behavior for Bash and Read chains under the same failures, and `UKIT_NODE_PATH` override.
|
|
18
|
+
|
|
19
|
+
## 2.2.3 - 2026-08-26
|
|
20
|
+
|
|
21
|
+
Sessions were stalling mid-task — the model would read source, then simply stop, with the work half done and nothing wrong upstream. This wave stops treating that as a prompting problem. Instructions asking a model to "continue until the edit is made" are advisory by nature: every hidden backend behind `unic-lite` / `unic-code` / `unic-smart` / `unic-vision` reads them slightly differently, and the ones that read them loosely stall. UKit now records what actually happened — real Edit/Write receipts, real verification exit codes — and enforces completion mechanically at the point of stopping, identically on Claude Code and omp. Nothing in the enforcement path branches on provider branding.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Silent stalls were unenforceable, because nothing measured them.** A stop after a read-only pass was indistinguishable from a stop after finished work: both were just the model ending its turn. UKit now keeps a session-scoped execution ledger (`.ukit/storage/cache/exec-ledger/<session>.json`) recording source reads, write attempts and outcomes, and verification commands with their exit codes. A new `Stop` hook (`completion-gate.sh`, and omp's `session_stop`) reads it against the routed contract's `completionEvidence` and blocks a premature stop with the specific missing evidence and a recovery instruction matched to the actual state — no source yet, failed write, no write, failed verification, or no verification each get different guidance. Recovery is capped at 6 continuations, then degrades to a warning, so the gate can never loop.
|
|
26
|
+
- **The omp bridge could exceed omp's own handler budget and freeze the session.** A single `Edit` fired 7 hook scripts as 7 serial `pi.exec` subprocesses at up to 8s each — worst case well past omp's ~30s outer timeout, which surfaces to the user as the session simply hanging. They now run in one bounded `hook-chain-runner.mjs` process: 4s per script, 10s total budget, under a 12s outer timeout. Per-event latency is appended to `.ukit/storage/cache/hook-latency/<session>.jsonl` so hook cost stops being invisible.
|
|
27
|
+
- **omp writes bypassed the safety gates entirely.** omp's `normalizeToolEventInput` emits `path` / `paths`; every UKit hook script reads `file_path`. So `protect-files.sh` and `stale-spec-guard.sh` saw no target on an omp edit and waved it through. The bridge now normalizes `path` / `paths[0]` → `file_path` at the host boundary, without mutating the caller's event.
|
|
28
|
+
- **An unmapped mutation-capable tool failed open and silently.** `mapToolName` returned `null` for any tool not in its table, and the bridge ran an empty script chain — so a host tool that writes files but is not yet mapped skipped every guard with no signal. Such tools are now blocked with a message naming the tool; unmapped read-only tools still pass through.
|
|
29
|
+
- **The ledger CLI silently did nothing under a symlinked project root.** Its main-guard compared `fileURLToPath(import.meta.url)` against `path.resolve(process.argv[1])`. Under macOS `/tmp` → `/private/tmp`, or any symlinked work directory, those differ — so `--record` and `--evaluate-stop` returned exit 0 having done nothing: no receipts, no gate. The whole unit suite stayed green; only a scratch install under `/tmp` exposed it. Both paths are now compared by realpath, covered by `tests/hooks/executionLedgerCli.test.js`.
|
|
30
|
+
- **`.omp/config.yml` documented the model aliases backwards**, calling the UNIC names "LITERAL model names, not aliases". They are stable provider-neutral aliases whose hidden backends change during development — which is precisely why orchestration must not branch on vendor names.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **`tests/hooks/executionLedgerCli.test.js`** (4 tests) — drives the real CLI through a symlinked root: receipts get written, a premature stop is blocked, a stop with write + passing verification is allowed, and the continuation cap engages at exactly 6. Verified RED against the pre-fix guard (3 of 4 fail).
|
|
35
|
+
- **Manifest entries and dev-mirror parity for the four new files** — `record-execution.sh`, `completion-gate.sh`, `execution-ledger.mjs`, `hook-chain-runner.mjs`. `templates/.claude/` is gitignored and existing template files were force-added, so new ones are invisible to both git and `npm pack` unless added the same way; verified present in the packed tarball, with hook scripts executable after a real `ukit install`.
|
|
36
|
+
|
|
5
37
|
## 2.2.2 - 2026-08-22
|
|
6
38
|
|
|
7
39
|
Running Claude Code and omp side by side exposed that they were not, in fact, running the same UKit. `CLAUDE.md` and `AGENTS.md` are hand-maintained forks of one instruction set read by different harnesses, and `AGENTS.md` had fallen three minor versions behind — so omp was never told the model-tier table existed. This wave re-unifies them and adds a test that makes the drift impossible to repeat.
|
|
@@ -1037,6 +1037,30 @@ items:
|
|
|
1037
1037
|
packs:
|
|
1038
1038
|
- core
|
|
1039
1039
|
|
|
1040
|
+
- id: hook-record-execution
|
|
1041
|
+
type: hook
|
|
1042
|
+
sourceTemplate: .claude/hooks/record-execution.sh
|
|
1043
|
+
targetPath: .claude/hooks/record-execution.sh
|
|
1044
|
+
requires:
|
|
1045
|
+
- ukit-runtime-execution-ledger-script
|
|
1046
|
+
mergeStrategy: overwrite_with_backup
|
|
1047
|
+
variables: []
|
|
1048
|
+
enabledByDefault: true
|
|
1049
|
+
packs:
|
|
1050
|
+
- core
|
|
1051
|
+
|
|
1052
|
+
- id: hook-completion-gate
|
|
1053
|
+
type: hook
|
|
1054
|
+
sourceTemplate: .claude/hooks/completion-gate.sh
|
|
1055
|
+
targetPath: .claude/hooks/completion-gate.sh
|
|
1056
|
+
requires:
|
|
1057
|
+
- ukit-runtime-execution-ledger-script
|
|
1058
|
+
mergeStrategy: overwrite_with_backup
|
|
1059
|
+
variables: []
|
|
1060
|
+
enabledByDefault: true
|
|
1061
|
+
packs:
|
|
1062
|
+
- core
|
|
1063
|
+
|
|
1040
1064
|
- id: hook-auto-allow-bash
|
|
1041
1065
|
type: hook
|
|
1042
1066
|
sourceTemplate: .claude/hooks/auto-allow-bash.sh
|
|
@@ -1223,6 +1247,28 @@ items:
|
|
|
1223
1247
|
packs:
|
|
1224
1248
|
- core
|
|
1225
1249
|
|
|
1250
|
+
- id: ukit-runtime-execution-ledger-script
|
|
1251
|
+
type: config
|
|
1252
|
+
sourceTemplate: .claude/ukit/runtime/execution-ledger.mjs
|
|
1253
|
+
targetPath: .claude/ukit/runtime/execution-ledger.mjs
|
|
1254
|
+
requires: []
|
|
1255
|
+
mergeStrategy: overwrite_with_backup
|
|
1256
|
+
variables: []
|
|
1257
|
+
enabledByDefault: true
|
|
1258
|
+
packs:
|
|
1259
|
+
- core
|
|
1260
|
+
|
|
1261
|
+
- id: ukit-runtime-hook-chain-runner-script
|
|
1262
|
+
type: config
|
|
1263
|
+
sourceTemplate: .claude/ukit/runtime/hook-chain-runner.mjs
|
|
1264
|
+
targetPath: .claude/ukit/runtime/hook-chain-runner.mjs
|
|
1265
|
+
requires: []
|
|
1266
|
+
mergeStrategy: overwrite_with_backup
|
|
1267
|
+
variables: []
|
|
1268
|
+
enabledByDefault: true
|
|
1269
|
+
packs:
|
|
1270
|
+
- core
|
|
1271
|
+
|
|
1226
1272
|
- id: ukit-runtime-safe-patch-core-script
|
|
1227
1273
|
type: config
|
|
1228
1274
|
sourceTemplate: .claude/ukit/runtime/safe-patch-core.mjs
|
package/package.json
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Stop hook: block premature terminal stops while routed completion evidence is missing.
|
|
3
|
+
|
|
4
|
+
INPUT=$(cat)
|
|
5
|
+
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
6
|
+
SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
|
|
7
|
+
|
|
8
|
+
if [ ! -f "$SCRIPT" ]; then
|
|
9
|
+
exit 0
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --evaluate-stop
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse hook: persist session-scoped source/write/verification receipts.
|
|
3
|
+
|
|
4
|
+
INPUT=$(cat)
|
|
5
|
+
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
6
|
+
SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
|
|
7
|
+
|
|
8
|
+
if [ ! -f "$SCRIPT" ]; then
|
|
9
|
+
exit 0
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --record
|
|
@@ -139,6 +139,16 @@
|
|
|
139
139
|
}
|
|
140
140
|
],
|
|
141
141
|
"PostToolUse": [
|
|
142
|
+
{
|
|
143
|
+
"matcher": "Read|Grep|Glob",
|
|
144
|
+
"hooks": [
|
|
145
|
+
{
|
|
146
|
+
"type": "command",
|
|
147
|
+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
|
|
148
|
+
"timeout": 4
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
},
|
|
142
152
|
{
|
|
143
153
|
"matcher": "Edit|Write",
|
|
144
154
|
"hooks": [
|
|
@@ -146,6 +156,11 @@
|
|
|
146
156
|
"type": "command",
|
|
147
157
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-verify.sh\"",
|
|
148
158
|
"timeout": 8
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"type": "command",
|
|
162
|
+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
|
|
163
|
+
"timeout": 4
|
|
149
164
|
}
|
|
150
165
|
]
|
|
151
166
|
},
|
|
@@ -156,6 +171,11 @@
|
|
|
156
171
|
"type": "command",
|
|
157
172
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/compress-output.sh\"",
|
|
158
173
|
"timeout": 8
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"type": "command",
|
|
177
|
+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
|
|
178
|
+
"timeout": 4
|
|
159
179
|
}
|
|
160
180
|
]
|
|
161
181
|
}
|
|
@@ -181,6 +201,17 @@
|
|
|
181
201
|
]
|
|
182
202
|
}
|
|
183
203
|
],
|
|
204
|
+
"Stop": [
|
|
205
|
+
{
|
|
206
|
+
"hooks": [
|
|
207
|
+
{
|
|
208
|
+
"type": "command",
|
|
209
|
+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\"",
|
|
210
|
+
"timeout": 4
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
}
|
|
214
|
+
],
|
|
184
215
|
"PreCompact": [
|
|
185
216
|
{
|
|
186
217
|
"hooks": [
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import fsSync from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const LEDGER_VERSION = 1;
|
|
10
|
+
const MAX_RECEIPTS = 24;
|
|
11
|
+
const MAX_CONTINUATIONS = 6;
|
|
12
|
+
const IMPLEMENT_MODES = new Set([
|
|
13
|
+
'tiny-fix',
|
|
14
|
+
'local-fix',
|
|
15
|
+
'local-build',
|
|
16
|
+
'shared-edit',
|
|
17
|
+
'find-cause',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function safeSegment(value) {
|
|
21
|
+
return String(value || 'default')
|
|
22
|
+
.trim()
|
|
23
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
24
|
+
.slice(0, 96) || 'default';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function firstDefined(values) {
|
|
28
|
+
return values.find((value) => value !== undefined && value !== null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sessionIdentity(payload = {}) {
|
|
32
|
+
const sessionId = firstDefined([
|
|
33
|
+
payload.session_id,
|
|
34
|
+
payload.sessionId,
|
|
35
|
+
]);
|
|
36
|
+
if (sessionId) return safeSegment(sessionId);
|
|
37
|
+
|
|
38
|
+
const transcriptPath = firstDefined([
|
|
39
|
+
payload.transcript_path,
|
|
40
|
+
payload.transcriptPath,
|
|
41
|
+
]);
|
|
42
|
+
if (transcriptPath) {
|
|
43
|
+
return `transcript-${crypto.createHash('sha256').update(String(transcriptPath)).digest('hex').slice(0, 20)}`;
|
|
44
|
+
}
|
|
45
|
+
return 'default';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ledgerPath(projectRoot, payload = {}) {
|
|
49
|
+
return path.join(
|
|
50
|
+
projectRoot,
|
|
51
|
+
'.ukit',
|
|
52
|
+
'storage',
|
|
53
|
+
'cache',
|
|
54
|
+
'exec-ledger',
|
|
55
|
+
`${sessionIdentity(payload)}.json`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readJson(filePath, fallback = null) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
62
|
+
} catch {
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writeJsonAtomic(filePath, value) {
|
|
68
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
69
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
70
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
71
|
+
await fs.rename(tempPath, filePath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function readRouteState(projectRoot) {
|
|
75
|
+
return readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readExecutionLedger(projectRoot, payload = {}) {
|
|
79
|
+
return readJson(ledgerPath(projectRoot, payload), null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function explicitError(payload = {}) {
|
|
83
|
+
return [
|
|
84
|
+
payload.isError,
|
|
85
|
+
payload.is_error,
|
|
86
|
+
payload.tool_output?.isError,
|
|
87
|
+
payload.tool_output?.is_error,
|
|
88
|
+
payload.tool_result?.isError,
|
|
89
|
+
payload.tool_result?.is_error,
|
|
90
|
+
payload.tool_response?.isError,
|
|
91
|
+
payload.tool_response?.is_error,
|
|
92
|
+
].some((value) => value === true);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractExitCode(payload = {}) {
|
|
96
|
+
const candidates = [
|
|
97
|
+
payload.tool_output?.exitCode,
|
|
98
|
+
payload.tool_output?.exit_code,
|
|
99
|
+
payload.tool_result?.details?.exitCode,
|
|
100
|
+
payload.tool_result?.details?.exit_code,
|
|
101
|
+
payload.tool_result?.exitCode,
|
|
102
|
+
payload.tool_result?.exit_code,
|
|
103
|
+
payload.tool_response?.exitCode,
|
|
104
|
+
payload.tool_response?.exit_code,
|
|
105
|
+
payload.exitCode,
|
|
106
|
+
payload.exit_code,
|
|
107
|
+
];
|
|
108
|
+
for (const value of candidates) {
|
|
109
|
+
const number = Number(value);
|
|
110
|
+
if (Number.isFinite(number)) return number;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isVerificationCommand(command) {
|
|
116
|
+
const text = String(command || '').trim();
|
|
117
|
+
if (!text) return false;
|
|
118
|
+
return [
|
|
119
|
+
/(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)/i,
|
|
120
|
+
/(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|lint|typecheck|check|build)(?:\s|$)/i,
|
|
121
|
+
/(?:^|\s)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$)/i,
|
|
122
|
+
/(?:^|\s)node\s+--check(?:\s|$)/i,
|
|
123
|
+
].some((pattern) => pattern.test(text));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function compactReceipt(receipt) {
|
|
127
|
+
const compact = {
|
|
128
|
+
ts: receipt.ts,
|
|
129
|
+
kind: receipt.kind,
|
|
130
|
+
success: receipt.success,
|
|
131
|
+
};
|
|
132
|
+
for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'error']) {
|
|
133
|
+
if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
|
|
134
|
+
compact[key] = receipt[key];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return compact;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function appendReceipt(receipts, receipt) {
|
|
141
|
+
return [...(receipts || []), compactReceipt(receipt)].slice(-MAX_RECEIPTS);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function freshLedger(payload, routeState, harness) {
|
|
145
|
+
return {
|
|
146
|
+
version: LEDGER_VERSION,
|
|
147
|
+
sessionKey: sessionIdentity(payload),
|
|
148
|
+
sessionId: payload.session_id || payload.sessionId || null,
|
|
149
|
+
transcriptPath: payload.transcript_path || payload.transcriptPath || null,
|
|
150
|
+
harness,
|
|
151
|
+
requestKey: routeState?.requestKey || null,
|
|
152
|
+
routeFingerprint: routeState?.fingerprint || null,
|
|
153
|
+
sourceSucceeded: false,
|
|
154
|
+
writeAttempted: false,
|
|
155
|
+
writeSucceeded: false,
|
|
156
|
+
verificationAttempted: false,
|
|
157
|
+
verificationSucceeded: false,
|
|
158
|
+
verificationFailed: false,
|
|
159
|
+
receipts: [],
|
|
160
|
+
blocker: null,
|
|
161
|
+
continuationCount: 0,
|
|
162
|
+
updatedAt: Date.now(),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function recordExecutionReceipt({
|
|
167
|
+
projectRoot,
|
|
168
|
+
payload = {},
|
|
169
|
+
toolName = payload.tool_name,
|
|
170
|
+
harness = 'unknown',
|
|
171
|
+
} = {}) {
|
|
172
|
+
if (!projectRoot || !toolName) return null;
|
|
173
|
+
const routeState = await readRouteState(projectRoot);
|
|
174
|
+
const current = await readExecutionLedger(projectRoot, payload);
|
|
175
|
+
const ledger = !current || current.requestKey !== (routeState?.requestKey || null)
|
|
176
|
+
? freshLedger(payload, routeState, harness)
|
|
177
|
+
: { ...current, harness: current.harness || harness };
|
|
178
|
+
|
|
179
|
+
const failed = explicitError(payload);
|
|
180
|
+
const exitCode = extractExitCode(payload);
|
|
181
|
+
const success = !failed && (exitCode === null || exitCode === 0);
|
|
182
|
+
const toolInput = payload.tool_input || {};
|
|
183
|
+
const receipt = {
|
|
184
|
+
ts: Date.now(),
|
|
185
|
+
toolName,
|
|
186
|
+
toolUseId: payload.tool_use_id || null,
|
|
187
|
+
success,
|
|
188
|
+
exitCode,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
|
|
192
|
+
receipt.kind = 'source';
|
|
193
|
+
receipt.file = toolInput.file_path || toolInput.path || null;
|
|
194
|
+
ledger.sourceSucceeded ||= success;
|
|
195
|
+
} else if (toolName === 'Edit' || toolName === 'Write') {
|
|
196
|
+
receipt.kind = 'write';
|
|
197
|
+
receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
|
|
198
|
+
ledger.writeAttempted = true;
|
|
199
|
+
ledger.writeSucceeded ||= success;
|
|
200
|
+
} else if (toolName === 'Bash' && isVerificationCommand(toolInput.command)) {
|
|
201
|
+
receipt.kind = 'verification';
|
|
202
|
+
receipt.command = String(toolInput.command || '').trim();
|
|
203
|
+
ledger.verificationAttempted = true;
|
|
204
|
+
ledger.verificationSucceeded ||= success;
|
|
205
|
+
ledger.verificationFailed ||= !success;
|
|
206
|
+
} else {
|
|
207
|
+
return ledger;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
ledger.receipts = appendReceipt(ledger.receipts, receipt);
|
|
211
|
+
ledger.updatedAt = Date.now();
|
|
212
|
+
await writeJsonAtomic(ledgerPath(projectRoot, payload), ledger);
|
|
213
|
+
return ledger;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function requiredEvidence(state = {}) {
|
|
217
|
+
const routeSummary = state?.routeSummary || {};
|
|
218
|
+
const contractEvidence = routeSummary.executionContract?.completionEvidence;
|
|
219
|
+
if (Array.isArray(contractEvidence) && contractEvidence.length > 0) {
|
|
220
|
+
return [...new Set(contractEvidence)];
|
|
221
|
+
}
|
|
222
|
+
return [...new Set(routeSummary.completionState?.missingEvidence || [])];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function evidenceSatisfied(evidence, ledger = {}) {
|
|
226
|
+
if (evidence === 'write-evidence') return ledger.writeSucceeded === true;
|
|
227
|
+
if (evidence === 'verification-evidence') return ledger.verificationSucceeded === true;
|
|
228
|
+
if (evidence === 'impact-evidence') return ledger.sourceSucceeded === true;
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function recoveryInstruction(missingEvidence, ledger = {}) {
|
|
233
|
+
if (missingEvidence.includes('write-evidence')) {
|
|
234
|
+
if (!ledger.sourceSucceeded) {
|
|
235
|
+
return 'Pull one bounded indexed source slice, then make the requested Edit/Write in this continuation.';
|
|
236
|
+
}
|
|
237
|
+
if (ledger.writeAttempted && !ledger.writeSucceeded) {
|
|
238
|
+
return 'Recover from the failed mutation using the current error, then retry the smallest correct Edit/Write.';
|
|
239
|
+
}
|
|
240
|
+
return 'Make the smallest correct Edit/Write now; do not end after more read-only analysis.';
|
|
241
|
+
}
|
|
242
|
+
if (missingEvidence.includes('verification-evidence')) {
|
|
243
|
+
if (ledger.verificationFailed) {
|
|
244
|
+
return 'Use the latest failed verification output, fix the failure, and rerun targeted verification.';
|
|
245
|
+
}
|
|
246
|
+
return 'Run the routed targeted verification now and inspect its result before stopping.';
|
|
247
|
+
}
|
|
248
|
+
return 'Complete the current routed milestone before stopping.';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
252
|
+
const routeSummary = state?.routeSummary || {};
|
|
253
|
+
const mode = routeSummary.executionMode || routeSummary.approachSelector?.executionMode || null;
|
|
254
|
+
const evidence = requiredEvidence(state);
|
|
255
|
+
if (!IMPLEMENT_MODES.has(mode) || evidence.length === 0 || ledger?.blocker) {
|
|
256
|
+
return { continue: false, missingEvidence: [] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const sameRequest = !ledger?.requestKey || !state?.requestKey || ledger.requestKey === state.requestKey;
|
|
260
|
+
const effectiveLedger = sameRequest ? ledger : {};
|
|
261
|
+
const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger));
|
|
262
|
+
if (missingEvidence.length === 0) {
|
|
263
|
+
return { continue: false, missingEvidence: [] };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const continuationCount = Number(effectiveLedger?.continuationCount || 0);
|
|
267
|
+
if (continuationCount >= MAX_CONTINUATIONS) {
|
|
268
|
+
return {
|
|
269
|
+
continue: false,
|
|
270
|
+
capped: true,
|
|
271
|
+
missingEvidence,
|
|
272
|
+
reason: `UKit continuation cap reached with missing evidence: ${missingEvidence.join(', ')}.`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const finalAttempt = continuationCount === MAX_CONTINUATIONS - 1;
|
|
277
|
+
const instruction = recoveryInstruction(missingEvidence, effectiveLedger);
|
|
278
|
+
return {
|
|
279
|
+
continue: true,
|
|
280
|
+
missingEvidence,
|
|
281
|
+
reason: [
|
|
282
|
+
`UKit completion gate: missing ${missingEvidence.join(', ')}.`,
|
|
283
|
+
instruction,
|
|
284
|
+
finalAttempt ? 'Final automatic recovery attempt: finish now or report a concrete blocker with its evidence.' : null,
|
|
285
|
+
].filter(Boolean).join(' '),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function incrementContinuation(projectRoot, payload = {}, ledger = null) {
|
|
290
|
+
const current = ledger || await readExecutionLedger(projectRoot, payload) || freshLedger(payload, null, 'unknown');
|
|
291
|
+
const next = {
|
|
292
|
+
...current,
|
|
293
|
+
continuationCount: Number(current.continuationCount || 0) + 1,
|
|
294
|
+
lastContinuationAt: Date.now(),
|
|
295
|
+
updatedAt: Date.now(),
|
|
296
|
+
};
|
|
297
|
+
await writeJsonAtomic(ledgerPath(projectRoot, payload), next);
|
|
298
|
+
return next;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function readStdin() {
|
|
302
|
+
if (process.stdin.isTTY) return '';
|
|
303
|
+
const chunks = [];
|
|
304
|
+
for await (const chunk of process.stdin) chunks.push(String(chunk));
|
|
305
|
+
return chunks.join('');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function main() {
|
|
309
|
+
const payload = JSON.parse((await readStdin()) || '{}');
|
|
310
|
+
const projectRoot = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
|
|
311
|
+
if (process.argv.includes('--record')) {
|
|
312
|
+
await recordExecutionReceipt({
|
|
313
|
+
projectRoot,
|
|
314
|
+
payload,
|
|
315
|
+
toolName: payload.tool_name,
|
|
316
|
+
harness: process.env.UKIT_HARNESS || 'claude-code',
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (process.argv.includes('--evaluate-stop')) {
|
|
321
|
+
const state = await readRouteState(projectRoot);
|
|
322
|
+
const ledger = await readExecutionLedger(projectRoot, payload) || {};
|
|
323
|
+
const result = evaluateCompletion({ state, ledger });
|
|
324
|
+
if (result.continue) {
|
|
325
|
+
await incrementContinuation(projectRoot, payload, ledger);
|
|
326
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
|
|
327
|
+
} else if (result.capped) {
|
|
328
|
+
process.stderr.write(`[ukit-completion] ${result.reason}\n`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Compare real paths: a project under a symlinked root (macOS /tmp -> /private/tmp, or a
|
|
334
|
+
// symlinked work directory) makes import.meta.url resolve to the real path while argv[1]
|
|
335
|
+
// keeps the symlinked spelling. Without realpath the guard silently never runs the CLI.
|
|
336
|
+
function isDirectRun() {
|
|
337
|
+
const argvPath = process.argv[1];
|
|
338
|
+
if (!argvPath) return false;
|
|
339
|
+
const selfPath = fileURLToPath(import.meta.url);
|
|
340
|
+
const resolved = path.resolve(argvPath);
|
|
341
|
+
if (selfPath === resolved) return true;
|
|
342
|
+
try {
|
|
343
|
+
return fsSync.realpathSync(selfPath) === fsSync.realpathSync(resolved);
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (isDirectRun()) {
|
|
350
|
+
main().catch((error) => {
|
|
351
|
+
process.stderr.write(`[ukit-execution-ledger] ${error?.message || error}\n`);
|
|
352
|
+
process.exitCode = 1;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const FAIL_CLOSED_SCRIPTS = new Set([
|
|
8
|
+
'protect-files.sh',
|
|
9
|
+
'stale-spec-guard.sh',
|
|
10
|
+
'handoff-model-guard.sh',
|
|
11
|
+
'vision-gate.sh',
|
|
12
|
+
'context-hardcap-gate.sh',
|
|
13
|
+
'block-dangerous.sh',
|
|
14
|
+
'verification-guard.sh',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const TOTAL_BUDGET_MS = 10000;
|
|
18
|
+
const CHILD_BUDGET_MS = 4000;
|
|
19
|
+
const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
function safeName(value) {
|
|
22
|
+
return String(value || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function recordTiming(projectRoot, payload, timing) {
|
|
26
|
+
try {
|
|
27
|
+
const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
const filePath = path.join(dir, `${safeName(payload?.session_id)}.jsonl`);
|
|
30
|
+
fs.appendFileSync(filePath, `${JSON.stringify(timing)}\n`, 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
// Timing telemetry is advisory and must never delay or block a tool call.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function run(payloadText, scriptPaths) {
|
|
37
|
+
const payload = JSON.parse(payloadText || '{}');
|
|
38
|
+
const firstScript = scriptPaths[0] || '';
|
|
39
|
+
const projectRoot = firstScript
|
|
40
|
+
? path.resolve(path.dirname(firstScript), '../..')
|
|
41
|
+
: (payload.cwd || process.cwd());
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
const deadline = startedAt + TOTAL_BUDGET_MS;
|
|
44
|
+
const results = [];
|
|
45
|
+
|
|
46
|
+
for (const scriptPath of scriptPaths) {
|
|
47
|
+
const scriptName = path.basename(scriptPath);
|
|
48
|
+
const remainingMs = deadline - Date.now();
|
|
49
|
+
if (remainingMs <= 0) {
|
|
50
|
+
results.push({
|
|
51
|
+
scriptName,
|
|
52
|
+
code: 1,
|
|
53
|
+
stdout: '',
|
|
54
|
+
stderr: `hook chain exceeded its ${TOTAL_BUDGET_MS}ms total budget`,
|
|
55
|
+
killed: true,
|
|
56
|
+
elapsedMs: 0,
|
|
57
|
+
});
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const childStartedAt = Date.now();
|
|
62
|
+
const result = spawnSync(scriptPath, [], {
|
|
63
|
+
cwd: projectRoot,
|
|
64
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
|
|
65
|
+
input: payloadText,
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
timeout: Math.min(CHILD_BUDGET_MS, remainingMs),
|
|
68
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
69
|
+
});
|
|
70
|
+
const code = Number.isFinite(result.status) ? result.status : 1;
|
|
71
|
+
const killed = Boolean(result.signal || result.error?.code === 'ETIMEDOUT');
|
|
72
|
+
const stderr = [result.stderr, result.error?.message].filter(Boolean).join('\n');
|
|
73
|
+
results.push({
|
|
74
|
+
scriptName,
|
|
75
|
+
code,
|
|
76
|
+
stdout: result.stdout || '',
|
|
77
|
+
stderr,
|
|
78
|
+
killed,
|
|
79
|
+
elapsedMs: Date.now() - childStartedAt,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (code === 2 || killed || (code !== 0 && FAIL_CLOSED_SCRIPTS.has(scriptName))) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const elapsedMs = Date.now() - startedAt;
|
|
88
|
+
recordTiming(projectRoot, payload, {
|
|
89
|
+
ts: Date.now(),
|
|
90
|
+
hookEvent: payload?.hook_event_name || null,
|
|
91
|
+
toolName: payload?.tool_name || null,
|
|
92
|
+
toolUseId: payload?.tool_use_id || null,
|
|
93
|
+
elapsedMs,
|
|
94
|
+
budgetMs: TOTAL_BUDGET_MS,
|
|
95
|
+
scripts: results.map(({ scriptName, code, killed, elapsedMs: scriptElapsedMs }) => ({
|
|
96
|
+
scriptName,
|
|
97
|
+
code,
|
|
98
|
+
killed,
|
|
99
|
+
elapsedMs: scriptElapsedMs,
|
|
100
|
+
})),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return { results, elapsedMs, budgetMs: TOTAL_BUDGET_MS };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const [, , payloadText = '{}', ...scriptPaths] = process.argv;
|
|
108
|
+
process.stdout.write(JSON.stringify(run(payloadText, scriptPaths)));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
process.stdout.write(JSON.stringify({
|
|
111
|
+
results: [],
|
|
112
|
+
wrapperError: error?.message || String(error),
|
|
113
|
+
}));
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
# UNIC gateway model names are
|
|
2
|
-
#
|
|
1
|
+
# UNIC gateway model names are stable provider-neutral aliases. Their hidden backend
|
|
2
|
+
# models may change during development; orchestration must not branch on vendor names.
|
|
3
|
+
# Do NOT substitute sonnet/opus/haiku here. See PLAN.md §3 D15.
|
|
3
4
|
#
|
|
4
5
|
# Non-UNIC omp provider? A maintainer edits the three cost tiers to the values in
|
|
5
6
|
# orchestration.modelTiers[*].claudeModel (.ukit/storage/config.json) — currently
|