@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/bin/ukit +30 -10
  3. package/package.json +1 -1
  4. package/scripts/bench/parallel-agents.mjs +42 -4
  5. package/scripts/release/verify-release.mjs +43 -6
  6. package/src/core/applyPlan.js +65 -0
  7. package/src/core/compact/threshold.js +33 -30
  8. package/src/core/fileOps.js +9 -2
  9. package/src/core/memory/store.js +10 -3
  10. package/src/core/runInstallPipeline.js +10 -1
  11. package/src/core/runtimeConfig.js +2 -0
  12. package/src/core/status.js +1 -1
  13. package/templates/.claude/hooks/auto-allow-bash.sh +29 -20
  14. package/templates/.claude/hooks/auto-prune-bash.sh +81 -30
  15. package/templates/.claude/hooks/completion-gate.sh +42 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +26 -4
  17. package/templates/.claude/hooks/handoff-model-guard.sh +10 -1
  18. package/templates/.claude/hooks/handoff-resume.sh +32 -17
  19. package/templates/.claude/hooks/post-edit-verify.sh +1 -0
  20. package/templates/.claude/hooks/pre-edit-backup.sh +1 -0
  21. package/templates/.claude/hooks/reset-compact-pressure.sh +39 -22
  22. package/templates/.claude/hooks/skill-router.sh +180 -27
  23. package/templates/.claude/hooks/stale-spec-guard.sh +1 -0
  24. package/templates/.claude/hooks/task-watchdog.sh +30 -5
  25. package/templates/.claude/hooks/verification-guard.sh +17 -1
  26. package/templates/.claude/hooks/vision-router.sh +14 -0
  27. package/templates/.claude/settings.json +4 -4
  28. package/templates/.claude/ukit/index/extract-image.mjs +49 -0
  29. package/templates/.claude/ukit/index/post-edit-verify.mjs +33 -1
  30. package/templates/.claude/ukit/index/stale-spec-check.mjs +32 -1
  31. package/templates/.claude/ukit/runtime/compact-threshold.mjs +70 -40
  32. package/templates/.claude/ukit/runtime/execution-ledger.mjs +37 -6
  33. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +11 -4
  34. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +24 -4
  35. package/templates/.claude/ukit/runtime/output-compression.mjs +73 -12
  36. package/templates/.claude/ukit/runtime/reinject-context.mjs +39 -7
  37. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +8 -1
  38. package/templates/.claude/ukit/runtime/token-utils.mjs +9 -2
  39. package/templates/.omp/hooks/pre/ukit-bridge.js +72 -4
  40. 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(() => process.exit(0), HOOK_DEADLINE_MS).unref();
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 names;
448
+ let dirents;
414
449
  try {
415
- names = await fs.readdir(dir);
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 names) {
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 `names.length` and would evict real entries while the dir is
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, entries.length - maxFiles);
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
- await maybeSweepTeeCache(teeCacheDir).catch(() => {});
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().catch((error) => {
1479
- console.error('[UKit] Failed to compress tool output:', error?.message || String(error));
1480
- process.exitCode = 1;
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(() => process.exit(0), HOOK_DEADLINE_MS).unref();
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().catch((error) => {
1051
- console.error('[UKit] Failed to build compact reinject context:', error?.message || String(error));
1052
- process.exitCode = 1;
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
- await fs.writeFile(statePath, `${JSON.stringify({ lastStop: { key: sessionKey, ts: now } }, null, 1)}\n`, 'utf8');
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
- continue; // lock vanished between mkdir and stat — retry immediately
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 names) {
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, entries.length - maxFiles);
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
- const evaluation = evaluateCompletion({ state, ledger });
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.",