@ngockhoale/ukit 2.4.2 → 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.
- package/manifests/platform.full.yaml +19 -111
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/core/compact/threshold.js +36 -6
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +44 -8
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +0 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
|
@@ -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 {
|
|
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).
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
function withLedgerLock(target, fn) {
|
|
43
|
-
return
|
|
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
|
|
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,
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
return
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
return
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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() {
|