@vibe-commit/capture 0.9.0 → 0.11.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 (88) hide show
  1. package/README.md +49 -1
  2. package/dist/agent_hooks.js +2 -2
  3. package/dist/cli.d.ts +35 -0
  4. package/dist/cli.js +412 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/backfill.js +19 -2
  7. package/dist/commands/backfill.js.map +1 -1
  8. package/dist/commands/finalize.d.ts +37 -1
  9. package/dist/commands/finalize.js +131 -29
  10. package/dist/commands/finalize.js.map +1 -1
  11. package/dist/commands/status.js +7 -0
  12. package/dist/commands/status.js.map +1 -1
  13. package/dist/commands/status_machine.js +5 -0
  14. package/dist/commands/status_machine.js.map +1 -1
  15. package/dist/commands/upload_worker.d.ts +63 -0
  16. package/dist/commands/upload_worker.js +336 -0
  17. package/dist/commands/upload_worker.js.map +1 -0
  18. package/dist/commands/why.d.ts +19 -1
  19. package/dist/commands/why.js +35 -3
  20. package/dist/commands/why.js.map +1 -1
  21. package/dist/commit_files.d.ts +64 -0
  22. package/dist/commit_files.js +83 -0
  23. package/dist/commit_files.js.map +1 -0
  24. package/dist/copy/strings.d.ts +34 -14
  25. package/dist/copy/strings.js +51 -14
  26. package/dist/copy/strings.js.map +1 -1
  27. package/dist/drain.d.ts +68 -0
  28. package/dist/drain.js +234 -0
  29. package/dist/drain.js.map +1 -0
  30. package/dist/firing.d.ts +169 -0
  31. package/dist/firing.js +334 -0
  32. package/dist/firing.js.map +1 -0
  33. package/dist/hooks/entry.d.ts +171 -26
  34. package/dist/hooks/entry.js +335 -507
  35. package/dist/hooks/entry.js.map +1 -1
  36. package/dist/hooks/fire_lock.d.ts +36 -87
  37. package/dist/hooks/fire_lock.js +40 -99
  38. package/dist/hooks/fire_lock.js.map +1 -1
  39. package/dist/hooks/post_commit.d.ts +11 -0
  40. package/dist/hooks/post_commit.js +16 -8
  41. package/dist/hooks/post_commit.js.map +1 -1
  42. package/dist/identity.d.ts +32 -0
  43. package/dist/identity.js +38 -0
  44. package/dist/identity.js.map +1 -0
  45. package/dist/index.d.ts +3 -17
  46. package/dist/index.js +59 -450
  47. package/dist/index.js.map +1 -1
  48. package/dist/paths.d.ts +18 -0
  49. package/dist/paths.js +31 -0
  50. package/dist/paths.js.map +1 -1
  51. package/dist/pending_prune.d.ts +2 -2
  52. package/dist/pending_prune.js +2 -2
  53. package/dist/policy.d.ts +17 -0
  54. package/dist/policy.js +7 -0
  55. package/dist/policy.js.map +1 -1
  56. package/dist/post.d.ts +63 -4
  57. package/dist/post.js +76 -24
  58. package/dist/post.js.map +1 -1
  59. package/dist/replay.d.ts +167 -0
  60. package/dist/replay.js +301 -0
  61. package/dist/replay.js.map +1 -0
  62. package/dist/session_targets.d.ts +12 -0
  63. package/dist/session_targets.js +6 -1
  64. package/dist/session_targets.js.map +1 -1
  65. package/dist/spool.d.ts +55 -21
  66. package/dist/spool.js +182 -38
  67. package/dist/spool.js.map +1 -1
  68. package/dist/spool_union.d.ts +18 -2
  69. package/dist/spool_union.js +23 -2
  70. package/dist/spool_union.js.map +1 -1
  71. package/dist/state.js +21 -14
  72. package/dist/state.js.map +1 -1
  73. package/dist/stdin.d.ts +2 -0
  74. package/dist/stdin.js +13 -0
  75. package/dist/stdin.js.map +1 -0
  76. package/dist/uploader_status.d.ts +34 -0
  77. package/dist/uploader_status.js +25 -0
  78. package/dist/uploader_status.js.map +1 -0
  79. package/dist/uploader_view.d.ts +39 -0
  80. package/dist/uploader_view.js +106 -0
  81. package/dist/uploader_view.js.map +1 -0
  82. package/dist/verbs.d.ts +11 -0
  83. package/dist/verbs.js +13 -0
  84. package/dist/verbs.js.map +1 -0
  85. package/dist/version.d.ts +1 -1
  86. package/dist/version.js +1 -1
  87. package/dist/version.js.map +1 -1
  88. package/package.json +2 -2
package/dist/spool.d.ts CHANGED
@@ -31,14 +31,15 @@
31
31
  * only when that question has an answer, and the answer carries the rung that
32
32
  * produced it so the server can tell an observation from a guess.
33
33
  *
34
- * ## The line is deliberately small, and the file list stays here
34
+ * ## The line is deliberately small; the file list rides in the BODY, never a header
35
35
  *
36
- * `{sha, branch, at, files}`. Only the SHA reaches the wire in wave 1 (`CR-170`
36
+ * `{sha, branch, at, files}`. Only the SHA reaches a HEADER (`CR-170`
37
37
  * §4): commit messages were MEASURED at 9/20, 8/20 and 5/20 commits over 4096
38
38
  * bytes across the three repos, largest 12,112, so a quarter of real commits
39
39
  * would land permanently truncated in a header. Metadata is backfillable from
40
- * the sha; the observation is not. `files` is carried for wave 2 and never put
41
- * in a header.
40
+ * the sha; the observation is not. `files` is git's changed-file list; since 0.10.0 it
41
+ * goes out in the length-framed body trailer of `commit_files.ts` (granted
42
+ * repositories only) and is never put in a header.
42
43
  */
43
44
  import { type PendingMode, type TurnStart } from "./pending_bound.js";
44
45
  import { type SessionKey } from "./paths.js";
@@ -132,7 +133,7 @@ export interface SpoolEntry {
132
133
  readonly branch: string | null;
133
134
  /** ISO-8601, from git's own `%cI`. */
134
135
  readonly at: string;
135
- /** Paths touched. Wave 2. ⛔ Never a header. */
136
+ /** Paths touched, git's own list. Sent in the body trailer since 0.10.0 (`commit_files.ts`). ⛔ Never a header. */
136
137
  readonly files: readonly string[];
137
138
  /**
138
139
  * ⛔ **HOW THE SESSION ON THIS LINE WAS DECIDED.** Recorded at OBSERVATION
@@ -141,9 +142,13 @@ export interface SpoolEntry {
141
142
  */
142
143
  readonly attribution: AttributionRung;
143
144
  /**
144
- * `TODOS[176]` — the WALL CLOCK (ms since the epoch) of the `post-commit` process when it spooled this line. Written
145
- * on PENDING lines only; `pending_bound.ts` compares it with the start of the turn being delivered (the first unsent prompt-like record). Additive: a
146
- * 0.4.0 line has none and is promoted as before. Never the committer date (settable).
145
+ * `TODOS[176]` — the WALL CLOCK (ms since the epoch) of the `post-commit` process when it spooled this line. `pending_bound.ts`
146
+ * compares it with the start of the turn being delivered (the first unsent prompt-like record). Never the committer date (settable).
147
+ *
148
+ * U2: written on EVERY line now, spool and pending alike, and kept by promotion. The upload worker replays a firing "as of" the moment
149
+ * its hook ran, and the commit lines that firing may carry are exactly those with `observedAt <= firing.at`; a later commit must ride a
150
+ * later delivery. A line from an older client has none and counts as OLD: it is carried by the first delivery that reads it, so a
151
+ * spool of legacy lines still drains (`undefined <= firing.at` is false in JS, which is why callers must test for absence first).
147
152
  */
148
153
  readonly observedAt?: number;
149
154
  }
@@ -216,6 +221,8 @@ export declare function appendSpool(home: string, key: SessionKey, entry: SpoolE
216
221
  * and losing one observation beats losing the whole spool.
217
222
  */
218
223
  export declare function readSpool(home: string, key: SessionKey): SpoolEntry[];
224
+ /** How many lines of `raw` are well-formed commit entries (the unit `count` is in). `cap` bounds the answer (a pending file is capped). */
225
+ export declare function wellFormedCommitLines(raw: string, cap?: number): number;
219
226
  /**
220
227
  * Drop the first `count` entries — called ONLY after the server's 2xx.
221
228
  *
@@ -223,15 +230,17 @@ export declare function readSpool(home: string, key: SessionKey): SpoolEntry[];
223
230
  * `post-commit` may have appended while the send was in flight. Slicing the
224
231
  * array we read BEFORE the send would rewrite the file without those lines and
225
232
  * lose them silently. Appends only ever go to the END, so the first `count`
226
- * lines are still the same ones we delivered, and dropping exactly that many is
227
- * correct against the current file rather than against a stale snapshot.
233
+ * well-formed lines are still the same ones we delivered, and dropping exactly
234
+ * that many is correct against the current file rather than against a stale snapshot.
228
235
  *
229
- * ⚠ **A residual race remains and is accepted:** a commit landing between this
230
- * re-read and the write is lost. The window is one `readFileSync` plus one
236
+ * ⚠ **A residual race remains and is accepted (D14):** a commit landing between this
237
+ * re-read and the end of the write is lost. The window is one `readFileSync` plus one
231
238
  * `writeFileSync` on a small file, the cost is one un-delivered commit row, and
232
239
  * closing it properly means a lock file on a path that must never block
233
240
  * (`post-commit` runs inside the user's `git commit`). Recorded rather than
234
- * hidden.
241
+ * hidden. The worker runs this mid-turn rather than at an idle Stop, so the window is
242
+ * hit more often than it was; the upgrade path if a lost commit is ever observed is
243
+ * one file per commit.
235
244
  */
236
245
  export declare function dropSpooled(home: string, key: SessionKey, count: number): boolean;
237
246
  /**
@@ -246,10 +255,10 @@ export declare function dropSpooled(home: string, key: SessionKey, count: number
246
255
  * BOUND one — and a bound sha resent into a sealed turn is a second, permanent
247
256
  * edge (`cr071:157`). Only the per-line decision is right.
248
257
  *
249
- * ⚠ THE SAME LINE ARITHMETIC AS `dropSpooled`, deliberately: it works on raw
250
- * non-empty lines, because `count` was computed from them via `capSpool`. A line
251
- * that does not parse, or carries a rung that never goes on the wire, is dropped
252
- * exactly as `dropSpooled` would drop it — the server cannot have named it.
258
+ * ⚠ THE SAME WALK AS `dropSpooled` (U2): `count` is a number of WELL-FORMED lines, as `capSpool` computes it, so the consumed region
259
+ * ends after the `count`-th well-formed line and a malformed line inside it is dropped with it. (This docblock used to say `count` was
260
+ * computed from raw lines. It was not.) A well-formed line that carries a rung that never goes on the wire is dropped exactly as
261
+ * `dropSpooled` would drop it — the server cannot have named it.
253
262
  *
254
263
  * ⚠ The residual race `dropSpooled` documents applies here unchanged.
255
264
  */
@@ -357,6 +366,11 @@ export interface Observed {
357
366
  readonly shas: readonly string[];
358
367
  /** ⛔ Same length as `shas`, same order. Wire rungs only. */
359
368
  readonly attributions: readonly AttributionRung[];
369
+ /**
370
+ * ⛔ Same length as `shas`, same order: git's changed-file list for each sha, UNCHANGED (E8). It never reaches a header;
371
+ * `commit_files.ts` turns it into the body trailer, and only for a granted repository.
372
+ */
373
+ readonly files: readonly (readonly string[])[];
360
374
  /** Spool lines consumed — ⛔ **held ones INCLUDED, so they drain.** */
361
375
  readonly count: number;
362
376
  }
@@ -434,7 +448,11 @@ export declare function rewriteSpoolPath(home: string, key: SessionKey): string
434
448
  export declare function appendRewrites(home: string, key: SessionKey, pairs: readonly RewritePair[]): number;
435
449
  /** Every well-formed pair in the rewrite spool, oldest first. */
436
450
  export declare function readRewrites(home: string, key: SessionKey): RewritePair[];
437
- /** Drop the first `count` pairs — called ONLY after the server's 2xx. */
451
+ /**
452
+ * Drop the first `count` pairs — called ONLY after the server's 2xx. `count` is a number of WELL-FORMED pairs (`capSuccessors(readRewrites)`),
453
+ * so it walks like `dropSpooled` does (U2, trap 8): a malformed line among the consumed ones is passed over, never counted. A re-sent pair is
454
+ * harmless on the server (`commit_sha_successors` treats a duplicate as success) but the arithmetic was the same defect.
455
+ */
438
456
  export declare function dropRewrites(home: string, key: SessionKey, count: number): boolean;
439
457
  /**
440
458
  * The pairs this hook will carry, and how many to drop after a 2xx.
@@ -594,6 +612,10 @@ export declare function isFullSha(value: unknown): value is string;
594
612
  export declare const MAX_PENDING_SHAS = 16;
595
613
  /** `<repo>/<session>.pending.jsonl`, beside the commit spool. */
596
614
  export declare function pendingSpoolPath(home: string, key: SessionKey): string | null;
615
+ /** Test seam (precedent: `PruneSeams` in `pending_prune.ts`): runs between `appendPending`'s dedupe read and its append, exactly where a promotion would land. */
616
+ export interface PendingSeams {
617
+ readonly afterDedupeRead?: () => void;
618
+ }
597
619
  /**
598
620
  * Record an uncorroborated observation. Returns whether it was written.
599
621
  *
@@ -602,9 +624,15 @@ export declare function pendingSpoolPath(home: string, key: SessionKey): string
602
624
  * would promote into two spool entries and two permanent rows —
603
625
  * `capture_commits`' PK is `(org_id, capture_id, commit_sha)`, so two capture
604
626
  * ids are two legal rows for one commit.
627
+ *
628
+ * ⛔ **A REAL APPEND, NOT A READ-MODIFY-WRITE (U2, trap 6).** This used to read the file, add the entry and WRITE THE WHOLE FILE BACK.
629
+ * Trace: pending holds [B]; `post-commit` for D reads [B]; the worker promotes B and removes the file; `post-commit` writes [B, D]. The
630
+ * next firing promotes B AGAIN, and once the first B is delivered that is a second permanent row. Now it reads only to dedupe, then
631
+ * `appendFileSync`s one line: a promotion that emptied the file in between leaves D alone in a fresh file, and B is never resurrected.
632
+ * The cap (`MAX_PENDING_SHAS`) moved to `readPending` and to promotion's own rewrite, so an append never has to rewrite anything.
605
633
  */
606
- export declare function appendPending(home: string, key: SessionKey, entry: SpoolEntry): boolean;
607
- /** Every uncorroborated entry for this session, oldest first. */
634
+ export declare function appendPending(home: string, key: SessionKey, entry: SpoolEntry, seams?: PendingSeams): boolean;
635
+ /** Every uncorroborated entry for this session, oldest first, at most the newest `MAX_PENDING_SHAS` (the cap lives HERE since `appendPending` no longer rewrites). */
608
636
  export declare function readPending(home: string, key: SessionKey): SpoolEntry[];
609
637
  /** Remove the pending file entirely. Used once its lines have been promoted. */
610
638
  export declare function clearPending(home: string, key: SessionKey): boolean;
@@ -652,4 +680,10 @@ corroboration: "this-session-is-running",
652
680
  * session's state file already existed (`stale-own`) or not (`first-turn`). A line observed before the turn start is DROPPED;
653
681
  * when no turn start is found a stale-own line is dropped and a first-turn line kept. See `pending_bound.ts`.
654
682
  */
655
- bound: TurnStart, mode: PendingMode): number;
683
+ bound: TurnStart, mode: PendingMode,
684
+ /**
685
+ * U2 — **THE MOMENT THE HOOK FIRED**, epoch ms. A worker replays a firing late; a commit observed AFTER the firing belongs to a later
686
+ * delivery, so a pending line with `observedAt > asOfMs` is neither promoted nor dropped: it stays in the file. Unset means "now" and
687
+ * every line is due, which is what the inline hook always meant. A line with NO `observedAt` (an older client) is always due.
688
+ */
689
+ asOfMs?: number): number;
package/dist/spool.js CHANGED
@@ -31,20 +31,22 @@
31
31
  * only when that question has an answer, and the answer carries the rung that
32
32
  * produced it so the server can tell an observation from a guess.
33
33
  *
34
- * ## The line is deliberately small, and the file list stays here
34
+ * ## The line is deliberately small; the file list rides in the BODY, never a header
35
35
  *
36
- * `{sha, branch, at, files}`. Only the SHA reaches the wire in wave 1 (`CR-170`
36
+ * `{sha, branch, at, files}`. Only the SHA reaches a HEADER (`CR-170`
37
37
  * §4): commit messages were MEASURED at 9/20, 8/20 and 5/20 commits over 4096
38
38
  * bytes across the three repos, largest 12,112, so a quarter of real commits
39
39
  * would land permanently truncated in a header. Metadata is backfillable from
40
- * the sha; the observation is not. `files` is carried for wave 2 and never put
41
- * in a header.
40
+ * the sha; the observation is not. `files` is git's changed-file list; since 0.10.0 it
41
+ * goes out in the length-framed body trailer of `commit_files.ts` (granted
42
+ * repositories only) and is never put in a header.
42
43
  */
43
44
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
44
45
  import { dirname, join } from "node:path";
45
46
  import { keepsPendingLine } from "./pending_bound.js";
46
47
  import { repoKeyNotePath, repoSessionsDir, sessionStatePath } from "./paths.js";
47
48
  import { writeFileAtomic } from "./atomic_write.js";
49
+ import { liveAtOf } from "./firing.js";
48
50
  /**
49
51
  * How many SHAs one hook may put on the wire.
50
52
  *
@@ -164,6 +166,37 @@ export function readSpool(home, key) {
164
166
  }
165
167
  return out;
166
168
  }
169
+ /**
170
+ * ⛔ **THE ONE WALK BEHIND EVERY SPOOL DROP (U2, trap 8).** `count` is a number of WELL-FORMED lines: `readSpool` skips a line that does
171
+ * not parse and `capSpool` counts the rest. The drops used to apply that count to RAW non-empty lines, so one malformed line among
172
+ * the consumed ones shifted the cut by one and left a DELIVERED commit at the head, which went out again on a new capture:
173
+ * `capture_commits`' key is `(org_id, capture_id, commit_sha)` and the write is a plain insert, so that is a SECOND PERMANENT ROW.
174
+ * (Raw [A, torn, B, C] sent A, B, C with count 3; `slice(3)` left C. Measured on 0.10.0.)
175
+ *
176
+ * The walk goes through raw non-empty lines, consuming a malformed one for free (it was never counted and can never be sent) and
177
+ * stopping after the `count`-th well-formed one. `consumed` is everything up to and including it; `rest` is the remaining raw text,
178
+ * untouched. A malformed line AFTER the cut stays in `rest`: it belongs to the next drop's walk.
179
+ */
180
+ function walkConsumed(raw, count, wellFormed) {
181
+ const lines = raw.split("\n").filter((l) => l.trim() !== "");
182
+ let seen = 0;
183
+ let cut = 0;
184
+ while (cut < lines.length && seen < count) {
185
+ if (wellFormed(lines[cut]))
186
+ seen += 1;
187
+ cut += 1;
188
+ }
189
+ return { consumed: lines.slice(0, cut), rest: lines.slice(cut) };
190
+ }
191
+ const isEntryLine = (line) => parseEntry(line) !== null;
192
+ /** How many lines of `raw` are well-formed commit entries (the unit `count` is in). `cap` bounds the answer (a pending file is capped). */
193
+ export function wellFormedCommitLines(raw, cap = Infinity) {
194
+ let n = 0;
195
+ for (const line of raw.split("\n"))
196
+ if (line.trim() !== "" && isEntryLine(line))
197
+ n += 1;
198
+ return Math.min(n, cap);
199
+ }
167
200
  /**
168
201
  * Drop the first `count` entries — called ONLY after the server's 2xx.
169
202
  *
@@ -171,23 +204,25 @@ export function readSpool(home, key) {
171
204
  * `post-commit` may have appended while the send was in flight. Slicing the
172
205
  * array we read BEFORE the send would rewrite the file without those lines and
173
206
  * lose them silently. Appends only ever go to the END, so the first `count`
174
- * lines are still the same ones we delivered, and dropping exactly that many is
175
- * correct against the current file rather than against a stale snapshot.
207
+ * well-formed lines are still the same ones we delivered, and dropping exactly
208
+ * that many is correct against the current file rather than against a stale snapshot.
176
209
  *
177
- * ⚠ **A residual race remains and is accepted:** a commit landing between this
178
- * re-read and the write is lost. The window is one `readFileSync` plus one
210
+ * ⚠ **A residual race remains and is accepted (D14):** a commit landing between this
211
+ * re-read and the end of the write is lost. The window is one `readFileSync` plus one
179
212
  * `writeFileSync` on a small file, the cost is one un-delivered commit row, and
180
213
  * closing it properly means a lock file on a path that must never block
181
214
  * (`post-commit` runs inside the user's `git commit`). Recorded rather than
182
- * hidden.
215
+ * hidden. The worker runs this mid-turn rather than at an idle Stop, so the window is
216
+ * hit more often than it was; the upgrade path if a lost commit is ever observed is
217
+ * one file per commit.
183
218
  */
184
219
  export function dropSpooled(home, key, count) {
185
220
  const path = spoolPath(home, key);
186
221
  if (path === null || count <= 0)
187
222
  return false;
188
223
  try {
189
- const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim() !== "");
190
- writeFileSync(path, lines.slice(count).map((l) => `${l}\n`).join(""), { mode: 0o600 });
224
+ const { rest } = walkConsumed(readFileSync(path, "utf8"), count, isEntryLine);
225
+ writeFileSync(path, rest.map((l) => `${l}\n`).join(""), { mode: 0o600 });
191
226
  chmodSync(path, 0o600);
192
227
  return true;
193
228
  }
@@ -207,10 +242,10 @@ export function dropSpooled(home, key, count) {
207
242
  * BOUND one — and a bound sha resent into a sealed turn is a second, permanent
208
243
  * edge (`cr071:157`). Only the per-line decision is right.
209
244
  *
210
- * ⚠ THE SAME LINE ARITHMETIC AS `dropSpooled`, deliberately: it works on raw
211
- * non-empty lines, because `count` was computed from them via `capSpool`. A line
212
- * that does not parse, or carries a rung that never goes on the wire, is dropped
213
- * exactly as `dropSpooled` would drop it — the server cannot have named it.
245
+ * ⚠ THE SAME WALK AS `dropSpooled` (U2): `count` is a number of WELL-FORMED lines, as `capSpool` computes it, so the consumed region
246
+ * ends after the `count`-th well-formed line and a malformed line inside it is dropped with it. (This docblock used to say `count` was
247
+ * computed from raw lines. It was not.) A well-formed line that carries a rung that never goes on the wire is dropped exactly as
248
+ * `dropSpooled` would drop it — the server cannot have named it.
214
249
  *
215
250
  * ⚠ The residual race `dropSpooled` documents applies here unchanged.
216
251
  */
@@ -219,12 +254,12 @@ export function settleSpooled(home, key, count, keep) {
219
254
  if (path === null || count <= 0)
220
255
  return false;
221
256
  try {
222
- const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim() !== "");
223
- const kept = lines.slice(0, count).filter((line) => {
257
+ const { consumed, rest } = walkConsumed(readFileSync(path, "utf8"), count, isEntryLine);
258
+ const kept = consumed.filter((line) => {
224
259
  const entry = parseEntry(line);
225
260
  return entry !== null && WIRE_RUNGS.includes(entry.attribution) && keep.has(entry.sha);
226
261
  });
227
- writeFileSync(path, [...kept, ...lines.slice(count)].map((l) => `${l}\n`).join(""), { mode: 0o600 });
262
+ writeFileSync(path, [...kept, ...rest].map((l) => `${l}\n`).join(""), { mode: 0o600 });
228
263
  chmodSync(path, 0o600);
229
264
  return true;
230
265
  }
@@ -388,6 +423,11 @@ export function activeSessionFor(home, repoKey, envSessionId, nowMs = Date.now()
388
423
  * field is one number. `state.ts` owns the schema and its docblock; this is the single reader of it outside that module.
389
424
  */
390
425
  function readLiveAt(path) {
426
+ // U3: through the ONE liveness reader (`firing.ts`): the later of the state file's `liveAt` and the hook's own `.live` stamp, which sits
427
+ // beside it. A session whose hook has stamped but whose worker has not yet delivered anything is live all the same.
428
+ return liveAtOf(readStateLiveAt(path), path.replace(/\.json$/, ".live"));
429
+ }
430
+ function readStateLiveAt(path) {
391
431
  try {
392
432
  const v = JSON.parse(readFileSync(path, "utf8")).liveAt;
393
433
  return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
@@ -423,12 +463,20 @@ function liveSessions(home, repoKey, nowMs, windowMs) {
423
463
  return [];
424
464
  }
425
465
  const live = [];
466
+ // U3: a session is a state file (`<sid>.json`) OR a hook stamp (`<sid>.live`), listed once. A stamp with no state file is a session whose
467
+ // hook has run and whose worker has not yet delivered: on Codex and Cursor (no env session id) its commit must still find it live.
468
+ const stems = new Set();
426
469
  for (const entry of entries) {
427
- // ⚠ State files only. `.spool.jsonl` and `.rewrites.jsonl` live here too and
428
- // are not sessions — and neither ends with `.json`, which is the same
470
+ // ⚠ State files and `.live` stamps only. `.spool.jsonl` and `.rewrites.jsonl` live here too and
471
+ // are not sessions — and neither ends with `.json` or `.live`, which is the same
429
472
  // load-bearing spelling `spoolPath` documents.
430
- if (!entry.endsWith(".json"))
431
- continue;
473
+ if (entry.endsWith(".json"))
474
+ stems.add(entry.slice(0, -".json".length));
475
+ else if (entry.endsWith(".live"))
476
+ stems.add(entry.slice(0, -".live".length));
477
+ }
478
+ for (const stem of stems) {
479
+ const entry = `${stem}.json`;
432
480
  // ⛔ THE CONTENT CLOCK, NEVER THE FILE'S MTIME (VG review 2, D2). `SessionState.liveAt` is written only by a hook;
433
481
  // a `vibecommit finalize` of a long-dead session rewrites the file and moved the mtime, which made that session
434
482
  // "live" and let a plain-terminal commit be filed under it at an edge-minting rung. A file with no `liveAt`
@@ -436,7 +484,7 @@ function liveSessions(home, repoKey, nowMs, windowMs) {
436
484
  const touchedAt = readLiveAt(join(dir, entry));
437
485
  if (touchedAt === null || nowMs - touchedAt > windowMs)
438
486
  continue;
439
- live.push({ sessionId: entry.slice(0, -".json".length), at: touchedAt });
487
+ live.push({ sessionId: stem, at: touchedAt });
440
488
  }
441
489
  // ⚠ Ties broken by name so the guess is at least DETERMINISTIC. Two state files
442
490
  // written in the same millisecond is ordinary on a coarse-grained filesystem,
@@ -491,13 +539,15 @@ export function capSpool(entries) {
491
539
  const taken = entries.slice(0, MAX_SPOOLED_SHAS);
492
540
  const shas = [];
493
541
  const attributions = [];
542
+ const files = [];
494
543
  for (const entry of taken) {
495
544
  if (!WIRE_RUNGS.includes(entry.attribution))
496
545
  continue;
497
546
  shas.push(entry.sha);
498
547
  attributions.push(entry.attribution);
548
+ files.push(entry.files);
499
549
  }
500
- return { shas, attributions, count: taken.length };
550
+ return { shas, attributions, files, count: taken.length };
501
551
  }
502
552
  /**
503
553
  * How many rewrite pairs one hook may put on the wire.
@@ -588,14 +638,18 @@ export function readRewrites(home, key) {
588
638
  }
589
639
  return out;
590
640
  }
591
- /** Drop the first `count` pairs — called ONLY after the server's 2xx. */
641
+ /**
642
+ * Drop the first `count` pairs — called ONLY after the server's 2xx. `count` is a number of WELL-FORMED pairs (`capSuccessors(readRewrites)`),
643
+ * so it walks like `dropSpooled` does (U2, trap 8): a malformed line among the consumed ones is passed over, never counted. A re-sent pair is
644
+ * harmless on the server (`commit_sha_successors` treats a duplicate as success) but the arithmetic was the same defect.
645
+ */
592
646
  export function dropRewrites(home, key, count) {
593
647
  const path = rewriteSpoolPath(home, key);
594
648
  if (path === null || count <= 0)
595
649
  return false;
596
650
  try {
597
- const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim() !== "");
598
- writeFileSync(path, lines.slice(count).map((l) => `${l}\n`).join(""), { mode: 0o600 });
651
+ const { rest } = walkConsumed(readFileSync(path, "utf8"), count, (l) => parsePair(l) !== null);
652
+ writeFileSync(path, rest.map((l) => `${l}\n`).join(""), { mode: 0o600 });
599
653
  chmodSync(path, 0o600);
600
654
  return true;
601
655
  }
@@ -1353,8 +1407,14 @@ export function pendingSpoolPath(home, key) {
1353
1407
  * would promote into two spool entries and two permanent rows —
1354
1408
  * `capture_commits`' PK is `(org_id, capture_id, commit_sha)`, so two capture
1355
1409
  * ids are two legal rows for one commit.
1410
+ *
1411
+ * ⛔ **A REAL APPEND, NOT A READ-MODIFY-WRITE (U2, trap 6).** This used to read the file, add the entry and WRITE THE WHOLE FILE BACK.
1412
+ * Trace: pending holds [B]; `post-commit` for D reads [B]; the worker promotes B and removes the file; `post-commit` writes [B, D]. The
1413
+ * next firing promotes B AGAIN, and once the first B is delivered that is a second permanent row. Now it reads only to dedupe, then
1414
+ * `appendFileSync`s one line: a promotion that emptied the file in between leaves D alone in a fresh file, and B is never resurrected.
1415
+ * The cap (`MAX_PENDING_SHAS`) moved to `readPending` and to promotion's own rewrite, so an append never has to rewrite anything.
1356
1416
  */
1357
- export function appendPending(home, key, entry) {
1417
+ export function appendPending(home, key, entry, seams = {}) {
1358
1418
  const path = pendingSpoolPath(home, key);
1359
1419
  if (path === null)
1360
1420
  return false;
@@ -1362,10 +1422,10 @@ export function appendPending(home, key, entry) {
1362
1422
  const existing = readPending(home, key);
1363
1423
  if (existing.some((e) => e.sha === entry.sha))
1364
1424
  return false;
1365
- const kept = [...existing, entry].slice(-MAX_PENDING_SHAS);
1425
+ seams.afterDedupeRead?.();
1366
1426
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1367
1427
  noteRepoKey(home, key.repoKey);
1368
- writeFileSync(path, kept.map((e) => `${JSON.stringify(e)}\n`).join(""), { mode: 0o600 });
1428
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
1369
1429
  chmodSync(path, 0o600);
1370
1430
  return true;
1371
1431
  }
@@ -1373,7 +1433,7 @@ export function appendPending(home, key, entry) {
1373
1433
  return false;
1374
1434
  }
1375
1435
  }
1376
- /** Every uncorroborated entry for this session, oldest first. */
1436
+ /** Every uncorroborated entry for this session, oldest first, at most the newest `MAX_PENDING_SHAS` (the cap lives HERE since `appendPending` no longer rewrites). */
1377
1437
  export function readPending(home, key) {
1378
1438
  const path = pendingSpoolPath(home, key);
1379
1439
  if (path === null)
@@ -1391,7 +1451,7 @@ export function readPending(home, key) {
1391
1451
  if (entry !== null)
1392
1452
  out.push(entry);
1393
1453
  }
1394
- return out;
1454
+ return out.slice(-MAX_PENDING_SHAS);
1395
1455
  }
1396
1456
  /** Remove the pending file entirely. Used once its lines have been promoted. */
1397
1457
  export function clearPending(home, key) {
@@ -1450,22 +1510,106 @@ corroboration,
1450
1510
  * session's state file already existed (`stale-own`) or not (`first-turn`). A line observed before the turn start is DROPPED;
1451
1511
  * when no turn start is found a stale-own line is dropped and a first-turn line kept. See `pending_bound.ts`.
1452
1512
  */
1453
- bound, mode) {
1513
+ bound, mode,
1514
+ /**
1515
+ * U2 — **THE MOMENT THE HOOK FIRED**, epoch ms. A worker replays a firing late; a commit observed AFTER the firing belongs to a later
1516
+ * delivery, so a pending line with `observedAt > asOfMs` is neither promoted nor dropped: it stays in the file. Unset means "now" and
1517
+ * every line is due, which is what the inline hook always meant. A line with NO `observedAt` (an older client) is always due.
1518
+ */
1519
+ asOfMs = Number.POSITIVE_INFINITY) {
1454
1520
  void corroboration;
1455
1521
  const pending = readPending(home, key);
1456
1522
  if (pending.length === 0)
1457
1523
  return 0;
1458
1524
  let promoted = 0;
1459
1525
  for (const entry of pending) {
1526
+ if (entry.observedAt !== undefined && entry.observedAt > asOfMs)
1527
+ continue;
1460
1528
  if (!keepsPendingLine(entry, bound, mode))
1461
1529
  continue;
1462
- // The pending-only observation clock stays behind: a promoted line is an ordinary spool line.
1463
- const { observedAt: _observedAt, ...rest } = entry;
1464
- void _observedAt;
1465
- if (appendSpool(home, key, { ...rest, attribution: "env_session_id" }))
1530
+ // U2: `observedAt` STAYS on the line. It is the clock the worker pins a delivery by (`observedAt <= firing.at`), and a promoted
1531
+ // line is no exception: a commit made in turn 2 must not ride a delivery capped at turn 1 because it passed through pending.
1532
+ if (insertSpooled(home, key, { ...entry, attribution: "env_session_id" }))
1466
1533
  promoted += 1;
1467
1534
  }
1468
- clearPending(home, key);
1535
+ settlePending(home, key, asOfMs);
1469
1536
  return promoted;
1470
1537
  }
1538
+ /**
1539
+ * U2 — put one promoted line into the spool BY `observedAt`, not at the end.
1540
+ *
1541
+ * The worker promotes late, so turn 2's commit C can already be in the spool when turn 1's commit B is promoted. Appending gives [C, B],
1542
+ * the pin's prefix (`observedAt <= firing.at`) is empty, and B rides turn 2's delivery unheld. Inserted before the first line that is
1543
+ * observed LATER, B is carried by turn 1's delivery. A line with no `observedAt` is neither newer nor older: it stays where it is. A
1544
+ * malformed line is passed over and kept as raw text, and never counts as newer.
1545
+ *
1546
+ * Appends when nothing newer is there (the common case: one `appendFileSync`); rewrites the file, keeping every other line's raw text,
1547
+ * only when something is. The rewrite shares `dropSpooled`'s accepted window (D14).
1548
+ */
1549
+ function insertSpooled(home, key, entry) {
1550
+ const path = spoolPath(home, key);
1551
+ if (path === null)
1552
+ return false;
1553
+ try {
1554
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1555
+ noteRepoKey(home, key.repoKey);
1556
+ const line = JSON.stringify(entry);
1557
+ const at = entry.observedAt;
1558
+ let lines = [];
1559
+ if (at !== undefined) {
1560
+ try {
1561
+ lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim() !== "");
1562
+ }
1563
+ catch {
1564
+ lines = [];
1565
+ }
1566
+ }
1567
+ const newer = at === undefined ? -1 : lines.findIndex((l) => {
1568
+ const e = parseEntry(l);
1569
+ return e !== null && e.observedAt !== undefined && e.observedAt > at;
1570
+ });
1571
+ if (newer === -1) {
1572
+ appendFileSync(path, `${line}\n`, { mode: 0o600 });
1573
+ }
1574
+ else {
1575
+ lines.splice(newer, 0, line);
1576
+ writeFileSync(path, lines.map((l) => `${l}\n`).join(""), { mode: 0o600 });
1577
+ }
1578
+ chmodSync(path, 0o600);
1579
+ return true;
1580
+ }
1581
+ catch {
1582
+ return false;
1583
+ }
1584
+ }
1585
+ /**
1586
+ * After a promotion pass: forget the pending lines that were settled (promoted or dropped), KEEP those observed after `asOfMs`.
1587
+ * Re-reads the file so a line `post-commit` appended since the pass began is judged by its own clock, not lost; with nothing newer
1588
+ * the file is removed, as `clearPending` always did. The kept lines are capped at `MAX_PENDING_SHAS`.
1589
+ */
1590
+ function settlePending(home, key, asOfMs) {
1591
+ const path = pendingSpoolPath(home, key);
1592
+ if (path === null)
1593
+ return;
1594
+ if (asOfMs === Number.POSITIVE_INFINITY) {
1595
+ clearPending(home, key);
1596
+ return;
1597
+ }
1598
+ try {
1599
+ const keep = readFileSync(path, "utf8")
1600
+ .split("\n")
1601
+ .filter((l) => {
1602
+ const e = parseEntry(l);
1603
+ return e !== null && e.observedAt !== undefined && e.observedAt > asOfMs;
1604
+ })
1605
+ .slice(-MAX_PENDING_SHAS);
1606
+ if (keep.length === 0)
1607
+ clearPending(home, key);
1608
+ else
1609
+ writeFileSync(path, keep.map((l) => `${l}\n`).join(""), { mode: 0o600 });
1610
+ }
1611
+ catch {
1612
+ clearPending(home, key);
1613
+ }
1614
+ }
1471
1615
  //# sourceMappingURL=spool.js.map