@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,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import fsSync from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
4
6
|
import {
|
|
5
7
|
buildCompactMachineKey,
|
|
6
8
|
buildRuntimePaths,
|
|
@@ -19,9 +21,42 @@ import { updateCompactPressureFromOutput } from './compact-threshold.mjs';
|
|
|
19
21
|
// Hook-context self-deadline (2.4.1 orphan-leak class): the compress-output hook passes
|
|
20
22
|
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
|
|
21
23
|
// budget. Non-hook usage never sets it and is never self-killed.
|
|
24
|
+
// SPEC §8 degrade channel: stdout is the only surface the hook consumer reliably
|
|
25
|
+
// shows the user; stderr is model-facing. writeSync(1) because a same-tick
|
|
26
|
+
// process.exit() can discard a buffered async write on a pipe.
|
|
27
|
+
function emitSystemMessage(text) {
|
|
28
|
+
try {
|
|
29
|
+
fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
|
|
30
|
+
} catch { /* stdout already gone */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// BUG-C23-10: bare `.catch(() => {})` swallowed every sweep failure — a
|
|
34
|
+
// persistent EACCES/ENOSPC on .ukit meant silent non-convergence of the tee/
|
|
35
|
+
// backlog bound (live dir observed at 2,417 files). Announce the first failure
|
|
36
|
+
// and then every 25th, C22 rate-limit posture.
|
|
37
|
+
let sweepFailureCount = 0;
|
|
38
|
+
export function noteSweepFailure(error) {
|
|
39
|
+
sweepFailureCount += 1;
|
|
40
|
+
if (sweepFailureCount !== 1 && sweepFailureCount % 25 !== 0) return null;
|
|
41
|
+
const message =
|
|
42
|
+
`UKit output-compression: tee cache sweep failed (${sweepFailureCount} failure(s) so far) — `
|
|
43
|
+
+ `the preserved output is safe but the backlog bound may lag. Cause: ${error?.message || error}`;
|
|
44
|
+
emitSystemMessage(message);
|
|
45
|
+
return message;
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
23
49
|
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
24
|
-
setTimeout(() =>
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
// BUG-C23-04: deadline expiry abandons configured compression work — a
|
|
52
|
+
// degrade, and §8(ii) requires it be announced before the exit.
|
|
53
|
+
emitSystemMessage(
|
|
54
|
+
`UKit output-compression exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
|
|
55
|
+
+ 'tool output passed through uncompressed this call (likely stalled filesystem). '
|
|
56
|
+
+ 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
|
|
57
|
+
);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
25
60
|
}
|
|
26
61
|
|
|
27
62
|
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
@@ -410,16 +445,29 @@ export async function sweepTeeCache(dir, {
|
|
|
410
445
|
maxEntries = TEE_SWEEP_MAX_ENTRIES,
|
|
411
446
|
maxRemovals = TEE_SWEEP_MAX_REMOVALS,
|
|
412
447
|
} = {}) {
|
|
413
|
-
let
|
|
448
|
+
let dirents;
|
|
414
449
|
try {
|
|
415
|
-
|
|
450
|
+
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
416
451
|
} catch {
|
|
417
452
|
return { sampled: true, scanned: 0, removed: 0 };
|
|
418
453
|
}
|
|
454
|
+
// Eligible-file count comes from the dirents (one bounded readdir, no per-name
|
|
455
|
+
// stat) so the count cap is enforceable even when maxFiles > maxEntries —
|
|
456
|
+
// BUG-C22-10: capping `scanned` at maxEntries made `entries.length` an
|
|
457
|
+
// under-count and `overflow` was always 0. Expected drain: each sampled sweep
|
|
458
|
+
// removes up to maxRemovals, so a backlog of B files converges after
|
|
459
|
+
// ~ceil(B/maxRemovals) sweeps (~16x that many writes at the default 1/16
|
|
460
|
+
// sampling); the 7d age cutoff drains the remainder only when new writes keep
|
|
461
|
+
// arriving — a stopped writer converges at ~ceil(B/maxRemovals) sweeps and no
|
|
462
|
+
// faster, since sweeps are sampled off the write path (BUG-C23-10, FR-006).
|
|
463
|
+
const eligibleNames = [];
|
|
464
|
+
for (const dirent of dirents) {
|
|
465
|
+
if (dirent.isFile()) eligibleNames.push(dirent.name);
|
|
466
|
+
}
|
|
419
467
|
const cutoff = now() - maxAgeMs;
|
|
420
468
|
const entries = [];
|
|
421
469
|
let scanned = 0;
|
|
422
|
-
for (const name of
|
|
470
|
+
for (const name of eligibleNames) {
|
|
423
471
|
if (scanned >= maxEntries) break;
|
|
424
472
|
scanned += 1;
|
|
425
473
|
try {
|
|
@@ -429,9 +477,9 @@ export async function sweepTeeCache(dir, {
|
|
|
429
477
|
}
|
|
430
478
|
entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
431
479
|
// Overflow counts eligible FILES only — foreign entries (non-matching names,
|
|
432
|
-
// dirs) inflate
|
|
480
|
+
// dirs) inflate the dir listing and would evict real entries while the dir is
|
|
433
481
|
// actually under the cap.
|
|
434
|
-
const overflow = Math.max(0,
|
|
482
|
+
const overflow = Math.max(0, eligibleNames.length - maxFiles);
|
|
435
483
|
let removed = 0;
|
|
436
484
|
for (const entry of entries) {
|
|
437
485
|
if (removed >= maxRemovals) break;
|
|
@@ -487,8 +535,9 @@ async function persistRawOutput(projectRoot, {
|
|
|
487
535
|
await fs.mkdir(teeCacheDir, { recursive: true });
|
|
488
536
|
await fs.writeFile(absolutePath, rawOutputText, 'utf8');
|
|
489
537
|
// Bounded, sampled tee/ prune (BUG-C21-07): advisory only — a sweep failure
|
|
490
|
-
// must never lose the preserved output this call just wrote.
|
|
491
|
-
|
|
538
|
+
// must never lose the preserved output this call just wrote. BUG-C23-10: a
|
|
539
|
+
// persistent failure (EACCES/ENOSPC) still surfaces as a bounded degrade.
|
|
540
|
+
await maybeSweepTeeCache(teeCacheDir).catch(noteSweepFailure);
|
|
492
541
|
|
|
493
542
|
return {
|
|
494
543
|
rawSaved: true,
|
|
@@ -1475,7 +1524,19 @@ async function main() {
|
|
|
1475
1524
|
process.stdout.write(result.summary);
|
|
1476
1525
|
}
|
|
1477
1526
|
|
|
1478
|
-
main()
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1527
|
+
// BUG-C23-13: only run main() on direct CLI invocation — an unguarded bottom
|
|
1528
|
+
// call executes on ANY import(), reading the importer's stdin and exiting its
|
|
1529
|
+
// process. Guard shape matches compact-threshold.mjs.
|
|
1530
|
+
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
|
|
1531
|
+
if (invokedPath && pathToFileURL(invokedPath).href === import.meta.url) {
|
|
1532
|
+
main().catch((error) => {
|
|
1533
|
+
console.error('[UKit] Failed to compress tool output:', error?.message || String(error));
|
|
1534
|
+
// BUG-C23-01: stderr alone is invisible to the hook consumer — the §8
|
|
1535
|
+
// announce contract requires a stdout systemMessage on the crash path too.
|
|
1536
|
+
emitSystemMessage(
|
|
1537
|
+
'UKit output-compression failed before it could compress this tool output; '
|
|
1538
|
+
+ 'the raw output was passed through uncompressed. Run `ukit doctor` if this repeats.',
|
|
1539
|
+
);
|
|
1540
|
+
process.exitCode = 1;
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import fsSync from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
4
6
|
import {
|
|
5
7
|
buildCompactMachineKey,
|
|
6
8
|
buildRuntimePaths,
|
|
@@ -22,9 +24,27 @@ import {
|
|
|
22
24
|
// Hook-context self-deadline (2.4.1 orphan-leak class): the reinject-context hook passes
|
|
23
25
|
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
|
|
24
26
|
// budget. Non-hook usage never sets it and is never self-killed.
|
|
27
|
+
// SPEC §8 degrade channel: stdout is the only surface the hook consumer reliably
|
|
28
|
+
// shows the user; stderr is model-facing. writeSync(1) because a same-tick
|
|
29
|
+
// process.exit() can discard a buffered async write on a pipe.
|
|
30
|
+
function emitSystemMessage(text) {
|
|
31
|
+
try {
|
|
32
|
+
fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
|
|
33
|
+
} catch { /* stdout already gone */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
26
37
|
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
27
|
-
setTimeout(() =>
|
|
38
|
+
setTimeout(() => {
|
|
39
|
+
// BUG-C23-04: deadline expiry abandons the configured context reinjection —
|
|
40
|
+
// a degrade, and §8(ii) requires it be announced before the exit.
|
|
41
|
+
emitSystemMessage(
|
|
42
|
+
`UKit reinject-context exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
|
|
43
|
+
+ 'post-compaction context reinjection skipped this pass (likely stalled filesystem). '
|
|
44
|
+
+ 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
|
|
45
|
+
);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
const STATE_TTL_MS = 30 * 60 * 1000;
|
|
@@ -108,7 +128,7 @@ async function main() {
|
|
|
108
128
|
// (writeThresholdCompactPlan) re-read from disk, so they build on the zeroed state and
|
|
109
129
|
// keep their own stats increments intact. Session-scoped: sibling sessions keep their
|
|
110
130
|
// own records.
|
|
111
|
-
await writeCompactPressureState(projectRoot, resetCompactPressureState(pressureState, sessionConfig), sessionConfig);
|
|
131
|
+
await writeCompactPressureState(projectRoot, await resetCompactPressureState(pressureState, sessionConfig), sessionConfig);
|
|
112
132
|
const anchorLines = dynamicLines.filter((line) => (
|
|
113
133
|
line.startsWith('- Recent routed lane:')
|
|
114
134
|
|| line.startsWith('- Current approach:')
|
|
@@ -124,7 +144,7 @@ async function main() {
|
|
|
124
144
|
|| line.startsWith('- Recent delegation hint:')
|
|
125
145
|
));
|
|
126
146
|
const maxTokens = Math.max(160, Math.min(320, Number(config.memory?.maxInjectionTokens) || 320));
|
|
127
|
-
const thresholdPlan = buildThresholdCompactPlan({
|
|
147
|
+
const thresholdPlan = await buildThresholdCompactPlan({
|
|
128
148
|
state: pressureState,
|
|
129
149
|
config,
|
|
130
150
|
routeState: state,
|
|
@@ -1047,7 +1067,19 @@ async function buildPreviousContextLines(projectRoot, state, config) {
|
|
|
1047
1067
|
return [`- Previous context: ${rankedItems.map((item) => buildPreviousContextSnippet(item)).join(' | ')}`];
|
|
1048
1068
|
}
|
|
1049
1069
|
|
|
1050
|
-
main()
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1070
|
+
// BUG-C23-13: only run main() on direct CLI invocation — an unguarded bottom
|
|
1071
|
+
// call executes on ANY import(), reading the importer's stdin and exiting its
|
|
1072
|
+
// process. Guard shape matches compact-threshold.mjs.
|
|
1073
|
+
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
|
|
1074
|
+
if (invokedPath && pathToFileURL(invokedPath).href === import.meta.url) {
|
|
1075
|
+
main().catch((error) => {
|
|
1076
|
+
console.error('[UKit] Failed to build compact reinject context:', error?.message || String(error));
|
|
1077
|
+
// BUG-C23-01: stderr alone is invisible to the hook consumer — the §8
|
|
1078
|
+
// announce contract requires a stdout systemMessage on the crash path too.
|
|
1079
|
+
emitSystemMessage(
|
|
1080
|
+
'UKit reinject-context failed before it could rebuild the compact context block; '
|
|
1081
|
+
+ 'the session continues without reinjected context this pass. Run `ukit doctor` if this repeats.',
|
|
1082
|
+
);
|
|
1083
|
+
process.exitCode = 1;
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
@@ -561,7 +561,11 @@ async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupe
|
|
|
561
561
|
return true;
|
|
562
562
|
}
|
|
563
563
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
564
|
-
|
|
564
|
+
// BUG-C23-08: spread `current` — a bare { lastStop } write erased the
|
|
565
|
+
// `handoff` stall-streak slot written by bumpHandoffStallCount, which ran
|
|
566
|
+
// AFTER this dedupe write each invocation → count always read 1 and the
|
|
567
|
+
// FR-004 liveness breaker could never fire.
|
|
568
|
+
await fs.writeFile(statePath, `${JSON.stringify({ ...current, lastStop: { key: sessionKey, ts: now } }, null, 1)}\n`, 'utf8');
|
|
565
569
|
return false;
|
|
566
570
|
});
|
|
567
571
|
return outcome.ok ? outcome.value === true : false;
|
|
@@ -683,6 +687,9 @@ function isDirectRun() {
|
|
|
683
687
|
selfUrlPath = null;
|
|
684
688
|
}
|
|
685
689
|
if (selfUrlPath && selfUrlPath === selfPath) return true;
|
|
690
|
+
// BUG-C22-01: skip fs entirely for non-matching importers — a stalled mount
|
|
691
|
+
// must not park the event loop during `await import` inside an armed deadline.
|
|
692
|
+
if (selfUrlPath && path.basename(selfUrlPath) !== path.basename(selfPath)) return false;
|
|
686
693
|
try {
|
|
687
694
|
return fsSync.realpathSync(selfUrlPath) === fsSync.realpathSync(selfPath);
|
|
688
695
|
} catch {
|
|
@@ -175,8 +175,15 @@ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxW
|
|
|
175
175
|
continue; // the slot is free now — retry immediately
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
-
} catch {
|
|
179
|
-
|
|
178
|
+
} catch (statError) {
|
|
179
|
+
// BUG-C22-16: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
|
|
180
|
+
// mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
|
|
181
|
+
// EEXIST) must not busy-spin — a bare `continue` skipped both the
|
|
182
|
+
// maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
|
|
183
|
+
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
184
|
+
if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
|
|
185
|
+
await sleep(lockBackoffDelayMs());
|
|
186
|
+
continue;
|
|
180
187
|
}
|
|
181
188
|
|
|
182
189
|
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
@@ -394,12 +394,16 @@ function sweepHookErrorsDir(dir, {
|
|
|
394
394
|
return { scanned: 0, removed: 0 };
|
|
395
395
|
}
|
|
396
396
|
const cutoff = now() - maxAgeMs;
|
|
397
|
+
// BUG-C22-13: eligible count comes from the full name listing (bounded by the
|
|
398
|
+
// single readdir, no per-name stat) so the count cap is enforceable even
|
|
399
|
+
// though maxFiles > maxEntries — capping `scanned` first made
|
|
400
|
+
// `entries.length` an under-count and `overflow` was always 0.
|
|
401
|
+
const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
|
|
397
402
|
const entries = [];
|
|
398
403
|
let scanned = 0;
|
|
399
|
-
for (const name of
|
|
404
|
+
for (const name of eligibleNames) {
|
|
400
405
|
if (scanned >= maxEntries) break;
|
|
401
406
|
scanned += 1;
|
|
402
|
-
if (!name.endsWith('.jsonl')) continue;
|
|
403
407
|
try {
|
|
404
408
|
const stats = fs.statSync(path.join(dir, name));
|
|
405
409
|
if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
|
|
@@ -409,7 +413,7 @@ function sweepHookErrorsDir(dir, {
|
|
|
409
413
|
// Overflow counts eligible FILES only — foreign entries (non-matching names,
|
|
410
414
|
// dirs) inflate `names.length` and would evict real entries while the dir is
|
|
411
415
|
// actually under the cap.
|
|
412
|
-
const overflow = Math.max(0,
|
|
416
|
+
const overflow = Math.max(0, eligibleNames.length - maxFiles);
|
|
413
417
|
let removed = 0;
|
|
414
418
|
for (const entry of entries) {
|
|
415
419
|
if (removed >= maxRemovals) break;
|
|
@@ -820,6 +824,42 @@ export async function runSessionCompact(pi, event, { projectRoot, context: exten
|
|
|
820
824
|
return undefined;
|
|
821
825
|
}
|
|
822
826
|
|
|
827
|
+
// ── BUG-C22-05 crash-streak breaker (parity with .claude/hooks/completion-gate.sh) ──
|
|
828
|
+
// A persistently crashing completion evaluator must not fail every Stop forever.
|
|
829
|
+
// The byte-append file is shared with the shell hook so one streak tracks both
|
|
830
|
+
// harnesses; a clean evaluation deletes it, so the count is consecutive crashes.
|
|
831
|
+
const COMPLETION_CRASH_STREAK_FILE = path.join('.ukit', 'storage', 'cache', 'completion-gate-crash.streak');
|
|
832
|
+
|
|
833
|
+
async function bumpCompletionCrashStreak(projectRoot) {
|
|
834
|
+
const file = path.join(projectRoot, COMPLETION_CRASH_STREAK_FILE);
|
|
835
|
+
try {
|
|
836
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
|
837
|
+
await fs.promises.appendFile(file, '.');
|
|
838
|
+
const buf = await fs.promises.readFile(file);
|
|
839
|
+
return buf.length || 1;
|
|
840
|
+
} catch {
|
|
841
|
+
return 1;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function resetCompletionCrashStreak(projectRoot) {
|
|
846
|
+
try {
|
|
847
|
+
await fs.promises.rm(path.join(projectRoot, COMPLETION_CRASH_STREAK_FILE), { force: true });
|
|
848
|
+
} catch { /* best effort */ }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function readStopGateMaxCrashStreaks(projectRoot) {
|
|
852
|
+
try {
|
|
853
|
+
const parsed = JSON.parse(
|
|
854
|
+
await fs.promises.readFile(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'),
|
|
855
|
+
);
|
|
856
|
+
const value = parsed?.handoff?.fullstack?.stopGateMaxCrashStreaks;
|
|
857
|
+
return Number.isFinite(value) ? Math.max(1, value) : 3;
|
|
858
|
+
} catch {
|
|
859
|
+
return 3;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
823
863
|
export async function runSessionStop(
|
|
824
864
|
pi,
|
|
825
865
|
event,
|
|
@@ -834,7 +874,35 @@ export async function runSessionStop(
|
|
|
834
874
|
const payload = buildHookPayload('Stop', metadata);
|
|
835
875
|
const state = suppliedState ?? await readRouteState(projectRoot, payload);
|
|
836
876
|
const ledger = suppliedLedger ?? await readExecutionLedger(projectRoot, payload) ?? {};
|
|
837
|
-
|
|
877
|
+
let evaluation;
|
|
878
|
+
try {
|
|
879
|
+
evaluation = evaluateCompletion({ state, ledger });
|
|
880
|
+
} catch (error) {
|
|
881
|
+
// BUG-C22-05: fail-closed like the shell gate (continue the turn), but only up
|
|
882
|
+
// to the configured consecutive-crash cap — a persistently broken evaluator
|
|
883
|
+
// must release loudly instead of looping every Stop forever.
|
|
884
|
+
const streak = await bumpCompletionCrashStreak(projectRoot);
|
|
885
|
+
const maxStreak = await readStopGateMaxCrashStreaks(projectRoot);
|
|
886
|
+
if (streak > maxStreak) {
|
|
887
|
+
await resetCompletionCrashStreak(projectRoot);
|
|
888
|
+
sendContext(pi, [
|
|
889
|
+
`[UKit] stop coordinator released this stop after ${maxStreak} consecutive infrastructure failures `
|
|
890
|
+
+ '(the completion evaluator crashed every Stop — gate fail-closed cap reached). '
|
|
891
|
+
+ 'The completion gate is DOWN, not your work: run `ukit install` to repair, '
|
|
892
|
+
+ 'then re-send the task in a new message if the run must continue.',
|
|
893
|
+
], 'nextTurn', { display: true });
|
|
894
|
+
return undefined;
|
|
895
|
+
}
|
|
896
|
+
pi.logger?.warn?.(`[UKit] completion evaluator crashed (streak ${streak}/${maxStreak}): ${error?.message || error}`);
|
|
897
|
+
return {
|
|
898
|
+
continue: true,
|
|
899
|
+
additionalContext:
|
|
900
|
+
`UKit stop coordinator: infrastructure failure while evaluating this stop (crash streak ${streak}/${maxStreak}) — `
|
|
901
|
+
+ 'the stop is blocked fail-closed (details withheld). Run: ukit install, then re-send the task in a new message.',
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
// A clean evaluation resets the consecutive-crash streak (shared with the shell hook).
|
|
905
|
+
await resetCompletionCrashStreak(projectRoot);
|
|
838
906
|
if (!evaluation.continue) {
|
|
839
907
|
if (evaluation.capped) pi.logger?.warn?.(`[UKit] ${evaluation.reason}`);
|
|
840
908
|
const missingEvidence = Array.isArray(evaluation.missingEvidence) ? evaluation.missingEvidence : [];
|
|
@@ -273,6 +273,7 @@
|
|
|
273
273
|
"fullstack": {
|
|
274
274
|
"stopGateEnabled": true,
|
|
275
275
|
"stopGateMaxStalledBlocks": 12,
|
|
276
|
+
"stopGateMaxCrashStreaks": 3,
|
|
276
277
|
"quietScansRequired": 2,
|
|
277
278
|
"idleWatchdogMin": 5,
|
|
278
279
|
"autoArchive": true,
|
|
@@ -580,6 +581,7 @@
|
|
|
580
581
|
"fullstack": {
|
|
581
582
|
"stopGateEnabled": "Bật Stop gate handoff: RUN.md Phase != done/blocked → Stop hook từ chối stop kèm Next step, giữ handoff-fullstack chạy tiếp thay vì đứng sau recap. Tắt chỉ khi debug.",
|
|
582
583
|
"stopGateMaxStalledBlocks": "Liveness breaker: cursor không nhích qua N lần block liên tiếp thì gate thả stop (tránh vòng lặp vô tận). Mặc định 12.",
|
|
584
|
+
"stopGateMaxCrashStreaks": "Liveness breaker cho coordinator crash: sau N lần block liên tiếp do infrastructure failure, gate thả stop kèm cảnh báo lớn. Mặc định 3.",
|
|
583
585
|
"quietScansRequired": "Số scan liên tiếp không thấy việc mới trước khi handoff-fullstack được phép đóng run. Mặc định 2.",
|
|
584
586
|
"idleWatchdogMin": "Khoảng phút cho scheduled wakeup nếu harness hỗ trợ (cron/loop). Mặc định 5.",
|
|
585
587
|
"autoArchive": "true = Phase F tự archive cycle vào docs/AI_HANDOFF/archive/cycle-NN/ khi hoàn tất.",
|