@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.
- package/CHANGELOG.md +123 -0
- package/bin/ukit +30 -10
- 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/memory/store.js +10 -3
- package/src/core/runInstallPipeline.js +10 -1
- package/src/core/runtimeConfig.js +2 -0
- 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 +81 -30
- package/templates/.claude/hooks/completion-gate.sh +42 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +26 -4
- package/templates/.claude/hooks/handoff-model-guard.sh +10 -1
- package/templates/.claude/hooks/handoff-resume.sh +32 -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/skill-router.sh +180 -27
- package/templates/.claude/hooks/stale-spec-guard.sh +1 -0
- package/templates/.claude/hooks/task-watchdog.sh +30 -5
- package/templates/.claude/hooks/verification-guard.sh +17 -1
- package/templates/.claude/hooks/vision-router.sh +14 -0
- package/templates/.claude/settings.json +4 -4
- package/templates/.claude/ukit/index/extract-image.mjs +49 -0
- package/templates/.claude/ukit/index/post-edit-verify.mjs +33 -1
- package/templates/.claude/ukit/index/stale-spec-check.mjs +32 -1
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +70 -40
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +37 -6
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +11 -4
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +24 -4
- package/templates/.claude/ukit/runtime/output-compression.mjs +73 -12
- package/templates/.claude/ukit/runtime/reinject-context.mjs +39 -7
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +8 -1
- 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
package/src/core/memory/store.js
CHANGED
|
@@ -207,15 +207,22 @@ async function readProjectMemoryForId(runtimePaths, projectId) {
|
|
|
207
207
|
return { filePath, memory };
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
|
|
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
|
|
package/src/core/status.js
CHANGED
|
@@ -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
|
|
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,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(
|
|
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");
|
|
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")
|
|
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
|
-
|
|
89
|
-
|
|
132
|
+
await fsp.mkdir(path.dirname(lockPath), { recursive: true });
|
|
133
|
+
await fsp.mkdir(lockPath);
|
|
90
134
|
try {
|
|
91
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|