@vibe-commit/capture 0.4.2 → 0.4.4

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 (42) hide show
  1. package/README.md +26 -0
  2. package/dist/commands/finalize.js +102 -2
  3. package/dist/commands/finalize.js.map +1 -1
  4. package/dist/commands/status.js +41 -2
  5. package/dist/commands/status.js.map +1 -1
  6. package/dist/copy/strings.d.ts +39 -1
  7. package/dist/copy/strings.js +65 -1
  8. package/dist/copy/strings.js.map +1 -1
  9. package/dist/credential.js +14 -2
  10. package/dist/credential.js.map +1 -1
  11. package/dist/git.d.ts +10 -0
  12. package/dist/git.js +16 -0
  13. package/dist/git.js.map +1 -1
  14. package/dist/header_safety.d.ts +41 -0
  15. package/dist/header_safety.js +48 -0
  16. package/dist/header_safety.js.map +1 -0
  17. package/dist/hooks/entry.d.ts +2 -0
  18. package/dist/hooks/entry.js +18 -3
  19. package/dist/hooks/entry.js.map +1 -1
  20. package/dist/hooks/post_commit.js +23 -4
  21. package/dist/hooks/post_commit.js.map +1 -1
  22. package/dist/inferred_squash.d.ts +67 -0
  23. package/dist/inferred_squash.js +151 -0
  24. package/dist/inferred_squash.js.map +1 -0
  25. package/dist/inferred_tally.d.ts +50 -0
  26. package/dist/inferred_tally.js +99 -0
  27. package/dist/inferred_tally.js.map +1 -0
  28. package/dist/policy.d.ts +17 -0
  29. package/dist/policy.js +7 -1
  30. package/dist/policy.js.map +1 -1
  31. package/dist/post.d.ts +60 -5
  32. package/dist/post.js +373 -50
  33. package/dist/post.js.map +1 -1
  34. package/dist/spool.d.ts +113 -0
  35. package/dist/spool.js +466 -1
  36. package/dist/spool.js.map +1 -1
  37. package/dist/state.d.ts +46 -0
  38. package/dist/state.js +114 -0
  39. package/dist/state.js.map +1 -1
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +2 -2
package/dist/spool.d.ts CHANGED
@@ -435,6 +435,119 @@ export declare function capSuccessors(pairs: readonly RewritePair[]): {
435
435
  pairs: string[];
436
436
  count: number;
437
437
  };
438
+ /** One INFERRED rewrite: the commit that was folded, the one that replaced it, and the patch-id of the squash (the evidence we store). */
439
+ export interface InferredPair {
440
+ readonly ancestor: string;
441
+ readonly successor: string;
442
+ readonly patchId: string;
443
+ }
444
+ /**
445
+ * How many inferred pairs one request may carry. ⛔ ITS OWN CONSTANT, and a WHOLE-GROUP budget, not a flat per-hook cap: `blame_commit`
446
+ * renders `squashed_from` as a plain list (mcp `blame.ts:454`), so a group split across requests would read as a complete answer when it
447
+ * is not — the same class of defect as [160]. One squash's fold is at most `MAX_INFERRED_COMMITS` (64) pairs (a larger fold is refused
448
+ * before it is ever spooled — "oversize", tallied, nothing sent), so 64 is also the largest a single group can be. `capInferred` packs
449
+ * whole groups, oldest first, up to this budget; a group that would not fit WHOLE waits, with everything after it, for a later request —
450
+ * never split. Measured on the wire (prod, invalid-bearer probe, 2026-10-08): 64 pairs = 7,871 bytes alongside a 10-pair exact-rewrites
451
+ * header (819 B) still returns 401 (passed the proxy and node's header layer); a 40,000 B control header returns 431, so the probe can see
452
+ * a real limit and did not hit it here.
453
+ */
454
+ export declare const MAX_INFERRED_PAIRS = 64;
455
+ /** `<repo>/<session>.inferred.jsonl`, beside the rewrite spool. */
456
+ export declare function inferredSpoolPath(home: string, key: SessionKey): string | null;
457
+ /** A 40-hex patch-id. Not `isFullSha`: the wire contract is `patchid40`, lowercase hex, exactly 40. */
458
+ export declare function isPatchId(value: unknown): value is string;
459
+ /** A group's identity on the wire and in the spool: one squash's successor + the patch-id that is its evidence. */
460
+ export interface InferredGroupId {
461
+ readonly successor: string;
462
+ readonly patchId: string;
463
+ }
464
+ /**
465
+ * Append inferred pairs as ONE GROUP LINE (they are the fold of one detected squash, so they share one successor and
466
+ * one patch-id by construction; grouped here anyway, defensively, in case a future caller ever passes more than one).
467
+ * A group whose identity is already on disk — spooled OR already delivered — is skipped whole: the same squash
468
+ * detected twice (a hook retried) must not duplicate it. Returns how many ancestor pairs were newly WRITTEN.
469
+ * ⛔ NEVER THROWS — this runs inside the user's `git commit`.
470
+ */
471
+ export declare function appendInferred(home: string, key: SessionKey, pairs: readonly InferredPair[]): number;
472
+ /**
473
+ * Every well-formed, still-ELIGIBLE inferred pair in the spool, oldest group first, ancestors in stored order
474
+ * within a group. "Eligible" excludes a group already DELIVERED and one already SET ASIDE (VL, 2026-10-08: a
475
+ * group that gave up after `INFERRED_SET_ASIDE_AFTER` short acks is never offered again; `inferred_tally.ts`'s
476
+ * `recordInferredSkipped(..., "set_aside", ...)` tallied it durably at that moment, for `status`). `capInferred`
477
+ * takes `pairs[0]`'s group as the one it sends, so this function's ORDER is load-bearing: the oldest eligible
478
+ * group must come first.
479
+ */
480
+ export declare function readInferred(home: string, key: SessionKey): InferredPair[];
481
+ /**
482
+ * Mark groups DELIVERED, by identity — never by position. Called ONLY after the server's 2xx AND its per-request ack
483
+ * (`x-rewrites-inferred-stored`) confirms every pair of every named group actually landed. An append-only tombstone
484
+ * mark, so a concurrent `appendInferred` for a DIFFERENT group racing this call is never clobbered: there is no file
485
+ * rewrite here to lose it inside. ⛔ NEVER THROWS.
486
+ */
487
+ export declare function dropInferredGroups(home: string, key: SessionKey, groups: readonly InferredGroupId[]): boolean;
488
+ /**
489
+ * The inferred pairs this hook will carry, in WIRE SPELLING — `<ancestor40>:<successor40>:<patchid40>`, lowercase hex, colon-separated —
490
+ * and the identity of the ONE group carried (`groups`, length 0 or 1 — plural for call-site symmetry with `dropInferredGroups`, never
491
+ * more than one member). Only 40-hex shas ride this header; a sha256 repository's pair is not spooled at all.
492
+ *
493
+ * ⛔⛔ ONE GROUP PER REQUEST (VL, 2026-10-08, VG's head-of-line finding over `vg-evidence/177-gate-7dde49e/hol.mjs`). The earlier
494
+ * version packed as many WHOLE groups as fit under `MAX_INFERRED_PAIRS` — correct for the concurrency bug, but it made the ack
495
+ * (`x-rewrites-inferred-stored`, ONE NUMBER per request) ambiguous whenever more than one group rode the same request: a short ack
496
+ * could not say WHICH group was short, so the client had to keep ALL of them, and the head group — if a server persistently
497
+ * refused or shorted it — blocked every group behind it forever (measured: 40+30 under a short ack reads `40,40,40,40,…` on every
498
+ * hook; the 30 is never sent). Sending exactly `readInferred(...)`'s FIRST eligible group makes `n` name exactly that group:
499
+ * `n == size` is unambiguous. A single group is never larger than `MAX_INFERRED_COMMITS` (64) by construction (a bigger fold is
500
+ * refused before it is ever spooled), so it always fits; `MAX_INFERRED_PAIRS` stays as that upper bound's own name.
501
+ */
502
+ export declare function capInferred(pairs: readonly InferredPair[]): {
503
+ pairs: string[];
504
+ count: number;
505
+ groups: readonly InferredGroupId[];
506
+ };
507
+ /**
508
+ * ⛔ PER-GROUP BOUND (VL, 2026-10-08). A SHORT ack (the header is present but `n < sent`) is a real signal the
509
+ * server has an issue with THIS group — unlike an ABSENT ack (an old server, which says nothing at all and is
510
+ * never counted: "keeping is right there"). Each short ack counts ONE attempt, by identity, append-only like the
511
+ * `delivered` mark. After `INFERRED_SET_ASIDE_AFTER` attempts the group is given up on (`setAsideInferredGroup`),
512
+ * so a persistently-refused group blocks its neighbours for at most that many hook cycles, never forever.
513
+ */
514
+ export declare const INFERRED_SET_ASIDE_AFTER = 3;
515
+ /** Record one short-ack attempt against a group, by identity. Returns the attempt count AFTER this one. ⛔ NEVER THROWS. */
516
+ export declare function recordInferredAttempt(home: string, key: SessionKey, group: InferredGroupId): number;
517
+ /**
518
+ * Give up on a group: it is never offered again (`readInferred` excludes it), but it is NOT deleted — it stays on
519
+ * disk, covered by the 7-day orphan prune like everything else here. Not counted by a live gauge here (VL,
520
+ * 2026-10-08, VG's double-count finding, item (b)): `recordInferredSkipped(..., "set_aside", ...)` at the call
521
+ * site is the ONE place this is counted, so a set-aside group is a sentence exactly once, never twice. ⛔ NEVER
522
+ * THROWS.
523
+ */
524
+ export declare function setAsideInferredGroup(home: string, key: SessionKey, group: InferredGroupId, reason: "short_ack"): boolean;
525
+ /**
526
+ * Whether EVERY group ever spooled for this session is resolved — delivered or set aside, none still pending —
527
+ * so the file can be deleted outright rather than waiting on the 7-day prune. Called ONLY by `finalize` after its
528
+ * drain loop ends, and ONLY because the session is then over: no concurrent `appendInferred` can still be running
529
+ * for a session finalize just confirmed is finished (the hook that would call it is the one deciding the session
530
+ * is over).
531
+ */
532
+ export declare function inferredFullyResolved(home: string, key: SessionKey): boolean;
533
+ /** Delete a fully-resolved inferred spool file outright, rather than leaving dead marks for the 7-day prune to find. ⛔ NEVER THROWS. */
534
+ export declare function deleteInferredSpool(home: string, key: SessionKey): boolean;
535
+ export declare const INFERRED_PRUNE_TTL_MS: number;
536
+ /**
537
+ * Prune `.inferred.jsonl` files whose mtime is more than 7 days old — the file as a whole, by the SAME rename
538
+ * -then-settle discipline as `pruneOrphanPending` (see that function's header for the race it narrows). Called from
539
+ * the Stop/SessionEnd hooks only, alongside `pruneOrphanPending`, never from `post-commit` or `status`.
540
+ *
541
+ * ⛔⛔ `pairs` is new (VL, 2026-10-08, VG's silent-loss finding): the CALLER must feed it to
542
+ * `recordInferredSkipped(..., "pruned", ..., pairs)` so a discard still shows up in `status` after this function's
543
+ * own rename-then-delete removes the file the live gauges read. `lines` (group count) is kept as `pruneExpiredInferred`
544
+ * already returned it and an existing test pins that shape; `pairs` is the one the tally wants.
545
+ */
546
+ export declare function pruneExpiredInferred(home: string, repoKey: string, nowMs: number): {
547
+ files: number;
548
+ lines: number;
549
+ pairs: number;
550
+ };
438
551
  /** `<sessions dir>/rebase-amends.jsonl` — per REPO, because a rebase has no session of its own. */
439
552
  export declare function rebaseAmendsPath(home: string, repoKey: string): string | null;
440
553
  /** Remember `amend` pairs seen DURING a rebase. Never throws: this runs inside the user's `git rebase`. */
package/dist/spool.js CHANGED
@@ -40,7 +40,7 @@
40
40
  * the sha; the observation is not. `files` is carried for wave 2 and never put
41
41
  * in a header.
42
42
  */
43
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
43
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
44
44
  import { dirname, join } from "node:path";
45
45
  import { keepsPendingLine } from "./pending_bound.js";
46
46
  import { repoSessionsDir, sessionStatePath } from "./paths.js";
@@ -588,6 +588,471 @@ export function capSuccessors(pairs) {
588
588
  const taken = pairs.slice(0, MAX_SPOOLED_PAIRS);
589
589
  return { pairs: taken.map(wirePair), count: taken.length };
590
590
  }
591
+ /**
592
+ * How many inferred pairs one request may carry. ⛔ ITS OWN CONSTANT, and a WHOLE-GROUP budget, not a flat per-hook cap: `blame_commit`
593
+ * renders `squashed_from` as a plain list (mcp `blame.ts:454`), so a group split across requests would read as a complete answer when it
594
+ * is not — the same class of defect as [160]. One squash's fold is at most `MAX_INFERRED_COMMITS` (64) pairs (a larger fold is refused
595
+ * before it is ever spooled — "oversize", tallied, nothing sent), so 64 is also the largest a single group can be. `capInferred` packs
596
+ * whole groups, oldest first, up to this budget; a group that would not fit WHOLE waits, with everything after it, for a later request —
597
+ * never split. Measured on the wire (prod, invalid-bearer probe, 2026-10-08): 64 pairs = 7,871 bytes alongside a 10-pair exact-rewrites
598
+ * header (819 B) still returns 401 (passed the proxy and node's header layer); a 40,000 B control header returns 431, so the probe can see
599
+ * a real limit and did not hit it here.
600
+ */
601
+ export const MAX_INFERRED_PAIRS = 64;
602
+ /** `<repo>/<session>.inferred.jsonl`, beside the rewrite spool. */
603
+ export function inferredSpoolPath(home, key) {
604
+ const state = sessionStatePath(home, key);
605
+ if (state === null)
606
+ return null;
607
+ return state.replace(/\.json$/, ".inferred.jsonl");
608
+ }
609
+ /** A 40-hex patch-id. Not `isFullSha`: the wire contract is `patchid40`, lowercase hex, exactly 40. */
610
+ export function isPatchId(value) {
611
+ return typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
612
+ }
613
+ function identityOf(g) {
614
+ return `${g.successor}:${g.patchId}`;
615
+ }
616
+ const IDENTITY_RE = /^[0-9a-f]{40}:[0-9a-f]{40}$/;
617
+ function parseStoredLine(line) {
618
+ let parsed;
619
+ try {
620
+ parsed = JSON.parse(line);
621
+ }
622
+ catch {
623
+ return null;
624
+ }
625
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
626
+ return null;
627
+ const o = parsed;
628
+ if (typeof o.delivered === "string" && IDENTITY_RE.test(o.delivered))
629
+ return { kind: "delivered", identity: o.delivered };
630
+ if (typeof o.attempt === "string" && IDENTITY_RE.test(o.attempt))
631
+ return { kind: "attempt", identity: o.attempt };
632
+ if (typeof o.set_aside === "string" && IDENTITY_RE.test(o.set_aside))
633
+ return { kind: "set_aside", identity: o.set_aside };
634
+ const successor = o.successor;
635
+ const patchId = o.patch_id;
636
+ const ancestors = o.ancestors;
637
+ if (typeof successor !== "string" || !/^[0-9a-f]{40}$/.test(successor))
638
+ return null;
639
+ if (!isPatchId(patchId))
640
+ return null;
641
+ if (!Array.isArray(ancestors) || ancestors.length === 0)
642
+ return null;
643
+ const clean = ancestors.filter((a) => typeof a === "string" && /^[0-9a-f]{40}$/.test(a) && a !== successor);
644
+ if (clean.length === 0)
645
+ return null;
646
+ return { kind: "group", group: { successor, patchId, ancestors: [...new Set(clean)] } };
647
+ }
648
+ /** Every group line and every mark (delivered / attempt / set-aside) currently on disk. Malformed lines are skipped, not fatal. */
649
+ function readStored(path) {
650
+ let raw;
651
+ try {
652
+ raw = readFileSync(path, "utf8");
653
+ }
654
+ catch {
655
+ return { groups: [], delivered: new Set(), setAside: new Set(), attempts: new Map() };
656
+ }
657
+ const groups = [];
658
+ const delivered = new Set();
659
+ const setAside = new Set();
660
+ const attempts = new Map();
661
+ for (const line of raw.split("\n")) {
662
+ if (line.trim() === "")
663
+ continue;
664
+ const parsed = parseStoredLine(line);
665
+ if (parsed === null)
666
+ continue;
667
+ if (parsed.kind === "delivered")
668
+ delivered.add(parsed.identity);
669
+ else if (parsed.kind === "set_aside")
670
+ setAside.add(parsed.identity);
671
+ else if (parsed.kind === "attempt")
672
+ attempts.set(parsed.identity, (attempts.get(parsed.identity) ?? 0) + 1);
673
+ else
674
+ groups.push(parsed.group);
675
+ }
676
+ return { groups, delivered, setAside, attempts };
677
+ }
678
+ /**
679
+ * Append inferred pairs as ONE GROUP LINE (they are the fold of one detected squash, so they share one successor and
680
+ * one patch-id by construction; grouped here anyway, defensively, in case a future caller ever passes more than one).
681
+ * A group whose identity is already on disk — spooled OR already delivered — is skipped whole: the same squash
682
+ * detected twice (a hook retried) must not duplicate it. Returns how many ancestor pairs were newly WRITTEN.
683
+ * ⛔ NEVER THROWS — this runs inside the user's `git commit`.
684
+ */
685
+ export function appendInferred(home, key, pairs) {
686
+ const path = inferredSpoolPath(home, key);
687
+ if (path === null)
688
+ return 0;
689
+ const valid = pairs.filter(validInferred);
690
+ if (valid.length === 0)
691
+ return 0;
692
+ const order = [];
693
+ const bySuccessor = new Map();
694
+ for (const pair of valid) {
695
+ let g = bySuccessor.get(pair.successor);
696
+ if (g === undefined) {
697
+ g = [];
698
+ bySuccessor.set(pair.successor, g);
699
+ order.push(pair.successor);
700
+ }
701
+ g.push(pair);
702
+ }
703
+ let written = 0;
704
+ try {
705
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
706
+ const existing = readStored(path);
707
+ const known = new Set([...existing.delivered, ...existing.setAside, ...existing.groups.map(identityOf)]);
708
+ for (const successor of order) {
709
+ const members = bySuccessor.get(successor);
710
+ const patchId = members[0].patchId;
711
+ if (!members.every((p) => p.patchId === patchId))
712
+ continue; // defensive: a mixed call names no group
713
+ const identity = identityOf({ successor, patchId });
714
+ if (known.has(identity))
715
+ continue;
716
+ known.add(identity);
717
+ const ancestors = [...new Set(members.map((p) => p.ancestor))];
718
+ appendFileSync(path, `${JSON.stringify({ successor, patch_id: patchId, ancestors })}\n`, { mode: 0o600 });
719
+ written += ancestors.length;
720
+ }
721
+ if (written > 0)
722
+ chmodSync(path, 0o600);
723
+ return written;
724
+ }
725
+ catch {
726
+ return written;
727
+ }
728
+ }
729
+ /**
730
+ * Every well-formed, still-ELIGIBLE inferred pair in the spool, oldest group first, ancestors in stored order
731
+ * within a group. "Eligible" excludes a group already DELIVERED and one already SET ASIDE (VL, 2026-10-08: a
732
+ * group that gave up after `INFERRED_SET_ASIDE_AFTER` short acks is never offered again; `inferred_tally.ts`'s
733
+ * `recordInferredSkipped(..., "set_aside", ...)` tallied it durably at that moment, for `status`). `capInferred`
734
+ * takes `pairs[0]`'s group as the one it sends, so this function's ORDER is load-bearing: the oldest eligible
735
+ * group must come first.
736
+ */
737
+ export function readInferred(home, key) {
738
+ const path = inferredSpoolPath(home, key);
739
+ if (path === null)
740
+ return [];
741
+ const { groups, delivered, setAside } = readStored(path);
742
+ const out = [];
743
+ for (const g of groups) {
744
+ if (delivered.has(identityOf(g)) || setAside.has(identityOf(g)))
745
+ continue;
746
+ for (const ancestor of g.ancestors)
747
+ out.push({ ancestor, successor: g.successor, patchId: g.patchId });
748
+ }
749
+ return out;
750
+ }
751
+ // ⛔ `inferredUnacknowledgedForRepo` MOVED TO `state.ts` (`TODOS[182]`): it now needs `FileState.lean`
752
+ // (`SessionState`'s own domain) to exclude a lean session's pairs, and `spool.ts` cannot import `state.ts`
753
+ // without a cycle — `state.ts` already imports several read/cap functions from this file.
754
+ /**
755
+ * Mark groups DELIVERED, by identity — never by position. Called ONLY after the server's 2xx AND its per-request ack
756
+ * (`x-rewrites-inferred-stored`) confirms every pair of every named group actually landed. An append-only tombstone
757
+ * mark, so a concurrent `appendInferred` for a DIFFERENT group racing this call is never clobbered: there is no file
758
+ * rewrite here to lose it inside. ⛔ NEVER THROWS.
759
+ */
760
+ export function dropInferredGroups(home, key, groups) {
761
+ const path = inferredSpoolPath(home, key);
762
+ if (path === null || groups.length === 0)
763
+ return false;
764
+ try {
765
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
766
+ const marked = new Set();
767
+ for (const g of groups) {
768
+ const identity = identityOf(g);
769
+ if (marked.has(identity))
770
+ continue;
771
+ marked.add(identity);
772
+ appendFileSync(path, `${JSON.stringify({ delivered: identity })}\n`, { mode: 0o600 });
773
+ }
774
+ chmodSync(path, 0o600);
775
+ return true;
776
+ }
777
+ catch {
778
+ return false;
779
+ }
780
+ }
781
+ /**
782
+ * The inferred pairs this hook will carry, in WIRE SPELLING — `<ancestor40>:<successor40>:<patchid40>`, lowercase hex, colon-separated —
783
+ * and the identity of the ONE group carried (`groups`, length 0 or 1 — plural for call-site symmetry with `dropInferredGroups`, never
784
+ * more than one member). Only 40-hex shas ride this header; a sha256 repository's pair is not spooled at all.
785
+ *
786
+ * ⛔⛔ ONE GROUP PER REQUEST (VL, 2026-10-08, VG's head-of-line finding over `vg-evidence/177-gate-7dde49e/hol.mjs`). The earlier
787
+ * version packed as many WHOLE groups as fit under `MAX_INFERRED_PAIRS` — correct for the concurrency bug, but it made the ack
788
+ * (`x-rewrites-inferred-stored`, ONE NUMBER per request) ambiguous whenever more than one group rode the same request: a short ack
789
+ * could not say WHICH group was short, so the client had to keep ALL of them, and the head group — if a server persistently
790
+ * refused or shorted it — blocked every group behind it forever (measured: 40+30 under a short ack reads `40,40,40,40,…` on every
791
+ * hook; the 30 is never sent). Sending exactly `readInferred(...)`'s FIRST eligible group makes `n` name exactly that group:
792
+ * `n == size` is unambiguous. A single group is never larger than `MAX_INFERRED_COMMITS` (64) by construction (a bigger fold is
793
+ * refused before it is ever spooled), so it always fits; `MAX_INFERRED_PAIRS` stays as that upper bound's own name.
794
+ */
795
+ export function capInferred(pairs) {
796
+ if (pairs.length === 0)
797
+ return { pairs: [], count: 0, groups: [] };
798
+ const { successor, patchId } = pairs[0];
799
+ // ⛔ Defensive, not load-bearing: a single group is never larger than `MAX_INFERRED_PAIRS` by construction
800
+ // (`inferred_squash.ts` refuses a bigger fold before it is ever spooled). The clamp exists so a future upstream
801
+ // change that weakens that guarantee fails SAFE here (an undersized request) rather than oversized on the wire.
802
+ const group = pairs.filter((p) => p.successor === successor && p.patchId === patchId).slice(0, MAX_INFERRED_PAIRS);
803
+ return { pairs: group.map(wireInferred), count: group.length, groups: [{ successor, patchId }] };
804
+ }
805
+ function wireInferred(pair) {
806
+ return `${pair.ancestor}:${pair.successor}:${pair.patchId}`;
807
+ }
808
+ function validInferred(pair) {
809
+ return /^[0-9a-f]{40}$/.test(pair.ancestor) && /^[0-9a-f]{40}$/.test(pair.successor) && pair.ancestor !== pair.successor && isPatchId(pair.patchId);
810
+ }
811
+ /**
812
+ * ⛔ PER-GROUP BOUND (VL, 2026-10-08). A SHORT ack (the header is present but `n < sent`) is a real signal the
813
+ * server has an issue with THIS group — unlike an ABSENT ack (an old server, which says nothing at all and is
814
+ * never counted: "keeping is right there"). Each short ack counts ONE attempt, by identity, append-only like the
815
+ * `delivered` mark. After `INFERRED_SET_ASIDE_AFTER` attempts the group is given up on (`setAsideInferredGroup`),
816
+ * so a persistently-refused group blocks its neighbours for at most that many hook cycles, never forever.
817
+ */
818
+ export const INFERRED_SET_ASIDE_AFTER = 3;
819
+ /** Record one short-ack attempt against a group, by identity. Returns the attempt count AFTER this one. ⛔ NEVER THROWS. */
820
+ export function recordInferredAttempt(home, key, group) {
821
+ const path = inferredSpoolPath(home, key);
822
+ if (path === null)
823
+ return 0;
824
+ try {
825
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
826
+ const identity = identityOf(group);
827
+ const before = readStored(path).attempts.get(identity) ?? 0;
828
+ appendFileSync(path, `${JSON.stringify({ attempt: identity })}\n`, { mode: 0o600 });
829
+ chmodSync(path, 0o600);
830
+ return before + 1;
831
+ }
832
+ catch {
833
+ return 0;
834
+ }
835
+ }
836
+ /**
837
+ * Give up on a group: it is never offered again (`readInferred` excludes it), but it is NOT deleted — it stays on
838
+ * disk, covered by the 7-day orphan prune like everything else here. Not counted by a live gauge here (VL,
839
+ * 2026-10-08, VG's double-count finding, item (b)): `recordInferredSkipped(..., "set_aside", ...)` at the call
840
+ * site is the ONE place this is counted, so a set-aside group is a sentence exactly once, never twice. ⛔ NEVER
841
+ * THROWS.
842
+ */
843
+ export function setAsideInferredGroup(home, key, group, reason) {
844
+ const path = inferredSpoolPath(home, key);
845
+ if (path === null)
846
+ return false;
847
+ try {
848
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
849
+ appendFileSync(path, `${JSON.stringify({ set_aside: identityOf(group), reason })}\n`, { mode: 0o600 });
850
+ chmodSync(path, 0o600);
851
+ return true;
852
+ }
853
+ catch {
854
+ return false;
855
+ }
856
+ }
857
+ /**
858
+ * Whether EVERY group ever spooled for this session is resolved — delivered or set aside, none still pending —
859
+ * so the file can be deleted outright rather than waiting on the 7-day prune. Called ONLY by `finalize` after its
860
+ * drain loop ends, and ONLY because the session is then over: no concurrent `appendInferred` can still be running
861
+ * for a session finalize just confirmed is finished (the hook that would call it is the one deciding the session
862
+ * is over).
863
+ */
864
+ export function inferredFullyResolved(home, key) {
865
+ const path = inferredSpoolPath(home, key);
866
+ if (path === null)
867
+ return false;
868
+ const { groups, delivered, setAside } = readStored(path);
869
+ if (groups.length === 0)
870
+ return false; // nothing to resolve is not the same as everything resolved
871
+ return groups.every((g) => delivered.has(identityOf(g)) || setAside.has(identityOf(g)));
872
+ }
873
+ /** Delete a fully-resolved inferred spool file outright, rather than leaving dead marks for the 7-day prune to find. ⛔ NEVER THROWS. */
874
+ export function deleteInferredSpool(home, key) {
875
+ const path = inferredSpoolPath(home, key);
876
+ if (path === null)
877
+ return false;
878
+ try {
879
+ rmSync(path, { force: true });
880
+ return true;
881
+ }
882
+ catch {
883
+ return false;
884
+ }
885
+ }
886
+ // ---------------------------------------------------------------------------
887
+ // ⭐ VL, 2026-10-08, item 5: `.inferred.jsonl` older than 7 days gets the SAME prune MECHANISM as `.pending.jsonl`
888
+ // (`pending_prune.ts`) — tombstone-rename, re-read, put back anything that was not actually expired. A SEPARATE
889
+ // function rather than a generalisation of that one: `pending_prune.ts`'s restore-merge dedups by `sha` (a field
890
+ // `.inferred.jsonl` lines do not have — every line would read as `sha: null` and collide), so reusing it unmodified
891
+ // would silently drop a legitimately-recovered line on every restore. The rule that DOES transfer: a mark line
892
+ // (`delivered`/`attempt`/`set_aside`) is safe to keep from BOTH the tombstone and a concurrently-created live file —
893
+ // duplicating a mark is harmless (sets) or correct (an attempt really happened twice); only a GROUP line must be
894
+ // deduped by identity, or its ancestors would be counted twice.
895
+ // ---------------------------------------------------------------------------
896
+ export const INFERRED_PRUNE_TTL_MS = 7 * 24 * 3600_000;
897
+ const INFERRED_TOMBSTONE = /^(.+\.inferred\.jsonl)\.expired-(\d+)$/;
898
+ const INFERRED_TOMBSTONE_GRACE_MS = 3600_000;
899
+ const INFERRED_PRUNE_MAX_FILES = 50;
900
+ function settleInferredLine(line, liveGroupIdentities) {
901
+ const parsed = parseStoredLine(line);
902
+ if (parsed === null)
903
+ return null; // a torn line in a tombstone is dropped, like everywhere else here
904
+ if (parsed.kind === "group" && liveGroupIdentities.has(identityOf(parsed.group)))
905
+ return null; // already present live
906
+ return line;
907
+ }
908
+ /** Merge a tombstone's lines back onto its original path, which a concurrent `appendInferred` may have recreated. */
909
+ function restoreInferred(tomb, original, raw) {
910
+ let live = null;
911
+ try {
912
+ live = readFileSync(original, "utf8");
913
+ }
914
+ catch {
915
+ /* nothing there */
916
+ }
917
+ if (live === null) {
918
+ renameSync(tomb, original);
919
+ return;
920
+ }
921
+ const liveGroups = new Set();
922
+ for (const l of live.split("\n")) {
923
+ if (l.trim() === "")
924
+ continue;
925
+ const p = parseStoredLine(l);
926
+ if (p?.kind === "group")
927
+ liveGroups.add(identityOf(p.group));
928
+ }
929
+ for (const l of raw.split("\n")) {
930
+ if (l.trim() === "")
931
+ continue;
932
+ const kept = settleInferredLine(l, liveGroups);
933
+ if (kept !== null)
934
+ appendFileSync(original, `${kept}\n`);
935
+ }
936
+ rmSync(tomb, { force: true });
937
+ }
938
+ /**
939
+ * Examine one tombstone: delete it if still expired by mtime, else restore it. Returns the GROUP lines discarded
940
+ * (`groups`) and the ANCESTOR PAIRS they carried (`pairs` — VL, 2026-10-08, VG's silent-loss finding: the caller
941
+ * tallies `pairs`, not `groups`, so a durable record survives the delete at the same GRANULARITY as the live
942
+ * gauges it replaces). Marks are not counted either way: they are not themselves data loss.
943
+ *
944
+ * ⛔⛔ COUNTS ONLY GROUPS WITH NEITHER A `delivered` NOR A `set_aside` MARK (VL, 2026-10-08, VG's double-count
945
+ * finding, item (a)). A group is removed from `readInferred`'s offer the moment it is delivered or set aside, but
946
+ * its GROUP LINE stays on disk — only `finalize`'s `inferredFullyResolved`/`deleteInferredSpool` deletes the
947
+ * whole file outright, and most sessions are never finalized. So an ordinary, successfully-delivered 40-pair
948
+ * squash sits in a file that ages past 7 days on the COMMON path, and the old code counted its group line as a
949
+ * fresh discard: a real server stored all 40, and a week later `status` announced 40 pairs "discarded after 7
950
+ * days with no acknowledgement" — a false loss report for a delivery that succeeded. A set-aside group has the
951
+ * SAME shape for a different reason: it was already tallied as `set_aside` at the moment `setAsideInferredGroup`
952
+ * ran, so counting it again here as `pruned` would double-announce the same 40 pairs under two reasons. Only a
953
+ * group with NEITHER mark is a genuine, newly-discovered loss — never offered, never resolved, now expired.
954
+ */
955
+ function settleInferred(tomb, original, nowMs) {
956
+ let raw;
957
+ let mtimeMs;
958
+ try {
959
+ raw = readFileSync(tomb, "utf8");
960
+ mtimeMs = statSync(tomb).mtimeMs;
961
+ }
962
+ catch {
963
+ return { groups: 0, pairs: 0 };
964
+ }
965
+ if (!(nowMs - mtimeMs > INFERRED_PRUNE_TTL_MS)) {
966
+ restoreInferred(tomb, original, raw);
967
+ return { groups: 0, pairs: 0 };
968
+ }
969
+ rmSync(tomb, { force: true });
970
+ const resolved = new Set();
971
+ const unresolvedGroups = [];
972
+ for (const l of raw.split("\n")) {
973
+ if (l.trim() === "")
974
+ continue;
975
+ const p = parseStoredLine(l);
976
+ if (p === null)
977
+ continue;
978
+ if (p.kind === "delivered" || p.kind === "set_aside")
979
+ resolved.add(p.identity);
980
+ else if (p.kind === "group")
981
+ unresolvedGroups.push(p.group);
982
+ }
983
+ let groups = 0;
984
+ let pairs = 0;
985
+ for (const g of unresolvedGroups) {
986
+ if (resolved.has(identityOf(g)))
987
+ continue; // delivered, or already tallied as set_aside — not a new loss
988
+ groups += 1;
989
+ pairs += g.ancestors.length;
990
+ }
991
+ return { groups, pairs };
992
+ }
993
+ /**
994
+ * Prune `.inferred.jsonl` files whose mtime is more than 7 days old — the file as a whole, by the SAME rename
995
+ * -then-settle discipline as `pruneOrphanPending` (see that function's header for the race it narrows). Called from
996
+ * the Stop/SessionEnd hooks only, alongside `pruneOrphanPending`, never from `post-commit` or `status`.
997
+ *
998
+ * ⛔⛔ `pairs` is new (VL, 2026-10-08, VG's silent-loss finding): the CALLER must feed it to
999
+ * `recordInferredSkipped(..., "pruned", ..., pairs)` so a discard still shows up in `status` after this function's
1000
+ * own rename-then-delete removes the file the live gauges read. `lines` (group count) is kept as `pruneExpiredInferred`
1001
+ * already returned it and an existing test pins that shape; `pairs` is the one the tally wants.
1002
+ */
1003
+ export function pruneExpiredInferred(home, repoKey, nowMs) {
1004
+ let files = 0;
1005
+ let lines = 0;
1006
+ let pairs = 0;
1007
+ try {
1008
+ const dir = repoSessionsDir(home, repoKey);
1009
+ if (dir === null)
1010
+ return { files, lines, pairs };
1011
+ const entries = readdirSync(dir).sort();
1012
+ let budget = INFERRED_PRUNE_MAX_FILES;
1013
+ for (const entry of entries) {
1014
+ if (budget <= 0)
1015
+ break;
1016
+ try {
1017
+ const t = INFERRED_TOMBSTONE.exec(entry);
1018
+ if (t !== null) {
1019
+ if (nowMs - Number(t[2]) <= INFERRED_TOMBSTONE_GRACE_MS)
1020
+ continue;
1021
+ budget -= 1;
1022
+ const n = settleInferred(join(dir, entry), join(dir, t[1]), nowMs);
1023
+ if (n.groups > 0) {
1024
+ files += 1;
1025
+ lines += n.groups;
1026
+ pairs += n.pairs;
1027
+ }
1028
+ continue;
1029
+ }
1030
+ if (!entry.endsWith(".inferred.jsonl"))
1031
+ continue;
1032
+ const path = join(dir, entry);
1033
+ const mtimeMs = statSync(path).mtimeMs;
1034
+ if (!(nowMs - mtimeMs > INFERRED_PRUNE_TTL_MS))
1035
+ continue;
1036
+ budget -= 1;
1037
+ const tomb = `${path}.expired-${nowMs}`;
1038
+ renameSync(path, tomb);
1039
+ const n = settleInferred(tomb, path, nowMs);
1040
+ if (n.groups > 0) {
1041
+ files += 1;
1042
+ lines += n.groups;
1043
+ pairs += n.pairs;
1044
+ }
1045
+ }
1046
+ catch {
1047
+ /* one unreadable entry must not stop the pass */
1048
+ }
1049
+ }
1050
+ }
1051
+ catch {
1052
+ /* never throw from a hook */
1053
+ }
1054
+ return { files, lines, pairs };
1055
+ }
591
1056
  // ---------------------------------------------------------------------------
592
1057
  // ⭐ TODOS[169] — the DANGLING INTERMEDIATE of an interactive-rebase squash.
593
1058
  //