@ngockhoale/ukit 2.6.2 → 2.6.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 +97 -0
- package/package.json +1 -1
- package/scripts/bench/parallel-agents.mjs +42 -4
- package/scripts/release/verify-release.mjs +43 -6
- package/src/core/applyPlan.js +65 -0
- package/src/core/compact/threshold.js +33 -30
- package/src/core/fileOps.js +9 -2
- package/src/core/runInstallPipeline.js +10 -1
- package/src/core/status.js +1 -1
- package/templates/.claude/hooks/auto-allow-bash.sh +29 -20
- package/templates/.claude/hooks/auto-prune-bash.sh +48 -30
- package/templates/.claude/hooks/completion-gate.sh +22 -1
- package/templates/.claude/hooks/context-hardcap-gate.sh +19 -4
- package/templates/.claude/hooks/handoff-model-guard.sh +10 -1
- package/templates/.claude/hooks/handoff-resume.sh +19 -17
- package/templates/.claude/hooks/post-edit-verify.sh +1 -0
- package/templates/.claude/hooks/pre-edit-backup.sh +1 -0
- package/templates/.claude/hooks/reset-compact-pressure.sh +39 -22
- package/templates/.claude/hooks/stale-spec-guard.sh +1 -0
- package/templates/.claude/hooks/verification-guard.sh +17 -1
- package/templates/.claude/settings.json +4 -4
- package/templates/.claude/ukit/index/extract-image.mjs +49 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +62 -40
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +34 -5
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +11 -4
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +7 -3
- package/templates/.claude/ukit/runtime/output-compression.mjs +16 -5
- package/templates/.claude/ukit/runtime/reinject-context.mjs +2 -2
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +3 -0
- package/templates/.claude/ukit/runtime/token-utils.mjs +9 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +72 -4
- package/templates/ukit/storage/config.json +2 -0
|
@@ -145,7 +145,7 @@ UKIT_RUNTIME_DIR="$SCRIPT_DIR/../ukit/runtime" node -e '
|
|
|
145
145
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
|
|
146
146
|
const LOCK_STARTED_AT = Date.now();
|
|
147
147
|
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
148
|
-
const
|
|
148
|
+
const fsp = require("fs").promises;
|
|
149
149
|
const path = require("path");
|
|
150
150
|
const { pathToFileURL } = require("url");
|
|
151
151
|
|
|
@@ -156,35 +156,44 @@ const rule = process.argv[4];
|
|
|
156
156
|
const nowUtc = process.argv[5];
|
|
157
157
|
const maxRules = Math.max(1, Number(process.argv[6]) || 150);
|
|
158
158
|
|
|
159
|
-
function
|
|
160
|
-
if (!fs.existsSync(filePath)) return fallback;
|
|
159
|
+
async function pathExists(filePath) {
|
|
161
160
|
try {
|
|
162
|
-
|
|
161
|
+
await fsp.access(filePath);
|
|
162
|
+
return true;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readJson(filePath, fallback) {
|
|
169
|
+
if (!(await pathExists(filePath))) return fallback;
|
|
170
|
+
try {
|
|
171
|
+
return JSON.parse(await fsp.readFile(filePath, "utf8"));
|
|
163
172
|
} catch {
|
|
164
173
|
return fallback;
|
|
165
174
|
}
|
|
166
175
|
}
|
|
167
176
|
|
|
168
|
-
function writeJsonAtomic(filePath, value) {
|
|
169
|
-
|
|
177
|
+
async function writeJsonAtomic(filePath, value) {
|
|
178
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
170
179
|
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
171
180
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
181
|
+
await fsp.writeFile(tempPath, JSON.stringify(value, null, 2) + "\n");
|
|
182
|
+
await fsp.rename(tempPath, filePath);
|
|
174
183
|
} catch (error) {
|
|
175
184
|
try {
|
|
176
|
-
|
|
185
|
+
await fsp.rm(tempPath, { force: true });
|
|
177
186
|
} catch {}
|
|
178
187
|
throw error;
|
|
179
188
|
}
|
|
180
189
|
}
|
|
181
190
|
|
|
182
|
-
function appendAudit(event, payload) {
|
|
183
|
-
|
|
191
|
+
async function appendAudit(event, payload) {
|
|
192
|
+
await fsp.appendFile(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
|
|
184
193
|
}
|
|
185
194
|
|
|
186
195
|
async function applyMutation() {
|
|
187
|
-
const settings = readJson(settingsPath, {});
|
|
196
|
+
const settings = await readJson(settingsPath, {});
|
|
188
197
|
if (!settings.permissions || typeof settings.permissions !== "object") {
|
|
189
198
|
settings.permissions = {};
|
|
190
199
|
}
|
|
@@ -192,7 +201,7 @@ async function applyMutation() {
|
|
|
192
201
|
settings.permissions.allow = [];
|
|
193
202
|
}
|
|
194
203
|
|
|
195
|
-
const usage = readJson(usagePath, {});
|
|
204
|
+
const usage = await readJson(usagePath, {});
|
|
196
205
|
if (!usage.managedRules || typeof usage.managedRules !== "object") {
|
|
197
206
|
usage.managedRules = {};
|
|
198
207
|
}
|
|
@@ -203,7 +212,7 @@ async function applyMutation() {
|
|
|
203
212
|
if (!allow.includes(rule)) {
|
|
204
213
|
allow.push(rule);
|
|
205
214
|
settingsChanged = true;
|
|
206
|
-
appendAudit("auto_add", { rule });
|
|
215
|
+
await appendAudit("auto_add", { rule });
|
|
207
216
|
}
|
|
208
217
|
|
|
209
218
|
const prevMeta = usage.managedRules[rule] || {};
|
|
@@ -227,27 +236,27 @@ async function applyMutation() {
|
|
|
227
236
|
allow.splice(idx, 1);
|
|
228
237
|
settingsChanged = true;
|
|
229
238
|
}
|
|
230
|
-
appendAudit("auto_prune_cap", { rule: evictRule, maxRules });
|
|
239
|
+
await appendAudit("auto_prune_cap", { rule: evictRule, maxRules });
|
|
231
240
|
}
|
|
232
241
|
}
|
|
233
242
|
|
|
234
243
|
if (settingsChanged) {
|
|
235
|
-
writeJsonAtomic(settingsPath, settings);
|
|
244
|
+
await writeJsonAtomic(settingsPath, settings);
|
|
236
245
|
}
|
|
237
|
-
writeJsonAtomic(usagePath, usage);
|
|
246
|
+
await writeJsonAtomic(usagePath, usage);
|
|
238
247
|
}
|
|
239
248
|
|
|
240
249
|
(async () => {
|
|
241
250
|
// The lock directory lives beside the usage file, so its parent must exist BEFORE
|
|
242
251
|
// the lock is taken — a first-ever run in a fresh project would otherwise fail
|
|
243
252
|
// mkdir with ENOENT.
|
|
244
|
-
|
|
245
|
-
|
|
253
|
+
await fsp.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
254
|
+
await fsp.mkdir(path.dirname(usagePath), { recursive: true });
|
|
246
255
|
|
|
247
256
|
const runtimeDir = process.env.UKIT_RUNTIME_DIR || "";
|
|
248
257
|
const lockModulePath = runtimeDir ? path.join(runtimeDir, "async-lock.mjs") : "";
|
|
249
258
|
let lockModule = null;
|
|
250
|
-
if (lockModulePath &&
|
|
259
|
+
if (lockModulePath && (await pathExists(lockModulePath))) {
|
|
251
260
|
try {
|
|
252
261
|
lockModule = await import(pathToFileURL(lockModulePath).href);
|
|
253
262
|
} catch {
|
|
@@ -17,9 +17,24 @@ node -e '
|
|
|
17
17
|
// Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
|
|
18
18
|
// watchdog can never fire while the mutation is still legally waiting for the lock.
|
|
19
19
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
// SPEC §8: a failed prune abandons configured work — announce it (success stays silent).
|
|
22
|
+
// Unpruned rules are harmless (stale entries simply never match again), so degrade is
|
|
23
|
+
// advisory-only and still exits 0.
|
|
24
|
+
function emitDegrade(reason) {
|
|
25
|
+
try {
|
|
26
|
+
process.stdout.write(JSON.stringify({
|
|
27
|
+
systemMessage: `UKit auto-prune-bash: ${reason}`,
|
|
28
|
+
}) + "\n");
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setTimeout(() => {
|
|
33
|
+
emitDegrade("prune exceeded its deadline; stale Bash auto-allow rules were not pruned this session — the next session retries.");
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
21
36
|
const crypto = require("crypto");
|
|
22
|
-
const
|
|
37
|
+
const fsp = require("fs").promises;
|
|
23
38
|
const path = require("path");
|
|
24
39
|
|
|
25
40
|
const settingsPath = process.argv[1];
|
|
@@ -29,30 +44,30 @@ const ttlDays = Math.max(1, Number(process.argv[4]) || 30);
|
|
|
29
44
|
const nowUtc = process.argv[5];
|
|
30
45
|
const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000;
|
|
31
46
|
|
|
32
|
-
function readJson(filePath) {
|
|
47
|
+
async function readJson(filePath) {
|
|
33
48
|
try {
|
|
34
|
-
return JSON.parse(
|
|
49
|
+
return JSON.parse(await fsp.readFile(filePath, "utf8"));
|
|
35
50
|
} catch {
|
|
36
51
|
return null;
|
|
37
52
|
}
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
function writeJsonAtomic(filePath, value) {
|
|
41
|
-
|
|
55
|
+
async function writeJsonAtomic(filePath, value) {
|
|
56
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
42
57
|
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
43
58
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
await fsp.writeFile(tempPath, JSON.stringify(value, null, 2) + "\n");
|
|
60
|
+
await fsp.rename(tempPath, filePath);
|
|
46
61
|
} catch (error) {
|
|
47
62
|
try {
|
|
48
|
-
|
|
63
|
+
await fsp.rm(tempPath, { force: true });
|
|
49
64
|
} catch {}
|
|
50
65
|
throw error;
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
|
|
54
|
-
function appendAudit(event, payload) {
|
|
55
|
-
|
|
69
|
+
async function appendAudit(event, payload) {
|
|
70
|
+
await fsp.appendFile(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
// Same lock point as auto-allow-bash.sh (<usage>.lock): the two hooks serialize their
|
|
@@ -70,8 +85,8 @@ function isPidAlive(pid) {
|
|
|
70
85
|
}
|
|
71
86
|
}
|
|
72
87
|
|
|
73
|
-
function readLockOwner(lockPath) {
|
|
74
|
-
const owner = readJson(path.join(lockPath, "owner")
|
|
88
|
+
async function readLockOwner(lockPath) {
|
|
89
|
+
const owner = await readJson(path.join(lockPath, "owner"));
|
|
75
90
|
return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
|
|
76
91
|
? owner
|
|
77
92
|
: null;
|
|
@@ -85,17 +100,17 @@ async function withLock(lockPath, fn) {
|
|
|
85
100
|
let held = false;
|
|
86
101
|
while (!held) {
|
|
87
102
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
await fsp.mkdir(path.dirname(lockPath), { recursive: true });
|
|
104
|
+
await fsp.mkdir(lockPath);
|
|
90
105
|
try {
|
|
91
|
-
|
|
106
|
+
await fsp.writeFile(path.join(lockPath, "owner"), JSON.stringify({
|
|
92
107
|
pid: process.pid,
|
|
93
108
|
token: ownerToken,
|
|
94
109
|
ts: Date.now(),
|
|
95
110
|
}));
|
|
96
111
|
} catch (error) {
|
|
97
112
|
try {
|
|
98
|
-
|
|
113
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
99
114
|
} catch {}
|
|
100
115
|
throw error;
|
|
101
116
|
}
|
|
@@ -103,12 +118,12 @@ async function withLock(lockPath, fn) {
|
|
|
103
118
|
} catch (error) {
|
|
104
119
|
if (!error || error.code !== "EEXIST") return fn();
|
|
105
120
|
try {
|
|
106
|
-
const stat =
|
|
121
|
+
const stat = await fsp.stat(lockPath);
|
|
107
122
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
108
|
-
const owner = readLockOwner(lockPath);
|
|
123
|
+
const owner = await readLockOwner(lockPath);
|
|
109
124
|
if (!owner || !isPidAlive(owner.pid)) {
|
|
110
125
|
try {
|
|
111
|
-
|
|
126
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
112
127
|
continue;
|
|
113
128
|
} catch {}
|
|
114
129
|
}
|
|
@@ -122,10 +137,10 @@ async function withLock(lockPath, fn) {
|
|
|
122
137
|
return await fn();
|
|
123
138
|
} finally {
|
|
124
139
|
if (held) {
|
|
125
|
-
const owner = readLockOwner(lockPath);
|
|
140
|
+
const owner = await readLockOwner(lockPath);
|
|
126
141
|
if (owner?.token === ownerToken) {
|
|
127
142
|
try {
|
|
128
|
-
|
|
143
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
129
144
|
} catch {}
|
|
130
145
|
}
|
|
131
146
|
}
|
|
@@ -133,8 +148,8 @@ async function withLock(lockPath, fn) {
|
|
|
133
148
|
}
|
|
134
149
|
|
|
135
150
|
withLock(`${usagePath}.lock`, async () => {
|
|
136
|
-
const settings = readJson(settingsPath);
|
|
137
|
-
const usage = readJson(usagePath);
|
|
151
|
+
const settings = await readJson(settingsPath);
|
|
152
|
+
const usage = await readJson(usagePath);
|
|
138
153
|
if (!settings || !usage) return;
|
|
139
154
|
if (!settings.permissions || typeof settings.permissions !== "object") return;
|
|
140
155
|
if (!Array.isArray(settings.permissions.allow)) return;
|
|
@@ -155,20 +170,23 @@ withLock(`${usagePath}.lock`, async () => {
|
|
|
155
170
|
}
|
|
156
171
|
delete usage.managedRules[rule];
|
|
157
172
|
prunedCount += 1;
|
|
158
|
-
appendAudit("auto_prune_ttl", { rule, ttlDays });
|
|
173
|
+
await appendAudit("auto_prune_ttl", { rule, ttlDays });
|
|
159
174
|
}
|
|
160
175
|
|
|
161
176
|
usage.lastPruneAt = nowUtc;
|
|
162
177
|
|
|
163
178
|
if (changedSettings) {
|
|
164
|
-
writeJsonAtomic(settingsPath, settings);
|
|
179
|
+
await writeJsonAtomic(settingsPath, settings);
|
|
165
180
|
}
|
|
166
|
-
writeJsonAtomic(usagePath, usage);
|
|
181
|
+
await writeJsonAtomic(usagePath, usage);
|
|
167
182
|
|
|
168
183
|
if (prunedCount > 0) {
|
|
169
|
-
appendAudit("auto_prune_summary", { prunedCount, ttlDays });
|
|
184
|
+
await appendAudit("auto_prune_summary", { prunedCount, ttlDays });
|
|
170
185
|
}
|
|
171
|
-
}).catch(() => {
|
|
172
|
-
|
|
186
|
+
}).catch((error) => {
|
|
187
|
+
emitDegrade(`prune failed (${error?.message ?? error}); stale Bash auto-allow rules were not pruned this session — the next session retries.`);
|
|
188
|
+
process.exit(0);
|
|
189
|
+
});
|
|
190
|
+
' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$TTL_DAYS" "$NOW_UTC" 2>/dev/null || true
|
|
173
191
|
|
|
174
192
|
exit 0
|
|
@@ -82,13 +82,34 @@ OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="
|
|
|
82
82
|
STATUS=$?
|
|
83
83
|
|
|
84
84
|
if [ "$STATUS" -ne 0 ]; then
|
|
85
|
+
# BUG-C22-05: a PERSISTENT coordinator crash must not block every Stop forever.
|
|
86
|
+
# Byte-append one marker per crash; a clean run deletes the file, so the count
|
|
87
|
+
# is consecutive crashes, not lifetime crashes. File-based (no node) so the
|
|
88
|
+
# breaker still works when node itself is what is crashing.
|
|
89
|
+
CRASH_FILE="$PROJECT_ROOT/.ukit/storage/cache/completion-gate-crash.streak"
|
|
90
|
+
mkdir -p "$(dirname "$CRASH_FILE")" 2>/dev/null
|
|
91
|
+
printf '.' >> "$CRASH_FILE" 2>/dev/null
|
|
92
|
+
STREAK="$(wc -c < "$CRASH_FILE" 2>/dev/null | tr -d '[:space:]')"
|
|
93
|
+
case "$STREAK" in ''|*[!0-9]*) STREAK=1 ;; esac
|
|
94
|
+
MAX_STREAK="$(sed -n 's/.*"stopGateMaxCrashStreaks"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$PROJECT_ROOT/.ukit/storage/config.json" 2>/dev/null | head -1)"
|
|
95
|
+
case "$MAX_STREAK" in ''|*[!0-9]*) MAX_STREAK=3 ;; esac
|
|
96
|
+
if [ "$STREAK" -gt "$MAX_STREAK" ]; then
|
|
97
|
+
# Loud release after the cap: the gate is broken, not the work — say so,
|
|
98
|
+
# name remediation, and let the session end instead of looping forever.
|
|
99
|
+
rm -f "$CRASH_FILE" 2>/dev/null
|
|
100
|
+
printf '%s\n' '{"systemMessage":"UKit stop coordinator released this stop after '"$MAX_STREAK"' consecutive infrastructure failures (the coordinator crashed every Stop — gate fail-closed cap reached). The completion gate is DOWN, not your work: run `ukit install` to repair, then re-send the task in a new message if the run must continue."}'
|
|
101
|
+
exit 0
|
|
102
|
+
fi
|
|
85
103
|
# TASK-025: the Stop coordinator is the fail-closed blocker — a crash must not
|
|
86
104
|
# release the stop. The reason is a fixed redacted string: crash detail (already
|
|
87
105
|
# written to stderr by the coordinator) never leaks into the decision.
|
|
88
|
-
printf '%s\n' '{"decision":"block","reason":"UKit stop coordinator: infrastructure failure while evaluating this stop — the stop is blocked fail-closed (details withheld). Run: ukit install, then re-send the task in a new message."}'
|
|
106
|
+
printf '%s\n' '{"decision":"block","reason":"UKit stop coordinator: infrastructure failure while evaluating this stop — the stop is blocked fail-closed (crash streak '"$STREAK"'/'"$MAX_STREAK"'; details withheld). Run: ukit install, then re-send the task in a new message."}'
|
|
89
107
|
exit 0
|
|
90
108
|
fi
|
|
91
109
|
|
|
110
|
+
# A clean coordinator run resets the consecutive-crash streak.
|
|
111
|
+
rm -f "$PROJECT_ROOT/.ukit/storage/cache/completion-gate-crash.streak" 2>/dev/null
|
|
112
|
+
|
|
92
113
|
if [ -n "$OUTPUT" ]; then
|
|
93
114
|
printf '%s\n' "$OUTPUT"
|
|
94
115
|
fi
|
|
@@ -89,7 +89,16 @@ fi
|
|
|
89
89
|
|
|
90
90
|
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" node <<'NODE'
|
|
91
91
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
92
|
-
|
|
92
|
+
// SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
|
|
93
|
+
// degrade (fail-open posture kept: still exit 0).
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
try {
|
|
96
|
+
process.stdout.write(JSON.stringify({
|
|
97
|
+
systemMessage: `UKit context hard-cap gate: evaluation exceeded its ${HOOK_DEADLINE_MS}ms deadline; this tool call proceeded without the hard-cap check.`,
|
|
98
|
+
}) + '\n');
|
|
99
|
+
} catch {}
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
93
102
|
const fs = require('fs');
|
|
94
103
|
const fsp = fs.promises;
|
|
95
104
|
const path = require('path');
|
|
@@ -179,6 +188,12 @@ async function readRunCursor() {
|
|
|
179
188
|
|
|
180
189
|
const thresholdModulePath = path.join(hookDir, '..', 'ukit', 'runtime', 'compact-threshold.mjs');
|
|
181
190
|
if (!(await pathExists(thresholdModulePath))) {
|
|
191
|
+
// SPEC §8: a missing runtime silently disables a fail-closed gate — announce.
|
|
192
|
+
try {
|
|
193
|
+
process.stdout.write(JSON.stringify({
|
|
194
|
+
systemMessage: 'UKit context hard-cap gate: compact-threshold runtime missing — gate skipped this pass; run: ukit install',
|
|
195
|
+
}) + '\n');
|
|
196
|
+
} catch {}
|
|
182
197
|
process.exit(0);
|
|
183
198
|
return;
|
|
184
199
|
}
|
|
@@ -196,7 +211,7 @@ async function readRunCursor() {
|
|
|
196
211
|
// bricks a healthy session at this cap. The transcript's own compact_boundary entries
|
|
197
212
|
// are the reliable record of real compactions, so sync before deciding, and persist the
|
|
198
213
|
// reset so it re-arms exactly once per boundary instead of disabling the gate.
|
|
199
|
-
let state = mod.buildCompactPressureState(rawState, sessionConfig);
|
|
214
|
+
let state = await mod.buildCompactPressureState(rawState, sessionConfig);
|
|
200
215
|
try {
|
|
201
216
|
const synced = await mod.syncCompactPressureStateWithTranscript(state, sessionConfig, payload.transcript_path);
|
|
202
217
|
if (synced.reset || synced.probed) {
|
|
@@ -205,12 +220,12 @@ async function readRunCursor() {
|
|
|
205
220
|
state = await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig);
|
|
206
221
|
}
|
|
207
222
|
} catch {
|
|
208
|
-
state = mod.buildCompactPressureState(rawState, sessionConfig);
|
|
223
|
+
state = await mod.buildCompactPressureState(rawState, sessionConfig);
|
|
209
224
|
}
|
|
210
225
|
// projectRoot is passed explicitly so the cap is negotiated from THIS project's
|
|
211
226
|
// published capacity record rather than from whatever cwd the host happened to use —
|
|
212
227
|
// the gate is fail-closed, so it must read the same evidence the guard wrote.
|
|
213
|
-
const thresholds = mod.buildCompactThresholds({ ...config, projectRoot });
|
|
228
|
+
const thresholds = await mod.buildCompactThresholds({ ...config, projectRoot });
|
|
214
229
|
|
|
215
230
|
// The grace budget must survive parallel subagents hitting the cap in the same episode.
|
|
216
231
|
// A read-modify-write of one JSON file under-counts (two processes both read used=3 and
|
|
@@ -83,7 +83,16 @@ fi
|
|
|
83
83
|
|
|
84
84
|
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
|
|
85
85
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
86
|
-
|
|
86
|
+
// SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
|
|
87
|
+
// degrade (fail-open posture kept: still exit 0).
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
try {
|
|
90
|
+
process.stdout.write(JSON.stringify({
|
|
91
|
+
systemMessage: `UKit handoff-model-guard: evaluation exceeded its ${HOOK_DEADLINE_MS}ms deadline; this tool call proceeded without the model check.`,
|
|
92
|
+
}) + '\n');
|
|
93
|
+
} catch {}
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
87
96
|
const fs = require('fs');
|
|
88
97
|
const fsp = fs.promises;
|
|
89
98
|
const path = require('path');
|
|
@@ -89,24 +89,14 @@ const { pathToFileURL } = require('url');
|
|
|
89
89
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
90
90
|
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
91
91
|
|
|
92
|
-
let rawInput = '';
|
|
93
|
-
try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
94
|
-
const payload = (() => {
|
|
95
|
-
try {
|
|
96
|
-
const parsed = JSON.parse(rawInput);
|
|
97
|
-
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
98
|
-
} catch {
|
|
99
|
-
return {};
|
|
100
|
-
}
|
|
101
|
-
})();
|
|
102
|
-
|
|
103
92
|
const projectRoot = process.env.PROJECT_ROOT;
|
|
104
93
|
const runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
|
|
105
94
|
const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'execution-ledger.mjs');
|
|
106
|
-
const source = typeof payload.source === 'string' ? payload.source : 'startup';
|
|
107
95
|
|
|
108
|
-
async function emitOrdinaryResume() {
|
|
109
|
-
|
|
96
|
+
async function emitOrdinaryResume(payload, source) {
|
|
97
|
+
let runtimeExists = false;
|
|
98
|
+
try { await fs.promises.access(runtimePath); runtimeExists = true; } catch {}
|
|
99
|
+
if (!runtimeExists) return false;
|
|
110
100
|
try {
|
|
111
101
|
const runtime = await import(pathToFileURL(runtimePath).href);
|
|
112
102
|
const intent = await runtime.readResumeIntent(projectRoot, payload, { consume: true });
|
|
@@ -130,13 +120,25 @@ async function emitOrdinaryResume() {
|
|
|
130
120
|
}
|
|
131
121
|
|
|
132
122
|
(async () => {
|
|
123
|
+
// Async on purpose — a sync read on a stalled mount would block the watchdog timer.
|
|
124
|
+
let rawInput = '';
|
|
125
|
+
try { rawInput = await fs.promises.readFile(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
126
|
+
const payload = (() => {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(rawInput);
|
|
129
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
130
|
+
} catch {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
const source = typeof payload.source === 'string' ? payload.source : 'startup';
|
|
135
|
+
|
|
133
136
|
let text;
|
|
134
137
|
try {
|
|
135
|
-
// Async on purpose — a sync read on a stalled mount would block the watchdog timer.
|
|
136
138
|
text = await fs.promises.readFile(runPath, 'utf8');
|
|
137
139
|
} catch {
|
|
138
140
|
// No handoff cursor: an ordinary routed-task intent may still be resumable.
|
|
139
|
-
await emitOrdinaryResume();
|
|
141
|
+
await emitOrdinaryResume(payload, source);
|
|
140
142
|
process.exit(0);
|
|
141
143
|
}
|
|
142
144
|
|
|
@@ -146,7 +148,7 @@ async function emitOrdinaryResume() {
|
|
|
146
148
|
// `done` and `blocked` are both terminal (blocked = closed with instructions, the
|
|
147
149
|
// same posture stop-coordinator.mjs uses). Anything else means a step was in flight.
|
|
148
150
|
if (!phase || /^(done|blocked)\b/i.test(phase)) {
|
|
149
|
-
await emitOrdinaryResume();
|
|
151
|
+
await emitOrdinaryResume(payload, source);
|
|
150
152
|
process.exit(0);
|
|
151
153
|
}
|
|
152
154
|
|
|
@@ -62,6 +62,7 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
|
62
62
|
SCRIPT="$PROJECT_ROOT/.claude/ukit/index/post-edit-verify.mjs"
|
|
63
63
|
|
|
64
64
|
if [ ! -f "$SCRIPT" ]; then
|
|
65
|
+
printf '%s\n' '{"systemMessage":"UKit post-edit-verify: index script missing — run: ukit install"}'
|
|
65
66
|
exit 0
|
|
66
67
|
fi
|
|
67
68
|
|
|
@@ -62,6 +62,7 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
|
62
62
|
SCRIPT="$PROJECT_ROOT/.claude/ukit/index/pre-edit-backup.mjs"
|
|
63
63
|
|
|
64
64
|
if [ ! -f "$SCRIPT" ]; then
|
|
65
|
+
printf '%s\n' '{"systemMessage":"UKit pre-edit-backup: index script missing — run: ukit install"}'
|
|
65
66
|
exit 0
|
|
66
67
|
fi
|
|
67
68
|
|
|
@@ -29,9 +29,23 @@ node -e '
|
|
|
29
29
|
// Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
|
|
30
30
|
// watchdog can never fire while the mutation is still legally waiting for the lock.
|
|
31
31
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
|
|
32
|
-
|
|
32
|
+
|
|
33
|
+
// SPEC §8: this hook is the documented escape hatch for a stuck compact gate, so a
|
|
34
|
+
// failure must be announced — a silent failure leaves the gate bricked with no hint.
|
|
35
|
+
function emitDegrade(reason) {
|
|
36
|
+
try {
|
|
37
|
+
process.stdout.write(JSON.stringify({
|
|
38
|
+
systemMessage: `UKit reset-compact-pressure: ${reason}`,
|
|
39
|
+
}) + "\n");
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
emitDegrade("reset exceeded its deadline; the compact-pressure record may still be stale — restarting the session retries the reset.");
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
33
47
|
const crypto = require("crypto");
|
|
34
|
-
const
|
|
48
|
+
const fsp = require("fs").promises;
|
|
35
49
|
const path = require("path");
|
|
36
50
|
|
|
37
51
|
function isPidAlive(pid) {
|
|
@@ -44,9 +58,9 @@ function isPidAlive(pid) {
|
|
|
44
58
|
}
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
function readLockOwner(lockPath) {
|
|
61
|
+
async function readLockOwner(lockPath) {
|
|
48
62
|
try {
|
|
49
|
-
const owner = JSON.parse(
|
|
63
|
+
const owner = JSON.parse(await fsp.readFile(path.join(lockPath, "owner"), "utf8"));
|
|
50
64
|
return Number.isInteger(owner?.pid) && typeof owner.token === "string" ? owner : null;
|
|
51
65
|
} catch {
|
|
52
66
|
return null;
|
|
@@ -61,16 +75,16 @@ async function withLock(lockPath, fn) {
|
|
|
61
75
|
let held = false;
|
|
62
76
|
while (!held) {
|
|
63
77
|
try {
|
|
64
|
-
|
|
78
|
+
await fsp.mkdir(lockPath);
|
|
65
79
|
try {
|
|
66
|
-
|
|
80
|
+
await fsp.writeFile(path.join(lockPath, "owner"), JSON.stringify({
|
|
67
81
|
pid: process.pid,
|
|
68
82
|
token: ownerToken,
|
|
69
83
|
ts: Date.now(),
|
|
70
84
|
}));
|
|
71
85
|
} catch (error) {
|
|
72
86
|
try {
|
|
73
|
-
|
|
87
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
74
88
|
} catch {}
|
|
75
89
|
throw error;
|
|
76
90
|
}
|
|
@@ -78,12 +92,12 @@ async function withLock(lockPath, fn) {
|
|
|
78
92
|
} catch (error) {
|
|
79
93
|
if (!error || error.code !== "EEXIST") return fn();
|
|
80
94
|
try {
|
|
81
|
-
const stat =
|
|
95
|
+
const stat = await fsp.stat(lockPath);
|
|
82
96
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
83
|
-
const owner = readLockOwner(lockPath);
|
|
97
|
+
const owner = await readLockOwner(lockPath);
|
|
84
98
|
if (!owner || !isPidAlive(owner.pid)) {
|
|
85
99
|
try {
|
|
86
|
-
|
|
100
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
87
101
|
continue;
|
|
88
102
|
} catch {}
|
|
89
103
|
}
|
|
@@ -97,10 +111,10 @@ async function withLock(lockPath, fn) {
|
|
|
97
111
|
return await fn();
|
|
98
112
|
} finally {
|
|
99
113
|
if (held) {
|
|
100
|
-
const owner = readLockOwner(lockPath);
|
|
114
|
+
const owner = await readLockOwner(lockPath);
|
|
101
115
|
if (owner?.token === ownerToken) {
|
|
102
116
|
try {
|
|
103
|
-
|
|
117
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
104
118
|
} catch {}
|
|
105
119
|
}
|
|
106
120
|
}
|
|
@@ -120,17 +134,17 @@ process.stdin.on("end", () => {
|
|
|
120
134
|
const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
|
|
121
135
|
const pressurePath = process.argv[1];
|
|
122
136
|
|
|
123
|
-
withLock(`${pressurePath}.lock`, () => {
|
|
124
|
-
const raw = (() => {
|
|
137
|
+
withLock(`${pressurePath}.lock`, async () => {
|
|
138
|
+
const raw = await (async () => {
|
|
125
139
|
try {
|
|
126
|
-
return JSON.parse(
|
|
140
|
+
return JSON.parse(await fsp.readFile(pressurePath, "utf8"));
|
|
127
141
|
} catch {
|
|
128
142
|
return null;
|
|
129
143
|
}
|
|
130
144
|
})();
|
|
131
145
|
if (!raw || typeof raw !== "object") {
|
|
132
146
|
try {
|
|
133
|
-
|
|
147
|
+
await fsp.rm(pressurePath, { force: true });
|
|
134
148
|
} catch {}
|
|
135
149
|
return;
|
|
136
150
|
}
|
|
@@ -139,7 +153,7 @@ process.stdin.on("end", () => {
|
|
|
139
153
|
// the documented escape hatch for a bricked gate, and it must stay one.
|
|
140
154
|
if (!sessionId || !raw.sessions || typeof raw.sessions !== "object") {
|
|
141
155
|
try {
|
|
142
|
-
|
|
156
|
+
await fsp.rm(pressurePath, { force: true });
|
|
143
157
|
} catch {}
|
|
144
158
|
return;
|
|
145
159
|
}
|
|
@@ -150,7 +164,7 @@ process.stdin.on("end", () => {
|
|
|
150
164
|
.sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
|
|
151
165
|
const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
152
166
|
try {
|
|
153
|
-
|
|
167
|
+
await fsp.writeFile(
|
|
154
168
|
tempPath,
|
|
155
169
|
JSON.stringify({
|
|
156
170
|
...(newest && typeof newest === "object" ? newest : {}),
|
|
@@ -159,15 +173,18 @@ process.stdin.on("end", () => {
|
|
|
159
173
|
sessions,
|
|
160
174
|
}, null, 2) + "\n",
|
|
161
175
|
);
|
|
162
|
-
|
|
176
|
+
await fsp.rename(tempPath, pressurePath);
|
|
163
177
|
} catch (error) {
|
|
164
178
|
try {
|
|
165
|
-
|
|
179
|
+
await fsp.rm(tempPath, { force: true });
|
|
166
180
|
} catch {}
|
|
167
181
|
throw error;
|
|
168
182
|
}
|
|
169
|
-
}).catch(() => {
|
|
183
|
+
}).catch((error) => {
|
|
184
|
+
emitDegrade(`reset failed (${error?.message ?? error}); the compact-pressure record may still be stale — restarting the session retries the reset.`);
|
|
185
|
+
process.exit(0);
|
|
186
|
+
});
|
|
170
187
|
});
|
|
171
|
-
' "$PRESSURE_FILE" >/dev/null
|
|
188
|
+
' "$PRESSURE_FILE" 2>/dev/null || true
|
|
172
189
|
|
|
173
190
|
exit 0
|
|
@@ -62,6 +62,7 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
|
62
62
|
SCRIPT="$PROJECT_ROOT/.claude/ukit/index/stale-spec-check.mjs"
|
|
63
63
|
|
|
64
64
|
if [ ! -f "$SCRIPT" ]; then
|
|
65
|
+
printf '%s\n' '{"systemMessage":"UKit stale-spec guard: index script missing — run: ukit install"}'
|
|
65
66
|
exit 0
|
|
66
67
|
fi
|
|
67
68
|
|