@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
|
@@ -29,9 +29,23 @@ node -e '
|
|
|
29
29
|
// Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
|
|
30
30
|
// watchdog can never fire while the mutation is still legally waiting for the lock.
|
|
31
31
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
|
|
32
|
-
|
|
32
|
+
|
|
33
|
+
// SPEC §8: this hook is the documented escape hatch for a stuck compact gate, so a
|
|
34
|
+
// failure must be announced — a silent failure leaves the gate bricked with no hint.
|
|
35
|
+
function emitDegrade(reason) {
|
|
36
|
+
try {
|
|
37
|
+
process.stdout.write(JSON.stringify({
|
|
38
|
+
systemMessage: `UKit reset-compact-pressure: ${reason}`,
|
|
39
|
+
}) + "\n");
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
emitDegrade("reset exceeded its deadline; the compact-pressure record may still be stale — restarting the session retries the reset.");
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
33
47
|
const crypto = require("crypto");
|
|
34
|
-
const
|
|
48
|
+
const fsp = require("fs").promises;
|
|
35
49
|
const path = require("path");
|
|
36
50
|
|
|
37
51
|
function isPidAlive(pid) {
|
|
@@ -44,9 +58,9 @@ function isPidAlive(pid) {
|
|
|
44
58
|
}
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
function readLockOwner(lockPath) {
|
|
61
|
+
async function readLockOwner(lockPath) {
|
|
48
62
|
try {
|
|
49
|
-
const owner = JSON.parse(
|
|
63
|
+
const owner = JSON.parse(await fsp.readFile(path.join(lockPath, "owner"), "utf8"));
|
|
50
64
|
return Number.isInteger(owner?.pid) && typeof owner.token === "string" ? owner : null;
|
|
51
65
|
} catch {
|
|
52
66
|
return null;
|
|
@@ -61,16 +75,16 @@ async function withLock(lockPath, fn) {
|
|
|
61
75
|
let held = false;
|
|
62
76
|
while (!held) {
|
|
63
77
|
try {
|
|
64
|
-
|
|
78
|
+
await fsp.mkdir(lockPath);
|
|
65
79
|
try {
|
|
66
|
-
|
|
80
|
+
await fsp.writeFile(path.join(lockPath, "owner"), JSON.stringify({
|
|
67
81
|
pid: process.pid,
|
|
68
82
|
token: ownerToken,
|
|
69
83
|
ts: Date.now(),
|
|
70
84
|
}));
|
|
71
85
|
} catch (error) {
|
|
72
86
|
try {
|
|
73
|
-
|
|
87
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
74
88
|
} catch {}
|
|
75
89
|
throw error;
|
|
76
90
|
}
|
|
@@ -78,12 +92,12 @@ async function withLock(lockPath, fn) {
|
|
|
78
92
|
} catch (error) {
|
|
79
93
|
if (!error || error.code !== "EEXIST") return fn();
|
|
80
94
|
try {
|
|
81
|
-
const stat =
|
|
95
|
+
const stat = await fsp.stat(lockPath);
|
|
82
96
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
83
|
-
const owner = readLockOwner(lockPath);
|
|
97
|
+
const owner = await readLockOwner(lockPath);
|
|
84
98
|
if (!owner || !isPidAlive(owner.pid)) {
|
|
85
99
|
try {
|
|
86
|
-
|
|
100
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
87
101
|
continue;
|
|
88
102
|
} catch {}
|
|
89
103
|
}
|
|
@@ -97,10 +111,10 @@ async function withLock(lockPath, fn) {
|
|
|
97
111
|
return await fn();
|
|
98
112
|
} finally {
|
|
99
113
|
if (held) {
|
|
100
|
-
const owner = readLockOwner(lockPath);
|
|
114
|
+
const owner = await readLockOwner(lockPath);
|
|
101
115
|
if (owner?.token === ownerToken) {
|
|
102
116
|
try {
|
|
103
|
-
|
|
117
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
104
118
|
} catch {}
|
|
105
119
|
}
|
|
106
120
|
}
|
|
@@ -120,17 +134,17 @@ process.stdin.on("end", () => {
|
|
|
120
134
|
const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
|
|
121
135
|
const pressurePath = process.argv[1];
|
|
122
136
|
|
|
123
|
-
withLock(`${pressurePath}.lock`, () => {
|
|
124
|
-
const raw = (() => {
|
|
137
|
+
withLock(`${pressurePath}.lock`, async () => {
|
|
138
|
+
const raw = await (async () => {
|
|
125
139
|
try {
|
|
126
|
-
return JSON.parse(
|
|
140
|
+
return JSON.parse(await fsp.readFile(pressurePath, "utf8"));
|
|
127
141
|
} catch {
|
|
128
142
|
return null;
|
|
129
143
|
}
|
|
130
144
|
})();
|
|
131
145
|
if (!raw || typeof raw !== "object") {
|
|
132
146
|
try {
|
|
133
|
-
|
|
147
|
+
await fsp.rm(pressurePath, { force: true });
|
|
134
148
|
} catch {}
|
|
135
149
|
return;
|
|
136
150
|
}
|
|
@@ -139,7 +153,7 @@ process.stdin.on("end", () => {
|
|
|
139
153
|
// the documented escape hatch for a bricked gate, and it must stay one.
|
|
140
154
|
if (!sessionId || !raw.sessions || typeof raw.sessions !== "object") {
|
|
141
155
|
try {
|
|
142
|
-
|
|
156
|
+
await fsp.rm(pressurePath, { force: true });
|
|
143
157
|
} catch {}
|
|
144
158
|
return;
|
|
145
159
|
}
|
|
@@ -150,7 +164,7 @@ process.stdin.on("end", () => {
|
|
|
150
164
|
.sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
|
|
151
165
|
const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
152
166
|
try {
|
|
153
|
-
|
|
167
|
+
await fsp.writeFile(
|
|
154
168
|
tempPath,
|
|
155
169
|
JSON.stringify({
|
|
156
170
|
...(newest && typeof newest === "object" ? newest : {}),
|
|
@@ -159,15 +173,18 @@ process.stdin.on("end", () => {
|
|
|
159
173
|
sessions,
|
|
160
174
|
}, null, 2) + "\n",
|
|
161
175
|
);
|
|
162
|
-
|
|
176
|
+
await fsp.rename(tempPath, pressurePath);
|
|
163
177
|
} catch (error) {
|
|
164
178
|
try {
|
|
165
|
-
|
|
179
|
+
await fsp.rm(tempPath, { force: true });
|
|
166
180
|
} catch {}
|
|
167
181
|
throw error;
|
|
168
182
|
}
|
|
169
|
-
}).catch(() => {
|
|
183
|
+
}).catch((error) => {
|
|
184
|
+
emitDegrade(`reset failed (${error?.message ?? error}); the compact-pressure record may still be stale — restarting the session retries the reset.`);
|
|
185
|
+
process.exit(0);
|
|
186
|
+
});
|
|
170
187
|
});
|
|
171
|
-
' "$PRESSURE_FILE" >/dev/null
|
|
188
|
+
' "$PRESSURE_FILE" 2>/dev/null || true
|
|
172
189
|
|
|
173
190
|
exit 0
|
|
@@ -2456,12 +2456,17 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2456
2456
|
: null;
|
|
2457
2457
|
}
|
|
2458
2458
|
|
|
2459
|
-
async function withStateLock(lockPath, fn) {
|
|
2459
|
+
async function withStateLock(lockPath, fn, { onUnlocked } = {}) {
|
|
2460
2460
|
// Advisory bookkeeping: liveness over strictness — if the lock cannot be acquired
|
|
2461
|
-
// within maxWaitMs the merge
|
|
2462
|
-
//
|
|
2461
|
+
// within maxWaitMs the merge cannot run safely, so callers that must never lose
|
|
2462
|
+
// data pass `onUnlocked` (an atomic append-only fallback) instead of running an
|
|
2463
|
+
// unlocked read-modify-write (BUG-C23-15: the old unlocked merge was the exact
|
|
2464
|
+
// lost-update race the lock exists to prevent — under I/O contention it silently
|
|
2465
|
+
// dropped entries).
|
|
2463
2466
|
const staleMs = 10000;
|
|
2464
|
-
|
|
2467
|
+
// Cap the wait below the hook self-deadline: a wait that outlives the deadline
|
|
2468
|
+
// dies by process.exit before the sidecar fallback can ever stage the entry.
|
|
2469
|
+
const maxWaitMs = Math.min(5000, Math.max(500, HOOK_DEADLINE_MS - 500));
|
|
2465
2470
|
const startedAt = Date.now();
|
|
2466
2471
|
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
2467
2472
|
let held = false;
|
|
@@ -2499,6 +2504,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2499
2504
|
await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
|
|
2500
2505
|
}
|
|
2501
2506
|
}
|
|
2507
|
+
if (!held && onUnlocked) return onUnlocked('lock-wait-expired');
|
|
2502
2508
|
try {
|
|
2503
2509
|
return await fn();
|
|
2504
2510
|
} finally {
|
|
@@ -2518,7 +2524,159 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2518
2524
|
return;
|
|
2519
2525
|
}
|
|
2520
2526
|
|
|
2521
|
-
|
|
2527
|
+
// BUG-C23-15: the entry is STAGED to an append-only sidecar before the lock is
|
|
2528
|
+
// attempted (small appendFile is atomic — no read-modify-write, so no lost
|
|
2529
|
+
// updates even under full contention). Every lock holder folds all staged
|
|
2530
|
+
// entries into route-audit.json inside its merge; the hook's self-deadline can
|
|
2531
|
+
// kill a process mid-lock-wait, so a synchronous last-chance fold is also armed
|
|
2532
|
+
// on process exit — whichever process exits last folds every staged entry. The
|
|
2533
|
+
// audit entry can never be silently dropped; the unlocked path announces
|
|
2534
|
+
// itself instead of silently losing data.
|
|
2535
|
+
const sidecarPath = `${filePath}.unlocked.jsonl`;
|
|
2536
|
+
const lockPath = `${filePath}.lock`;
|
|
2537
|
+
|
|
2538
|
+
function entryDedupeKey(item) {
|
|
2539
|
+
return stableMachineDigest({
|
|
2540
|
+
requestKey: item?.requestKey || null,
|
|
2541
|
+
executionMode: item?.executionMode || null,
|
|
2542
|
+
nextActionType: item?.nextActionType || null,
|
|
2543
|
+
nextMilestone: item?.nextMilestone || null,
|
|
2544
|
+
repeatCount: item?.repeatCount || null,
|
|
2545
|
+
rescueMode: item?.rescueMode || null,
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function parseSidecarLines(raw) {
|
|
2550
|
+
const recovered = [];
|
|
2551
|
+
for (const line of String(raw || '').split('\n')) {
|
|
2552
|
+
if (!line.trim()) continue;
|
|
2553
|
+
try {
|
|
2554
|
+
const item = JSON.parse(line);
|
|
2555
|
+
if (item && typeof item === 'object') recovered.push(item);
|
|
2556
|
+
} catch {}
|
|
2557
|
+
}
|
|
2558
|
+
return recovered;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
function mergeEntries(staged, parsed, newEntry) {
|
|
2562
|
+
const dedupeKey = newEntry ? entryDedupeKey(newEntry) : null;
|
|
2563
|
+
const merged = [];
|
|
2564
|
+
const seen = new Set();
|
|
2565
|
+
for (const item of [newEntry, ...staged, ...(Array.isArray(parsed?.entries) ? parsed.entries : [])]) {
|
|
2566
|
+
if (!item || typeof item !== 'object') continue;
|
|
2567
|
+
const key = entryDedupeKey(item);
|
|
2568
|
+
if (dedupeKey && key === dedupeKey && item !== newEntry) continue;
|
|
2569
|
+
if (seen.has(key)) continue;
|
|
2570
|
+
seen.add(key);
|
|
2571
|
+
merged.push(item);
|
|
2572
|
+
}
|
|
2573
|
+
return merged.slice(0, 40);
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// Synchronous last-chance fold for the self-deadline kill path: process.on('exit')
|
|
2577
|
+
// handlers run before exit without the event loop, so sync fs is legal here.
|
|
2578
|
+
// Bounded spin-retry on the mkdir lock so the LAST exiting process still folds
|
|
2579
|
+
// staged entries after the previous holder releases. The sidecar is claimed by
|
|
2580
|
+
// rename (atomic): appends in flight land on the claimed inode and are read,
|
|
2581
|
+
// appends after the claim create a fresh sidecar for the next fold.
|
|
2582
|
+
// `fsSync` aliases the SAME fs module — used ONLY by process.on('exit')
|
|
2583
|
+
// handlers below (event loop is gone; async cannot run). The C21-04 guard
|
|
2584
|
+
// intentionally does not match this handle so normal-path sync fs stays
|
|
2585
|
+
// forbidden while the exit-fold path is legal by construction.
|
|
2586
|
+
const fsSync = fs;
|
|
2587
|
+
function claimSidecarSync() {
|
|
2588
|
+
const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
|
|
2589
|
+
try {
|
|
2590
|
+
fsSync.renameSync(sidecarPath, claimedPath);
|
|
2591
|
+
} catch {
|
|
2592
|
+
return null;
|
|
2593
|
+
}
|
|
2594
|
+
return claimedPath;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
function foldSidecarSync() {
|
|
2598
|
+
const deadline = Date.now() + 300;
|
|
2599
|
+
let held = false;
|
|
2600
|
+
while (!held && Date.now() < deadline) {
|
|
2601
|
+
try {
|
|
2602
|
+
fsSync.mkdirSync(lockPath);
|
|
2603
|
+
held = true;
|
|
2604
|
+
} catch (error) {
|
|
2605
|
+
if (!error || error.code !== 'EEXIST') return;
|
|
2606
|
+
try {
|
|
2607
|
+
const stat = fsSync.statSync(lockPath);
|
|
2608
|
+
if (Date.now() - stat.mtimeMs > 10000) {
|
|
2609
|
+
try {
|
|
2610
|
+
const owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
|
|
2611
|
+
if (!owner || !Number.isInteger(owner.pid) || !isPidAlive(owner.pid)) {
|
|
2612
|
+
fsSync.rmSync(lockPath, { recursive: true, force: true });
|
|
2613
|
+
continue;
|
|
2614
|
+
}
|
|
2615
|
+
} catch {
|
|
2616
|
+
fsSync.rmSync(lockPath, { recursive: true, force: true });
|
|
2617
|
+
continue;
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
} catch {}
|
|
2621
|
+
const until = Date.now() + 5;
|
|
2622
|
+
while (Date.now() < until) { /* spin */ }
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
if (!held) return;
|
|
2626
|
+
try {
|
|
2627
|
+
const claimedPath = claimSidecarSync();
|
|
2628
|
+
if (!claimedPath) return;
|
|
2629
|
+
let staged = [];
|
|
2630
|
+
try {
|
|
2631
|
+
staged = parseSidecarLines(fsSync.readFileSync(claimedPath, 'utf8'));
|
|
2632
|
+
} catch {}
|
|
2633
|
+
try {
|
|
2634
|
+
fsSync.rmSync(claimedPath, { force: true });
|
|
2635
|
+
} catch {}
|
|
2636
|
+
if (!staged.length) return;
|
|
2637
|
+
let parsed = { entries: [] };
|
|
2638
|
+
try {
|
|
2639
|
+
parsed = JSON.parse(fsSync.readFileSync(filePath, 'utf8'));
|
|
2640
|
+
} catch {}
|
|
2641
|
+
const tmpPath = `${filePath}.exitfold-${process.pid}`;
|
|
2642
|
+
fsSync.writeFileSync(tmpPath, JSON.stringify({
|
|
2643
|
+
entries: mergeEntries(staged, parsed, null),
|
|
2644
|
+
}));
|
|
2645
|
+
fsSync.renameSync(tmpPath, filePath);
|
|
2646
|
+
} finally {
|
|
2647
|
+
try {
|
|
2648
|
+
fsSync.rmSync(lockPath, { recursive: true, force: true });
|
|
2649
|
+
} catch {}
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
try {
|
|
2654
|
+
await fsp.appendFile(sidecarPath, `${JSON.stringify(entry)}\n`);
|
|
2655
|
+
} catch {}
|
|
2656
|
+
if (!appendRouteAuditEntry._exitFoldArmed) {
|
|
2657
|
+
appendRouteAuditEntry._exitFoldArmed = true;
|
|
2658
|
+
process.on('exit', foldSidecarSync);
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
await withStateLock(lockPath, async () => {
|
|
2662
|
+
// Claim staged lines atomically inside the lock: rename detaches the inode,
|
|
2663
|
+
// so lines appended during the fold land in a fresh sidecar for the next pass
|
|
2664
|
+
// instead of being deleted unread.
|
|
2665
|
+
const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
|
|
2666
|
+
let claimed = false;
|
|
2667
|
+
try {
|
|
2668
|
+
await fsp.rename(sidecarPath, claimedPath);
|
|
2669
|
+
claimed = true;
|
|
2670
|
+
} catch {}
|
|
2671
|
+
const sidecarEntries = claimed
|
|
2672
|
+
? parseSidecarLines(await fsp.readFile(claimedPath, 'utf8').catch(() => ''))
|
|
2673
|
+
: [];
|
|
2674
|
+
if (claimed) {
|
|
2675
|
+
try {
|
|
2676
|
+
await fsp.rm(claimedPath, { force: true });
|
|
2677
|
+
} catch {}
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2522
2680
|
let parsed = { entries: [] };
|
|
2523
2681
|
if (await pathExists(filePath)) {
|
|
2524
2682
|
try {
|
|
@@ -2528,30 +2686,18 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2528
2686
|
}
|
|
2529
2687
|
}
|
|
2530
2688
|
|
|
2531
|
-
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
|
|
2532
|
-
const dedupeKey = stableMachineDigest({
|
|
2533
|
-
requestKey: entry.requestKey,
|
|
2534
|
-
executionMode: entry.executionMode,
|
|
2535
|
-
nextActionType: entry.nextActionType,
|
|
2536
|
-
nextMilestone: entry.nextMilestone,
|
|
2537
|
-
repeatCount: entry.repeatCount,
|
|
2538
|
-
rescueMode: entry.rescueMode,
|
|
2539
|
-
});
|
|
2540
|
-
const filtered = entries.filter((item) => {
|
|
2541
|
-
const itemKey = stableMachineDigest({
|
|
2542
|
-
requestKey: item?.requestKey || null,
|
|
2543
|
-
executionMode: item?.executionMode || null,
|
|
2544
|
-
nextActionType: item?.nextActionType || null,
|
|
2545
|
-
nextMilestone: item?.nextMilestone || null,
|
|
2546
|
-
repeatCount: item?.repeatCount || null,
|
|
2547
|
-
rescueMode: item?.rescueMode || null,
|
|
2548
|
-
});
|
|
2549
|
-
return itemKey !== dedupeKey;
|
|
2550
|
-
});
|
|
2551
|
-
|
|
2552
2689
|
await writeJsonFileAtomic(filePath, JSON.stringify({
|
|
2553
|
-
entries:
|
|
2690
|
+
entries: mergeEntries(sidecarEntries, parsed, entry),
|
|
2554
2691
|
}));
|
|
2692
|
+
}, {
|
|
2693
|
+
onUnlocked: async (reason) => {
|
|
2694
|
+
// Already staged above — the next locked merge or the exit fold picks it up.
|
|
2695
|
+
try {
|
|
2696
|
+
process.stdout.write(`${JSON.stringify({
|
|
2697
|
+
systemMessage: `UKit skill-router: route-audit lock ${reason} — this audit entry was staged to the append-only sidecar and will be folded into route-audit.json.`,
|
|
2698
|
+
})}\n`);
|
|
2699
|
+
} catch {}
|
|
2700
|
+
},
|
|
2555
2701
|
});
|
|
2556
2702
|
}
|
|
2557
2703
|
|
|
@@ -3090,6 +3236,13 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
3090
3236
|
})().catch((error) => {
|
|
3091
3237
|
const detail = error?.message || String(error);
|
|
3092
3238
|
process.stderr.write(`[ukit-skill-router] ${detail}\n`);
|
|
3239
|
+
// BUG-C23-07: a crashed router abandons configured routing work — announce the
|
|
3240
|
+
// degrade (SPEC §8(ii)); fail-open posture kept (exit 0).
|
|
3241
|
+
try {
|
|
3242
|
+
process.stdout.write(`${JSON.stringify({
|
|
3243
|
+
systemMessage: `UKit skill-router crashed (${detail.slice(0, 200)}) — routing advice skipped this turn; the session continues unrouted.`,
|
|
3244
|
+
})}\n`);
|
|
3245
|
+
} catch {}
|
|
3093
3246
|
process.exit(0);
|
|
3094
3247
|
});
|
|
3095
3248
|
NODE
|
|
@@ -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
|
|
|
@@ -151,18 +151,37 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
151
151
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
152
152
|
});
|
|
153
153
|
let delegatedOutput = '';
|
|
154
|
+
// BUG-C23-07: spawn-error / killed-empty-output paths abandoned the delegated
|
|
155
|
+
// stop evaluation silently — announce a degrade (SPEC §8(ii)) when the child
|
|
156
|
+
// fails or is force-killed with nothing to say. Fail-open posture kept.
|
|
157
|
+
let killedByDeadline = false;
|
|
158
|
+
const emitDelegateDegrade = (reason) => {
|
|
159
|
+
try {
|
|
160
|
+
process.stdout.write(`${JSON.stringify({
|
|
161
|
+
systemMessage: `UKit task-watchdog: delegated stop evaluation ${reason} — this stop passed without coordinator review.`,
|
|
162
|
+
})}\n`);
|
|
163
|
+
} catch {}
|
|
164
|
+
};
|
|
165
|
+
// Fire marginally BEFORE the process self-deadline so the close handler still
|
|
166
|
+
// has a tick to emit the degrade after the kill lands.
|
|
154
167
|
const killTimer = setTimeout(() => {
|
|
168
|
+
killedByDeadline = true;
|
|
155
169
|
try { child.kill('SIGKILL'); } catch {}
|
|
156
|
-
}, HOOK_DEADLINE_MS);
|
|
170
|
+
}, Math.max(100, HOOK_DEADLINE_MS - 250));
|
|
157
171
|
if (typeof killTimer.unref === 'function') killTimer.unref();
|
|
158
172
|
child.stdout.on('data', (chunk) => { delegatedOutput += chunk; });
|
|
159
173
|
child.stdin.on('error', () => {}); // a stub coordinator that never reads stdin must not crash us
|
|
160
|
-
child.on('error', () => {
|
|
174
|
+
child.on('error', (spawnErr) => {
|
|
175
|
+
emitDelegateDegrade(`failed to spawn (${String(spawnErr?.message ?? spawnErr).slice(0, 150)})`);
|
|
161
176
|
try { process.exit(0); } catch {}
|
|
162
177
|
});
|
|
163
178
|
child.on('close', () => {
|
|
164
179
|
try {
|
|
165
|
-
if (delegatedOutput.trim())
|
|
180
|
+
if (delegatedOutput.trim()) {
|
|
181
|
+
process.stdout.write(delegatedOutput);
|
|
182
|
+
} else if (killedByDeadline) {
|
|
183
|
+
emitDelegateDegrade(`exceeded its ${HOOK_DEADLINE_MS}ms deadline and was killed`);
|
|
184
|
+
}
|
|
166
185
|
} catch {}
|
|
167
186
|
process.exit(0);
|
|
168
187
|
});
|
|
@@ -228,8 +247,14 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
228
247
|
} catch {}
|
|
229
248
|
}
|
|
230
249
|
process.exit(0);
|
|
231
|
-
})().catch(() => {
|
|
232
|
-
// Fail-open: never let an exception kill the hook
|
|
250
|
+
})().catch((err) => {
|
|
251
|
+
// Fail-open: never let an exception kill the hook — but BUG-C23-07: a crashed
|
|
252
|
+
// watchdog abandons configured budget evaluation, so announce it (SPEC §8(ii)).
|
|
253
|
+
try {
|
|
254
|
+
process.stdout.write(`${JSON.stringify({
|
|
255
|
+
systemMessage: `UKit task-watchdog crashed (${String(err?.message ?? err).slice(0, 200)}) — task budget evaluation skipped this pass.`,
|
|
256
|
+
})}\n`);
|
|
257
|
+
} catch {}
|
|
233
258
|
try {
|
|
234
259
|
process.exit(0);
|
|
235
260
|
} catch {}
|
|
@@ -65,7 +65,16 @@ PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
|
|
|
65
65
|
INPUT_FILE="$UKIT_INPUT_FILE" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" UKIT_RUNTIME_DIR="$SCRIPT_DIR/../ukit/runtime" node <<'NODE'
|
|
66
66
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
67
67
|
const LOCK_STARTED_AT = Date.now();
|
|
68
|
-
|
|
68
|
+
// SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
|
|
69
|
+
// degrade (fail-open posture kept: still exit 0).
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
try {
|
|
72
|
+
process.stdout.write(JSON.stringify({
|
|
73
|
+
systemMessage: `UKit verification-guard: evaluation exceeded its ${HOOK_DEADLINE_MS}ms deadline; this command was allowed without verification tracking.`,
|
|
74
|
+
}) + '\n');
|
|
75
|
+
} catch {}
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
69
78
|
// TASK-015 fix round 1: the awaitable fs surface. Every state/progress read and
|
|
70
79
|
// the atomic progress mutation is awaited so the unref'd self-deadline above can
|
|
71
80
|
// always preempt it; synchronous fs calls park the event loop instead.
|
|
@@ -544,6 +553,13 @@ process.exit(0);
|
|
|
544
553
|
})().catch((err) => {
|
|
545
554
|
// Advisory hook: an unexpected error (including a rejected lock) must never
|
|
546
555
|
// turn into a non-zero exit — degrade to a bounded diagnostic and exit 0.
|
|
556
|
+
// SPEC §8: the degrade is emitted on stdout too — stderr alone may never reach
|
|
557
|
+
// the user, and a wedged gate must not look identical to a clean pass.
|
|
558
|
+
try {
|
|
559
|
+
process.stdout.write(JSON.stringify({
|
|
560
|
+
systemMessage: `UKit verification-guard: unexpected error (${err?.message ?? err}); command allowed without verification tracking.`,
|
|
561
|
+
}) + '\n');
|
|
562
|
+
} catch {}
|
|
547
563
|
try {
|
|
548
564
|
process.stderr.write(`verification-guard: unexpected error (${err?.message ?? err})\n`);
|
|
549
565
|
} catch {}
|
|
@@ -305,6 +305,13 @@ async function readTextSafe(filePath) {
|
|
|
305
305
|
// anything) plus the failure reason. Never the whole payload.
|
|
306
306
|
const head = typeof out === 'string' && out ? `; output head: ${out.slice(0, 200)}` : '';
|
|
307
307
|
process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})${head}\n`);
|
|
308
|
+
// BUG-C23-07: a failed extractor call abandons the configured vision hint —
|
|
309
|
+
// announce the degrade (SPEC §8(ii)); fail-open posture kept.
|
|
310
|
+
try {
|
|
311
|
+
process.stdout.write(`${JSON.stringify({
|
|
312
|
+
systemMessage: `UKit vision-router: image extractor call failed (${String(err?.message ?? err).slice(0, 150)}) — vision routing hint skipped this turn.`,
|
|
313
|
+
})}\n`);
|
|
314
|
+
} catch {}
|
|
308
315
|
}
|
|
309
316
|
} catch {
|
|
310
317
|
// Missing extractor is a normal fail-open condition.
|
|
@@ -457,6 +464,13 @@ async function readTextSafe(filePath) {
|
|
|
457
464
|
process.exit(0);
|
|
458
465
|
})().catch((err) => {
|
|
459
466
|
process.stderr.write(`vision-router: unexpected error (${err?.message ?? err})\n`);
|
|
467
|
+
// BUG-C23-07: a crashed vision router abandons configured advisory work —
|
|
468
|
+
// announce the degrade (SPEC §8(ii)); fail-open posture kept.
|
|
469
|
+
try {
|
|
470
|
+
process.stdout.write(`${JSON.stringify({
|
|
471
|
+
systemMessage: `UKit vision-router crashed (${String(err?.message ?? err).slice(0, 200)}) — vision routing hint skipped this turn.`,
|
|
472
|
+
})}\n`);
|
|
473
|
+
} catch {}
|
|
460
474
|
process.exit(0);
|
|
461
475
|
});
|
|
462
476
|
NODE
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
{
|
|
74
74
|
"type": "command",
|
|
75
75
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\"",
|
|
76
|
-
"timeout":
|
|
76
|
+
"timeout": 11
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
"type": "command",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
{
|
|
84
84
|
"type": "command",
|
|
85
85
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
|
|
86
|
-
"timeout":
|
|
86
|
+
"timeout": 20
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
"type": "command",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
{
|
|
104
104
|
"type": "command",
|
|
105
105
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\"",
|
|
106
|
-
"timeout":
|
|
106
|
+
"timeout": 15
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"type": "command",
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
{
|
|
192
192
|
"type": "command",
|
|
193
193
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
|
|
194
|
-
"timeout":
|
|
194
|
+
"timeout": 20
|
|
195
195
|
},
|
|
196
196
|
{
|
|
197
197
|
"type": "command",
|
|
@@ -46,6 +46,12 @@ const DEFAULT_LIMIT = 3;
|
|
|
46
46
|
const MAX_DECODED_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
47
47
|
const RETENTION_MAX_IMAGE_FILES = 20;
|
|
48
48
|
const MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
49
|
+
// analyzed-<sha>.json receipts outlive their marker dir: deleting the dir at
|
|
50
|
+
// 24h also deletes the receipt isAlreadyAnalyzed() reads, which re-arms
|
|
51
|
+
// pending-<sha>.json for an already-analyzed image and re-fires the vision
|
|
52
|
+
// gate every sweep (BUG-C22-14). Receipts get their own longer TTL, shared
|
|
53
|
+
// with the legacy top-level analyzed-*.json pruner (BUG-C22-18).
|
|
54
|
+
const ANALYZED_RECEIPT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30d
|
|
49
55
|
|
|
50
56
|
/**
|
|
51
57
|
* Single shared hash function for both the image filename sha and the
|
|
@@ -491,6 +497,23 @@ function pruneImageFiles(outDir) {
|
|
|
491
497
|
// ignore
|
|
492
498
|
}
|
|
493
499
|
}
|
|
500
|
+
|
|
501
|
+
// BUG-C22-18: legacy top-level analyzed-<sha>.json receipts match neither
|
|
502
|
+
// pruner and never converge — remove them once past the shared receipt TTL.
|
|
503
|
+
// Safe: isAlreadyAnalyzed() only reads receipts INSIDE marker dirs; nothing
|
|
504
|
+
// consults the top-level orphans.
|
|
505
|
+
const now = Date.now();
|
|
506
|
+
for (const e of entries) {
|
|
507
|
+
if (!e.isFile() || !e.name.startsWith('analyzed-') || !e.name.endsWith('.json')) continue;
|
|
508
|
+
try {
|
|
509
|
+
const full = path.join(outDir, e.name);
|
|
510
|
+
if (now - fs.statSync(full).mtimeMs > ANALYZED_RECEIPT_MAX_AGE_MS) {
|
|
511
|
+
fs.unlinkSync(full);
|
|
512
|
+
}
|
|
513
|
+
} catch {
|
|
514
|
+
// ignore
|
|
515
|
+
}
|
|
516
|
+
}
|
|
494
517
|
}
|
|
495
518
|
|
|
496
519
|
function pruneMarkerDirs(outDir) {
|
|
@@ -511,6 +534,10 @@ function pruneMarkerDirs(outDir) {
|
|
|
511
534
|
continue;
|
|
512
535
|
}
|
|
513
536
|
if (now - mtimeMs > MARKER_MAX_AGE_MS) {
|
|
537
|
+
// BUG-C22-14: a dir holding an analyzed-<sha>.json receipt younger than
|
|
538
|
+
// the receipt TTL is kept — deleting it would erase the only record
|
|
539
|
+
// isAlreadyAnalyzed() reads and re-arm the vision gate every sweep.
|
|
540
|
+
if (hasLiveAnalyzedReceipt(full, now)) continue;
|
|
514
541
|
try {
|
|
515
542
|
fs.rmSync(full, { recursive: true, force: true });
|
|
516
543
|
} catch {
|
|
@@ -520,6 +547,28 @@ function pruneMarkerDirs(outDir) {
|
|
|
520
547
|
}
|
|
521
548
|
}
|
|
522
549
|
|
|
550
|
+
// Returns true when the marker dir still carries an analyzed-<sha>.json receipt
|
|
551
|
+
// inside its TTL — the receipt outlives the 24h dir sweep so the analyzed-sha
|
|
552
|
+
// filter keeps suppressing re-arms until the receipt itself ages out.
|
|
553
|
+
function hasLiveAnalyzedReceipt(markerDir, now) {
|
|
554
|
+
let names;
|
|
555
|
+
try {
|
|
556
|
+
names = fs.readdirSync(markerDir);
|
|
557
|
+
} catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
for (const name of names) {
|
|
561
|
+
if (!name.startsWith('analyzed-') || !name.endsWith('.json')) continue;
|
|
562
|
+
try {
|
|
563
|
+
const mtimeMs = fs.statSync(path.join(markerDir, name)).mtimeMs;
|
|
564
|
+
if (now - mtimeMs <= ANALYZED_RECEIPT_MAX_AGE_MS) return true;
|
|
565
|
+
} catch {
|
|
566
|
+
// ignore
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
|
|
523
572
|
function printResult(opts, result) {
|
|
524
573
|
if (opts.json) {
|
|
525
574
|
console.log(JSON.stringify(result));
|