@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/package.json +1 -1
  3. package/scripts/bench/parallel-agents.mjs +42 -4
  4. package/scripts/release/verify-release.mjs +43 -6
  5. package/src/core/applyPlan.js +65 -0
  6. package/src/core/compact/threshold.js +33 -30
  7. package/src/core/fileOps.js +9 -2
  8. package/src/core/runInstallPipeline.js +10 -1
  9. package/src/core/status.js +1 -1
  10. package/templates/.claude/hooks/auto-allow-bash.sh +29 -20
  11. package/templates/.claude/hooks/auto-prune-bash.sh +48 -30
  12. package/templates/.claude/hooks/completion-gate.sh +22 -1
  13. package/templates/.claude/hooks/context-hardcap-gate.sh +19 -4
  14. package/templates/.claude/hooks/handoff-model-guard.sh +10 -1
  15. package/templates/.claude/hooks/handoff-resume.sh +19 -17
  16. package/templates/.claude/hooks/post-edit-verify.sh +1 -0
  17. package/templates/.claude/hooks/pre-edit-backup.sh +1 -0
  18. package/templates/.claude/hooks/reset-compact-pressure.sh +39 -22
  19. package/templates/.claude/hooks/stale-spec-guard.sh +1 -0
  20. package/templates/.claude/hooks/verification-guard.sh +17 -1
  21. package/templates/.claude/settings.json +4 -4
  22. package/templates/.claude/ukit/index/extract-image.mjs +49 -0
  23. package/templates/.claude/ukit/runtime/compact-threshold.mjs +62 -40
  24. package/templates/.claude/ukit/runtime/execution-ledger.mjs +34 -5
  25. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +11 -4
  26. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +7 -3
  27. package/templates/.claude/ukit/runtime/output-compression.mjs +16 -5
  28. package/templates/.claude/ukit/runtime/reinject-context.mjs +2 -2
  29. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +3 -0
  30. package/templates/.claude/ukit/runtime/token-utils.mjs +9 -2
  31. package/templates/.omp/hooks/pre/ukit-bridge.js +72 -4
  32. 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 fs = require("fs");
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 readJson(filePath, fallback) {
160
- if (!fs.existsSync(filePath)) return fallback;
159
+ async function pathExists(filePath) {
161
160
  try {
162
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
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
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
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
- fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n");
173
- fs.renameSync(tempPath, filePath);
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
- fs.rmSync(tempPath, { force: true });
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
- fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
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
- fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
245
- fs.mkdirSync(path.dirname(usagePath), { recursive: true });
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 && fs.existsSync(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
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
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 fs = require("fs");
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(fs.readFileSync(filePath, "utf8"));
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
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
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
- fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n");
45
- fs.renameSync(tempPath, filePath);
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
- fs.rmSync(tempPath, { force: true });
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
- fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
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"), null);
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
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
89
- fs.mkdirSync(lockPath);
103
+ await fsp.mkdir(path.dirname(lockPath), { recursive: true });
104
+ await fsp.mkdir(lockPath);
90
105
  try {
91
- fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
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
- fs.rmSync(lockPath, { recursive: true, force: true });
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 = fs.statSync(lockPath);
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
- fs.rmSync(lockPath, { recursive: true, force: true });
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
- fs.rmSync(lockPath, { recursive: true, force: true });
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
- ' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$TTL_DAYS" "$NOW_UTC" >/dev/null 2>&1 || true
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
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
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
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
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
- if (!fs.existsSync(runtimePath)) return false;
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
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
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 fs = require("fs");
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(fs.readFileSync(path.join(lockPath, "owner"), "utf8"));
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
- fs.mkdirSync(lockPath);
78
+ await fsp.mkdir(lockPath);
65
79
  try {
66
- fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
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
- fs.rmSync(lockPath, { recursive: true, force: true });
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 = fs.statSync(lockPath);
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
- fs.rmSync(lockPath, { recursive: true, force: true });
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
- fs.rmSync(lockPath, { recursive: true, force: true });
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(fs.readFileSync(pressurePath, "utf8"));
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
- fs.rmSync(pressurePath, { force: true });
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
- fs.rmSync(pressurePath, { force: true });
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
- fs.writeFileSync(
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
- fs.renameSync(tempPath, pressurePath);
176
+ await fsp.rename(tempPath, pressurePath);
163
177
  } catch (error) {
164
178
  try {
165
- fs.rmSync(tempPath, { force: true });
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 2>&1 || true
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