@ngockhoale/ukit 2.6.3 → 2.6.5

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 (40) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/bin/ukit +30 -10
  3. package/package.json +1 -1
  4. package/scripts/bench/parallel-agents.mjs +42 -4
  5. package/scripts/release/verify-release.mjs +43 -6
  6. package/src/core/applyPlan.js +65 -0
  7. package/src/core/compact/threshold.js +33 -30
  8. package/src/core/fileOps.js +9 -2
  9. package/src/core/memory/store.js +10 -3
  10. package/src/core/runInstallPipeline.js +10 -1
  11. package/src/core/runtimeConfig.js +2 -0
  12. package/src/core/status.js +1 -1
  13. package/templates/.claude/hooks/auto-allow-bash.sh +29 -20
  14. package/templates/.claude/hooks/auto-prune-bash.sh +81 -30
  15. package/templates/.claude/hooks/completion-gate.sh +42 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +26 -4
  17. package/templates/.claude/hooks/handoff-model-guard.sh +10 -1
  18. package/templates/.claude/hooks/handoff-resume.sh +32 -17
  19. package/templates/.claude/hooks/post-edit-verify.sh +1 -0
  20. package/templates/.claude/hooks/pre-edit-backup.sh +1 -0
  21. package/templates/.claude/hooks/reset-compact-pressure.sh +39 -22
  22. package/templates/.claude/hooks/skill-router.sh +180 -27
  23. package/templates/.claude/hooks/stale-spec-guard.sh +1 -0
  24. package/templates/.claude/hooks/task-watchdog.sh +30 -5
  25. package/templates/.claude/hooks/verification-guard.sh +17 -1
  26. package/templates/.claude/hooks/vision-router.sh +14 -0
  27. package/templates/.claude/settings.json +4 -4
  28. package/templates/.claude/ukit/index/extract-image.mjs +49 -0
  29. package/templates/.claude/ukit/index/post-edit-verify.mjs +33 -1
  30. package/templates/.claude/ukit/index/stale-spec-check.mjs +32 -1
  31. package/templates/.claude/ukit/runtime/compact-threshold.mjs +70 -40
  32. package/templates/.claude/ukit/runtime/execution-ledger.mjs +37 -6
  33. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +11 -4
  34. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +24 -4
  35. package/templates/.claude/ukit/runtime/output-compression.mjs +73 -12
  36. package/templates/.claude/ukit/runtime/reinject-context.mjs +39 -7
  37. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +8 -1
  38. package/templates/.claude/ukit/runtime/token-utils.mjs +9 -2
  39. package/templates/.omp/hooks/pre/ukit-bridge.js +72 -4
  40. package/templates/ukit/storage/config.json +2 -0
@@ -207,15 +207,22 @@ async function readProjectMemoryForId(runtimePaths, projectId) {
207
207
  return { filePath, memory };
208
208
  }
209
209
 
210
- async function appendSessionArchive(runtimePaths, projectId, archivedSessions) {
210
+ const DEFAULT_MAX_ARCHIVED_SESSIONS = 50;
211
+
212
+ async function appendSessionArchive(runtimePaths, projectId, archivedSessions, maxArchivedSessions) {
211
213
  if (!archivedSessions || archivedSessions.length === 0) {
212
214
  return;
213
215
  }
214
216
 
217
+ // Math.max(1, …): a fractional cap in (0,1) would floor to 0 and `slice(-0)`
218
+ // silently disables the bound — exactly the unbounded-growth class this caps.
219
+ const cap = Number.isFinite(maxArchivedSessions) && maxArchivedSessions > 0
220
+ ? Math.max(1, Math.floor(maxArchivedSessions))
221
+ : DEFAULT_MAX_ARCHIVED_SESSIONS;
215
222
  const archivePath = path.join(runtimePaths.projectsDir, `${sanitizeProjectId(projectId)}.archive.json`);
216
223
  const existing = (await readMemoryJson(archivePath)) ?? { sessions: [] };
217
224
  const sessions = Array.isArray(existing.sessions) ? existing.sessions : [];
218
- await writeJson(archivePath, { sessions: [...sessions, ...archivedSessions] });
225
+ await writeJson(archivePath, { sessions: [...sessions, ...archivedSessions].slice(-cap) });
219
226
  }
220
227
 
221
228
  async function persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory) {
@@ -228,7 +235,7 @@ async function persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projec
228
235
  };
229
236
 
230
237
  const { projectMemory: hygienicMemory, archivedSessions } = runHygiene(memory, hygieneConfig);
231
- await appendSessionArchive(runtimePaths, projectId, archivedSessions);
238
+ await appendSessionArchive(runtimePaths, projectId, archivedSessions, memoryConfig.maxArchivedSessions);
232
239
  await writeJson(filePath, hygienicMemory);
233
240
  return hygienicMemory;
234
241
  }
@@ -7,7 +7,7 @@ import { detectProviders } from '../context/detectProviders.js';
7
7
  import { buildTemplateVariables } from '../render/buildVariables.js';
8
8
  import { buildInstallPlan } from './buildPlan.js';
9
9
  import { diffInstallPlan } from './diffPlan.js';
10
- import { applyDiffResults } from './applyPlan.js';
10
+ import { applyDiffResults, pruneOldBackups } from './applyPlan.js';
11
11
  import { summarizeDiff, toDiffRows } from './report.js';
12
12
  import { writeInstallMetadata } from './metadata.js';
13
13
  import { cleanupLegacyPaths, migrateLegacyRuntimeRoot } from './migrateLegacy.js';
@@ -310,6 +310,15 @@ export async function runInstallPipeline({
310
310
  projectRoot: pathConfig.projectRoot,
311
311
  });
312
312
 
313
+ // BUG-C22-19: bound the .bak accumulation — every overwrite_with_backup write
314
+ // adds one and nothing removed them. Advisory: a prune failure must never
315
+ // fail the install.
316
+ try {
317
+ await pruneOldBackups(pathConfig.backupRoot);
318
+ } catch (pruneError) {
319
+ console.warn(`[UKit] Warning: backup pruning skipped — ${pruneError?.message ?? pruneError}`);
320
+ }
321
+
313
322
  // Only run migration cleanup on reinstalls. Fresh installs have no legacy
314
323
  // UKit files to migrate, so running cleanup risks deleting user files that
315
324
  // happen to share a legacy path name (e.g. .claude/skills/delivery.md).
@@ -164,6 +164,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
164
164
  maxInjectionTokens: 1000,
165
165
  archiveAfterDays: 30,
166
166
  maxSessions: 20,
167
+ maxArchivedSessions: 50,
167
168
  redactSecrets: true,
168
169
  },
169
170
  validation: {
@@ -362,6 +363,7 @@ export function validateRuntimeConfig(config) {
362
363
  pushPositiveNumberError(errors, config.memory.maxInjectionTokens, 'memory.maxInjectionTokens');
363
364
  pushPositiveNumberError(errors, config.memory.archiveAfterDays, 'memory.archiveAfterDays');
364
365
  pushPositiveNumberError(errors, config.memory.maxSessions, 'memory.maxSessions');
366
+ pushPositiveNumberError(errors, config.memory.maxArchivedSessions, 'memory.maxArchivedSessions');
365
367
  pushBooleanError(errors, config.memory.redactSecrets, 'memory.redactSecrets');
366
368
  }
367
369
 
@@ -143,7 +143,7 @@ export async function buildStatusReport(projectRoot) {
143
143
  const config = await loadRuntimeConfig(projectRoot);
144
144
  const compactHistory = normalizeCompactEntries(await readStatusJson(runtimePaths.compactHistoryPath));
145
145
  const compactSummary = buildCompactSummary(compactHistory);
146
- const compactPressure = buildCompactPressureState(
146
+ const compactPressure = await buildCompactPressureState(
147
147
  await readStatusJson(runtimePaths.compactPressurePath),
148
148
  config,
149
149
  );
@@ -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,59 @@ 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");
71
+ }
72
+
73
+ // BUG-C23-12: permission-audit.log was append-only (auto-allow-bash + this hook both
74
+ // append; only an explicit reset ever truncated it). Bound: this prune pass rewrites
75
+ // the log keeping only lines with ts >= cutoff (same TTL window as rule pruning, so
76
+ // the audit trail for every retained rule survives). Unparseable/missing-ts lines are
77
+ // dropped; a missing or unreadable log is a no-op.
78
+ async function pruneAuditLog() {
79
+ let raw;
80
+ try {
81
+ raw = await fsp.readFile(auditPath, "utf8");
82
+ } catch {
83
+ return;
84
+ }
85
+ const kept = raw
86
+ .split("\n")
87
+ .filter((line) => {
88
+ if (!line.trim()) return false;
89
+ let ts = NaN;
90
+ try {
91
+ ts = Date.parse(JSON.parse(line)?.ts || "");
92
+ } catch {
93
+ ts = NaN;
94
+ }
95
+ return Number.isFinite(ts) && ts >= cutoff;
96
+ });
97
+ const tempPath = `${auditPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
98
+ await fsp.writeFile(tempPath, kept.length ? kept.join("\n") + "\n" : "");
99
+ await fsp.rename(tempPath, auditPath);
56
100
  }
57
101
 
58
102
  // Same lock point as auto-allow-bash.sh (<usage>.lock): the two hooks serialize their
@@ -70,8 +114,8 @@ function isPidAlive(pid) {
70
114
  }
71
115
  }
72
116
 
73
- function readLockOwner(lockPath) {
74
- const owner = readJson(path.join(lockPath, "owner"), null);
117
+ async function readLockOwner(lockPath) {
118
+ const owner = await readJson(path.join(lockPath, "owner"));
75
119
  return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
76
120
  ? owner
77
121
  : null;
@@ -85,17 +129,17 @@ async function withLock(lockPath, fn) {
85
129
  let held = false;
86
130
  while (!held) {
87
131
  try {
88
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
89
- fs.mkdirSync(lockPath);
132
+ await fsp.mkdir(path.dirname(lockPath), { recursive: true });
133
+ await fsp.mkdir(lockPath);
90
134
  try {
91
- fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
135
+ await fsp.writeFile(path.join(lockPath, "owner"), JSON.stringify({
92
136
  pid: process.pid,
93
137
  token: ownerToken,
94
138
  ts: Date.now(),
95
139
  }));
96
140
  } catch (error) {
97
141
  try {
98
- fs.rmSync(lockPath, { recursive: true, force: true });
142
+ await fsp.rm(lockPath, { recursive: true, force: true });
99
143
  } catch {}
100
144
  throw error;
101
145
  }
@@ -103,12 +147,12 @@ async function withLock(lockPath, fn) {
103
147
  } catch (error) {
104
148
  if (!error || error.code !== "EEXIST") return fn();
105
149
  try {
106
- const stat = fs.statSync(lockPath);
150
+ const stat = await fsp.stat(lockPath);
107
151
  if (Date.now() - stat.mtimeMs > staleMs) {
108
- const owner = readLockOwner(lockPath);
152
+ const owner = await readLockOwner(lockPath);
109
153
  if (!owner || !isPidAlive(owner.pid)) {
110
154
  try {
111
- fs.rmSync(lockPath, { recursive: true, force: true });
155
+ await fsp.rm(lockPath, { recursive: true, force: true });
112
156
  continue;
113
157
  } catch {}
114
158
  }
@@ -122,10 +166,10 @@ async function withLock(lockPath, fn) {
122
166
  return await fn();
123
167
  } finally {
124
168
  if (held) {
125
- const owner = readLockOwner(lockPath);
169
+ const owner = await readLockOwner(lockPath);
126
170
  if (owner?.token === ownerToken) {
127
171
  try {
128
- fs.rmSync(lockPath, { recursive: true, force: true });
172
+ await fsp.rm(lockPath, { recursive: true, force: true });
129
173
  } catch {}
130
174
  }
131
175
  }
@@ -133,8 +177,12 @@ async function withLock(lockPath, fn) {
133
177
  }
134
178
 
135
179
  withLock(`${usagePath}.lock`, async () => {
136
- const settings = readJson(settingsPath);
137
- const usage = readJson(usagePath);
180
+ // Rotate the audit log inside the same lock as the rule prune: both are
181
+ // read-modify-write passes on files these two hooks share.
182
+ await pruneAuditLog();
183
+
184
+ const settings = await readJson(settingsPath);
185
+ const usage = await readJson(usagePath);
138
186
  if (!settings || !usage) return;
139
187
  if (!settings.permissions || typeof settings.permissions !== "object") return;
140
188
  if (!Array.isArray(settings.permissions.allow)) return;
@@ -155,20 +203,23 @@ withLock(`${usagePath}.lock`, async () => {
155
203
  }
156
204
  delete usage.managedRules[rule];
157
205
  prunedCount += 1;
158
- appendAudit("auto_prune_ttl", { rule, ttlDays });
206
+ await appendAudit("auto_prune_ttl", { rule, ttlDays });
159
207
  }
160
208
 
161
209
  usage.lastPruneAt = nowUtc;
162
210
 
163
211
  if (changedSettings) {
164
- writeJsonAtomic(settingsPath, settings);
212
+ await writeJsonAtomic(settingsPath, settings);
165
213
  }
166
- writeJsonAtomic(usagePath, usage);
214
+ await writeJsonAtomic(usagePath, usage);
167
215
 
168
216
  if (prunedCount > 0) {
169
- appendAudit("auto_prune_summary", { prunedCount, ttlDays });
217
+ await appendAudit("auto_prune_summary", { prunedCount, ttlDays });
170
218
  }
171
- }).catch(() => {});
172
- ' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$TTL_DAYS" "$NOW_UTC" >/dev/null 2>&1 || true
219
+ }).catch((error) => {
220
+ emitDegrade(`prune failed (${error?.message ?? error}); stale Bash auto-allow rules were not pruned this session — the next session retries.`);
221
+ process.exit(0);
222
+ });
223
+ ' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$TTL_DAYS" "$NOW_UTC" 2>/dev/null || true
173
224
 
174
225
  exit 0
@@ -78,17 +78,57 @@ if [ ! -f "$SCRIPT" ]; then
78
78
  exit 0
79
79
  fi
80
80
 
81
- OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --evaluate-stop)
81
+ # BUG-C23-06: bound the coordinator spawn. The coordinator's own unref'd deadline
82
+ # cannot fire while its event loop is parked in sync fs on a stalled mount, so the
83
+ # shell used to wait until the host's 8s SIGKILL — a silent release window for a
84
+ # fail-closed gate. Run the spawn in the background with a background killer at
85
+ # (coordinator deadline + margin); a forced kill lands on the existing crash-streak
86
+ # path (non-zero STATUS) so the outcome is announced, never silently released.
87
+ __ukit_coord_out="$(mktemp "${TMPDIR:-/tmp}/ukit-coord-out.XXXXXX")"
88
+ printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --evaluate-stop > "$__ukit_coord_out" 2>/dev/null &
89
+ __ukit_coord_pid=$!
90
+ __ukit_coord_ms="${UKIT_HOOK_DEADLINE_MS:-3000}"
91
+ case "$__ukit_coord_ms" in ''|*[!0-9]*) __ukit_coord_ms=3000 ;; esac
92
+ __ukit_coord_ms=$((__ukit_coord_ms + 1500))
93
+ printf -v __ukit_coord_s '%d.%03d' $((__ukit_coord_ms / 1000)) $((__ukit_coord_ms % 1000))
94
+ ( sleep "$__ukit_coord_s" 2>/dev/null; kill -9 "$__ukit_coord_pid" 2>/dev/null ) <&- >/dev/null 2>&1 &
95
+ __ukit_killer_pid=$!
96
+ wait "$__ukit_coord_pid" 2>/dev/null
82
97
  STATUS=$?
98
+ kill "$__ukit_killer_pid" 2>/dev/null
99
+ wait "$__ukit_killer_pid" 2>/dev/null
100
+ OUTPUT="$(cat "$__ukit_coord_out" 2>/dev/null)"
101
+ rm -f "$__ukit_coord_out"
83
102
 
84
103
  if [ "$STATUS" -ne 0 ]; then
104
+ # BUG-C22-05: a PERSISTENT coordinator crash must not block every Stop forever.
105
+ # Byte-append one marker per crash; a clean run deletes the file, so the count
106
+ # is consecutive crashes, not lifetime crashes. File-based (no node) so the
107
+ # breaker still works when node itself is what is crashing.
108
+ CRASH_FILE="$PROJECT_ROOT/.ukit/storage/cache/completion-gate-crash.streak"
109
+ mkdir -p "$(dirname "$CRASH_FILE")" 2>/dev/null
110
+ printf '.' >> "$CRASH_FILE" 2>/dev/null
111
+ STREAK="$(wc -c < "$CRASH_FILE" 2>/dev/null | tr -d '[:space:]')"
112
+ case "$STREAK" in ''|*[!0-9]*) STREAK=1 ;; esac
113
+ 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)"
114
+ case "$MAX_STREAK" in ''|*[!0-9]*) MAX_STREAK=3 ;; esac
115
+ if [ "$STREAK" -gt "$MAX_STREAK" ]; then
116
+ # Loud release after the cap: the gate is broken, not the work — say so,
117
+ # name remediation, and let the session end instead of looping forever.
118
+ rm -f "$CRASH_FILE" 2>/dev/null
119
+ 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."}'
120
+ exit 0
121
+ fi
85
122
  # TASK-025: the Stop coordinator is the fail-closed blocker — a crash must not
86
123
  # release the stop. The reason is a fixed redacted string: crash detail (already
87
124
  # 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."}'
125
+ 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
126
  exit 0
90
127
  fi
91
128
 
129
+ # A clean coordinator run resets the consecutive-crash streak.
130
+ rm -f "$PROJECT_ROOT/.ukit/storage/cache/completion-gate-crash.streak" 2>/dev/null
131
+
92
132
  if [ -n "$OUTPUT" ]; then
93
133
  printf '%s\n' "$OUTPUT"
94
134
  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
@@ -387,7 +402,14 @@ async function readRunCursor() {
387
402
  })().catch((err) => {
388
403
  // A logic error here fails OPEN: this is a backstop on top of advisory nudges,
389
404
  // not a correctness gate — a broken gate must not brick every session.
405
+ // BUG-C23-05: fail-open still needs a user-visible signal — stderr alone leaves a
406
+ // persistent internal error looking exactly like a clean pass (SPEC §8(ii)).
390
407
  process.stderr.write(`context-hardcap-gate: internal error, failing open: ${err?.message ?? err}\n`);
408
+ try {
409
+ process.stdout.write(`${JSON.stringify({
410
+ systemMessage: `UKit context hard-cap gate: internal error (${String(err?.message ?? err).slice(0, 200)}) — the gate failed open and did NOT enforce the hard cap this call; run: ukit install to repair.`,
411
+ })}\n`);
412
+ } catch {}
391
413
  process.exit(0);
392
414
  });
393
415
  NODE
@@ -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');
@@ -87,26 +87,29 @@ const { pathToFileURL } = require('url');
87
87
  // Every read below is async on purpose: sync I/O would block the loop and this timer
88
88
  // could never fire. Keep it that way.
89
89
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
90
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
91
-
92
- let rawInput = '';
93
- try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
94
- const payload = (() => {
90
+ setTimeout(() => {
91
+ // BUG-C23-04: deadline expiry abandons the configured resume hint — a
92
+ // degrade, and SPEC §8(ii) requires it be announced on stdout before the
93
+ // fail-open exit. fs.writeSync(1) because a same-tick exit can drop a
94
+ // buffered async write on a pipe.
95
95
  try {
96
- const parsed = JSON.parse(rawInput);
97
- return parsed && typeof parsed === 'object' ? parsed : {};
98
- } catch {
99
- return {};
100
- }
101
- })();
96
+ fs.writeSync(1, JSON.stringify({
97
+ systemMessage: 'UKit handoff-resume exceeded its ' + HOOK_DEADLINE_MS + 'ms deadline — '
98
+ + 'the resume check was skipped this session start (likely stalled filesystem). '
99
+ + 'If a handoff run was in flight, resume it via /ukit:handoff-fullstack.',
100
+ }) + '\n');
101
+ } catch {}
102
+ process.exit(0);
103
+ }, HOOK_DEADLINE_MS).unref();
102
104
 
103
105
  const projectRoot = process.env.PROJECT_ROOT;
104
106
  const runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
105
107
  const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'execution-ledger.mjs');
106
- const source = typeof payload.source === 'string' ? payload.source : 'startup';
107
108
 
108
- async function emitOrdinaryResume() {
109
- if (!fs.existsSync(runtimePath)) return false;
109
+ async function emitOrdinaryResume(payload, source) {
110
+ let runtimeExists = false;
111
+ try { await fs.promises.access(runtimePath); runtimeExists = true; } catch {}
112
+ if (!runtimeExists) return false;
110
113
  try {
111
114
  const runtime = await import(pathToFileURL(runtimePath).href);
112
115
  const intent = await runtime.readResumeIntent(projectRoot, payload, { consume: true });
@@ -130,13 +133,25 @@ async function emitOrdinaryResume() {
130
133
  }
131
134
 
132
135
  (async () => {
136
+ // Async on purpose — a sync read on a stalled mount would block the watchdog timer.
137
+ let rawInput = '';
138
+ try { rawInput = await fs.promises.readFile(process.env.INPUT_FILE || '', 'utf8'); } catch {}
139
+ const payload = (() => {
140
+ try {
141
+ const parsed = JSON.parse(rawInput);
142
+ return parsed && typeof parsed === 'object' ? parsed : {};
143
+ } catch {
144
+ return {};
145
+ }
146
+ })();
147
+ const source = typeof payload.source === 'string' ? payload.source : 'startup';
148
+
133
149
  let text;
134
150
  try {
135
- // Async on purpose — a sync read on a stalled mount would block the watchdog timer.
136
151
  text = await fs.promises.readFile(runPath, 'utf8');
137
152
  } catch {
138
153
  // No handoff cursor: an ordinary routed-task intent may still be resumable.
139
- await emitOrdinaryResume();
154
+ await emitOrdinaryResume(payload, source);
140
155
  process.exit(0);
141
156
  }
142
157
 
@@ -146,7 +161,7 @@ async function emitOrdinaryResume() {
146
161
  // `done` and `blocked` are both terminal (blocked = closed with instructions, the
147
162
  // same posture stop-coordinator.mjs uses). Anything else means a step was in flight.
148
163
  if (!phase || /^(done|blocked)\b/i.test(phase)) {
149
- await emitOrdinaryResume();
164
+ await emitOrdinaryResume(payload, source);
150
165
  process.exit(0);
151
166
  }
152
167
 
@@ -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