@ngockhoale/ukit 2.4.1 → 2.4.3

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 (56) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/manifests/platform.full.yaml +19 -111
  3. package/package.json +2 -1
  4. package/scripts/index/refresh-index.mjs +48 -18
  5. package/src/cli/commands/doctor.js +59 -2
  6. package/src/core/compact/threshold.js +36 -6
  7. package/src/core/gatewayProbe.js +143 -15
  8. package/src/core/gatewayResilienceEnv.js +136 -7
  9. package/src/diagnostics/classifyHang.js +246 -0
  10. package/src/index/buildIndex.js +1096 -75
  11. package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
  12. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  13. package/templates/.claude/hooks/block-dangerous.sh +46 -1
  14. package/templates/.claude/hooks/completion-gate.sh +65 -7
  15. package/templates/.claude/hooks/compress-output.sh +49 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
  17. package/templates/.claude/hooks/context-window-guard.sh +204 -71
  18. package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
  19. package/templates/.claude/hooks/handoff-resume.sh +47 -3
  20. package/templates/.claude/hooks/post-edit-verify.sh +45 -2
  21. package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
  22. package/templates/.claude/hooks/protect-files.sh +46 -1
  23. package/templates/.claude/hooks/record-execution.sh +46 -2
  24. package/templates/.claude/hooks/reinject-context.sh +1 -1
  25. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  26. package/templates/.claude/hooks/sensitive-data-guard.sh +101 -18
  27. package/templates/.claude/hooks/skill-router.sh +59 -5
  28. package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
  29. package/templates/.claude/hooks/task-watchdog.sh +129 -126
  30. package/templates/.claude/hooks/verification-guard.sh +136 -106
  31. package/templates/.claude/hooks/vision-router.sh +138 -18
  32. package/templates/.claude/settings.json +0 -5
  33. package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
  34. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  35. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  36. package/templates/.claude/ukit/index/refresh-index.mjs +48 -18
  37. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  38. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  40. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  41. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  42. package/templates/.claude/ukit/runtime/execution-ledger.mjs +672 -170
  43. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  44. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  45. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  46. package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
  47. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  48. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  49. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  51. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  52. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  53. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  54. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  55. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
  56. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -5,7 +5,15 @@ import fs from 'node:fs/promises';
5
5
  import fsSync from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { withFileLock } from './token-utils.mjs';
8
+ import { withAsyncLock, LOCK_MAX_SLICE_MS } from './async-lock.mjs';
9
+
10
+ // Hook-context self-deadline (2.4.1 orphan-leak class): wrapper hooks pass
11
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the
12
+ // hook budget. CLI usage never sets it and is never self-killed.
13
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
14
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
15
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
16
+ }
9
17
 
10
18
  const LEDGER_VERSION = 1;
11
19
  const RESUME_INTENT_VERSION = 1;
@@ -25,14 +33,22 @@ const MAX_GATE_CRASHES = 3;
25
33
  const VERIFICATION_LOOP_STREAK = 2;
26
34
  const VIBECODE_VERIFICATION_FAILURE_LIMIT = 4;
27
35
  const MAX_TRACKED_VERIFICATIONS = 8;
36
+ // TASK-026 vibecode liveness circuit breaker (H18): vibecode bypasses MAX_CONTINUATIONS,
37
+ // so an evidence-free Stop loop had NO absolute escape — the verification-loop blocker only
38
+ // fires when a check actually fails. This ceiling bounds consecutive PROGRESS-FREE
39
+ // continuations; past it, vibecode releases exactly like the ordinary cap (one final
40
+ // notice, then a loud capped stop naming the missing evidence). It never fabricates
41
+ // completion. Genuinely new verifiable progress (see progressDigest) resets the counter,
42
+ // so a run that keeps producing real evidence stays autonomous indefinitely.
43
+ const VIBECODE_NO_PROGRESS_CAP = 20;
28
44
  // hook-chain-runner.mjs gives every hook script a 4s child budget, and omp blocks Edit|Write
29
45
  // when a chain script is killed. Waiting longer than that for a contended lock got the
30
- // recording hook killed mid-chain (receipt lost, edit blocked). Give up and fail open well
31
- // inside the budget instead — under extreme contention a receipt may be lost, but the hook
32
- // is never killed by its own chain.
33
- const HOOK_SAFE_LOCK_WAIT_MS = 2500;
34
- function withLedgerLock(target, fn) {
35
- return withFileLock(target, fn, { maxWaitMs: HOOK_SAFE_LOCK_WAIT_MS });
46
+ // recording hook killed mid-chain (receipt lost, edit blocked). Locking is FAIL-CLOSED
47
+ // (TASK-027): the acquisition budget is the short 1.2s slice from async-lock (callers may
48
+ // derive a tighter deadline budget); when it expires the callback is NEVER run unlocked —
49
+ // the event is journaled beside the ledger and reconciled by the next acquired lock.
50
+ function withLedgerLock(target, { signal, deadlineMs } = {}, fn) {
51
+ return withAsyncLock(target, { signal, deadlineMs: deadlineMs ?? LOCK_MAX_SLICE_MS }, fn);
36
52
  }
37
53
  const IMPLEMENT_MODES = new Set([
38
54
  'tiny-fix',
@@ -122,14 +138,18 @@ async function readJson(filePath, fallback = null) {
122
138
  // a single `pid`-suffixed temp path: one rename removed the file under the other and the
123
139
  // writer crashed with ENOENT mid-hook. Make every write's temp path unique.
124
140
  let atomicWriteCounter = 0;
125
- async function writeJsonAtomic(filePath, value) {
141
+ async function writeTextAtomic(filePath, text) {
126
142
  await fs.mkdir(path.dirname(filePath), { recursive: true });
127
143
  atomicWriteCounter += 1;
128
144
  const tempPath = `${filePath}.${process.pid}-${atomicWriteCounter}-${Math.random().toString(16).slice(2)}.tmp`;
129
- await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
145
+ await fs.writeFile(tempPath, text, 'utf8');
130
146
  await fs.rename(tempPath, filePath);
131
147
  }
132
148
 
149
+ async function writeJsonAtomic(filePath, value) {
150
+ await writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`);
151
+ }
152
+
133
153
  export async function readRouteState(projectRoot, payload = {}) {
134
154
  const state = await readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
135
155
  const sessionId = explicitSessionId(payload);
@@ -451,6 +471,71 @@ function appendReceipt(receipts, receipt) {
451
471
  return [...(receipts || []), compactReceipt(receipt)].slice(-MAX_RECEIPTS);
452
472
  }
453
473
 
474
+ // TASK-026: the monotone verifiable-progress BANK. The digest used to be recomputed from
475
+ // ledger.receipts, which appendReceipt caps at MAX_RECEIPTS by recency — so REPLAYED evidence
476
+ // re-entered the set as older receipts evicted, moving the digest with zero new progress and
477
+ // pinning noProgressCount at 0 forever (a stuck loop rewriting >MAX_RECEIPTS distinct targets
478
+ // in rotation never reached VIBECODE_NO_PROGRESS_CAP). Evidence is now banked MONOTONICALLY:
479
+ // the first successful write to a target, or the first successful run of a verification
480
+ // command, mints a bank key that is NEVER evicted by later receipts — so a replay is a no-op
481
+ // however much recency churn happens around it. Keys are hashed to keep the bank compact; it
482
+ // lives for one request only (a genuinely new promptKey gets a fresh ledger). Reads are
483
+ // deliberately excluded: an endless read-only loop must not reset an absolute liveness
484
+ // escape. Route state is not consulted (the bank is derived from the ledger itself) so
485
+ // journaled replays stay route-state-free.
486
+ const EVIDENCE_BANK_KEY_LENGTH = 16;
487
+ const BANK_FIELDS = { write: 'bankedWrites', verification: 'bankedVerifications' };
488
+
489
+ function evidenceBankKey(target) {
490
+ return crypto.createHash('sha256').update(String(target)).digest('hex').slice(0, EVIDENCE_BANK_KEY_LENGTH);
491
+ }
492
+
493
+ function bankedEvidenceTarget(receipt) {
494
+ if (!receipt || receipt.success !== true) return null;
495
+ if (receipt.kind === 'write') return receipt.file ? String(receipt.file) : null;
496
+ if (receipt.kind === 'verification') {
497
+ return receipt.command ? (terminalShellCommandUnit(receipt.command) || String(receipt.command)) : null;
498
+ }
499
+ return null;
500
+ }
501
+
502
+ function seedBankFromReceipts(ledger = {}, field) {
503
+ const bank = {};
504
+ const receipts = Array.isArray(ledger?.receipts) ? ledger.receipts : [];
505
+ for (const receipt of receipts) {
506
+ const target = bankedEvidenceTarget(receipt);
507
+ if (!target || BANK_FIELDS[receipt.kind] !== field) continue;
508
+ bank[evidenceBankKey(target)] = true;
509
+ }
510
+ return bank;
511
+ }
512
+
513
+ // A ledger written before the bank existed is migrated from the receipt window it still
514
+ // carries: an in-flight run must not re-count already-seen evidence as new progress.
515
+ function evidenceBank(ledger = {}, field) {
516
+ const bank = ledger?.[field];
517
+ if (bank && typeof bank === 'object' && !Array.isArray(bank)) return bank;
518
+ return seedBankFromReceipts(ledger, field);
519
+ }
520
+
521
+ // Returns the next bank when `target` is genuinely NEW, or null when it is already banked
522
+ // (a replay — never progress).
523
+ function bankEvidence(ledger, field, target) {
524
+ const bank = evidenceBank(ledger, field);
525
+ const key = evidenceBankKey(target);
526
+ if (bank[key] === true) return null;
527
+ return { ...bank, [key]: true };
528
+ }
529
+
530
+ function progressDigest(ledger = {}) {
531
+ return crypto.createHash('sha256')
532
+ .update(JSON.stringify({
533
+ writes: Object.keys(evidenceBank(ledger, 'bankedWrites')).sort(),
534
+ verifications: Object.keys(evidenceBank(ledger, 'bankedVerifications')).sort(),
535
+ }))
536
+ .digest('hex');
537
+ }
538
+
454
539
  // A re-key within the same logical request must not drop the evidence the request
455
540
  // already produced — that turned one finished task into a cap-exhausted forced stop.
456
541
  // A genuinely different prompt keeps a clean slate so old work never satisfies a new
@@ -479,6 +564,19 @@ function carriedEvidenceLedger(fresh, current) {
479
564
  // must keep counting toward the cap. Resetting it here made the cap unreachable and the
480
565
  // Stop gate loop forever on harnesses without a stop_hook_active valve.
481
566
  continuationCount: Math.max(Number(fresh.continuationCount || 0), Number(current.continuationCount || 0)),
567
+ // The vibecode liveness breaker (TASK-026) belongs to the same logical request: a
568
+ // same-prompt re-key must keep the no-progress streak, or a re-key mid-loop would
569
+ // grant a fresh escape budget. A genuinely different prompt returns `fresh` above,
570
+ // which starts the breaker at zero.
571
+ noProgressCount: Math.max(Number(fresh.noProgressCount || 0), Number(current.noProgressCount || 0)),
572
+ lastProgressDigest: current.lastProgressDigest || fresh.lastProgressDigest || null,
573
+ // The evidence bank is monotone and request-scoped, so a same-prompt re-key must keep
574
+ // it: dropping it would let every already-banked target count as NEW progress again.
575
+ bankedWrites: { ...evidenceBank(current, 'bankedWrites'), ...evidenceBank(fresh, 'bankedWrites') },
576
+ bankedVerifications: {
577
+ ...evidenceBank(current, 'bankedVerifications'),
578
+ ...evidenceBank(fresh, 'bankedVerifications'),
579
+ },
482
580
  continuationRequestKey: current.continuationRequestKey || fresh.continuationRequestKey || null,
483
581
  notified: fresh.notified === true || current.notified === true,
484
582
  // Verification-loop tracking and any minted blocker belong to the same logical request
@@ -486,6 +584,11 @@ function carriedEvidenceLedger(fresh, current) {
486
584
  failedVerificationStreak: current.failedVerificationStreak || null,
487
585
  verificationFailureCounts: current.verificationFailureCounts || {},
488
586
  blocker: current.blocker || null,
587
+ // Journal bookkeeping belongs to the file, not the request: the dedupe memory must
588
+ // survive a re-key or replayed journal events would be applied twice (TASK-027).
589
+ journalSeen: current.journalSeen || [],
590
+ journalQuarantined: current.journalQuarantined || 0,
591
+ lastJournalQuarantineAt: current.lastJournalQuarantineAt || null,
489
592
  };
490
593
  }
491
594
 
@@ -522,135 +625,530 @@ function freshLedger(payload, routeState, harness) {
522
625
  receipts: [],
523
626
  blocker: null,
524
627
  continuationCount: 0,
628
+ noProgressCount: 0,
629
+ lastProgressDigest: null,
630
+ // Monotone verifiable-progress bank (TASK-026 fix round 1): request-scoped, never
631
+ // evicted, so replaying banked evidence can never look like new progress.
632
+ bankedWrites: {},
633
+ bankedVerifications: {},
525
634
  notified: false,
526
635
  updatedAt: Date.now(),
527
636
  };
528
637
  }
529
638
 
530
- export async function recordExecutionReceipt({
531
- projectRoot,
532
- payload = {},
533
- toolName = payload.tool_name,
534
- harness = 'unknown',
535
- } = {}) {
536
- if (!projectRoot || !toolName) return null;
537
- // The whole read-modify-write is locked: parallel subagents record receipts through the
538
- // same session ledger file, and an unlocked snapshot rewrite dropped whichever receipts
539
- // landed between the read and the write.
540
- return withLedgerLock(ledgerPath(projectRoot, payload), async () => {
541
- const routeState = await readRouteState(projectRoot, payload);
542
- const current = await readExecutionLedger(projectRoot, payload);
543
- const nextRequestKey = routeState?.requestKey || null;
544
- // A missing/foreign route state must never blank the evidence this session already
545
- // banked. The shared state slot is single-owner (a parallel subagent re-stamps it), so
546
- // routeState === null is routine mid-request — rebuilding from fresh there re-demanded
547
- // every evidence the request had produced and froze the run. Without a live requestKey
548
- // the only safe move is to keep appending to the current ledger.
549
- const ledger = current && !nextRequestKey
550
- ? { ...current, harness: current.harness || harness }
551
- : (!current || current.requestKey !== nextRequestKey
552
- ? carriedEvidenceLedger(freshLedger(payload, routeState, harness), current)
553
- : { ...current, harness: current.harness || harness });
554
-
555
- const failed = explicitError(payload);
556
- const exitCode = extractExitCode(payload);
557
- const success = !failed && (exitCode === null || exitCode === 0);
558
- const toolInput = payload.tool_input || {};
559
- const receipt = {
560
- ts: Date.now(),
561
- toolName,
562
- toolUseId: payload.tool_use_id || null,
563
- success,
564
- exitCode,
565
- };
639
+ // --- TASK-027: fail-closed ledger events with a bounded lock-timeout journal -------------------
640
+ // The ledger used to lock fail-open: when the wait expired, the read-modify-write ran
641
+ // unlocked and parallel writers interleaved — evidence was lost exactly when the machine
642
+ // was most contended. Every mutation now goes through recordLedgerEvent:
643
+ // * lock acquired -> pending journal events are reconciled first (idempotent, by
644
+ // eventId), then the caller's event is applied, and ONE atomic ledger write lands.
645
+ // Reconciliation therefore happens inside the caller's own deadline, before its own
646
+ // evidence.
647
+ // * lock expired/aborted -> the main ledger is NEVER touched; the event is appended
648
+ // once to `<ledger>.journal` (JSONL, route-scoped) and reported as `journaled`.
649
+ // Journal invariants: records are route-scoped (the sessionKey must match the journal
650
+ // owner), idempotent (eventId deduped against ledger.journalSeen), bounded (record cap at
651
+ // append time, bounded quarantine, bounded seen-list), and atomic per record (single
652
+ // O_APPEND write; a torn trailing line is quarantined, never applied). Records carry
653
+ // receipts and route metadata only — never prompt bodies or tool output; the receipt
654
+ // whitelist below is the gate.
655
+ const JOURNAL_VERSION = 1;
656
+ const MAX_JOURNAL_SEEN = 64;
657
+ const MAX_JOURNAL_RECORDS = 128;
658
+ const MAX_JOURNAL_QUARANTINE_LINES = 32;
659
+ const LEDGER_EVENT_TYPES = new Set(['receipt', 'continuation', 'notified', 'stop-progress']);
660
+ const RECEIPT_KINDS = new Set(['source', 'write', 'verification']);
661
+
662
+ function journalPathFor(target) {
663
+ return `${target}.journal`;
664
+ }
566
665
 
567
- if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
568
- receipt.kind = 'source';
569
- receipt.file = toolInput.file_path || toolInput.path || null;
570
- ledger.sourceSucceeded ||= success;
571
- if (success && receipt.file) {
572
- ledger.sourceFiles = [...new Set([...(ledger.sourceFiles || []), receipt.file])].slice(-MAX_SOURCE_FILES);
573
- }
574
- } else if (toolName === 'Edit' || toolName === 'Write') {
575
- receipt.kind = 'write';
576
- receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
577
- ledger.writeAttempted = true;
578
- ledger.writeSucceeded ||= success;
579
- // A mutation attempt between failures breaks the "no change in between" loop shape.
580
- if (ledger.failedVerificationStreak) ledger.failedVerificationStreak = null;
581
- } else if (toolName === 'Bash' && isVerificationCommand(toolInput.command)) {
582
- receipt.kind = 'verification';
583
- receipt.command = String(toolInput.command || '').trim();
584
- // WS-C routed-verification receipt: a command counts as "targeted" when it matches the
585
- // routed plan (preferredOrder / primaryCommands). Off-plan verification still records
586
- // as broad — counted only when the route carried no commands at all.
587
- const routedCommands = routedVerificationCommands(routeState?.routeSummary);
588
- if (routedCommands.length > 0) {
589
- const matched = routedCommands.find((cmd) => matchesRoutedCommand(receipt.command, cmd));
590
- receipt.scope = matched ? 'targeted' : 'broad';
591
- if (receipt.scope === 'targeted') {
592
- ledger.targetedVerificationSucceeded ||= success;
593
- }
594
- }
595
- ledger.verificationAttempted = true;
596
- ledger.verificationSucceeded ||= success;
597
- ledger.verificationFailed ||= !success;
598
-
599
- // Verification-loop tracking. `terminalShellCommandUnit` gives the loop identity:
600
- // the same failing check rerun — `setup && yarn test` and `yarn test 2>&1 | tail`
601
- // both count as the same command, while a different check resets the streak.
602
- const fingerprint = terminalShellCommandUnit(receipt.command) || receipt.command;
603
- const counts = { ...(ledger.verificationFailureCounts || {}) };
604
- if (success) {
605
- delete counts[fingerprint];
606
- ledger.verificationFailureCounts = counts;
607
- if (ledger.failedVerificationStreak?.fingerprint === fingerprint) {
608
- ledger.failedVerificationStreak = null;
666
+ function journalQuarantinePathFor(target) {
667
+ return `${journalPathFor(target)}.quarantine`;
668
+ }
669
+
670
+ function newEventId() {
671
+ return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
672
+ }
673
+
674
+ const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope'];
675
+
676
+ function sanitizeReceiptForJournal(receipt) {
677
+ const clean = {};
678
+ for (const key of JOURNAL_RECEIPT_FIELDS) {
679
+ if (receipt && receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
680
+ clean[key] = receipt[key];
681
+ }
682
+ }
683
+ return clean;
684
+ }
685
+
686
+ function buildJournalRecord({ event, eventId, payload, routeState }) {
687
+ return {
688
+ v: JOURNAL_VERSION,
689
+ eventId,
690
+ ts: Date.now(),
691
+ sessionKey: sessionIdentity(payload),
692
+ sessionId: explicitSessionId(payload),
693
+ requestKey: routeState?.requestKey ?? null,
694
+ promptKey: evidencePromptKey(routeState),
695
+ type: event.type,
696
+ event: event.type === 'receipt'
697
+ ? { receipt: sanitizeReceiptForJournal(event.receipt), vibecode: event.vibecode === true }
698
+ : { ...event, type: undefined },
699
+ };
700
+ }
701
+
702
+ async function appendJournalRecord(target, record, { signal, deadlineMs } = {}) {
703
+ const journalPath = journalPathFor(target);
704
+ try {
705
+ // Serialize the bounded-cap check with every producer. The main ledger lock is
706
+ // intentionally unavailable here (that is why this path is running), but a
707
+ // journal-local lock prevents concurrent read/check/append flows from exceeding
708
+ // the cap. A busy journal-local lock rejects the new event and leaves the existing
709
+ // unreconciled evidence untouched.
710
+ const outcome = await withAsyncLock(
711
+ journalPath,
712
+ { signal, deadlineMs: deadlineMs ?? LOCK_MAX_SLICE_MS },
713
+ async () => {
714
+ let existing = null;
715
+ try {
716
+ existing = await fs.readFile(journalPath, 'utf8');
717
+ } catch (error) {
718
+ if (error?.code !== 'ENOENT') return { ok: false, reason: 'journal-unavailable' };
609
719
  }
610
- } else {
611
- const streakFingerprint = ledger.failedVerificationStreak?.fingerprint;
612
- const streakCount = streakFingerprint === fingerprint
613
- ? Number(ledger.failedVerificationStreak.count || 0) + 1
614
- : 1;
615
- ledger.failedVerificationStreak = { fingerprint, count: streakCount };
616
- counts[fingerprint] = Number(counts[fingerprint] || 0) + 1;
617
- const trackedKeys = Object.keys(counts);
618
- if (trackedKeys.length > MAX_TRACKED_VERIFICATIONS) {
619
- for (const key of trackedKeys.slice(0, trackedKeys.length - MAX_TRACKED_VERIFICATIONS)) {
620
- delete counts[key];
621
- }
720
+ // Never rewrite or trim an unlocked journal: a concurrent drain may be relying on
721
+ // its prefix, and trimming would silently destroy unreconciled evidence. Once the
722
+ // bounded producer queue is full, apply backpressure and reject this event while
723
+ // preserving every record for the next lock holder to reconcile.
724
+ const lines = existing === null
725
+ ? []
726
+ : existing.split('\n').filter((line) => line.trim());
727
+ if (lines.length >= MAX_JOURNAL_RECORDS) return { ok: false, reason: 'journal-full' };
728
+ await fs.appendFile(journalPath, `${JSON.stringify(record)}\n`, 'utf8');
729
+ return { ok: true };
730
+ },
731
+ );
732
+ if (!outcome.ok) return { ok: false, reason: 'journal-unavailable' };
733
+ return outcome.value;
734
+ } catch {
735
+ return { ok: false, reason: 'journal-unavailable' };
736
+ }
737
+ }
738
+
739
+ function parseJournalRecord(line, expectedSessionKey) {
740
+ let record;
741
+ try {
742
+ record = JSON.parse(line);
743
+ } catch {
744
+ return null;
745
+ }
746
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
747
+ if (record.v !== JOURNAL_VERSION) return null;
748
+ if (typeof record.eventId !== 'string' || !record.eventId) return null;
749
+ if (record.sessionKey !== expectedSessionKey) return null; // route-scoped
750
+ if (!LEDGER_EVENT_TYPES.has(record.type)) return null;
751
+ if (!record.event || typeof record.event !== 'object') return null;
752
+ if (record.type === 'receipt') {
753
+ const receipt = record.event.receipt;
754
+ if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
755
+ }
756
+ return record;
757
+ }
758
+
759
+ async function quarantineJournalLines(target, lines) {
760
+ const quarantinePath = journalQuarantinePathFor(target);
761
+ try {
762
+ await fs.mkdir(path.dirname(quarantinePath), { recursive: true });
763
+ let existing = '';
764
+ try {
765
+ existing = await fs.readFile(quarantinePath, 'utf8');
766
+ } catch {
767
+ existing = '';
768
+ }
769
+ const stamped = lines.map((line) => `${new Date().toISOString()} ${line}`);
770
+ const kept = [...existing.split('\n').filter((line) => line.trim()), ...stamped]
771
+ .slice(-MAX_JOURNAL_QUARANTINE_LINES);
772
+ await writeTextAtomic(quarantinePath, `${kept.join('\n')}\n`);
773
+ } catch {
774
+ // Quarantine write failed: the malformed lines are still consumed from the journal
775
+ // and the ledger's quarantine report records the loss. Never crash the caller.
776
+ }
777
+ }
778
+
779
+ // Uniform applier: returns { ledger, value } or null when the record is unappliable
780
+ // (the caller quarantines it instead of applying it).
781
+ function applyJournalRecord(ledger, record) {
782
+ if (record.type === 'receipt') {
783
+ return applyReceiptToLedger(ledger, record.event?.receipt, { vibecode: record.event?.vibecode === true });
784
+ }
785
+ if (record.type === 'continuation') {
786
+ return { ledger: applyContinuationToLedger(ledger, record.event), value: null };
787
+ }
788
+ if (record.type === 'notified') {
789
+ return { ledger: { ...ledger, notified: true, updatedAt: Date.now() }, value: null };
790
+ }
791
+ if (record.type === 'stop-progress') {
792
+ return applyStopProgressToLedger(ledger);
793
+ }
794
+ return null;
795
+ }
796
+
797
+ // Read the journal and apply every unseen record onto `ledger` IN MEMORY. The journal is
798
+ // consumed only after the caller's ledger write lands (commit()), so a crash anywhere
799
+ // before that leaves the journal intact and the records re-apply idempotently by eventId.
800
+ async function drainJournal(target, { ledger, payload, signal, deadlineMs }) {
801
+ const journalPath = journalPathFor(target);
802
+ let text = null;
803
+ try {
804
+ text = await fs.readFile(journalPath, 'utf8');
805
+ } catch {
806
+ return { ledger, applied: 0, quarantined: 0, commit: null };
807
+ }
808
+ if (!text) return { ledger, applied: 0, quarantined: 0, commit: null };
809
+
810
+ const expectedSessionKey = sessionIdentity(payload);
811
+ const seen = new Set(Array.isArray(ledger.journalSeen) ? ledger.journalSeen : []);
812
+ const malformed = [];
813
+ let next = ledger;
814
+ let applied = 0;
815
+ for (const line of text.split('\n')) {
816
+ if (!line.trim()) continue; // structural newline
817
+ const record = parseJournalRecord(line, expectedSessionKey);
818
+ if (!record) {
819
+ malformed.push(line);
820
+ continue;
821
+ }
822
+ if (seen.has(record.eventId)) continue; // idempotent replay
823
+ const result = applyJournalRecord(next, record);
824
+ if (!result) {
825
+ malformed.push(line);
826
+ continue;
827
+ }
828
+ next = result.ledger;
829
+ seen.add(record.eventId);
830
+ applied += 1;
831
+ }
832
+ if (malformed.length > 0) {
833
+ await quarantineJournalLines(target, malformed);
834
+ next = {
835
+ ...next,
836
+ journalQuarantined: Number(next.journalQuarantined || 0) + malformed.length,
837
+ lastJournalQuarantineAt: Date.now(),
838
+ };
839
+ }
840
+ next = { ...next, journalSeen: [...seen].slice(-MAX_JOURNAL_SEEN) };
841
+
842
+ // Consume the drained records, preserving anything appended while we held the lock:
843
+ // journal writers append only, so a fresh read is our snapshot plus a suffix.
844
+ // The fresh-read + remainder-write/`rm` runs under the SAME journal-local lock that
845
+ // serializes producers in appendJournalRecord — an acknowledged `journaled` append
846
+ // can never land inside the read→replace window and be silently deleted. Lock order
847
+ // is safe (main→journal only): appenders take the journal lock precisely because the
848
+ // main lock was unavailable, so no appender ever holds journal while waiting on main.
849
+ const commit = async () => {
850
+ const outcome = await withAsyncLock(
851
+ journalPath,
852
+ { signal, deadlineMs: deadlineMs ?? LOCK_MAX_SLICE_MS },
853
+ async () => {
854
+ let fresh = null;
855
+ try {
856
+ fresh = await fs.readFile(journalPath, 'utf8');
857
+ } catch {
858
+ return;
622
859
  }
623
- ledger.verificationFailureCounts = counts;
624
-
625
- const vibecode = routeState?.routeSummary?.autonomyLevel === 'vibecode';
626
- const shortCommand = fingerprint.length > 80 ? `${fingerprint.slice(0, 77)}…` : fingerprint;
627
- if (streakCount >= VERIFICATION_LOOP_STREAK) {
628
- ledger.blocker = {
629
- kind: 'verification-loop',
630
- visible: true,
631
- detail: `verification "${shortCommand}" failed ${streakCount} times in a row with no edit in between — this is a loop, not progress. Report the failing output as a concrete blocker instead of rerunning it.`,
632
- command: fingerprint,
633
- ts: Date.now(),
634
- };
635
- } else if (vibecode && counts[fingerprint] >= VIBECODE_VERIFICATION_FAILURE_LIMIT) {
636
- ledger.blocker = {
637
- kind: 'verification-loop',
638
- visible: true,
639
- detail: `verification "${shortCommand}" failed ${counts[fingerprint]} times in continuous (vibecode) execution — report the failing output as a concrete blocker instead of retrying.`,
640
- command: fingerprint,
641
- ts: Date.now(),
642
- };
860
+ if (!fresh.startsWith(text)) return; // rotated/rewritten under us: retry on the next drain
861
+ const remainder = fresh.slice(text.length);
862
+ try {
863
+ if (remainder.trim()) await writeTextAtomic(journalPath, remainder);
864
+ else await fs.rm(journalPath, { force: true });
865
+ } catch {
866
+ // Leftover consumed lines drain again as seen ids on the next lock — no duplication.
643
867
  }
868
+ },
869
+ );
870
+ if (!outcome.ok) {
871
+ // Journal-local lock stayed busy: leave the journal intact. The consumed lines
872
+ // re-drain as `seen` ids on the next acquired lock — no duplication, no loss.
873
+ }
874
+ };
875
+ return { ledger: next, applied, quarantined: malformed.length, commit };
876
+ }
877
+
878
+ function applyReceiptToLedger(ledger, receipt, { vibecode = false } = {}) {
879
+ if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
880
+ const next = { ...ledger };
881
+ if (receipt.kind === 'source') {
882
+ next.sourceSucceeded = next.sourceSucceeded || receipt.success;
883
+ if (receipt.success && receipt.file) {
884
+ next.sourceFiles = [...new Set([...(next.sourceFiles || []), receipt.file])].slice(-MAX_SOURCE_FILES);
885
+ }
886
+ } else if (receipt.kind === 'write') {
887
+ next.writeAttempted = true;
888
+ next.writeSucceeded = next.writeSucceeded || receipt.success;
889
+ // A mutation attempt between failures breaks the "no change in between" loop shape.
890
+ if (next.failedVerificationStreak) next.failedVerificationStreak = null;
891
+ // TASK-026 fix round 1: bank the FIRST successful write to this target. Later replays of
892
+ // a banked target are no-ops, so recency eviction of the receipt window can never make a
893
+ // rotation of already-seen targets look like new progress.
894
+ if (receipt.success && receipt.file) {
895
+ const banked = bankEvidence(next, 'bankedWrites', String(receipt.file));
896
+ if (banked) next.bankedWrites = banked;
897
+ }
898
+ } else if (receipt.kind === 'verification') {
899
+ // The targeted/broad scope is resolved at receipt time from the routed plan and
900
+ // travels with the receipt, so a journaled replay needs no route state.
901
+ if (receipt.scope === 'targeted') {
902
+ next.targetedVerificationSucceeded = next.targetedVerificationSucceeded || receipt.success;
903
+ }
904
+ next.verificationAttempted = true;
905
+ next.verificationSucceeded = next.verificationSucceeded || receipt.success;
906
+ next.verificationFailed = next.verificationFailed || !receipt.success;
907
+ // TASK-026 fix round 1: bank the FIRST successful run of this command (same
908
+ // terminal-shell identity the loop tracker uses), so rerunning a banked check is a
909
+ // replay — never progress — regardless of receipt-window eviction.
910
+ if (receipt.success && receipt.command) {
911
+ const banked = bankEvidence(
912
+ next,
913
+ 'bankedVerifications',
914
+ terminalShellCommandUnit(receipt.command) || String(receipt.command),
915
+ );
916
+ if (banked) next.bankedVerifications = banked;
917
+ }
918
+
919
+ // Verification-loop tracking. `terminalShellCommandUnit` gives the loop identity:
920
+ // the same failing check rerun — `setup && yarn test` and `yarn test 2>&1 | tail`
921
+ // both count as the same command, while a different check resets the streak.
922
+ const fingerprint = terminalShellCommandUnit(receipt.command) || receipt.command;
923
+ const counts = { ...(next.verificationFailureCounts || {}) };
924
+ if (receipt.success) {
925
+ delete counts[fingerprint];
926
+ next.verificationFailureCounts = counts;
927
+ if (next.failedVerificationStreak?.fingerprint === fingerprint) {
928
+ next.failedVerificationStreak = null;
644
929
  }
645
930
  } else {
646
- return ledger;
931
+ const streakFingerprint = next.failedVerificationStreak?.fingerprint;
932
+ const streakCount = streakFingerprint === fingerprint
933
+ ? Number(next.failedVerificationStreak.count || 0) + 1
934
+ : 1;
935
+ next.failedVerificationStreak = { fingerprint, count: streakCount };
936
+ counts[fingerprint] = Number(counts[fingerprint] || 0) + 1;
937
+ const trackedKeys = Object.keys(counts);
938
+ if (trackedKeys.length > MAX_TRACKED_VERIFICATIONS) {
939
+ for (const key of trackedKeys.slice(0, trackedKeys.length - MAX_TRACKED_VERIFICATIONS)) {
940
+ delete counts[key];
941
+ }
942
+ }
943
+ next.verificationFailureCounts = counts;
944
+
945
+ const shortCommand = fingerprint.length > 80 ? `${fingerprint.slice(0, 77)}…` : fingerprint;
946
+ if (streakCount >= VERIFICATION_LOOP_STREAK) {
947
+ next.blocker = {
948
+ kind: 'verification-loop',
949
+ visible: true,
950
+ detail: `verification "${shortCommand}" failed ${streakCount} times in a row with no edit in between — this is a loop, not progress. Report the failing output as a concrete blocker instead of rerunning it.`,
951
+ command: fingerprint,
952
+ ts: Date.now(),
953
+ };
954
+ } else if (vibecode && counts[fingerprint] >= VIBECODE_VERIFICATION_FAILURE_LIMIT) {
955
+ next.blocker = {
956
+ kind: 'verification-loop',
957
+ visible: true,
958
+ detail: `verification "${shortCommand}" failed ${counts[fingerprint]} times in continuous (vibecode) execution — report the failing output as a concrete blocker instead of retrying.`,
959
+ command: fingerprint,
960
+ ts: Date.now(),
961
+ };
962
+ }
647
963
  }
964
+ }
965
+ next.receipts = appendReceipt(next.receipts, receipt);
966
+ next.updatedAt = Date.now();
967
+ return { ledger: next, value: null };
968
+ }
648
969
 
649
- ledger.receipts = appendReceipt(ledger.receipts, receipt);
650
- ledger.updatedAt = Date.now();
651
- await writeJsonAtomic(ledgerPath(projectRoot, payload), ledger);
652
- return ledger;
970
+ function applyContinuationToLedger(ledger, event) {
971
+ // Mirror evaluateCompletion's staleness rule (see incrementContinuation): a count
972
+ // minted by an earlier request must not carry into the new request's budget, or the
973
+ // cap fires early. Same-prompt re-keys are the same request and keep counting.
974
+ const stale = event?.requestKey
975
+ && ledger?.continuationRequestKey
976
+ && ledger.continuationRequestKey !== event.requestKey
977
+ && !(event?.promptKey && ledger.promptKey && event.promptKey === ledger.promptKey);
978
+ // TASK-026 vibecode liveness breaker: compare the verifiable-progress digest against
979
+ // the one banked at the previous continuation. The digest is computed over the MONOTONE
980
+ // evidence bank (see progressDigest), so it moves only when genuinely NEW write/test
981
+ // evidence landed since — replaying already-banked evidence, however much receipt-window
982
+ // churn surrounds it, leaves the digest unchanged and the streak growing. A moved digest
983
+ // resets the streak for the current route. The very first continuation has no banked
984
+ // digest and never counts as no-progress.
985
+ const digest = progressDigest(ledger);
986
+ const progressed = ledger?.lastProgressDigest !== digest;
987
+ return {
988
+ ...ledger,
989
+ continuationCount: (stale ? 0 : Number(ledger?.continuationCount || 0)) + 1,
990
+ noProgressCount: progressed ? 0 : Number(ledger?.noProgressCount || 0) + 1,
991
+ lastProgressDigest: digest,
992
+ ...(event?.requestKey ? { continuationRequestKey: event.requestKey } : {}),
993
+ lastContinuationAt: Date.now(),
994
+ updatedAt: Date.now(),
995
+ };
996
+ }
997
+
998
+ // The reentrant-stop fingerprint is computed at APPLICATION time from the ledger's own
999
+ // receipts (not at mint time), so a journal replay after further receipts compares
1000
+ // against the then-current shape — a stale replay errs toward "not reentrant", i.e. one
1001
+ // extra recovery attempt, which is the safe direction.
1002
+ function applyStopProgressToLedger(ledger) {
1003
+ const receipts = Array.isArray(ledger.receipts) ? ledger.receipts : [];
1004
+ const fingerprint = `${receipts.length}:${receipts[receipts.length - 1]?.ts || 0}`;
1005
+ const reentrant = typeof ledger.stopProgressFingerprint === 'string'
1006
+ && ledger.stopProgressFingerprint === fingerprint;
1007
+ return {
1008
+ ledger: { ...ledger, stopProgressFingerprint: fingerprint, updatedAt: Date.now() },
1009
+ value: { reentrant },
1010
+ };
1011
+ }
1012
+
1013
+ function liveBaseLedger(event, current, payload, routeState, harness, fallbackLedger) {
1014
+ if (event.type === 'receipt') {
1015
+ // A missing/foreign route state must never blank the evidence this session already
1016
+ // banked. The shared state slot is single-owner (a parallel subagent re-stamps it),
1017
+ // so routeState === null is routine mid-request — rebuilding from fresh there
1018
+ // re-demanded every evidence the request had produced and froze the run. Without a
1019
+ // live requestKey the only safe move is to keep appending to the current ledger.
1020
+ const nextRequestKey = routeState?.requestKey || null;
1021
+ if (current && !nextRequestKey) return { ...current, harness: current.harness || harness };
1022
+ if (!current || current.requestKey !== nextRequestKey) {
1023
+ return carriedEvidenceLedger(freshLedger(payload, routeState, harness), current);
1024
+ }
1025
+ return { ...current, harness: current.harness || harness };
1026
+ }
1027
+ return current || fallbackLedger || freshLedger(payload, null, 'unknown');
1028
+ }
1029
+
1030
+ /**
1031
+ * The one locked mutation protocol for the execution ledger (TASK-027). Never mutates
1032
+ * the ledger without lock ownership; journals the event when the lock cannot be taken.
1033
+ * @param {{ type: string, receipt?: object, vibecode?: boolean, requestKey?: string|null, promptKey?: string|null }} event
1034
+ * @param {{ projectRoot: string, payload?: object, harness?: string, signal?: AbortSignal,
1035
+ * deadlineMs?: number, routeState?: object|null, fallbackLedger?: object|null }} options
1036
+ * @returns {Promise<{ committed?: boolean, journaled?: boolean, rejected?: boolean,
1037
+ * eventId: string|null, value?: *, drained?: number, quarantined?: number, reason?: string }>}
1038
+ */
1039
+ export async function recordLedgerEvent(event, {
1040
+ projectRoot,
1041
+ payload = {},
1042
+ harness = 'unknown',
1043
+ signal,
1044
+ deadlineMs,
1045
+ routeState = null,
1046
+ fallbackLedger = null,
1047
+ } = {}) {
1048
+ if (!projectRoot || !event || !LEDGER_EVENT_TYPES.has(event.type)) {
1049
+ return { rejected: true, eventId: null, reason: 'invalid-event' };
1050
+ }
1051
+ const eventId = newEventId();
1052
+ const target = ledgerPath(projectRoot, payload);
1053
+ const outcome = await withLedgerLock(target, { signal, deadlineMs }, async () => {
1054
+ const state = routeState || await readRouteState(projectRoot, payload);
1055
+ const current = await readExecutionLedger(projectRoot, payload);
1056
+ let ledger = liveBaseLedger(event, current, payload, state, harness, fallbackLedger);
1057
+ // Reconcile pending journaled events first — inside this caller's acquired lock and
1058
+ // before its own evidence, so a timed-out writer's receipt is never stranded.
1059
+ const drained = await drainJournal(target, { ledger, payload, signal, deadlineMs });
1060
+ ledger = drained.ledger;
1061
+ let value;
1062
+ if (event.type === 'receipt') {
1063
+ const applied = applyReceiptToLedger(ledger, event.receipt, { vibecode: event.vibecode === true });
1064
+ if (!applied) return { committed: false, eventId, reason: 'invalid-receipt' };
1065
+ ledger = applied.ledger;
1066
+ } else if (event.type === 'continuation') {
1067
+ ledger = applyContinuationToLedger(ledger, event);
1068
+ } else if (event.type === 'notified') {
1069
+ ledger = { ...ledger, notified: true, updatedAt: Date.now() };
1070
+ } else if (event.type === 'stop-progress') {
1071
+ if (!current && drained.applied === 0) {
1072
+ // A stop with no ledger at all creates nothing (historic noteStopProgress shape).
1073
+ return { committed: true, eventId, value: { reentrant: false }, drained: 0, quarantined: 0 };
1074
+ }
1075
+ const applied = applyStopProgressToLedger(ledger);
1076
+ ledger = applied.ledger;
1077
+ value = applied.value;
1078
+ }
1079
+ await writeJsonAtomic(target, ledger);
1080
+ // Consume the journal only after the ledger write landed: a crash before this line
1081
+ // leaves the journal intact and the next drain re-applies idempotently by eventId.
1082
+ if (drained.commit) await drained.commit();
1083
+ return { committed: true, eventId, value, drained: drained.applied, quarantined: drained.quarantined };
653
1084
  });
1085
+ if (outcome.ok) return outcome.value;
1086
+
1087
+ // Fail-closed: the acquisition budget expired or the wait aborted. The main ledger is
1088
+ // NEVER mutated unlocked — journal the event once; the next acquired lock reconciles it.
1089
+ const record = buildJournalRecord({ event, eventId, payload, routeState: routeState || null });
1090
+ const journalResult = await appendJournalRecord(target, record, { signal, deadlineMs });
1091
+ return journalResult.ok
1092
+ ? { journaled: true, eventId }
1093
+ : { rejected: true, eventId, reason: journalResult.reason };
1094
+ }
1095
+
1096
+ // TASK-027: the receipt is classified (kind, file, command, targeted/broad scope) from the
1097
+ // advisory route state BEFORE locking — that read is read-only — and then applied through
1098
+ // recordLedgerEvent, which owns the fail-closed lock/journal protocol.
1099
+ export async function recordExecutionReceipt({
1100
+ projectRoot,
1101
+ payload = {},
1102
+ toolName = payload.tool_name,
1103
+ harness = 'unknown',
1104
+ signal,
1105
+ deadlineMs,
1106
+ } = {}) {
1107
+ if (!projectRoot || !toolName) return { rejected: true, eventId: null };
1108
+ const routeState = await readRouteState(projectRoot, payload);
1109
+ const failed = explicitError(payload);
1110
+ const exitCode = extractExitCode(payload);
1111
+ const success = !failed && (exitCode === null || exitCode === 0);
1112
+ const toolInput = payload.tool_input || {};
1113
+ const receipt = {
1114
+ ts: Date.now(),
1115
+ toolName,
1116
+ toolUseId: payload.tool_use_id || null,
1117
+ success,
1118
+ exitCode,
1119
+ };
1120
+
1121
+ if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
1122
+ receipt.kind = 'source';
1123
+ receipt.file = toolInput.file_path || toolInput.path || null;
1124
+ } else if (toolName === 'Edit' || toolName === 'Write') {
1125
+ receipt.kind = 'write';
1126
+ receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
1127
+ } else if (toolName === 'Bash' && isVerificationCommand(toolInput.command)) {
1128
+ receipt.kind = 'verification';
1129
+ receipt.command = String(toolInput.command || '').trim();
1130
+ // WS-C routed-verification receipt: a command counts as "targeted" when it matches the
1131
+ // routed plan (preferredOrder / primaryCommands). Off-plan verification still records
1132
+ // as broad — counted only when the route carried no commands at all. The resolved
1133
+ // scope travels with the receipt so a journaled replay needs no route state.
1134
+ const routedCommands = routedVerificationCommands(routeState?.routeSummary);
1135
+ if (routedCommands.length > 0) {
1136
+ const matched = routedCommands.find((cmd) => matchesRoutedCommand(receipt.command, cmd));
1137
+ receipt.scope = matched ? 'targeted' : 'broad';
1138
+ }
1139
+ } else {
1140
+ // Untracked tool: no event, no lock, no write — same as the old unlocked early return.
1141
+ return { rejected: true, eventId: null };
1142
+ }
1143
+
1144
+ return recordLedgerEvent(
1145
+ {
1146
+ type: 'receipt',
1147
+ receipt,
1148
+ vibecode: routeState?.routeSummary?.autonomyLevel === 'vibecode',
1149
+ },
1150
+ { projectRoot, payload, harness, signal, deadlineMs, routeState },
1151
+ );
654
1152
  }
655
1153
 
656
1154
  function requiredEvidence(state = {}) {
@@ -872,11 +1370,40 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
872
1370
  };
873
1371
  }
874
1372
 
1373
+ // TASK-026 vibecode liveness circuit breaker: vibecode bypasses the continuation cap
1374
+ // ONLY while verifiable progress occurs. A no-progress streak at the ceiling releases
1375
+ // with the exact two-phase shape of the ordinary cap — one final notice, then a loud
1376
+ // capped stop — so the escape is absolute for evidence-free loops. It never fabricates
1377
+ // completion: every release still names the missing evidence.
1378
+ const noProgressCount = Number(effectiveLedger?.noProgressCount || 0);
1379
+ if (vibecode && noProgressCount >= VIBECODE_NO_PROGRESS_CAP) {
1380
+ if (effectiveLedger?.notified === true) {
1381
+ return {
1382
+ continue: false,
1383
+ capped: true,
1384
+ notify: true,
1385
+ noProgressCount,
1386
+ missingEvidence,
1387
+ reason: `UKit vibecode liveness breaker: ${noProgressCount} continuations produced no new verifiable progress (missing evidence: ${missingEvidence.join(', ')}). Stop and tell the user what is unfinished; do not keep continuing without new evidence.`,
1388
+ };
1389
+ }
1390
+ return {
1391
+ continue: true,
1392
+ finalNotice: true,
1393
+ notify: true,
1394
+ capped: true,
1395
+ noProgressCount,
1396
+ missingEvidence,
1397
+ reason: `UKit stopping with unfinished work: ${noProgressCount} continuations produced no new verifiable progress (${missingEvidence.join(', ')}). Tell the user what is unfinished and stop; do not continue further.`,
1398
+ };
1399
+ }
1400
+
875
1401
  const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
876
1402
  const instruction = recoveryInstruction(missingEvidence, effectiveLedger, routeSummary);
877
1403
  return {
878
1404
  continue: true,
879
1405
  missingEvidence,
1406
+ noProgressCount,
880
1407
  reason: [
881
1408
  `UKit completion gate: missing ${missingEvidence.join(', ')}.`,
882
1409
  instruction,
@@ -886,60 +1413,35 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
886
1413
  };
887
1414
  }
888
1415
 
889
- export async function incrementContinuation(projectRoot, payload = {}, ledger = null, requestKey = null, promptKey = null) {
890
- const target = ledgerPath(projectRoot, payload);
891
- // Locked re-read: parallel subagents firing Stop hooks share one ledger file, and a
892
- // stale-snapshot rewrite would reset each other's continuationCount — the budget would
893
- // never advance and the gate would keep issuing continuations.
894
- return withLedgerLock(target, async () => {
895
- const current = await readExecutionLedger(projectRoot, payload) || ledger || freshLedger(payload, null, 'unknown');
896
- // Mirror evaluateCompletion's staleness rule: a count minted by an earlier request must
897
- // not be carried into the new request's budget, or the cap fires early (evaluate says 0,
898
- // persist says 7) and the next request inherits a nearly exhausted budget. Same-prompt
899
- // re-keys are the same request, so their budget must keep counting toward the cap.
900
- const stale = requestKey
901
- && current?.continuationRequestKey
902
- && current.continuationRequestKey !== requestKey
903
- && !(promptKey && current.promptKey && promptKey === current.promptKey);
904
- const next = {
905
- ...current,
906
- continuationCount: (stale ? 0 : Number(current.continuationCount || 0)) + 1,
907
- ...(requestKey ? { continuationRequestKey: requestKey } : {}),
908
- lastContinuationAt: Date.now(),
909
- updatedAt: Date.now(),
910
- };
911
- await writeJsonAtomic(target, next);
912
- return next;
913
- });
1416
+ export async function incrementContinuation(projectRoot, payload = {}, ledger = null, requestKey = null, promptKey = null, { signal, deadlineMs } = {}) {
1417
+ // Locked (and fail-closed) via recordLedgerEvent: parallel subagents firing Stop hooks
1418
+ // share one ledger file, and a stale-snapshot rewrite would reset each other's
1419
+ // continuationCount. An expired lock wait journals the increment instead of running
1420
+ // the read-modify-write unlocked.
1421
+ return recordLedgerEvent(
1422
+ { type: 'continuation', requestKey, promptKey },
1423
+ { projectRoot, payload, fallbackLedger: ledger, signal, deadlineMs },
1424
+ );
914
1425
  }
915
1426
 
916
- export async function markNotified(projectRoot, payload = {}, ledger = null) {
917
- const target = ledgerPath(projectRoot, payload);
918
- return withLedgerLock(target, async () => {
919
- const current = await readExecutionLedger(projectRoot, payload) || ledger || freshLedger(payload, null, 'unknown');
920
- const next = { ...current, notified: true, updatedAt: Date.now() };
921
- await writeJsonAtomic(target, next);
922
- return next;
923
- });
1427
+ export async function markNotified(projectRoot, payload = {}, ledger = null, { signal, deadlineMs } = {}) {
1428
+ return recordLedgerEvent(
1429
+ { type: 'notified' },
1430
+ { projectRoot, payload, fallbackLedger: ledger, signal, deadlineMs },
1431
+ );
924
1432
  }
925
1433
 
926
1434
  // omp's session_stop carries no stop_hook_active marker, so reentrancy must be detected from
927
1435
  // the ledger itself: a stop whose recovery turn produced no new receipts is the reentrant
928
1436
  // shape Claude Code flags natively. The fingerprint deliberately excludes continuation
929
1437
  // bookkeeping fields (count/notified/updatedAt) so this call's own writes stay invisible to
930
- // the next comparison — only real receipts change it.
931
- export async function noteStopProgress(projectRoot, payload = {}) {
932
- const target = ledgerPath(projectRoot, payload);
933
- return withLedgerLock(target, async () => {
934
- const current = await readExecutionLedger(projectRoot, payload);
935
- if (!current) return { reentrant: false };
936
- const receipts = Array.isArray(current.receipts) ? current.receipts : [];
937
- const fingerprint = `${receipts.length}:${receipts[receipts.length - 1]?.ts || 0}`;
938
- const reentrant = typeof current.stopProgressFingerprint === 'string'
939
- && current.stopProgressFingerprint === fingerprint;
940
- await writeJsonAtomic(target, { ...current, stopProgressFingerprint: fingerprint, updatedAt: Date.now() });
941
- return { reentrant };
942
- });
1438
+ // the next comparison — only real receipts change it. The fingerprint is computed under the
1439
+ // lock at application time (see applyStopProgressToLedger).
1440
+ export async function noteStopProgress(projectRoot, payload = {}, { signal, deadlineMs } = {}) {
1441
+ return recordLedgerEvent(
1442
+ { type: 'stop-progress' },
1443
+ { projectRoot, payload, signal, deadlineMs },
1444
+ );
943
1445
  }
944
1446
 
945
1447
  async function readStdin() {