@ngockhoale/ukit 2.6.4 → 2.6.6
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 +73 -0
- package/bin/ukit +30 -10
- package/manifests/documentation.yaml +605 -0
- package/package.json +2 -2
- package/src/core/memory/store.js +10 -3
- package/src/core/runtimeConfig.js +2 -0
- package/templates/.claude/hooks/auto-prune-bash.sh +33 -0
- package/templates/.claude/hooks/completion-gate.sh +20 -1
- package/templates/.claude/hooks/context-hardcap-gate.sh +7 -0
- package/templates/.claude/hooks/handoff-resume.sh +14 -1
- package/templates/.claude/hooks/skill-router.sh +180 -27
- package/templates/.claude/hooks/task-watchdog.sh +30 -5
- package/templates/.claude/hooks/vision-router.sh +14 -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 +16 -8
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +19 -1
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +17 -1
- package/templates/.claude/ukit/runtime/output-compression.mjs +58 -8
- package/templates/.claude/ukit/runtime/reinject-context.mjs +37 -5
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +5 -1
- package/templates/.codex/README.md +1 -1
- package/templates/ukit/README.md +1 -1
|
@@ -70,6 +70,35 @@ async function appendAudit(event, payload) {
|
|
|
70
70
|
await fsp.appendFile(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
|
|
71
71
|
}
|
|
72
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);
|
|
100
|
+
}
|
|
101
|
+
|
|
73
102
|
// Same lock point as auto-allow-bash.sh (<usage>.lock): the two hooks serialize their
|
|
74
103
|
// settings/usage rewrites against each other and against parallel invocations. Atomic
|
|
75
104
|
// writes because Claude Code re-reads settings.local.json — a torn write would drop the
|
|
@@ -148,6 +177,10 @@ async function withLock(lockPath, fn) {
|
|
|
148
177
|
}
|
|
149
178
|
|
|
150
179
|
withLock(`${usagePath}.lock`, async () => {
|
|
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
|
+
|
|
151
184
|
const settings = await readJson(settingsPath);
|
|
152
185
|
const usage = await readJson(usagePath);
|
|
153
186
|
if (!settings || !usage) return;
|
|
@@ -78,8 +78,27 @@ 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
|
|
85
104
|
# BUG-C22-05: a PERSISTENT coordinator crash must not block every Stop forever.
|
|
@@ -402,7 +402,14 @@ async function readRunCursor() {
|
|
|
402
402
|
})().catch((err) => {
|
|
403
403
|
// A logic error here fails OPEN: this is a backstop on top of advisory nudges,
|
|
404
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)).
|
|
405
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 {}
|
|
406
413
|
process.exit(0);
|
|
407
414
|
});
|
|
408
415
|
NODE
|
|
@@ -87,7 +87,20 @@ 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(() =>
|
|
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
|
+
try {
|
|
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();
|
|
91
104
|
|
|
92
105
|
const projectRoot = process.env.PROJECT_ROOT;
|
|
93
106
|
const runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
|
|
@@ -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
|
|
@@ -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 {}
|
|
@@ -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
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { analyzeTextBuffer, analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
|
|
@@ -7,9 +8,27 @@ import { classifySafePatchRisk, isSafePatchAdvisoryOnly, parseJsonInput, pathExi
|
|
|
7
8
|
// Hook-context self-deadline (2.4.1 orphan-leak class): the post-edit-verify hook passes
|
|
8
9
|
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
|
|
9
10
|
// budget. Non-hook usage never sets it and is never self-killed.
|
|
11
|
+
// SPEC §8: stdout is the only surface the hook consumer reliably shows the
|
|
12
|
+
// user (stderr reaches the model). writeSync(1) because a same-tick
|
|
13
|
+
// process.exit() can discard a buffered async write on a pipe.
|
|
14
|
+
function emitSystemMessage(text) {
|
|
15
|
+
try {
|
|
16
|
+
fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
|
|
17
|
+
} catch { /* stdout already gone */ }
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
11
21
|
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
12
|
-
setTimeout(() =>
|
|
22
|
+
setTimeout(() => {
|
|
23
|
+
// BUG-C23-04: deadline expiry abandons the configured post-edit verify — a
|
|
24
|
+
// degrade, and §8(ii) requires it be announced before the exit.
|
|
25
|
+
emitSystemMessage(
|
|
26
|
+
`UKit post-edit-verify exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
|
|
27
|
+
+ 'Safe Patch delta verification skipped this pass (likely stalled filesystem). '
|
|
28
|
+
+ 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
|
|
29
|
+
);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
13
32
|
}
|
|
14
33
|
|
|
15
34
|
async function readStdin() {
|
|
@@ -220,6 +239,13 @@ async function main() {
|
|
|
220
239
|
process.stderr.write('[ukit-safe-patch] advisoryOnly=true — change is already written; continue planned follow-up without stopping.\n');
|
|
221
240
|
return;
|
|
222
241
|
}
|
|
242
|
+
// BUG-C23-03: exit-2 blocks must pair the stderr reason with a stdout §8
|
|
243
|
+
// emission — stderr reaches the model, not reliably the user
|
|
244
|
+
// (precedent: context-hardcap-gate.sh, handoff-model-guard.sh).
|
|
245
|
+
// --json mode: the verdict JSON already written above IS the structured
|
|
246
|
+
// announcement (status+message fields); a second raw line would corrupt
|
|
247
|
+
// the machine-parseable stdout contract.
|
|
248
|
+
if (!json) emitSystemMessage(`[ukit-safe-patch] ${message}`);
|
|
223
249
|
process.exit(2);
|
|
224
250
|
}
|
|
225
251
|
}
|
|
@@ -227,6 +253,12 @@ async function main() {
|
|
|
227
253
|
if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
|
|
228
254
|
main().catch((error) => {
|
|
229
255
|
process.stderr.write(`[ukit-safe-patch] post-edit ERROR: ${error?.message || error}\n`);
|
|
256
|
+
// BUG-C23-01: stderr-only crash announces nothing to the user — §8 requires
|
|
257
|
+
// the paired stdout systemMessage before the non-zero exit.
|
|
258
|
+
emitSystemMessage(
|
|
259
|
+
`UKit post-edit-verify crashed before it could verify the edit (${error?.message || error}); `
|
|
260
|
+
+ 'the Safe Patch check was skipped this pass. Run `ukit doctor` if this repeats.',
|
|
261
|
+
);
|
|
230
262
|
process.exit(1);
|
|
231
263
|
});
|
|
232
264
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import fsSync from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import {
|
|
5
6
|
classifySafePatchRisk,
|
|
@@ -16,9 +17,27 @@ import {
|
|
|
16
17
|
// Hook-context self-deadline (2.4.1 orphan-leak class): the stale-spec-guard hook passes
|
|
17
18
|
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
|
|
18
19
|
// budget. Non-hook usage never sets it and is never self-killed.
|
|
20
|
+
// SPEC §8: stdout is the only surface the hook consumer reliably shows the
|
|
21
|
+
// user (stderr reaches the model). writeSync(1) because a same-tick
|
|
22
|
+
// process.exit() can discard a buffered async write on a pipe.
|
|
23
|
+
function emitSystemMessage(text) {
|
|
24
|
+
try {
|
|
25
|
+
fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
|
|
26
|
+
} catch { /* stdout already gone */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
20
30
|
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
21
|
-
setTimeout(() =>
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
// BUG-C23-04: deadline expiry abandons the configured stale-spec check — a
|
|
33
|
+
// degrade, and §8(ii) requires it be announced before the exit.
|
|
34
|
+
emitSystemMessage(
|
|
35
|
+
`UKit stale-spec-check exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
|
|
36
|
+
+ 'Safe Patch spec verification skipped this pass (likely stalled filesystem). '
|
|
37
|
+
+ 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
|
|
38
|
+
);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
22
41
|
}
|
|
23
42
|
|
|
24
43
|
function getToolName(payload = {}) {
|
|
@@ -198,6 +217,13 @@ async function main() {
|
|
|
198
217
|
process.stderr.write('[ukit-safe-patch] advisoryOnly=true — continue with corrected old_string; do not stop and ask the user.\n');
|
|
199
218
|
return;
|
|
200
219
|
}
|
|
220
|
+
// BUG-C23-03: exit-2 blocks must pair the stderr reason with a stdout §8
|
|
221
|
+
// emission — stderr reaches the model, not reliably the user
|
|
222
|
+
// (precedent: context-hardcap-gate.sh, handoff-model-guard.sh).
|
|
223
|
+
// --json mode: the verdict JSON above IS the structured announcement —
|
|
224
|
+
// a second raw line would corrupt the parseable stdout contract
|
|
225
|
+
// (sibling parity: post-edit-verify.mjs).
|
|
226
|
+
if (!args.json) emitSystemMessage(`[ukit-safe-patch] ${message}`);
|
|
201
227
|
process.exit(2);
|
|
202
228
|
}
|
|
203
229
|
}
|
|
@@ -205,6 +231,11 @@ async function main() {
|
|
|
205
231
|
if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
|
|
206
232
|
main().catch((error) => {
|
|
207
233
|
process.stderr.write(`[ukit-safe-patch] ERROR: ${error?.message || error}\n`);
|
|
234
|
+
// Same stderr-only crash class as BUG-C23-01 — pair the §8 stdout emission.
|
|
235
|
+
emitSystemMessage(
|
|
236
|
+
`UKit stale-spec-check crashed before it could verify the edit (${error?.message || error}); `
|
|
237
|
+
+ 'the Safe Patch spec check was skipped this pass. Run `ukit doctor` if this repeats.',
|
|
238
|
+
);
|
|
208
239
|
process.exit(1);
|
|
209
240
|
});
|
|
210
241
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { pathToFileURL } from 'node:url';
|
|
4
5
|
import {
|
|
@@ -474,12 +475,15 @@ export async function readContextCapacityRecord(projectRoot) {
|
|
|
474
475
|
|
|
475
476
|
// Negotiated capacity: env override > config override > the model the guard last observed >
|
|
476
477
|
// conservative fallback. Exported so the source twin and diagnostics read the same number.
|
|
477
|
-
export async function resolveCompactCapacity(config = {}) {
|
|
478
|
-
|
|
478
|
+
export async function resolveCompactCapacity(config = {}, { record } = {}) {
|
|
479
|
+
// BUG-C23-09: callers that already read the capacity record (buildCompactThresholds)
|
|
480
|
+
// pass it in — a second serial fs.readFile on .ukit doubles the stalled-mount
|
|
481
|
+
// window on the hardcap hot path.
|
|
482
|
+
const resolvedRecord = record !== undefined ? record : await readContextCapacityRecord(config?.projectRoot);
|
|
479
483
|
return resolveContextCapTokens({
|
|
480
484
|
env: process.env,
|
|
481
485
|
config,
|
|
482
|
-
modelMetadata: { model:
|
|
486
|
+
modelMetadata: { model: resolvedRecord?.model },
|
|
483
487
|
});
|
|
484
488
|
}
|
|
485
489
|
|
|
@@ -489,7 +493,7 @@ export async function buildCompactThresholds(config = {}) {
|
|
|
489
493
|
// Only tighten once a record exists: an install with no evidence yet (and every caller
|
|
490
494
|
// that passes no project root) keeps the shipped tuning unchanged.
|
|
491
495
|
const record = await readContextCapacityRecord(config?.projectRoot);
|
|
492
|
-
const negotiated = record ? await resolveCompactCapacity(config) : null;
|
|
496
|
+
const negotiated = record ? await resolveCompactCapacity(config, { record }) : null;
|
|
493
497
|
const hardCapTokens = negotiated
|
|
494
498
|
? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
|
|
495
499
|
: shippedHardCap;
|
|
@@ -1317,12 +1321,16 @@ async function runCli() {
|
|
|
1317
1321
|
// silently). Arm a self-deadline so ALL invokers get a bounded, announced exit.
|
|
1318
1322
|
const CLI_DEADLINE_MS = Number.parseInt(process.env.UKIT_COMPACT_CLI_DEADLINE_MS || '', 10) || 4000;
|
|
1319
1323
|
const cliDeadline = setTimeout(() => {
|
|
1324
|
+
// BUG-C23-02: fsSync.writeSync(1, …), never process.stdout.write — on a pipe
|
|
1325
|
+
// (this CLI is always piped) a same-tick process.exit() can discard the
|
|
1326
|
+
// buffered async write, and the bare text line wasn't §8 JSON anyway.
|
|
1320
1327
|
try {
|
|
1321
|
-
|
|
1322
|
-
|
|
1328
|
+
fsSync.writeSync(1, `${JSON.stringify({
|
|
1329
|
+
systemMessage:
|
|
1330
|
+
`[ukit-skill-router] compact-threshold prompt CLI exceeded its ${CLI_DEADLINE_MS}ms deadline — `
|
|
1323
1331
|
+ 'pressure bookkeeping skipped this pass (likely stalled filesystem). The hook continues unharmed; '
|
|
1324
|
-
+ 'if this repeats, check the .ukit volume or raise UKIT_COMPACT_CLI_DEADLINE_MS
|
|
1325
|
-
);
|
|
1332
|
+
+ 'if this repeats, check the .ukit volume or raise UKIT_COMPACT_CLI_DEADLINE_MS.',
|
|
1333
|
+
})}\n`);
|
|
1326
1334
|
} catch { /* stdout already gone */ }
|
|
1327
1335
|
process.exit(0);
|
|
1328
1336
|
}, CLI_DEADLINE_MS);
|
|
@@ -204,6 +204,23 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
204
204
|
|
|
205
205
|
// Sampled gate — by default only ~1 in 16 ledger mutations pays for a bounded
|
|
206
206
|
// sweep at all. Advisory: a sweep failure must never lose a ledger event.
|
|
207
|
+
// BUG-C23-10: a bare `catch {}` around the sweep swallowed every failure — a
|
|
208
|
+
// persistent EACCES/ENOSPC meant silent non-convergence of the exec-ledger dir
|
|
209
|
+
// bound. Announce the first failure and then every 25th, C22 rate-limit
|
|
210
|
+
// posture. writeSync(1) because a same-tick exit can drop a buffered write.
|
|
211
|
+
let sweepFailureCount = 0;
|
|
212
|
+
export function noteSweepFailure(error) {
|
|
213
|
+
sweepFailureCount += 1;
|
|
214
|
+
if (sweepFailureCount !== 1 && sweepFailureCount % 25 !== 0) return null;
|
|
215
|
+
const message =
|
|
216
|
+
`UKit execution-ledger: dir sweep failed (${sweepFailureCount} failure(s) so far) — `
|
|
217
|
+
+ `the committed event is safe but the backlog bound may lag. Cause: ${error?.message || error}`;
|
|
218
|
+
try {
|
|
219
|
+
fsSync.writeSync(1, `${JSON.stringify({ systemMessage: message })}\n`);
|
|
220
|
+
} catch { /* stdout already gone */ }
|
|
221
|
+
return message;
|
|
222
|
+
}
|
|
223
|
+
|
|
207
224
|
export async function maybeSweepExecLedgerDir(dir, {
|
|
208
225
|
probability,
|
|
209
226
|
random = Math.random,
|
|
@@ -1186,7 +1203,8 @@ export async function recordLedgerEvent(event, {
|
|
|
1186
1203
|
if (outcome.ok) {
|
|
1187
1204
|
// Sampled bounded dir sweep (BUG-C21-11): runs outside the ledger lock so a
|
|
1188
1205
|
// contended acquisition never pays sweep latency. Advisory — never throws.
|
|
1189
|
-
|
|
1206
|
+
// BUG-C23-10: a persistent failure is still surfaced as a bounded degrade.
|
|
1207
|
+
try { await maybeSweepExecLedgerDir(path.dirname(target)); } catch (error) { noteSweepFailure(error); }
|
|
1190
1208
|
return outcome.value;
|
|
1191
1209
|
}
|
|
1192
1210
|
|