@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
|
@@ -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
|
}
|
|
@@ -456,13 +456,17 @@ export const CAPACITY_RECORD_SEGMENTS = ['.ukit', 'storage', 'cache', 'context-c
|
|
|
456
456
|
// smaller verified window moves the advisory phases down with the cap they precede.
|
|
457
457
|
export const SOFT_TO_CAP_RATIO = 0.3;
|
|
458
458
|
|
|
459
|
-
|
|
459
|
+
// BUG-C22-01: async on purpose — every consumer of this module runs inside an
|
|
460
|
+
// unref'd hook deadline (context-hardcap-gate.sh, skill-router's prompt CLI). A
|
|
461
|
+
// synchronous readFileSync on a stalled .ukit mount parks the event loop so the
|
|
462
|
+
// armed deadline can never fire (C21-04 class). Callers must await.
|
|
463
|
+
export async function readContextCapacityRecord(projectRoot) {
|
|
460
464
|
const root = projectRoot
|
|
461
465
|
|| process.env.CLAUDE_PROJECT_DIR
|
|
462
466
|
|| process.env.PROJECT_ROOT
|
|
463
467
|
|| process.cwd();
|
|
464
468
|
try {
|
|
465
|
-
const parsed = JSON.parse(
|
|
469
|
+
const parsed = JSON.parse(await fs.readFile(path.join(root, ...CAPACITY_RECORD_SEGMENTS), 'utf8'));
|
|
466
470
|
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
467
471
|
} catch {
|
|
468
472
|
return null;
|
|
@@ -471,22 +475,25 @@ export function readContextCapacityRecord(projectRoot) {
|
|
|
471
475
|
|
|
472
476
|
// Negotiated capacity: env override > config override > the model the guard last observed >
|
|
473
477
|
// conservative fallback. Exported so the source twin and diagnostics read the same number.
|
|
474
|
-
export function resolveCompactCapacity(config = {}) {
|
|
475
|
-
|
|
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);
|
|
476
483
|
return resolveContextCapTokens({
|
|
477
484
|
env: process.env,
|
|
478
485
|
config,
|
|
479
|
-
modelMetadata: { model:
|
|
486
|
+
modelMetadata: { model: resolvedRecord?.model },
|
|
480
487
|
});
|
|
481
488
|
}
|
|
482
489
|
|
|
483
|
-
export function buildCompactThresholds(config = {}) {
|
|
490
|
+
export async function buildCompactThresholds(config = {}) {
|
|
484
491
|
const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
|
|
485
492
|
const shippedHardCap = positiveInteger(config?.compact?.hardCapTokens, 500_000);
|
|
486
493
|
// Only tighten once a record exists: an install with no evidence yet (and every caller
|
|
487
494
|
// that passes no project root) keeps the shipped tuning unchanged.
|
|
488
|
-
const record = readContextCapacityRecord(config?.projectRoot);
|
|
489
|
-
const negotiated = record ? resolveCompactCapacity(config) : null;
|
|
495
|
+
const record = await readContextCapacityRecord(config?.projectRoot);
|
|
496
|
+
const negotiated = record ? await resolveCompactCapacity(config, { record }) : null;
|
|
490
497
|
const hardCapTokens = negotiated
|
|
491
498
|
? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
|
|
492
499
|
: shippedHardCap;
|
|
@@ -511,16 +518,16 @@ export function buildCompactThresholds(config = {}) {
|
|
|
511
518
|
|
|
512
519
|
// Absolute, non-negotiable ceiling — separate from the soft/hard advisory phases above.
|
|
513
520
|
// Crossing this means the advisory nudges were not followed; real compaction is mandatory.
|
|
514
|
-
export function isHardCapExceeded(estimatedTotalTokens, config = {}) {
|
|
515
|
-
const { hardCapTokens } = buildCompactThresholds(config);
|
|
521
|
+
export async function isHardCapExceeded(estimatedTotalTokens, config = {}) {
|
|
522
|
+
const { hardCapTokens } = await buildCompactThresholds(config);
|
|
516
523
|
return finiteNumber(estimatedTotalTokens, 0) >= hardCapTokens;
|
|
517
524
|
}
|
|
518
525
|
|
|
519
526
|
// Called from the PreCompact hook path once real compaction has happened, so the
|
|
520
527
|
// hard-cap gate unblocks on the next tool call instead of staying tripped forever.
|
|
521
|
-
export function resetCompactPressureState(rawState = {}, config = {}) {
|
|
522
|
-
const current = buildCompactPressureState(rawState, config);
|
|
523
|
-
return buildCompactPressureState({
|
|
528
|
+
export async function resetCompactPressureState(rawState = {}, config = {}) {
|
|
529
|
+
const current = await buildCompactPressureState(rawState, config);
|
|
530
|
+
return await buildCompactPressureState({
|
|
524
531
|
...current,
|
|
525
532
|
updatedAt: Date.now(),
|
|
526
533
|
sessionTokens: 0,
|
|
@@ -638,7 +645,7 @@ export async function syncCompactPressureStateWithTranscript(
|
|
|
638
645
|
}
|
|
639
646
|
return {
|
|
640
647
|
state: {
|
|
641
|
-
...resetCompactPressureState(rawState ?? {}, config),
|
|
648
|
+
...(await resetCompactPressureState(rawState ?? {}, config)),
|
|
642
649
|
lastCompactBoundaryAt: boundaryAt,
|
|
643
650
|
transcriptBoundaryProbe: probe,
|
|
644
651
|
},
|
|
@@ -647,7 +654,7 @@ export async function syncCompactPressureStateWithTranscript(
|
|
|
647
654
|
};
|
|
648
655
|
}
|
|
649
656
|
|
|
650
|
-
function buildReleaseThresholds(thresholds
|
|
657
|
+
function buildReleaseThresholds(thresholds) {
|
|
651
658
|
const softReleaseThreshold = Math.max(
|
|
652
659
|
Math.min(thresholds.softThreshold, Math.round(thresholds.softThreshold * SOFT_RELEASE_RATIO)),
|
|
653
660
|
1,
|
|
@@ -663,11 +670,12 @@ function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
|
|
|
663
670
|
};
|
|
664
671
|
}
|
|
665
672
|
|
|
666
|
-
export function resolveCompactPhase(
|
|
673
|
+
export async function resolveCompactPhase(
|
|
667
674
|
estimatedTotalTokens,
|
|
668
|
-
thresholds =
|
|
675
|
+
thresholds = null,
|
|
669
676
|
previousState = {},
|
|
670
677
|
) {
|
|
678
|
+
thresholds = thresholds ?? await buildCompactThresholds();
|
|
671
679
|
const previousPhase = normalizePhase(previousState?.phase ?? previousState);
|
|
672
680
|
const releaseThresholds = buildReleaseThresholds(thresholds);
|
|
673
681
|
if (estimatedTotalTokens >= thresholds.hardThreshold) {
|
|
@@ -689,7 +697,7 @@ function resolvePhaseCooldownMs(phase) {
|
|
|
689
697
|
return PHASE_COOLDOWN_MS[normalizePhase(phase)] ?? 0;
|
|
690
698
|
}
|
|
691
699
|
|
|
692
|
-
function resolvePlanDeltaThreshold(phase, thresholds
|
|
700
|
+
function resolvePlanDeltaThreshold(phase, thresholds) {
|
|
693
701
|
return normalizePhase(phase) === 'hard'
|
|
694
702
|
? Math.max(60, Math.round(thresholds.hardThreshold * 0.14))
|
|
695
703
|
: Math.max(40, Math.round(thresholds.softThreshold * 0.12));
|
|
@@ -821,9 +829,9 @@ function projectPressureDocument(sessions) {
|
|
|
821
829
|
};
|
|
822
830
|
}
|
|
823
831
|
|
|
824
|
-
export function buildCompactPressureState(rawState = null, config = {}) {
|
|
832
|
+
export async function buildCompactPressureState(rawState = null, config = {}) {
|
|
825
833
|
rawState = resolvePressureRecord(rawState, config);
|
|
826
|
-
const thresholds = buildCompactThresholds(config);
|
|
834
|
+
const thresholds = await buildCompactThresholds(config);
|
|
827
835
|
const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
|
|
828
836
|
const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
|
|
829
837
|
if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
|
|
@@ -872,7 +880,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
872
880
|
estimatedContextTokens,
|
|
873
881
|
sessionTokens,
|
|
874
882
|
estimatedTotalTokens,
|
|
875
|
-
phase: resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
|
|
883
|
+
phase: await resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
|
|
876
884
|
taskMode,
|
|
877
885
|
cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
|
|
878
886
|
lastCompactBoundaryAt: finiteNumber(rawState?.lastCompactBoundaryAt, 0),
|
|
@@ -888,14 +896,14 @@ export function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
888
896
|
};
|
|
889
897
|
}
|
|
890
898
|
|
|
891
|
-
export function registerPromptPressure(state, {
|
|
899
|
+
export async function registerPromptPressure(state, {
|
|
892
900
|
promptText = '',
|
|
893
901
|
routingContext = {},
|
|
894
902
|
previousContext = null,
|
|
895
903
|
routeSummary = null,
|
|
896
904
|
timestamp = Date.now(),
|
|
897
905
|
} = {}, config = {}) {
|
|
898
|
-
const current = buildCompactPressureState(state, config);
|
|
906
|
+
const current = await buildCompactPressureState(state, config);
|
|
899
907
|
const normalizedPrompt = String(promptText || routingContext?.lastExplicitUserPromptText || '').trim();
|
|
900
908
|
if (!normalizedPrompt) {
|
|
901
909
|
return current;
|
|
@@ -916,7 +924,7 @@ export function registerPromptPressure(state, {
|
|
|
916
924
|
})
|
|
917
925
|
: current.recentPrompts;
|
|
918
926
|
|
|
919
|
-
return buildCompactPressureState({
|
|
927
|
+
return await buildCompactPressureState({
|
|
920
928
|
...current,
|
|
921
929
|
updatedAt: timestamp,
|
|
922
930
|
routingContext,
|
|
@@ -936,7 +944,7 @@ export function registerPromptPressure(state, {
|
|
|
936
944
|
}, config);
|
|
937
945
|
}
|
|
938
946
|
|
|
939
|
-
export function registerOutputPressure(state, {
|
|
947
|
+
export async function registerOutputPressure(state, {
|
|
940
948
|
command = '',
|
|
941
949
|
profile = 'generic',
|
|
942
950
|
summary = '',
|
|
@@ -946,7 +954,7 @@ export function registerOutputPressure(state, {
|
|
|
946
954
|
exitCode = null,
|
|
947
955
|
timestamp = Date.now(),
|
|
948
956
|
} = {}, config = {}) {
|
|
949
|
-
const current = buildCompactPressureState(state, config);
|
|
957
|
+
const current = await buildCompactPressureState(state, config);
|
|
950
958
|
const normalizedSummary = summarizeOutputText(summary, command);
|
|
951
959
|
if (!normalizedSummary) {
|
|
952
960
|
return current;
|
|
@@ -968,7 +976,7 @@ export function registerOutputPressure(state, {
|
|
|
968
976
|
keyBuilder: (candidate) => `${candidate.command ?? ''}|${candidate.summary ?? ''}`,
|
|
969
977
|
});
|
|
970
978
|
|
|
971
|
-
return buildCompactPressureState({
|
|
979
|
+
return await buildCompactPressureState({
|
|
972
980
|
...current,
|
|
973
981
|
updatedAt: timestamp,
|
|
974
982
|
recentOutputs,
|
|
@@ -982,8 +990,8 @@ export function registerOutputPressure(state, {
|
|
|
982
990
|
}, config);
|
|
983
991
|
}
|
|
984
992
|
|
|
985
|
-
export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
986
|
-
const current = buildCompactPressureState(state, config);
|
|
993
|
+
export async function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
994
|
+
const current = await buildCompactPressureState(state, config);
|
|
987
995
|
if (!plan?.active) {
|
|
988
996
|
return current;
|
|
989
997
|
}
|
|
@@ -999,7 +1007,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
999
1007
|
lines: plan.lines,
|
|
1000
1008
|
});
|
|
1001
1009
|
if (current.latestPlan?.fingerprint === fingerprint) {
|
|
1002
|
-
return buildCompactPressureState({
|
|
1010
|
+
return await buildCompactPressureState({
|
|
1003
1011
|
...current,
|
|
1004
1012
|
updatedAt: now,
|
|
1005
1013
|
latestPlan: {
|
|
@@ -1017,7 +1025,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
1017
1025
|
const materialChange = isMaterialThresholdPlanChange(current.latestPlan, plan, thresholds);
|
|
1018
1026
|
|
|
1019
1027
|
if (withinCooldown && !materialChange) {
|
|
1020
|
-
return buildCompactPressureState({
|
|
1028
|
+
return await buildCompactPressureState({
|
|
1021
1029
|
...current,
|
|
1022
1030
|
updatedAt: now,
|
|
1023
1031
|
latestPlan: {
|
|
@@ -1052,7 +1060,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
1052
1060
|
? Math.min(current.sessionTokens, decayTarget)
|
|
1053
1061
|
: current.sessionTokens;
|
|
1054
1062
|
|
|
1055
|
-
return buildCompactPressureState({
|
|
1063
|
+
return await buildCompactPressureState({
|
|
1056
1064
|
...current,
|
|
1057
1065
|
updatedAt: now,
|
|
1058
1066
|
sessionTokens: decayedSessionTokens,
|
|
@@ -1073,7 +1081,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
|
|
|
1073
1081
|
}, config);
|
|
1074
1082
|
}
|
|
1075
1083
|
|
|
1076
|
-
export function buildThresholdCompactPlan({
|
|
1084
|
+
export async function buildThresholdCompactPlan({
|
|
1077
1085
|
state,
|
|
1078
1086
|
config = {},
|
|
1079
1087
|
routeState = {},
|
|
@@ -1081,8 +1089,8 @@ export function buildThresholdCompactPlan({
|
|
|
1081
1089
|
dynamicLines = [],
|
|
1082
1090
|
maxTokens = 280,
|
|
1083
1091
|
} = {}) {
|
|
1084
|
-
const normalizedState = buildCompactPressureState(state, config);
|
|
1085
|
-
const thresholds = buildCompactThresholds(config);
|
|
1092
|
+
const normalizedState = await buildCompactPressureState(state, config);
|
|
1093
|
+
const thresholds = await buildCompactThresholds(config);
|
|
1086
1094
|
const phase = normalizedState.phase;
|
|
1087
1095
|
if (phase === 'monitor') {
|
|
1088
1096
|
return {
|
|
@@ -1205,7 +1213,7 @@ export function buildThresholdCompactPlan({
|
|
|
1205
1213
|
|
|
1206
1214
|
export async function readCompactPressureState(projectRoot, config = {}) {
|
|
1207
1215
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1208
|
-
return buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
|
|
1216
|
+
return await buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
|
|
1209
1217
|
}
|
|
1210
1218
|
|
|
1211
1219
|
// All compact-pressure mutations share one lock on the state file: without it,
|
|
@@ -1225,9 +1233,9 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
|
1225
1233
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1226
1234
|
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1227
1235
|
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1228
|
-
const current = buildCompactPressureState(record, scopedConfig);
|
|
1229
|
-
const next = mutator(current);
|
|
1230
|
-
const normalized = buildCompactPressureState(next, scopedConfig);
|
|
1236
|
+
const current = await buildCompactPressureState(record, scopedConfig);
|
|
1237
|
+
const next = await mutator(current);
|
|
1238
|
+
const normalized = await buildCompactPressureState(next, scopedConfig);
|
|
1231
1239
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1232
1240
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1233
1241
|
return normalized;
|
|
@@ -1240,7 +1248,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
|
|
|
1240
1248
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1241
1249
|
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1242
1250
|
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1243
|
-
const normalized = buildCompactPressureState(state, scopedConfig);
|
|
1251
|
+
const normalized = await buildCompactPressureState(state, scopedConfig);
|
|
1244
1252
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1245
1253
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1246
1254
|
return normalized;
|
|
@@ -1306,6 +1314,28 @@ function extractPrompt(payload = {}) {
|
|
|
1306
1314
|
}
|
|
1307
1315
|
|
|
1308
1316
|
async function runCli() {
|
|
1317
|
+
// BUG-C22-15: this CLI is spawned unbounded by skill-router.sh — every read it
|
|
1318
|
+
// does (config, pressure record, transcript tail, capacity record) sits on the
|
|
1319
|
+
// .ukit/transcript filesystem, so a stalled mount used to hang the child forever
|
|
1320
|
+
// (the shell `|| true` swallows exit codes, not time, and the host SIGKILLs
|
|
1321
|
+
// silently). Arm a self-deadline so ALL invokers get a bounded, announced exit.
|
|
1322
|
+
const CLI_DEADLINE_MS = Number.parseInt(process.env.UKIT_COMPACT_CLI_DEADLINE_MS || '', 10) || 4000;
|
|
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.
|
|
1327
|
+
try {
|
|
1328
|
+
fsSync.writeSync(1, `${JSON.stringify({
|
|
1329
|
+
systemMessage:
|
|
1330
|
+
`[ukit-skill-router] compact-threshold prompt CLI exceeded its ${CLI_DEADLINE_MS}ms deadline — `
|
|
1331
|
+
+ 'pressure bookkeeping skipped this pass (likely stalled filesystem). The hook continues unharmed; '
|
|
1332
|
+
+ 'if this repeats, check the .ukit volume or raise UKIT_COMPACT_CLI_DEADLINE_MS.',
|
|
1333
|
+
})}\n`);
|
|
1334
|
+
} catch { /* stdout already gone */ }
|
|
1335
|
+
process.exit(0);
|
|
1336
|
+
}, CLI_DEADLINE_MS);
|
|
1337
|
+
cliDeadline.unref();
|
|
1338
|
+
|
|
1309
1339
|
const mode = String(process.argv[2] ?? '').trim();
|
|
1310
1340
|
if (!mode) {
|
|
1311
1341
|
return;
|
|
@@ -149,17 +149,25 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
149
149
|
maxEntries = LEDGER_SWEEP_MAX_ENTRIES,
|
|
150
150
|
maxRemovals = LEDGER_SWEEP_MAX_REMOVALS,
|
|
151
151
|
} = {}) {
|
|
152
|
-
let
|
|
152
|
+
let dirents;
|
|
153
153
|
try {
|
|
154
|
-
|
|
154
|
+
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
155
155
|
} catch {
|
|
156
156
|
return { scanned: 0, removed: 0 };
|
|
157
157
|
}
|
|
158
158
|
const cutoff = now() - maxAgeMs;
|
|
159
159
|
const countCapCutoff = now() - LEDGER_COUNT_CAP_MIN_AGE_MS;
|
|
160
|
+
// BUG-C22-12: eligible count comes from the dirents (one bounded readdir, no
|
|
161
|
+
// per-name stat) so the count cap is enforceable even though
|
|
162
|
+
// maxFiles > maxEntries — capping `scanned` first made `entries.length` an
|
|
163
|
+
// under-count and `overflow` was always 0.
|
|
164
|
+
const eligibleNames = [];
|
|
165
|
+
for (const dirent of dirents) {
|
|
166
|
+
if (dirent.isFile()) eligibleNames.push(dirent.name);
|
|
167
|
+
}
|
|
160
168
|
const entries = [];
|
|
161
169
|
let scanned = 0;
|
|
162
|
-
for (const name of
|
|
170
|
+
for (const name of eligibleNames) {
|
|
163
171
|
if (scanned >= maxEntries) break;
|
|
164
172
|
scanned += 1;
|
|
165
173
|
try {
|
|
@@ -169,9 +177,9 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
169
177
|
}
|
|
170
178
|
entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
171
179
|
// Overflow counts eligible FILES only — foreign entries (`.lock` dirs, other
|
|
172
|
-
// state files sharing the dir) inflate
|
|
180
|
+
// state files sharing the dir) inflate the dir listing and would evict real
|
|
173
181
|
// ledgers while the dir is actually under the cap.
|
|
174
|
-
const overflow = Math.max(0,
|
|
182
|
+
const overflow = Math.max(0, eligibleNames.length - maxFiles);
|
|
175
183
|
let removed = 0;
|
|
176
184
|
for (const entry of entries) {
|
|
177
185
|
if (removed >= maxRemovals) break;
|
|
@@ -196,6 +204,23 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
196
204
|
|
|
197
205
|
// Sampled gate — by default only ~1 in 16 ledger mutations pays for a bounded
|
|
198
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
|
+
|
|
199
224
|
export async function maybeSweepExecLedgerDir(dir, {
|
|
200
225
|
probability,
|
|
201
226
|
random = Math.random,
|
|
@@ -1178,7 +1203,8 @@ export async function recordLedgerEvent(event, {
|
|
|
1178
1203
|
if (outcome.ok) {
|
|
1179
1204
|
// Sampled bounded dir sweep (BUG-C21-11): runs outside the ledger lock so a
|
|
1180
1205
|
// contended acquisition never pays sweep latency. Advisory — never throws.
|
|
1181
|
-
|
|
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); }
|
|
1182
1208
|
return outcome.value;
|
|
1183
1209
|
}
|
|
1184
1210
|
|
|
@@ -1701,6 +1727,11 @@ function isDirectRun() {
|
|
|
1701
1727
|
const selfPath = fileURLToPath(import.meta.url);
|
|
1702
1728
|
const resolved = path.resolve(argvPath);
|
|
1703
1729
|
if (selfPath === resolved) return true;
|
|
1730
|
+
// BUG-C22-01: a stalled mount must not park the event loop during `await import`
|
|
1731
|
+
// — importing this module inside an armed hook deadline used to run realpathSync
|
|
1732
|
+
// here for EVERY importer. Skip fs entirely when the basename cannot match (any
|
|
1733
|
+
// importer argv like reinject-context.mjs differs from execution-ledger.mjs).
|
|
1734
|
+
if (path.basename(selfPath) !== path.basename(resolved)) return false;
|
|
1704
1735
|
try {
|
|
1705
1736
|
return fsSync.realpathSync(selfPath) === fsSync.realpathSync(resolved);
|
|
1706
1737
|
} catch {
|
|
@@ -17,13 +17,20 @@
|
|
|
17
17
|
//
|
|
18
18
|
// The clamps are intentionally generous (up to 120s) because they are an
|
|
19
19
|
// explicit operator/test affordance, not a default: an unconfigured chain still
|
|
20
|
-
// resolves to the 10s/
|
|
21
|
-
//
|
|
20
|
+
// resolves to the 10s/12s/52s defaults documented below.
|
|
21
|
+
//
|
|
22
|
+
// BUG-C22-25: the child fallback MUST cover a child's own internal worst case —
|
|
23
|
+
// every staged .sh hook stages stdin (UKIT_HOOK_STAGE_MS default 2000) then arms
|
|
24
|
+
// a node deadline of 3000-8000ms, so a child legitimately needs up to ~10s. The
|
|
25
|
+
// old 4000ms fallback killed legal hooks mid-evaluation (recorded
|
|
26
|
+
// failureKind:'timeout'; on tool_call chains the killed script can be a
|
|
27
|
+
// FAIL_CLOSED gate). 12000 = 2000 stage + 8000 deadline + 2000 margin. The
|
|
28
|
+
// ceiling fallback scales with it: 4 chained children × 12000 + 4000 slack.
|
|
22
29
|
|
|
23
30
|
export const CHAIN_BUDGET_SPECS = {
|
|
24
31
|
base: { name: 'UKIT_HOOK_CHAIN_BASE_MS', fallback: 10000, min: 500, max: 60000 },
|
|
25
|
-
child: { name: 'UKIT_HOOK_CHILD_BUDGET_MS', fallback:
|
|
26
|
-
ceiling: { name: 'UKIT_HOOK_CHAIN_CEILING_MS', fallback:
|
|
32
|
+
child: { name: 'UKIT_HOOK_CHILD_BUDGET_MS', fallback: 12000, min: 100, max: 60000 },
|
|
33
|
+
ceiling: { name: 'UKIT_HOOK_CHAIN_CEILING_MS', fallback: 52000, min: 500, max: 120000 },
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
// Margin the outer timeout adds on top of the runner's own budget: enough to
|
|
@@ -153,12 +153,16 @@ export function sweepTelemetryDir(dir, {
|
|
|
153
153
|
return { scanned: 0, removed: 0 };
|
|
154
154
|
}
|
|
155
155
|
const cutoff = now() - maxAgeMs;
|
|
156
|
+
// BUG-C22-11: eligible count is computed from the full name listing (bounded
|
|
157
|
+
// by the single readdir, no per-name stat) so the count cap is enforceable
|
|
158
|
+
// even though maxFiles > maxEntries — capping `scanned` first made
|
|
159
|
+
// `entries.length` an under-count and `overflow` was always 0.
|
|
160
|
+
const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
|
|
156
161
|
const entries = [];
|
|
157
162
|
let scanned = 0;
|
|
158
|
-
for (const name of
|
|
163
|
+
for (const name of eligibleNames) {
|
|
159
164
|
if (scanned >= maxEntries) break;
|
|
160
165
|
scanned += 1;
|
|
161
|
-
if (!name.endsWith('.jsonl')) continue;
|
|
162
166
|
try {
|
|
163
167
|
const stats = fs.statSync(path.join(dir, name));
|
|
164
168
|
if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
|
|
@@ -168,7 +172,7 @@ export function sweepTelemetryDir(dir, {
|
|
|
168
172
|
// Overflow counts eligible FILES only — foreign entries (non-matching names,
|
|
169
173
|
// dirs) inflate `names.length` and would evict real entries while the dir is
|
|
170
174
|
// actually under the cap.
|
|
171
|
-
const overflow = Math.max(0,
|
|
175
|
+
const overflow = Math.max(0, eligibleNames.length - maxFiles);
|
|
172
176
|
let removed = 0;
|
|
173
177
|
for (const entry of entries) {
|
|
174
178
|
if (removed >= maxRemovals) break;
|
|
@@ -196,6 +200,22 @@ export function maybeSweepTelemetryDir(dir, {
|
|
|
196
200
|
return { sampled: true, ...sweepTelemetryDir(dir, options) };
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
// BUG-C23-10: a bare `catch {}` around the sweep swallowed every failure — a
|
|
204
|
+
// persistent EACCES/ENOSPC meant silent non-convergence of the dir bound.
|
|
205
|
+
// Announce the first failure and then every 25th, C22 rate-limit posture.
|
|
206
|
+
let sweepFailureCount = 0;
|
|
207
|
+
export function noteSweepFailure(error) {
|
|
208
|
+
sweepFailureCount += 1;
|
|
209
|
+
if (sweepFailureCount !== 1 && sweepFailureCount % 25 !== 0) return null;
|
|
210
|
+
const message =
|
|
211
|
+
`UKit hook-telemetry: latency dir sweep failed (${sweepFailureCount} failure(s) so far) — `
|
|
212
|
+
+ `the appended row is safe but the backlog bound may lag. Cause: ${error?.message || error}`;
|
|
213
|
+
try {
|
|
214
|
+
fs.writeSync(1, `${JSON.stringify({ systemMessage: message })}\n`);
|
|
215
|
+
} catch { /* stdout already gone */ }
|
|
216
|
+
return message;
|
|
217
|
+
}
|
|
218
|
+
|
|
199
219
|
export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
|
|
200
220
|
try {
|
|
201
221
|
if (!row || typeof row !== 'object') return false;
|
|
@@ -207,7 +227,7 @@ export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
|
|
|
207
227
|
fs.appendFileSync(filePath, line, 'utf8');
|
|
208
228
|
// Sampled bounded dir sweep (BUG-C21-10): advisory — a sweep failure must
|
|
209
229
|
// never alter the row this call just appended.
|
|
210
|
-
try { maybeSweepTelemetryDir(dir); } catch {
|
|
230
|
+
try { maybeSweepTelemetryDir(dir); } catch (error) { noteSweepFailure(error); }
|
|
211
231
|
return true;
|
|
212
232
|
} catch {
|
|
213
233
|
// Advisory: an unwritable or corrupt telemetry target must never alter a
|