@ngockhoale/ukit 2.4.2 → 2.5.0

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 (59) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -0
  3. package/manifests/platform.full.yaml +51 -112
  4. package/package.json +2 -1
  5. package/scripts/index/refresh-index.mjs +47 -22
  6. package/src/cli/commands/doctor.js +132 -2
  7. package/src/cli/commands/uninstall.js +18 -0
  8. package/src/core/applyPlan.js +17 -2
  9. package/src/core/compact/threshold.js +36 -6
  10. package/src/core/diffPlan.js +35 -0
  11. package/src/core/fileOps.js +26 -0
  12. package/src/core/projectImportant.js +430 -0
  13. package/src/core/sensitiveValueScanner.js +118 -0
  14. package/src/core/status.js +55 -1
  15. package/src/core/uninstall.js +183 -3
  16. package/src/diagnostics/classifyHang.js +246 -0
  17. package/src/index/buildIndex.js +1033 -62
  18. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  19. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  20. package/templates/.claude/hooks/completion-gate.sh +51 -10
  21. package/templates/.claude/hooks/compress-output.sh +38 -6
  22. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  23. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  24. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  25. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  26. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  27. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  28. package/templates/.claude/hooks/project-important.sh +67 -0
  29. package/templates/.claude/hooks/protect-files.sh +31 -5
  30. package/templates/.claude/hooks/record-execution.sh +31 -5
  31. package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
  32. package/templates/.claude/hooks/skill-router.sh +31 -5
  33. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  34. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  35. package/templates/.claude/hooks/verification-guard.sh +107 -112
  36. package/templates/.claude/hooks/vision-router.sh +49 -13
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  39. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  40. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  41. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  42. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  43. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  44. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  45. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  46. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  47. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  48. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  49. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  51. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  52. package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
  53. package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
  54. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  55. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  56. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  57. package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
  58. package/templates/AGENTS.md +8 -0
  59. package/templates/PROJECT_IMPORTANT.md +9 -0
@@ -5,7 +5,7 @@ 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
9
 
10
10
  // Hook-context self-deadline (2.4.1 orphan-leak class): wrapper hooks pass
11
11
  // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the
@@ -33,14 +33,22 @@ const MAX_GATE_CRASHES = 3;
33
33
  const VERIFICATION_LOOP_STREAK = 2;
34
34
  const VIBECODE_VERIFICATION_FAILURE_LIMIT = 4;
35
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;
36
44
  // hook-chain-runner.mjs gives every hook script a 4s child budget, and omp blocks Edit|Write
37
45
  // when a chain script is killed. Waiting longer than that for a contended lock got the
38
- // recording hook killed mid-chain (receipt lost, edit blocked). Give up and fail open well
39
- // inside the budget instead — under extreme contention a receipt may be lost, but the hook
40
- // is never killed by its own chain.
41
- const HOOK_SAFE_LOCK_WAIT_MS = 2500;
42
- function withLedgerLock(target, fn) {
43
- 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);
44
52
  }
45
53
  const IMPLEMENT_MODES = new Set([
46
54
  'tiny-fix',
@@ -130,14 +138,18 @@ async function readJson(filePath, fallback = null) {
130
138
  // a single `pid`-suffixed temp path: one rename removed the file under the other and the
131
139
  // writer crashed with ENOENT mid-hook. Make every write's temp path unique.
132
140
  let atomicWriteCounter = 0;
133
- async function writeJsonAtomic(filePath, value) {
141
+ async function writeTextAtomic(filePath, text) {
134
142
  await fs.mkdir(path.dirname(filePath), { recursive: true });
135
143
  atomicWriteCounter += 1;
136
144
  const tempPath = `${filePath}.${process.pid}-${atomicWriteCounter}-${Math.random().toString(16).slice(2)}.tmp`;
137
- await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
145
+ await fs.writeFile(tempPath, text, 'utf8');
138
146
  await fs.rename(tempPath, filePath);
139
147
  }
140
148
 
149
+ async function writeJsonAtomic(filePath, value) {
150
+ await writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`);
151
+ }
152
+
141
153
  export async function readRouteState(projectRoot, payload = {}) {
142
154
  const state = await readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
143
155
  const sessionId = explicitSessionId(payload);
@@ -459,6 +471,71 @@ function appendReceipt(receipts, receipt) {
459
471
  return [...(receipts || []), compactReceipt(receipt)].slice(-MAX_RECEIPTS);
460
472
  }
461
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
+
462
539
  // A re-key within the same logical request must not drop the evidence the request
463
540
  // already produced — that turned one finished task into a cap-exhausted forced stop.
464
541
  // A genuinely different prompt keeps a clean slate so old work never satisfies a new
@@ -487,6 +564,19 @@ function carriedEvidenceLedger(fresh, current) {
487
564
  // must keep counting toward the cap. Resetting it here made the cap unreachable and the
488
565
  // Stop gate loop forever on harnesses without a stop_hook_active valve.
489
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
+ },
490
580
  continuationRequestKey: current.continuationRequestKey || fresh.continuationRequestKey || null,
491
581
  notified: fresh.notified === true || current.notified === true,
492
582
  // Verification-loop tracking and any minted blocker belong to the same logical request
@@ -494,6 +584,11 @@ function carriedEvidenceLedger(fresh, current) {
494
584
  failedVerificationStreak: current.failedVerificationStreak || null,
495
585
  verificationFailureCounts: current.verificationFailureCounts || {},
496
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,
497
592
  };
498
593
  }
499
594
 
@@ -530,135 +625,530 @@ function freshLedger(payload, routeState, harness) {
530
625
  receipts: [],
531
626
  blocker: null,
532
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: {},
533
634
  notified: false,
534
635
  updatedAt: Date.now(),
535
636
  };
536
637
  }
537
638
 
538
- export async function recordExecutionReceipt({
539
- projectRoot,
540
- payload = {},
541
- toolName = payload.tool_name,
542
- harness = 'unknown',
543
- } = {}) {
544
- if (!projectRoot || !toolName) return null;
545
- // The whole read-modify-write is locked: parallel subagents record receipts through the
546
- // same session ledger file, and an unlocked snapshot rewrite dropped whichever receipts
547
- // landed between the read and the write.
548
- return withLedgerLock(ledgerPath(projectRoot, payload), async () => {
549
- const routeState = await readRouteState(projectRoot, payload);
550
- const current = await readExecutionLedger(projectRoot, payload);
551
- const nextRequestKey = routeState?.requestKey || null;
552
- // A missing/foreign route state must never blank the evidence this session already
553
- // banked. The shared state slot is single-owner (a parallel subagent re-stamps it), so
554
- // routeState === null is routine mid-request — rebuilding from fresh there re-demanded
555
- // every evidence the request had produced and froze the run. Without a live requestKey
556
- // the only safe move is to keep appending to the current ledger.
557
- const ledger = current && !nextRequestKey
558
- ? { ...current, harness: current.harness || harness }
559
- : (!current || current.requestKey !== nextRequestKey
560
- ? carriedEvidenceLedger(freshLedger(payload, routeState, harness), current)
561
- : { ...current, harness: current.harness || harness });
562
-
563
- const failed = explicitError(payload);
564
- const exitCode = extractExitCode(payload);
565
- const success = !failed && (exitCode === null || exitCode === 0);
566
- const toolInput = payload.tool_input || {};
567
- const receipt = {
568
- ts: Date.now(),
569
- toolName,
570
- toolUseId: payload.tool_use_id || null,
571
- success,
572
- exitCode,
573
- };
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
+ }
574
665
 
575
- if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
576
- receipt.kind = 'source';
577
- receipt.file = toolInput.file_path || toolInput.path || null;
578
- ledger.sourceSucceeded ||= success;
579
- if (success && receipt.file) {
580
- ledger.sourceFiles = [...new Set([...(ledger.sourceFiles || []), receipt.file])].slice(-MAX_SOURCE_FILES);
581
- }
582
- } else if (toolName === 'Edit' || toolName === 'Write') {
583
- receipt.kind = 'write';
584
- receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
585
- ledger.writeAttempted = true;
586
- ledger.writeSucceeded ||= success;
587
- // A mutation attempt between failures breaks the "no change in between" loop shape.
588
- if (ledger.failedVerificationStreak) ledger.failedVerificationStreak = null;
589
- } else if (toolName === 'Bash' && isVerificationCommand(toolInput.command)) {
590
- receipt.kind = 'verification';
591
- receipt.command = String(toolInput.command || '').trim();
592
- // WS-C routed-verification receipt: a command counts as "targeted" when it matches the
593
- // routed plan (preferredOrder / primaryCommands). Off-plan verification still records
594
- // as broad — counted only when the route carried no commands at all.
595
- const routedCommands = routedVerificationCommands(routeState?.routeSummary);
596
- if (routedCommands.length > 0) {
597
- const matched = routedCommands.find((cmd) => matchesRoutedCommand(receipt.command, cmd));
598
- receipt.scope = matched ? 'targeted' : 'broad';
599
- if (receipt.scope === 'targeted') {
600
- ledger.targetedVerificationSucceeded ||= success;
601
- }
602
- }
603
- ledger.verificationAttempted = true;
604
- ledger.verificationSucceeded ||= success;
605
- ledger.verificationFailed ||= !success;
606
-
607
- // Verification-loop tracking. `terminalShellCommandUnit` gives the loop identity:
608
- // the same failing check rerun — `setup && yarn test` and `yarn test 2>&1 | tail`
609
- // both count as the same command, while a different check resets the streak.
610
- const fingerprint = terminalShellCommandUnit(receipt.command) || receipt.command;
611
- const counts = { ...(ledger.verificationFailureCounts || {}) };
612
- if (success) {
613
- delete counts[fingerprint];
614
- ledger.verificationFailureCounts = counts;
615
- if (ledger.failedVerificationStreak?.fingerprint === fingerprint) {
616
- 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' };
617
719
  }
618
- } else {
619
- const streakFingerprint = ledger.failedVerificationStreak?.fingerprint;
620
- const streakCount = streakFingerprint === fingerprint
621
- ? Number(ledger.failedVerificationStreak.count || 0) + 1
622
- : 1;
623
- ledger.failedVerificationStreak = { fingerprint, count: streakCount };
624
- counts[fingerprint] = Number(counts[fingerprint] || 0) + 1;
625
- const trackedKeys = Object.keys(counts);
626
- if (trackedKeys.length > MAX_TRACKED_VERIFICATIONS) {
627
- for (const key of trackedKeys.slice(0, trackedKeys.length - MAX_TRACKED_VERIFICATIONS)) {
628
- delete counts[key];
629
- }
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;
630
859
  }
631
- ledger.verificationFailureCounts = counts;
632
-
633
- const vibecode = routeState?.routeSummary?.autonomyLevel === 'vibecode';
634
- const shortCommand = fingerprint.length > 80 ? `${fingerprint.slice(0, 77)}…` : fingerprint;
635
- if (streakCount >= VERIFICATION_LOOP_STREAK) {
636
- ledger.blocker = {
637
- kind: 'verification-loop',
638
- visible: true,
639
- 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.`,
640
- command: fingerprint,
641
- ts: Date.now(),
642
- };
643
- } else if (vibecode && counts[fingerprint] >= VIBECODE_VERIFICATION_FAILURE_LIMIT) {
644
- ledger.blocker = {
645
- kind: 'verification-loop',
646
- visible: true,
647
- detail: `verification "${shortCommand}" failed ${counts[fingerprint]} times in continuous (vibecode) execution — report the failing output as a concrete blocker instead of retrying.`,
648
- command: fingerprint,
649
- ts: Date.now(),
650
- };
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.
651
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;
652
929
  }
653
930
  } else {
654
- 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
+ }
963
+ }
964
+ }
965
+ next.receipts = appendReceipt(next.receipts, receipt);
966
+ next.updatedAt = Date.now();
967
+ return { ledger: next, value: null };
968
+ }
969
+
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);
655
1024
  }
1025
+ return { ...current, harness: current.harness || harness };
1026
+ }
1027
+ return current || fallbackLedger || freshLedger(payload, null, 'unknown');
1028
+ }
656
1029
 
657
- ledger.receipts = appendReceipt(ledger.receipts, receipt);
658
- ledger.updatedAt = Date.now();
659
- await writeJsonAtomic(ledgerPath(projectRoot, payload), ledger);
660
- return ledger;
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 };
661
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
+ );
662
1152
  }
663
1153
 
664
1154
  function requiredEvidence(state = {}) {
@@ -880,11 +1370,40 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
880
1370
  };
881
1371
  }
882
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
+
883
1401
  const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
884
1402
  const instruction = recoveryInstruction(missingEvidence, effectiveLedger, routeSummary);
885
1403
  return {
886
1404
  continue: true,
887
1405
  missingEvidence,
1406
+ noProgressCount,
888
1407
  reason: [
889
1408
  `UKit completion gate: missing ${missingEvidence.join(', ')}.`,
890
1409
  instruction,
@@ -894,60 +1413,35 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
894
1413
  };
895
1414
  }
896
1415
 
897
- export async function incrementContinuation(projectRoot, payload = {}, ledger = null, requestKey = null, promptKey = null) {
898
- const target = ledgerPath(projectRoot, payload);
899
- // Locked re-read: parallel subagents firing Stop hooks share one ledger file, and a
900
- // stale-snapshot rewrite would reset each other's continuationCount — the budget would
901
- // never advance and the gate would keep issuing continuations.
902
- return withLedgerLock(target, async () => {
903
- const current = await readExecutionLedger(projectRoot, payload) || ledger || freshLedger(payload, null, 'unknown');
904
- // Mirror evaluateCompletion's staleness rule: a count minted by an earlier request must
905
- // not be carried into the new request's budget, or the cap fires early (evaluate says 0,
906
- // persist says 7) and the next request inherits a nearly exhausted budget. Same-prompt
907
- // re-keys are the same request, so their budget must keep counting toward the cap.
908
- const stale = requestKey
909
- && current?.continuationRequestKey
910
- && current.continuationRequestKey !== requestKey
911
- && !(promptKey && current.promptKey && promptKey === current.promptKey);
912
- const next = {
913
- ...current,
914
- continuationCount: (stale ? 0 : Number(current.continuationCount || 0)) + 1,
915
- ...(requestKey ? { continuationRequestKey: requestKey } : {}),
916
- lastContinuationAt: Date.now(),
917
- updatedAt: Date.now(),
918
- };
919
- await writeJsonAtomic(target, next);
920
- return next;
921
- });
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
+ );
922
1425
  }
923
1426
 
924
- export async function markNotified(projectRoot, payload = {}, ledger = null) {
925
- const target = ledgerPath(projectRoot, payload);
926
- return withLedgerLock(target, async () => {
927
- const current = await readExecutionLedger(projectRoot, payload) || ledger || freshLedger(payload, null, 'unknown');
928
- const next = { ...current, notified: true, updatedAt: Date.now() };
929
- await writeJsonAtomic(target, next);
930
- return next;
931
- });
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
+ );
932
1432
  }
933
1433
 
934
1434
  // omp's session_stop carries no stop_hook_active marker, so reentrancy must be detected from
935
1435
  // the ledger itself: a stop whose recovery turn produced no new receipts is the reentrant
936
1436
  // shape Claude Code flags natively. The fingerprint deliberately excludes continuation
937
1437
  // bookkeeping fields (count/notified/updatedAt) so this call's own writes stay invisible to
938
- // the next comparison — only real receipts change it.
939
- export async function noteStopProgress(projectRoot, payload = {}) {
940
- const target = ledgerPath(projectRoot, payload);
941
- return withLedgerLock(target, async () => {
942
- const current = await readExecutionLedger(projectRoot, payload);
943
- if (!current) return { reentrant: false };
944
- const receipts = Array.isArray(current.receipts) ? current.receipts : [];
945
- const fingerprint = `${receipts.length}:${receipts[receipts.length - 1]?.ts || 0}`;
946
- const reentrant = typeof current.stopProgressFingerprint === 'string'
947
- && current.stopProgressFingerprint === fingerprint;
948
- await writeJsonAtomic(target, { ...current, stopProgressFingerprint: fingerprint, updatedAt: Date.now() });
949
- return { reentrant };
950
- });
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
+ );
951
1445
  }
952
1446
 
953
1447
  async function readStdin() {