@vibe-commit/capture 0.4.0 → 0.4.2

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.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * THE PROMOTION BOUND — `TODOS[176]`; revised after VG's review of 68eb3a7 (option A, VL ruling 2026-10-05).
3
+ *
4
+ * A pending line is promoted into the wire spool by its own session's running hook. Since the cold rung also holds the
5
+ * lines of a RESUMED session (state file present but not live), a pending line can be days old when that session's hook
6
+ * finally runs: a terminal that still exports the session id, committing by hand, would otherwise have those commits
7
+ * adopted as the session's own the moment it resumed. So a line is promoted only if it was OBSERVED no earlier than the
8
+ * START OF THE TURN the hook is delivering: `observedAt >= turnStart`.
9
+ *
10
+ * ## What "the start of the turn" is — and what 68eb3a7 got wrong
11
+ * 68eb3a7 compared against the FIRST unsent record. Measured with real `claude -p` (VG), that is not the turn start:
12
+ * 1. A session left OPEN and idle (no SessionEnd flush): after a Stop, the first unsent record is the previous Stop's
13
+ * TRAILING record (attachment / stop_hook_summary), stamped at the OLD Stop time, so a lingering-id commit anywhere in
14
+ * the idle gap passed `>=` and was adopted.
15
+ * 2. Many real records carry NO timestamp (mode, agent-name, custom-title, last-prompt, permission-mode,
16
+ * file-history-snapshot, cost-state); "no bound can be computed" then meant "keep", which adopted lines 0.4.0 refused.
17
+ * So the turn start is the timestamp of the first unsent PROMPT-LIKE record, found by SKIPPING everything else:
18
+ * - `type:"user"` whose `message.content` is a STRING (a tool_result is an array; an `isMeta` record is skipped), or
19
+ * - `type:"queue-operation"` with `operation:"enqueue"` (the resume-time record `claude -p --resume` writes first).
20
+ * Local slash-command / `!` records are skipped (`LOCAL_RECORD`).
21
+ * A prompt always precedes the turn's tool calls, so it is never later than a commit made in the turn.
22
+ *
23
+ * ## Which clock is compared to which
24
+ * `observedAt` is the WALL CLOCK of the `post-commit` process (our process clock, written when the line was spooled).
25
+ * The prompt time is the `timestamp` the AGENT wrote on that record. ⛔ NEVER the committer date: it is settable
26
+ * (`GIT_COMMITTER_DATE`, `rebase --committer-date-is-author-date`). STRICT `>=`, no tolerance.
27
+ *
28
+ * ## The scan cap (stated): at most 64 complete records, and 8 MiB, from the sent offset — read in 64 KiB chunks
29
+ * 0.4.1 read ONE 64 KiB window; a big record (an attachment, a large tool_result) before the prompt pushed it out in ~1.8% of
30
+ * resumes (VG), and the line was dropped. Now the file is read in chunks until a prompt-like record with a timestamp is
31
+ * found, so a big record no longer hides the prompt. The 64-RECORD cap is unchanged, and an 8 MiB ceiling stops one
32
+ * pathological record from making a hook read for ever. Garbled lines and records without a valid timestamp are skipped and
33
+ * count toward the 64. A trailing unterminated line is not a record. A prompt beyond either cap is not found.
34
+ *
35
+ * ## What happens when no prompt time is found — the two paths differ ON PURPOSE
36
+ * - STALE-OWN path (the session's state file already existed): the line is DROPPED. That is exactly what 0.4.0 did with
37
+ * such a line (it never wrote one), so dropping adopts nothing 0.4.0 refused. A line with no `observedAt` is dropped
38
+ * too: no 0.4.0 client wrote a stale-own pending line, so it cannot be ours.
39
+ * - FIRST-TURN path (no state file yet): the line is KEPT. There the first record IS the prompt, and keeping is today's
40
+ * behaviour. A legacy 0.4.0 line (no `observedAt`) is kept on this path only.
41
+ * A prompt dated AFTER the hook's own clock (skew: a record cannot be written ahead of now) disables the bound on both
42
+ * paths (kept): a bound that is demonstrably wrong must not drop a legitimate line.
43
+ *
44
+ * ## The cost, accepted (the safe direction: a lost edge, never a wrong one)
45
+ * A commit whose line is promoted by a hook whose unsent span has no prompt (its tool_result is the first unsent record,
46
+ * e.g. a hook that delivered the tool_use before the commit without refreshing liveAt) is dropped on the stale-own path.
47
+ */
48
+ import { closeSync, openSync, readSync } from "node:fs";
49
+ /** The scan reads this many bytes at a time from the sent offset… */
50
+ export const SCAN_CHUNK_BYTES = 64 * 1024;
51
+ /** …and at most this many in all (a whole number of chunks). Past it the answer is `none`. */
52
+ export const SCAN_MAX_BYTES = 8 * 1024 * 1024;
53
+ /** …and considers at most this many complete records. */
54
+ export const SCAN_MAX_RECORDS = 64;
55
+ const NONE = { kind: "none" };
56
+ function isPromptLike(rec) {
57
+ if (rec.type === "queue-operation")
58
+ return rec.operation === "enqueue";
59
+ if (rec.type !== "user" || rec.isMeta === true)
60
+ return false;
61
+ const message = rec.message;
62
+ if (message === null || typeof message !== "object")
63
+ return false;
64
+ const content = message.content;
65
+ return typeof content === "string" && !LOCAL_RECORD.test(content);
66
+ }
67
+ /**
68
+ * A LOCAL record the CLI writes for a slash command or a `!` shell escape (`<command-name>`, `<command-message>`,
69
+ * `<local-command-…>`, `<bash-…>`) is `type:"user"` with STRING content and not `isMeta`, but it starts no model turn and
70
+ * triggers no Stop. Run as the last thing before an idle spell it is still unsent at the next turn's hook, stamped HOURS
71
+ * before the real prompt, so a lingering-id line observed in between would pass `>=` (VG, cell 12). Skipped. The price is
72
+ * the safe direction: a turn started ONLY by such a record has no turn start, so a stale-own line is dropped. Anchored at
73
+ * the start of the trimmed text: a real prompt that merely MENTIONS a tag still counts.
74
+ */
75
+ const LOCAL_RECORD = /^\s*<(?:command-name>|command-message>|local-command|bash-)/;
76
+ /**
77
+ * The turn start of the unsent span of `path` beginning at byte `offset`, as the timestamp of the first prompt-like
78
+ * record with a valid timestamp (see the header). `nowMs` is the hook's own clock (the skew guard).
79
+ */
80
+ export function turnStart(path, offset, nowMs) {
81
+ if (!Number.isSafeInteger(offset) || offset < 0)
82
+ return NONE;
83
+ let fd = null;
84
+ try {
85
+ fd = openSync(path, "r");
86
+ const buf = Buffer.alloc(SCAN_CHUNK_BYTES);
87
+ // A record that crosses a chunk boundary is carried in `partial` as BYTES (never decoded early: a multi-byte character
88
+ // may be cut by the boundary) and decoded only once its newline arrives.
89
+ let partial = [];
90
+ let records = 0;
91
+ let read = 0;
92
+ while (read < SCAN_MAX_BYTES) {
93
+ const got = readSync(fd, buf, 0, SCAN_CHUNK_BYTES, offset + read);
94
+ if (got <= 0)
95
+ break;
96
+ read += got;
97
+ let start = 0;
98
+ for (let nl = buf.indexOf(0x0a, 0); nl !== -1 && nl < got; nl = buf.indexOf(0x0a, start)) {
99
+ const text = Buffer.concat([...partial, buf.subarray(start, nl)]).toString("utf8");
100
+ partial = [];
101
+ start = nl + 1;
102
+ records += 1;
103
+ if (records > SCAN_MAX_RECORDS)
104
+ return NONE;
105
+ const found = promptStart(text, nowMs);
106
+ if (found !== null)
107
+ return found;
108
+ }
109
+ partial.push(Buffer.from(buf.subarray(start, got))); // copy: `buf` is reused by the next read
110
+ }
111
+ return NONE;
112
+ }
113
+ catch {
114
+ return NONE;
115
+ }
116
+ finally {
117
+ if (fd !== null) {
118
+ try {
119
+ closeSync(fd);
120
+ }
121
+ catch {
122
+ /* nothing to do */
123
+ }
124
+ }
125
+ }
126
+ }
127
+ /** One complete record: its turn-start answer if it is a prompt-like record with a valid timestamp, else null. */
128
+ function promptStart(line, nowMs) {
129
+ let rec;
130
+ try {
131
+ rec = JSON.parse(line);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ if (rec === null || typeof rec !== "object" || Array.isArray(rec))
137
+ return null;
138
+ const r = rec;
139
+ if (!isPromptLike(r) || typeof r.timestamp !== "string")
140
+ return null;
141
+ const ms = Date.parse(r.timestamp);
142
+ if (!Number.isFinite(ms))
143
+ return null;
144
+ return ms > nowMs ? { kind: "future" } : { kind: "ts", ms };
145
+ }
146
+ /** Keep (promote) this pending line? See the header for the two paths. */
147
+ export function keepsPendingLine(line, bound, mode) {
148
+ if (bound.kind === "future")
149
+ return true;
150
+ if (bound.kind === "none")
151
+ return mode === "first-turn";
152
+ if (line.observedAt === undefined)
153
+ return mode === "first-turn";
154
+ return line.observedAt >= bound.ms;
155
+ }
156
+ //# sourceMappingURL=pending_bound.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending_bound.js","sourceRoot":"","sources":["../src/pending_bound.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ORPHAN PENDING FILES — `TODOS[175]`.
3
+ *
4
+ * A `<sid>.pending.jsonl` has ONE consumer: its own session's running hook (D2 / cross-promotion forbid any other). Two
5
+ * producers write them — the cold rung (an env id never seen) and, since 0.4.1, a STALE-OWN id — so an id whose session
6
+ * never runs again leaves its file for ever, and `status` counted it beside the real spool: a permanent "N commits are
7
+ * waiting" with no remedy (`finalize` has no session to close).
8
+ *
9
+ * ## Why deleting one is safe
10
+ * Since 0.4.1 a pending line is promotable only if it was observed no earlier than the start of the turn being delivered
11
+ * (`pending_bound.ts`), i.e. only by a turn that was ALREADY RUNNING when the commit was observed. A line older than any
12
+ * plausible turn can never pass that bound: it is dead weight, not a recoverable edge. (A turn longer than the TTL loses
13
+ * its edge — the safe direction: a lost edge, never a wrong one.)
14
+ *
15
+ * ## The rule
16
+ * - TTL 7 days, STRICTLY older. A file is EXPIRED when its mtime AND the `observedAt` of every line are past it. A legacy
17
+ * 0.4.0 line has no `observedAt`; the mtime ages it. ⛔ NEVER the line's `at` (the committer date — settable).
18
+ * - WHOLE FILES ONLY, and only `*.pending.jsonl`. Survivors are never rewritten (`appendPending` is an unlocked
19
+ * read-modify-write, so a rewrite could race it). `.spool.jsonl` (real, attributed commits), rewrites and state files are
20
+ * never touched.
21
+ * - At most 50 files per call. Called from the Stop and SessionEnd hooks only — not `post-commit` (it runs under git) and
22
+ * not `status` (readers stay read-only).
23
+ *
24
+ * ## The race, narrowed: rename to a tombstone, re-read, put back anything fresh
25
+ * Check-then-delete leaves a window in which an append lands in a file we then delete. So the file is first RENAMED to
26
+ * `<name>.expired-<ts>` — an atomic step after which `appendPending` creates a NEW file at the old path — and the tombstone
27
+ * is re-read: if it is no longer expired (a line landed before the rename) it goes back, MERGED into any new file by sha
28
+ * rather than clobbering it; otherwise it is deleted. The lost-append window is the rename itself. A crashed prune leaves a
29
+ * tombstone; one older than an hour (judged by the timestamp in its NAME — a rename keeps the old mtime) is settled the
30
+ * same way on the next pass.
31
+ *
32
+ * ## Self-announcing, counts only
33
+ * A silent delete would turn a loss into a non-event. What was discarded is tallied in `pruned-pending.tally` (a name no
34
+ * other scan of the sessions directory matches) as `{files, lines, at}`: no sha, no session id. `status` shows one sentence
35
+ * for up to 7 days after the last discard; an older tally restarts instead of accumulating.
36
+ *
37
+ * Nothing here throws: a hook must exit 0.
38
+ */
39
+ export declare const PENDING_TTL_MS: number;
40
+ export declare const PRUNE_MAX_FILES = 50;
41
+ /** A tombstone younger than this may belong to a prune that is still running in another process. */
42
+ export declare const TOMBSTONE_GRACE_MS = 3600000;
43
+ export interface PruneResult {
44
+ readonly files: number;
45
+ readonly lines: number;
46
+ }
47
+ /** Test seam: runs between the rename and the re-read — exactly where a concurrent append would land. */
48
+ export interface PruneSeams {
49
+ readonly afterRename?: (tombstone: string, original: string) => void;
50
+ }
51
+ /** Pure. Expired = strictly older than the TTL on the file's mtime AND on every line's `observedAt`. */
52
+ export declare function pendingExpired(raw: string, mtimeMs: number, nowMs: number): boolean;
53
+ /** Reads the file; an unreadable one is NOT expired (never count or delete what cannot be examined). */
54
+ export declare function pendingFileExpired(path: string, nowMs: number): boolean;
55
+ export declare function pruneOrphanPending(home: string, repoKey: string, nowMs: number, seams?: PruneSeams): PruneResult;
56
+ /** The counts to announce, or null when nothing was discarded in the last 7 days. */
57
+ export declare function readPruneTally(home: string, repoKey: string, nowMs: number): {
58
+ files: number;
59
+ lines: number;
60
+ } | null;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * ORPHAN PENDING FILES — `TODOS[175]`.
3
+ *
4
+ * A `<sid>.pending.jsonl` has ONE consumer: its own session's running hook (D2 / cross-promotion forbid any other). Two
5
+ * producers write them — the cold rung (an env id never seen) and, since 0.4.1, a STALE-OWN id — so an id whose session
6
+ * never runs again leaves its file for ever, and `status` counted it beside the real spool: a permanent "N commits are
7
+ * waiting" with no remedy (`finalize` has no session to close).
8
+ *
9
+ * ## Why deleting one is safe
10
+ * Since 0.4.1 a pending line is promotable only if it was observed no earlier than the start of the turn being delivered
11
+ * (`pending_bound.ts`), i.e. only by a turn that was ALREADY RUNNING when the commit was observed. A line older than any
12
+ * plausible turn can never pass that bound: it is dead weight, not a recoverable edge. (A turn longer than the TTL loses
13
+ * its edge — the safe direction: a lost edge, never a wrong one.)
14
+ *
15
+ * ## The rule
16
+ * - TTL 7 days, STRICTLY older. A file is EXPIRED when its mtime AND the `observedAt` of every line are past it. A legacy
17
+ * 0.4.0 line has no `observedAt`; the mtime ages it. ⛔ NEVER the line's `at` (the committer date — settable).
18
+ * - WHOLE FILES ONLY, and only `*.pending.jsonl`. Survivors are never rewritten (`appendPending` is an unlocked
19
+ * read-modify-write, so a rewrite could race it). `.spool.jsonl` (real, attributed commits), rewrites and state files are
20
+ * never touched.
21
+ * - At most 50 files per call. Called from the Stop and SessionEnd hooks only — not `post-commit` (it runs under git) and
22
+ * not `status` (readers stay read-only).
23
+ *
24
+ * ## The race, narrowed: rename to a tombstone, re-read, put back anything fresh
25
+ * Check-then-delete leaves a window in which an append lands in a file we then delete. So the file is first RENAMED to
26
+ * `<name>.expired-<ts>` — an atomic step after which `appendPending` creates a NEW file at the old path — and the tombstone
27
+ * is re-read: if it is no longer expired (a line landed before the rename) it goes back, MERGED into any new file by sha
28
+ * rather than clobbering it; otherwise it is deleted. The lost-append window is the rename itself. A crashed prune leaves a
29
+ * tombstone; one older than an hour (judged by the timestamp in its NAME — a rename keeps the old mtime) is settled the
30
+ * same way on the next pass.
31
+ *
32
+ * ## Self-announcing, counts only
33
+ * A silent delete would turn a loss into a non-event. What was discarded is tallied in `pruned-pending.tally` (a name no
34
+ * other scan of the sessions directory matches) as `{files, lines, at}`: no sha, no session id. `status` shows one sentence
35
+ * for up to 7 days after the last discard; an older tally restarts instead of accumulating.
36
+ *
37
+ * Nothing here throws: a hook must exit 0.
38
+ */
39
+ import { appendFileSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
40
+ import { join } from "node:path";
41
+ import { repoSessionsDir } from "./paths.js";
42
+ export const PENDING_TTL_MS = 7 * 24 * 3600_000;
43
+ export const PRUNE_MAX_FILES = 50;
44
+ /** A tombstone younger than this may belong to a prune that is still running in another process. */
45
+ export const TOMBSTONE_GRACE_MS = 3600_000;
46
+ const PENDING_SUFFIX = ".pending.jsonl";
47
+ const TOMBSTONE = /^(.+\.pending\.jsonl)\.expired-(\d+)$/;
48
+ const TALLY_NAME = "pruned-pending.tally";
49
+ const nonBlank = (raw) => raw.split("\n").filter((l) => l.trim() !== "");
50
+ /** Pure. Expired = strictly older than the TTL on the file's mtime AND on every line's `observedAt`. */
51
+ export function pendingExpired(raw, mtimeMs, nowMs) {
52
+ if (!(nowMs - mtimeMs > PENDING_TTL_MS))
53
+ return false;
54
+ for (const l of nonBlank(raw)) {
55
+ let rec;
56
+ try {
57
+ rec = JSON.parse(l);
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ const at = rec !== null && typeof rec === "object" ? rec.observedAt : undefined;
63
+ if (typeof at === "number" && Number.isFinite(at) && !(nowMs - at > PENDING_TTL_MS))
64
+ return false;
65
+ }
66
+ return true;
67
+ }
68
+ /** Reads the file; an unreadable one is NOT expired (never count or delete what cannot be examined). */
69
+ export function pendingFileExpired(path, nowMs) {
70
+ try {
71
+ return pendingExpired(readFileSync(path, "utf8"), statSync(path).mtimeMs, nowMs);
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ function shaOf(line) {
78
+ try {
79
+ const s = JSON.parse(line).sha;
80
+ return typeof s === "string" ? s : null;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ /** Put a tombstone's lines back under its original name, merging into a file that appeared meanwhile; then remove it. */
87
+ function restore(tomb, original, raw) {
88
+ let live = null;
89
+ try {
90
+ live = readFileSync(original, "utf8");
91
+ }
92
+ catch {
93
+ /* nothing there */
94
+ }
95
+ if (live === null) {
96
+ renameSync(tomb, original);
97
+ return;
98
+ }
99
+ const have = new Set(nonBlank(live).map(shaOf));
100
+ for (const l of nonBlank(raw))
101
+ if (!have.has(shaOf(l)))
102
+ appendFileSync(original, `${l}\n`);
103
+ rmSync(tomb, { force: true });
104
+ }
105
+ /** Examine one tombstone (just made, or left by a crash): delete it if still expired, else restore it. Returns the lines discarded. */
106
+ function settle(tomb, original, nowMs) {
107
+ let raw;
108
+ let mtimeMs;
109
+ try {
110
+ raw = readFileSync(tomb, "utf8");
111
+ mtimeMs = statSync(tomb).mtimeMs;
112
+ }
113
+ catch {
114
+ return 0;
115
+ }
116
+ if (!pendingExpired(raw, mtimeMs, nowMs)) {
117
+ restore(tomb, original, raw);
118
+ return 0;
119
+ }
120
+ rmSync(tomb, { force: true });
121
+ return nonBlank(raw).length;
122
+ }
123
+ export function pruneOrphanPending(home, repoKey, nowMs, seams = {}) {
124
+ let files = 0;
125
+ let lines = 0;
126
+ try {
127
+ const dir = repoSessionsDir(home, repoKey);
128
+ if (dir === null)
129
+ return { files, lines };
130
+ const entries = readdirSync(dir).sort();
131
+ let budget = PRUNE_MAX_FILES;
132
+ for (const entry of entries) {
133
+ if (budget <= 0)
134
+ break;
135
+ try {
136
+ const t = TOMBSTONE.exec(entry);
137
+ if (t !== null) {
138
+ if (nowMs - Number(t[2]) <= TOMBSTONE_GRACE_MS)
139
+ continue; // maybe mid-flight in another process
140
+ budget -= 1;
141
+ const n = settle(join(dir, entry), join(dir, t[1]), nowMs);
142
+ if (n > 0) {
143
+ files += 1;
144
+ lines += n;
145
+ }
146
+ continue;
147
+ }
148
+ if (!entry.endsWith(PENDING_SUFFIX))
149
+ continue;
150
+ const path = join(dir, entry);
151
+ if (!pendingFileExpired(path, nowMs))
152
+ continue;
153
+ budget -= 1;
154
+ const tomb = `${path}.expired-${nowMs}`;
155
+ renameSync(path, tomb);
156
+ seams.afterRename?.(tomb, path);
157
+ const n = settle(tomb, path, nowMs);
158
+ if (n > 0) {
159
+ files += 1;
160
+ lines += n;
161
+ }
162
+ }
163
+ catch {
164
+ /* one unreadable entry must not stop the pass */
165
+ }
166
+ }
167
+ if (files > 0 || lines > 0)
168
+ recordTally(dir, files, lines, nowMs);
169
+ }
170
+ catch {
171
+ /* never throw from a hook */
172
+ }
173
+ return { files, lines };
174
+ }
175
+ function readTally(dir) {
176
+ try {
177
+ const t = JSON.parse(readFileSync(join(dir, TALLY_NAME), "utf8"));
178
+ if (Number.isSafeInteger(t.files) && Number.isSafeInteger(t.lines) && typeof t.at === "number" && Number.isFinite(t.at))
179
+ return t;
180
+ }
181
+ catch {
182
+ /* absent or damaged: no tally */
183
+ }
184
+ return null;
185
+ }
186
+ function recordTally(dir, files, lines, nowMs) {
187
+ try {
188
+ const prev = readTally(dir);
189
+ const carry = prev !== null && nowMs - prev.at <= PENDING_TTL_MS ? prev : { files: 0, lines: 0, at: nowMs };
190
+ const next = { files: carry.files + files, lines: carry.lines + lines, at: nowMs };
191
+ writeFileSync(join(dir, TALLY_NAME), `${JSON.stringify(next)}\n`, { mode: 0o600 });
192
+ }
193
+ catch {
194
+ /* the discard already happened; failing to announce it must not undo or throw */
195
+ }
196
+ }
197
+ /** The counts to announce, or null when nothing was discarded in the last 7 days. */
198
+ export function readPruneTally(home, repoKey, nowMs) {
199
+ const dir = repoSessionsDir(home, repoKey);
200
+ if (dir === null)
201
+ return null;
202
+ const t = readTally(dir);
203
+ if (t === null || nowMs - t.at > PENDING_TTL_MS || (t.files === 0 && t.lines === 0))
204
+ return null;
205
+ return { files: t.files, lines: t.lines };
206
+ }
207
+ //# sourceMappingURL=pending_prune.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending_prune.js","sourceRoot":"","sources":["../src/pending_prune.ts"],"names":[],"mappings":"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"}
package/dist/spool.d.ts CHANGED
@@ -40,6 +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 { type PendingMode, type TurnStart } from "./pending_bound.js";
43
44
  import { type SessionKey } from "./paths.js";
44
45
  /**
45
46
  * ⛔ **WHICH RUNG OF THE LADDER ANSWERED — the difference between an observation,
@@ -139,6 +140,12 @@ export interface SpoolEntry {
139
140
  * variable belonged to the committing process, and the mtimes have moved on.
140
141
  */
141
142
  readonly attribution: AttributionRung;
143
+ /**
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).
147
+ */
148
+ readonly observedAt?: number;
142
149
  }
143
150
  /**
144
151
  * How many SHAs one hook may put on the wire.
@@ -311,6 +318,13 @@ export interface ActiveSession {
311
318
  * forgot the env path, and this seam has no other check.
312
319
  */
313
320
  export declare function activeSessionFor(home: string, repoKey: string, envSessionId: string | null, nowMs?: number, windowMs?: number): ActiveSession | null;
321
+ /**
322
+ * `TODOS[176]` — which promotion path a pending line is on, decided by the session's OWN state at the moment its hook runs:
323
+ * `stale-own` = its state file EXISTS and is not live (a RESUMED session: the cold rung put the line here because rung 1 failed),
324
+ * `first-turn` = no state file yet, or one that is live (the original cold case — the first Stop promotes BEFORE it saves state).
325
+ * Reads only; writes nothing (D2).
326
+ */
327
+ export declare function pendingModeFor(home: string, key: SessionKey, nowMs?: number, windowMs?: number): PendingMode;
314
328
  /**
315
329
  * ⛔ **THE TWO RUNGS THAT MAY GO ON `x-commit-attributions` (`D190`).**
316
330
  *
@@ -506,4 +520,11 @@ export declare function promotePending(home: string, key: SessionKey,
506
520
  * a lingering exported `CLAUDE_CODE_SESSION_ID` accumulates pending lines that
507
521
  * are never promoted and age out under `MAX_PENDING_SHAS`.
508
522
  */
509
- corroboration: "this-session-is-running"): number;
523
+ corroboration: "this-session-is-running",
524
+ /**
525
+ * ⛔ `TODOS[176]` — THE PROMOTION BOUND AND ITS PATH, BOTH REQUIRED SO A CALLER CANNOT FORGET THEM. `bound` is the start
526
+ * of the turn this hook is delivering (`turnStart`: the first unsent PROMPT-LIKE record's timestamp); `mode` says whether the
527
+ * session's state file already existed (`stale-own`) or not (`first-turn`). A line observed before the turn start is DROPPED;
528
+ * when no turn start is found a stale-own line is dropped and a first-turn line kept. See `pending_bound.ts`.
529
+ */
530
+ bound: TurnStart, mode: PendingMode): number;
package/dist/spool.js CHANGED
@@ -42,6 +42,7 @@
42
42
  */
43
43
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
44
  import { dirname, join } from "node:path";
45
+ import { keepsPendingLine } from "./pending_bound.js";
45
46
  import { repoSessionsDir, sessionStatePath } from "./paths.js";
46
47
  /**
47
48
  * How many SHAs one hook may put on the wire.
@@ -306,36 +307,33 @@ export function activeSessionFor(home, repoKey, envSessionId, nowMs = Date.now()
306
307
  // forever. The refusal's premise — *we were TOLD who committed, and it is none of these* —
307
308
  // is false for a session that has merely not written its state file YET.
308
309
  //
309
- // ⛔ **ONLY FOR A SESSION THAT HAS NO STATE FILE AT ALL, NEVER A STALE ONE.**
310
- // `activeSessionFor(…, SESSION, +9h)` must stay `null`, and the reason is
311
- // pinned by a cell: *"an id naming a session that went quiet nine hours ago
312
- // is still nothing — otherwise a shell that exported the value once would
313
- // keep a session alive for as long as the terminal lived."* That hazard is
314
- // real and this rung would have re-opened it, because state files are never
315
- // deleted: a lingering variable would file every future commit under one
316
- // ancient session, and promotion would then mint permanent edges for it.
310
+ // ⭐ **`TODOS[176]` — AND IT IS THE SAME FOR A SESSION WHOSE STATE FILE EXISTS BUT HAS GONE STALE.**
311
+ // A RESUMED session (idle past the 30-minute window) is exactly as "not yet corroborated" as a brand-new one: the
312
+ // resumed turn's first commit is made BEFORE any hook of that turn has fired, and `liveAt` is refreshed only by a
313
+ // hook. Measured on the registry 0.4.0 binary: alone, that commit left no spool line anywhere (null); with another
314
+ // session live it was filed under the OTHER session as `env_session_unmatched` and then discarded. Either way the
315
+ // edge was lost for good. So rung 1 failing (X is not live) with X named by the environment now ALWAYS takes the cold
316
+ // rung, keyed to X's own id.
317
317
  //
318
- // ⚠ The distinction is exactly the case this rung is for: a session on its
319
- // FIRST turn has written nothing yet, while a dead one left a file behind.
320
- // ⚠ Absence of the file is the line between too EARLY and too LATE. (Worded
321
- // to avoid `from` followed by a quoted string: `provenance.test.ts` walls
322
- // RAW TEXT and reads that shape as an escaping import specifier. Third time
323
- // this guard has fired on ordinary prose in this package; the house response
324
- // is to move the wording and leave the guard alone.)
318
+ // ⛔ WHAT MAKES THAT SAFE (the old comment here forbade it, for a good reason): the hazard was a shell that exported a
319
+ // session id once and kept committing, with the commits then adopted by a long-dead session and PROMOTED to
320
+ // edge-minting rows. Three things close it, and none of them is this function:
321
+ // 1. this rung writes ONE `.pending.jsonl` line (post_commit.ts picks the sidecar for this rung): no state file, no
322
+ // `liveAt`, no spool line. A non-hook writer cannot make X live (D2; asserted as bytes in
323
+ // `test/stale-own-pending.test.ts`).
324
+ // 2. only X's OWN running hook promotes X's bucket (`promotePending`'s call site is keyed by the executing hook's
325
+ // session, never by the environment): a dead session never runs a hook, and another session's hook cannot touch it.
326
+ // 3. the promotion bound (`pending_bound.ts`): a line observed before the START OF THE TURN X is delivering (its first unsent prompt-like record) is
327
+ // DROPPED, so a lingering-id commit from days ago is not adopted when X is finally resumed.
328
+ // Residual, stated: a stale exported id committing DURING the resumed turn looks exactly like X's own commit.
329
+ // (Worded to avoid `from` followed by a quoted string: `provenance.test.ts` walls RAW TEXT and reads that shape as an
330
+ // escaping import specifier.)
325
331
  //
326
- // ⚠ WHAT THIS COSTS, stated rather than discovered: an environment naming a session this
327
- // clone has NEVER captured now opens a `.pending.jsonl` bucket even when others are live
328
- // (Codex under an outer Claude session is the case `agents/registry.ts:11-13` describes).
329
- // That is the same exposure the all-quiet case already accepted. The line is `pending`, not
330
- // sendable: it promotes only if THAT session's own hook later corroborates it, and otherwise
331
- // ages out under `MAX_PENDING_SHAS`.
332
- const envHasNoStateFile = (() => {
333
- if (envSessionId === null)
334
- return false;
335
- const state = sessionStatePath(home, { repoKey, sessionId: envSessionId });
336
- return state !== null && !existsSync(state);
337
- })();
338
- if (envSessionId !== null && envHasNoStateFile) {
332
+ // ⚠ WHAT THIS COSTS, stated rather than discovered: an environment naming a session this clone has never captured, or
333
+ // captured long ago, opens a `.pending.jsonl` bucket even when others are live (Codex under an outer Claude session is
334
+ // the case `agents/registry.ts:11-13` describes). The line is `pending`, not sendable: it promotes only if THAT
335
+ // session's own hook later corroborates it, and otherwise ages out under `MAX_PENDING_SHAS`.
336
+ if (envSessionId !== null && sessionStatePath(home, { repoKey, sessionId: envSessionId }) !== null) {
339
337
  return { sessionId: envSessionId, attribution: "env_session_uncorroborated" };
340
338
  }
341
339
  if (live.length === 0)
@@ -373,6 +371,19 @@ function readLiveAt(path) {
373
371
  return null;
374
372
  }
375
373
  }
374
+ /**
375
+ * `TODOS[176]` — which promotion path a pending line is on, decided by the session's OWN state at the moment its hook runs:
376
+ * `stale-own` = its state file EXISTS and is not live (a RESUMED session: the cold rung put the line here because rung 1 failed),
377
+ * `first-turn` = no state file yet, or one that is live (the original cold case — the first Stop promotes BEFORE it saves state).
378
+ * Reads only; writes nothing (D2).
379
+ */
380
+ export function pendingModeFor(home, key, nowMs = Date.now(), windowMs = SESSION_LIVE_WINDOW_MS) {
381
+ const state = sessionStatePath(home, key);
382
+ if (state === null || !existsSync(state))
383
+ return "first-turn";
384
+ const at = readLiveAt(state);
385
+ return at !== null && nowMs - at <= windowMs ? "first-turn" : "stale-own";
386
+ }
376
387
  /** Every session whose hook last ran inside the window, most recent first. */
377
388
  function liveSessions(home, repoKey, nowMs, windowMs) {
378
389
  const dir = repoSessionsDir(home, repoKey);
@@ -764,7 +775,8 @@ function parseEntry(line) {
764
775
  const at = typeof o.at === "string" ? o.at : "";
765
776
  const branch = typeof o.branch === "string" && o.branch !== "" ? o.branch : null;
766
777
  const files = Array.isArray(o.files) ? o.files.filter((f) => typeof f === "string") : [];
767
- return { sha, branch, at, files, attribution: parseRung(o.attribution) };
778
+ const observedAt = typeof o.observedAt === "number" && Number.isFinite(o.observedAt) && o.observedAt > 0 ? o.observedAt : undefined;
779
+ return { sha, branch, at, files, attribution: parseRung(o.attribution), ...(observedAt === undefined ? {} : { observedAt }) };
768
780
  }
769
781
  /**
770
782
  * ⛔ **AN UNLABELLED LINE IS `recency_heuristic`, AND THAT IS NOT A DEFAULT — IT
@@ -938,14 +950,26 @@ export function promotePending(home, key,
938
950
  * a lingering exported `CLAUDE_CODE_SESSION_ID` accumulates pending lines that
939
951
  * are never promoted and age out under `MAX_PENDING_SHAS`.
940
952
  */
941
- corroboration) {
953
+ corroboration,
954
+ /**
955
+ * ⛔ `TODOS[176]` — THE PROMOTION BOUND AND ITS PATH, BOTH REQUIRED SO A CALLER CANNOT FORGET THEM. `bound` is the start
956
+ * of the turn this hook is delivering (`turnStart`: the first unsent PROMPT-LIKE record's timestamp); `mode` says whether the
957
+ * session's state file already existed (`stale-own`) or not (`first-turn`). A line observed before the turn start is DROPPED;
958
+ * when no turn start is found a stale-own line is dropped and a first-turn line kept. See `pending_bound.ts`.
959
+ */
960
+ bound, mode) {
942
961
  void corroboration;
943
962
  const pending = readPending(home, key);
944
963
  if (pending.length === 0)
945
964
  return 0;
946
965
  let promoted = 0;
947
966
  for (const entry of pending) {
948
- if (appendSpool(home, key, { ...entry, attribution: "env_session_id" }))
967
+ if (!keepsPendingLine(entry, bound, mode))
968
+ continue;
969
+ // The pending-only observation clock stays behind: a promoted line is an ordinary spool line.
970
+ const { observedAt: _observedAt, ...rest } = entry;
971
+ void _observedAt;
972
+ if (appendSpool(home, key, { ...rest, attribution: "env_session_id" }))
949
973
  promoted += 1;
950
974
  }
951
975
  clearPending(home, key);