@vibe-commit/capture 0.8.0 → 0.10.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 (111) hide show
  1. package/README.md +149 -1
  2. package/dist/agents/claude_code.js +13 -0
  3. package/dist/agents/claude_code.js.map +1 -1
  4. package/dist/agents/codex_cli.js +11 -0
  5. package/dist/agents/codex_cli.js.map +1 -1
  6. package/dist/agents/cursor.js +7 -1
  7. package/dist/agents/cursor.js.map +1 -1
  8. package/dist/agents/registry.d.ts +7 -0
  9. package/dist/agents/registry.js +9 -0
  10. package/dist/agents/registry.js.map +1 -1
  11. package/dist/agents/types.d.ts +32 -0
  12. package/dist/agents/types.js.map +1 -1
  13. package/dist/bounded_id.d.ts +29 -0
  14. package/dist/bounded_id.js +42 -0
  15. package/dist/bounded_id.js.map +1 -0
  16. package/dist/codex_folders.d.ts +340 -0
  17. package/dist/codex_folders.js +1448 -0
  18. package/dist/codex_folders.js.map +1 -0
  19. package/dist/commands/backfill.d.ts +29 -7
  20. package/dist/commands/backfill.js +387 -109
  21. package/dist/commands/backfill.js.map +1 -1
  22. package/dist/commands/connect.d.ts +1 -1
  23. package/dist/commands/connect.js +1 -1
  24. package/dist/commands/connect.js.map +1 -1
  25. package/dist/commands/connect_picker.js +48 -7
  26. package/dist/commands/connect_picker.js.map +1 -1
  27. package/dist/commands/finalize.d.ts +67 -0
  28. package/dist/commands/finalize.js +452 -114
  29. package/dist/commands/finalize.js.map +1 -1
  30. package/dist/commands/scan.d.ts +28 -0
  31. package/dist/commands/scan.js +11 -0
  32. package/dist/commands/scan.js.map +1 -1
  33. package/dist/commands/status_machine.js +47 -10
  34. package/dist/commands/status_machine.js.map +1 -1
  35. package/dist/commands/why.d.ts +19 -1
  36. package/dist/commands/why.js +35 -3
  37. package/dist/commands/why.js.map +1 -1
  38. package/dist/commit_files.d.ts +64 -0
  39. package/dist/commit_files.js +83 -0
  40. package/dist/commit_files.js.map +1 -0
  41. package/dist/consent.d.ts +22 -0
  42. package/dist/consent.js +38 -1
  43. package/dist/consent.js.map +1 -1
  44. package/dist/copy/strings.d.ts +55 -9
  45. package/dist/copy/strings.js +60 -12
  46. package/dist/copy/strings.js.map +1 -1
  47. package/dist/folder_grants.d.ts +44 -0
  48. package/dist/folder_grants.js +101 -0
  49. package/dist/folder_grants.js.map +1 -0
  50. package/dist/git.d.ts +26 -4
  51. package/dist/git.js +82 -11
  52. package/dist/git.js.map +1 -1
  53. package/dist/hooks/entry.d.ts +30 -50
  54. package/dist/hooks/entry.js +595 -559
  55. package/dist/hooks/entry.js.map +1 -1
  56. package/dist/hooks/fire_lock.d.ts +31 -0
  57. package/dist/hooks/fire_lock.js +120 -12
  58. package/dist/hooks/fire_lock.js.map +1 -1
  59. package/dist/hooks/post_commit.d.ts +9 -0
  60. package/dist/hooks/post_commit.js +9 -5
  61. package/dist/hooks/post_commit.js.map +1 -1
  62. package/dist/machine_state.d.ts +44 -1
  63. package/dist/machine_state.js +75 -12
  64. package/dist/machine_state.js.map +1 -1
  65. package/dist/oauth/discovery.js +2 -1
  66. package/dist/oauth/discovery.js.map +1 -1
  67. package/dist/oauth/pkce.js +2 -1
  68. package/dist/oauth/pkce.js.map +1 -1
  69. package/dist/paths.d.ts +34 -1
  70. package/dist/paths.js +62 -12
  71. package/dist/paths.js.map +1 -1
  72. package/dist/physical_path.d.ts +46 -0
  73. package/dist/physical_path.js +151 -0
  74. package/dist/physical_path.js.map +1 -0
  75. package/dist/picker.d.ts +52 -3
  76. package/dist/picker.js +129 -5
  77. package/dist/picker.js.map +1 -1
  78. package/dist/picker_checklist.d.ts +9 -1
  79. package/dist/picker_checklist.js +41 -2
  80. package/dist/picker_checklist.js.map +1 -1
  81. package/dist/policy.d.ts +9 -1
  82. package/dist/policy.js +11 -2
  83. package/dist/policy.js.map +1 -1
  84. package/dist/post.d.ts +25 -0
  85. package/dist/post.js +49 -13
  86. package/dist/post.js.map +1 -1
  87. package/dist/project.d.ts +89 -0
  88. package/dist/project.js +123 -0
  89. package/dist/project.js.map +1 -1
  90. package/dist/redact.d.ts +77 -2
  91. package/dist/redact.js +713 -47
  92. package/dist/redact.js.map +1 -1
  93. package/dist/session_targets.d.ts +713 -0
  94. package/dist/session_targets.js +2602 -0
  95. package/dist/session_targets.js.map +1 -0
  96. package/dist/spool.d.ts +23 -5
  97. package/dist/spool.js +37 -6
  98. package/dist/spool.js.map +1 -1
  99. package/dist/spool_union.d.ts +51 -0
  100. package/dist/spool_union.js +57 -0
  101. package/dist/spool_union.js.map +1 -0
  102. package/dist/state.d.ts +5 -0
  103. package/dist/state.js +19 -1
  104. package/dist/state.js.map +1 -1
  105. package/dist/text_tail.d.ts +8 -0
  106. package/dist/text_tail.js +19 -0
  107. package/dist/text_tail.js.map +1 -0
  108. package/dist/version.d.ts +1 -1
  109. package/dist/version.js +1 -1
  110. package/dist/version.js.map +1 -1
  111. package/package.json +3 -3
@@ -36,17 +36,21 @@ import { join } from "node:path";
36
36
  import { DEFAULT_AGENT_ID, admitsTranscript, transcriptRoots } from "../agents/registry.js";
37
37
  import { BACKFILL, COMMANDS, ERRORS, FINALIZE } from "../copy/index.js";
38
38
  import { renderVerbHelp } from "../verb_help.js";
39
- import { isCaptureAllowed } from "../consent.js";
39
+ import { hasAnyConsent, isCaptureAllowed } from "../consent.js";
40
40
  import { authCommandFor, channelCommand, connectCommandFor } from "../install.js";
41
41
  import { loadCredential } from "../credential.js";
42
42
  import { EXIT } from "../exit.js";
43
- import { headRef, onceRepoSlug } from "../git.js";
43
+ import { headRef, onceRepoSlug, onceStrictRepoSlug } from "../git.js";
44
44
  import { confinementRoots, deliverSubagents, fileSize, readSpan, recordCutter, sendTimeoutMs, wait } from "../hooks/entry.js";
45
+ import { STALE_MARGIN_MS, claimSessionWriter } from "../hooks/fire_lock.js";
46
+ import { FOLDER_REPOSITORY_FORBIDDEN } from "../policy.js";
47
+ import { LockLostError, dialectIsolates, isolationOf, pastCursorOf, resolveTargets, stopKeyOf, stopTarget } from "../session_targets.js";
45
48
  import { repoSessionsDir, sessionStatePath } from "../paths.js";
46
49
  import { deliver, ingestUrl } from "../post.js";
47
50
  import { resolveProjectKeys } from "../project.js";
48
- import { capInferred, capSpool, capSuccessors, deleteInferredSpool, inferredFullyResolved, readInferred, readRewrites, readSpool } from "../spool.js";
49
- import { fileState, loadSessionState, saveSessionState } from "../state.js";
51
+ import { deleteInferredSpool, inferredFullyResolved } from "../spool.js";
52
+ import { fileState, loadSessionState, pruneDeliveredEndHold } from "../state.js";
53
+ import { readSpoolUnion } from "../spool_union.js";
50
54
  import { renderErrorBlock, wrap } from "../term.js";
51
55
  import { notAGitRepoBlock, writeFailure, writeLines } from "./context.js";
52
56
  import { credentialProblem } from "./credential_problem.js";
@@ -96,7 +100,141 @@ function parseArgs(argv) {
96
100
  return null; // exactly one of the two
97
101
  return { session, latest, force };
98
102
  }
103
+ /** How long the verb waits for another writer of the same session (a hook firing) before it says so and stops. */
104
+ const WRITER_WAIT_MS = 30_000;
105
+ /**
106
+ * ⛔ HOW LONG THE VERB'S WRITER LOCK MAY SIT UNTOUCHED before another writer may take it from a holder presumed dead (C25).
107
+ *
108
+ * The verb HEARTBEATS the lock (`FireClaim.refresh`, same nonce) before every request, every delegated file, every target and every
109
+ * root the resolver names, so what matters is the LONGEST GAP between two heartbeats, not the length of the job. That gap is one
110
+ * request (`sendTimeoutMs(SEND_BUDGET_MS, 0)` = 36 s at most) or one root's naming (a few git calls at 5 s each, one ssh at 2 s),
111
+ * plus the local read, redaction and compression of one delta: well under a minute. The bound is TWICE the request gap plus the
112
+ * hook margin (76 s): slack for a slow disk without leaving a dead holder's lock in the way for long. It is stamped into the lock
113
+ * (C18 #2), so a hook contender honours it instead of its own seven seconds, and `fire_lock.ts` caps it at ten minutes whatever
114
+ * a file claims. It used to be the whole send budget plus the margin (62 s) with NO heartbeat, so a session that took longer than
115
+ * that, a long backfill of a large transcript, was reclaimed while its holder was still sending, and two writers ran at once.
116
+ */
117
+ function holderStaleMs() {
118
+ return 2 * (sendTimeoutMs(SEND_BUDGET_MS, 0) + STALE_MARGIN_MS);
119
+ }
120
+ /** Resolver calls one `finalize` makes while discovery is still moving (each reads a bounded window). */
121
+ /**
122
+ * A SAFETY bound on the interactive resolver loop, not a limit anyone should meet (C18 #11). The loop ends when discovery is settled or
123
+ * a round changes nothing the resolver keeps; every round either advances a scan cursor, resolves a root, or counts a bounded failure,
124
+ * so it is finite. A fixed 16 meant a transcript naming more than 128 repositories returned unsettled and uploaded only some targets.
125
+ */
126
+ const RESOLVE_ROUNDS_MAX = 100_000;
127
+ /**
128
+ * Take the session's one writer lock (O5) and resolve every target the session names, as a session that is OVER (`end` mode, the
129
+ * only mode that may decide a folder target). The lock is released by the caller's `releases`, after the sends. Shared by
130
+ * `finalize` and `backfill`: one resolver loop, so a session is judged the same whichever verb closes it.
131
+ * `busy`: another writer held the session for the whole wait.
132
+ */
133
+ export async function resolveSessionTargets(env, sessionId, transcriptPath, known, releases) {
134
+ const claim = await claimSessionWriter(env.home, sessionId, {
135
+ staleAfterMs: holderStaleMs(),
136
+ deadlineAtMs: Date.now() + WRITER_WAIT_MS,
137
+ wait,
138
+ });
139
+ if (claim === null)
140
+ return { kind: "busy" };
141
+ releases.push(() => claim.release());
142
+ const heartbeat = () => claim.refresh();
143
+ const base = {
144
+ home: env.home,
145
+ env: env.env,
146
+ sessionId,
147
+ transcriptPath,
148
+ turnEndCwd: env.turnEndCwd,
149
+ mode: "end",
150
+ nowMs: env.nowMs,
151
+ heartbeat,
152
+ ...(known === null ? {} : { known }),
153
+ };
154
+ // Each call reads one bounded window AND resolves at most a bounded number of roots, so a fully-scanned transcript can still have
155
+ // a queue of unresolved roots (the granted repo may be the last of twenty). Keep going until discovery is SETTLED, and stop
156
+ // only when a call changed nothing the resolver keeps: not the cursors, not the queue, not the roots, not the failure counts.
157
+ //
158
+ // ⛔ The queue's ORDER is not progress (C21 iv): failed roots rotate to the back, so a round in which every tried root fails again
159
+ // changes only the order. `unresolved` is compared as a SET, and a root's failure counts once per invocation (`counted`), so each
160
+ // round either moves a cursor, resolves a root, counts a NEW failure, or the loop stops. The record keeps its rotated order.
161
+ const progress = (r) => JSON.stringify([
162
+ r.record.scan,
163
+ [...r.record.unresolved].sort(),
164
+ Object.keys(r.record.roots),
165
+ r.record.failures,
166
+ r.record.oversize,
167
+ r.targets.length,
168
+ ]);
169
+ const counted = new Set();
170
+ const round0 = { ...base, countedThisInvocation: counted };
171
+ let res;
172
+ try {
173
+ res = resolveTargets(round0);
174
+ for (let round = 1; round < RESOLVE_ROUNDS_MAX && !res.settled; round += 1) {
175
+ if (!heartbeat())
176
+ return { kind: "busy" };
177
+ const next = resolveTargets(round0);
178
+ const same = progress(next) === progress(res);
179
+ res = next;
180
+ if (same)
181
+ break;
182
+ }
183
+ }
184
+ catch (error) {
185
+ // C25: the lock was lost while naming a root, before the resolver wrote anything. Another writer has the session: stop.
186
+ if (error instanceof LockLostError)
187
+ return { kind: "busy" };
188
+ throw error;
189
+ }
190
+ const record = res.record;
191
+ const targets = res.targets.filter((target) => record.stopped[stopKeyOf(target)] === undefined);
192
+ return {
193
+ kind: "ok",
194
+ targets,
195
+ scan: Object.fromEntries(Object.entries(record.scan).map(([fileKey, cursor]) => [fileKey, cursor.offset])),
196
+ record,
197
+ capRepos: dialectIsolates(env.home, env.env, transcriptPath),
198
+ heartbeat,
199
+ };
200
+ }
201
+ /** What `closeSession` is told for one resolved target: the repo (or folder) and everything `closeSession` needs about it. */
202
+ export function closeSpecOf(target, scan, facts) {
203
+ return specOf(target, scan, facts);
204
+ }
205
+ function specOf(target, scan, facts) {
206
+ // C43: a copy delivered from the start of a stream whose call checkpoint has been pruned (a repo granted after the others were sent) cannot judge a
207
+ // result by the log, so a repo copy of an isolating dialect observes its own delivered bytes in order (the same observer the hook uses past the cursor).
208
+ const base = isolationOf(target, facts?.record);
209
+ const isolation = facts?.capRepos === true && facts.record !== undefined ? { ...base, past: pastCursorOf(base, scan) } : base;
210
+ return {
211
+ projectKey: target.ledgerKey,
212
+ redactionRoots: target.redactionRoots,
213
+ repoSlug: target.slug,
214
+ worktrees: target.worktrees.length > 0 ? target.worktrees : [target.ledgerKey],
215
+ allowNeverCaptured: true,
216
+ ...(target.kind === "folder" || facts?.capRepos === true ? { sizeCap: scan } : {}),
217
+ isolation,
218
+ target,
219
+ };
220
+ }
99
221
  export async function finalize(ctx, argv) {
222
+ const releases = [];
223
+ try {
224
+ return await finalizeBody(ctx, argv, releases);
225
+ }
226
+ finally {
227
+ for (const release of releases) {
228
+ try {
229
+ release();
230
+ }
231
+ catch {
232
+ // A lock that cannot be released ages out by itself.
233
+ }
234
+ }
235
+ }
236
+ }
237
+ async function finalizeBody(ctx, argv, releases) {
100
238
  // `TODOS[184]`. Check-first, before `parseArgs`/anything else — the ONE change this ticket makes to
101
239
  // `finalize`. Reads and writes nothing; every other line below (`parseArgs`, session resolution, the
102
240
  // seal call, the drain loop, every exit code and receipt string) is byte-identical to 0.4.4 for any
@@ -118,24 +256,106 @@ export async function finalize(ctx, argv) {
118
256
  }, ctx.colour));
119
257
  return EXIT.usage;
120
258
  }
121
- const keys = resolveProjectKeys(ctx.cwd);
122
- if (keys === null) {
123
- writeFailure(ctx, notAGitRepoBlock(ctx));
124
- return EXIT.failure;
125
- }
126
- const projectKey = keys.worktree;
127
- const slugOf = onceRepoSlug(projectKey);
128
- if (!isCaptureAllowed(ctx.home, keys, slugOf)) {
129
- // "This repository is not allowed to be captured": the repo's own consent, not the machine's credential.
259
+ const nowMs = ctx.now().getTime();
260
+ const stillWriting = (sessionId) => {
130
261
  writeLines(ctx.stderr, renderErrorBlock({
131
262
  kind: "bad",
132
- code: "NOT_CONNECTED",
133
- what: ERRORS.notConnected,
263
+ code: "FINALIZE_STILL_WRITING",
264
+ what: FINALIZE.stillWriting,
134
265
  why: [],
135
- fixLabel: ERRORS.notConnectedFix,
136
- fixes: [connectCommandFor(ctx.env, ctx.selfPath)],
266
+ fixLabel: FINALIZE.stillWritingFix,
267
+ fixes: [channelCommand(ctx.env, ctx.selfPath, FINALIZE.commandSessionForce(sessionId))],
137
268
  }, ctx.colour));
138
- return EXIT.notConnected;
269
+ return EXIT.usage;
270
+ };
271
+ // ANY-FOLDER (A-T3): at the three places this verb used to refuse because the folder it ran in is not the session's repo, a
272
+ // `--session <id>` whose transcript exists is resolved by the SAME resolver the hook uses. Only `--session`: `--latest` names a
273
+ // session through THIS repo's ledgers, so it has nothing to look up elsewhere. No targets (or no transcript) leaves the
274
+ // ORIGINAL refusal and its exit code exactly as they were.
275
+ // C25: the heartbeat of the session lock `resolveSessionTargets` took, handed to every `closeSession`. Absent only when no resolver
276
+ // ran (a plain in-repo close of a session picked by `--latest`), which holds no session lock to keep.
277
+ let heartbeat;
278
+ const prepareAnyFolder = async () => {
279
+ if (args.session === null || !SESSION_ID.test(args.session))
280
+ return { kind: "none" };
281
+ const sessionId = args.session;
282
+ // ⛔ NOTHING IS READ FOR A MACHINE THAT HAS GRANTED NOTHING (C18 #5): the resolver reads the transcript for evidence, so the same
283
+ // gate the hook has comes first, before any transcript is looked for or opened and before any state is written.
284
+ if (!hasAnyConsent(ctx.home))
285
+ return { kind: "none" };
286
+ const transcriptPath = findTranscript(ctx.home, ctx.env, sessionId);
287
+ if (transcriptPath === null)
288
+ return { kind: "none" };
289
+ try {
290
+ if (!args.force && nowMs - statSync(transcriptPath).mtimeMs < STILL_WRITING_MS)
291
+ return { kind: "still-writing", sessionId };
292
+ }
293
+ catch {
294
+ return { kind: "none" };
295
+ }
296
+ const eof = fileSize(transcriptPath);
297
+ if (eof === null)
298
+ return { kind: "none" };
299
+ // ⛔ `turnEndCwd` is NULL (C18 #1): the CLI's cwd is where the person TYPED this, not where the session ended. Passing it made
300
+ // `finalize --session S` run inside granted repo B create a B target for a session that never touched B. Which repos a session
301
+ // belongs to comes from the session's own records only; the cwd repo's own close is the ledger-gated branch below.
302
+ const resolved = await resolveSessionTargets({ home: ctx.home, env: ctx.env, nowMs: ctx.now().getTime(), turnEndCwd: null }, sessionId, transcriptPath, null, releases);
303
+ if (resolved.kind === "busy")
304
+ return { kind: "busy" };
305
+ heartbeat = resolved.heartbeat;
306
+ if (resolved.targets.length === 0)
307
+ return { kind: "none" };
308
+ return { kind: "ready", sessionId, transcriptPath, eof, specs: resolved.targets.map((target) => specOf(target, resolved.scan, resolved)) };
309
+ };
310
+ const busyBlock = () => {
311
+ writeLines(ctx.stderr, renderErrorBlock({
312
+ kind: "bad",
313
+ code: "DELIVERY_FAILED",
314
+ what: FINALIZE.notDelivered,
315
+ why: [],
316
+ fixLabel: FINALIZE.retryFix,
317
+ fixes: [channelCommand(ctx.env, ctx.selfPath, args.session === null ? FINALIZE.commandSessionAny : FINALIZE.commandSession(args.session))],
318
+ }, ctx.colour));
319
+ return EXIT.failure;
320
+ };
321
+ const refuseAnyFolder = (prepared) => {
322
+ if (prepared.kind === "still-writing")
323
+ return stillWriting(prepared.sessionId);
324
+ if (prepared.kind === "busy")
325
+ return busyBlock();
326
+ return null;
327
+ };
328
+ let anyFolder = { kind: "none" };
329
+ const keys = resolveProjectKeys(ctx.cwd);
330
+ if (keys === null) {
331
+ anyFolder = await prepareAnyFolder();
332
+ const refused = refuseAnyFolder(anyFolder);
333
+ if (refused !== null)
334
+ return refused;
335
+ if (anyFolder.kind !== "ready") {
336
+ writeFailure(ctx, notAGitRepoBlock(ctx));
337
+ return EXIT.failure;
338
+ }
339
+ }
340
+ const projectKey = keys === null ? null : keys.worktree;
341
+ const slugOf = projectKey === null ? null : onceRepoSlug(projectKey);
342
+ if (keys !== null && slugOf !== null && !isCaptureAllowed(ctx.home, keys, slugOf)) {
343
+ anyFolder = await prepareAnyFolder();
344
+ const refused = refuseAnyFolder(anyFolder);
345
+ if (refused !== null)
346
+ return refused;
347
+ if (anyFolder.kind !== "ready") {
348
+ // "This repository is not allowed to be captured": the repo's own consent, not the machine's credential.
349
+ writeLines(ctx.stderr, renderErrorBlock({
350
+ kind: "bad",
351
+ code: "NOT_CONNECTED",
352
+ what: ERRORS.notConnected,
353
+ why: [],
354
+ fixLabel: ERRORS.notConnectedFix,
355
+ fixes: [connectCommandFor(ctx.env, ctx.selfPath)],
356
+ }, ctx.colour));
357
+ return EXIT.notConnected;
358
+ }
139
359
  }
140
360
  const load = loadCredential({ env: ctx.env, home: ctx.home });
141
361
  if (load.kind !== "ok") {
@@ -160,94 +380,173 @@ export async function finalize(ctx, argv) {
160
380
  return EXIT.failure;
161
381
  }
162
382
  // ── Which session. ──
163
- const nowMs = ctx.now().getTime();
164
383
  let sessionId;
165
- if (args.session !== null) {
166
- if (!SESSION_ID.test(args.session)) {
167
- writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "FINALIZE_BAD_SESSION_ID", what: FINALIZE.badSessionId, why: [] }, ctx.colour));
168
- return EXIT.usage;
169
- }
170
- sessionId = args.session;
384
+ let transcriptPath;
385
+ let eof;
386
+ let specs;
387
+ if (anyFolder.kind === "ready") {
388
+ ({ sessionId, transcriptPath, eof, specs } = anyFolder);
171
389
  }
172
390
  else {
173
- const recent = recentSessions(ctx.home, projectKey, nowMs);
174
- if (recent.length === 0) {
175
- writeLines(ctx.stderr, renderErrorBlock({
176
- kind: "bad",
177
- code: "FINALIZE_NO_RECENT_SESSION",
178
- what: FINALIZE.noRecentSession,
179
- why: [],
180
- fixLabel: FINALIZE.noRecentSessionFix,
181
- fixes: [channelCommand(ctx.env, ctx.selfPath, FINALIZE.commandSessionAny)],
182
- }, ctx.colour));
183
- return EXIT.usage;
391
+ // Here the cwd IS an allowed repo (the two gates above passed), so `keys`, `projectKey` and `slugOf` exist.
392
+ if (keys === null || projectKey === null || slugOf === null)
393
+ return EXIT.failure;
394
+ if (args.session !== null) {
395
+ if (!SESSION_ID.test(args.session)) {
396
+ writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "FINALIZE_BAD_SESSION_ID", what: FINALIZE.badSessionId, why: [] }, ctx.colour));
397
+ return EXIT.usage;
398
+ }
399
+ sessionId = args.session;
184
400
  }
185
- if (recent.length > 1) {
186
- writeLines(ctx.stderr, renderErrorBlock({
187
- kind: "bad",
188
- code: "FINALIZE_SEVERAL_RECENT",
189
- what: FINALIZE.severalRecent,
190
- why: recent.map((r) => FINALIZE.recentRow(r.id, Math.round((nowMs - r.at) / 60000))),
191
- fixLabel: FINALIZE.severalRecentFix,
192
- fixes: [channelCommand(ctx.env, ctx.selfPath, FINALIZE.commandSessionAny)],
193
- }, ctx.colour));
194
- return EXIT.usage;
401
+ else {
402
+ const recent = recentSessions(ctx.home, projectKey, nowMs);
403
+ if (recent.length === 0) {
404
+ writeLines(ctx.stderr, renderErrorBlock({
405
+ kind: "bad",
406
+ code: "FINALIZE_NO_RECENT_SESSION",
407
+ what: FINALIZE.noRecentSession,
408
+ why: [],
409
+ fixLabel: FINALIZE.noRecentSessionFix,
410
+ fixes: [channelCommand(ctx.env, ctx.selfPath, FINALIZE.commandSessionAny)],
411
+ }, ctx.colour));
412
+ return EXIT.usage;
413
+ }
414
+ if (recent.length > 1) {
415
+ writeLines(ctx.stderr, renderErrorBlock({
416
+ kind: "bad",
417
+ code: "FINALIZE_SEVERAL_RECENT",
418
+ what: FINALIZE.severalRecent,
419
+ why: recent.map((r) => FINALIZE.recentRow(r.id, Math.round((nowMs - r.at) / 60000))),
420
+ fixLabel: FINALIZE.severalRecentFix,
421
+ fixes: [channelCommand(ctx.env, ctx.selfPath, FINALIZE.commandSessionAny)],
422
+ }, ctx.colour));
423
+ return EXIT.usage;
424
+ }
425
+ sessionId = recent[0].id;
195
426
  }
196
- sessionId = recent[0].id;
197
- }
198
- // ── The session must be one THIS repo captured (it has a state file here) and its transcript must be findable inside the agent's own root. ──
199
- const state = sessionStatePath(ctx.home, { repoKey: projectKey, sessionId });
200
- if (state === null || !existsSync(state)) {
201
- writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "FINALIZE_UNKNOWN_SESSION", what: FINALIZE.unknownSession(sessionId), why: [] }, ctx.colour));
202
- return EXIT.usage;
203
- }
204
- const transcriptPath = findTranscript(ctx.home, ctx.env, sessionId);
205
- if (transcriptPath === null) {
206
- writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "FINALIZE_TRANSCRIPT_MISSING", what: FINALIZE.transcriptMissing(sessionId), why: [] }, ctx.colour));
207
- return EXIT.failure;
208
- }
209
- try {
210
- if (!args.force && nowMs - statSync(transcriptPath).mtimeMs < STILL_WRITING_MS) {
211
- writeLines(ctx.stderr, renderErrorBlock({
212
- kind: "bad",
213
- code: "FINALIZE_STILL_WRITING",
214
- what: FINALIZE.stillWriting,
215
- why: [],
216
- fixLabel: FINALIZE.stillWritingFix,
217
- fixes: [channelCommand(ctx.env, ctx.selfPath, FINALIZE.commandSessionForce(sessionId))],
218
- }, ctx.colour));
219
- return EXIT.usage;
427
+ // ── The session must be one THIS repo captured (it has a state file here) and its transcript must be findable inside the agent's own root. ──
428
+ const state = sessionStatePath(ctx.home, { repoKey: projectKey, sessionId });
429
+ if (state === null || !existsSync(state)) {
430
+ // A session this folder's repo never captured may still belong to other repos it named (any-folder capture).
431
+ anyFolder = await prepareAnyFolder();
432
+ const refused = refuseAnyFolder(anyFolder);
433
+ if (refused !== null)
434
+ return refused;
435
+ if (anyFolder.kind !== "ready") {
436
+ writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "FINALIZE_UNKNOWN_SESSION", what: FINALIZE.unknownSession(sessionId), why: [] }, ctx.colour));
437
+ return EXIT.usage;
438
+ }
439
+ }
440
+ if (anyFolder.kind === "ready") {
441
+ ({ transcriptPath, eof, specs } = anyFolder);
442
+ }
443
+ else {
444
+ const found = findTranscript(ctx.home, ctx.env, sessionId);
445
+ if (found === null) {
446
+ writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "FINALIZE_TRANSCRIPT_MISSING", what: FINALIZE.transcriptMissing(sessionId), why: [] }, ctx.colour));
447
+ return EXIT.failure;
448
+ }
449
+ transcriptPath = found;
450
+ try {
451
+ if (!args.force && nowMs - statSync(transcriptPath).mtimeMs < STILL_WRITING_MS)
452
+ return stillWriting(sessionId);
453
+ }
454
+ catch {
455
+ return EXIT.failure;
456
+ }
457
+ const size = fileSize(transcriptPath);
458
+ if (size === null)
459
+ return EXIT.failure;
460
+ eof = size;
461
+ // The session's other repos (parent-folder turns touched them too): resolved by the one resolver, under the one session lock.
462
+ // This repo's own ledger target is closed exactly as before; a resolved target for the same repo REPLACES it (the hook's own
463
+ // ledger choice), and a resolver that finds nothing leaves exactly today's single target.
464
+ const resolved = await resolveSessionTargets({ home: ctx.home, env: ctx.env, nowMs: ctx.now().getTime(), turnEndCwd: null }, sessionId, transcriptPath, { keys, slugOf: onceStrictRepoSlug(keys.worktree) }, releases);
465
+ if (resolved.kind === "busy")
466
+ return busyBlock();
467
+ heartbeat = resolved.heartbeat;
468
+ const own = resolved.targets.find((target) => target.worktrees.includes(projectKey) || target.ledgerKey === projectKey);
469
+ const manual = {
470
+ projectKey,
471
+ redactionRoots: confinementRoots(keys),
472
+ repoSlug: slugOf(),
473
+ worktrees: [projectKey],
474
+ allowNeverCaptured: false,
475
+ target: null,
476
+ };
477
+ specs = [
478
+ own === undefined ? manual : specOf(own, resolved.scan, resolved),
479
+ ...resolved.targets.filter((target) => target !== own).map((target) => specOf(target, resolved.scan, resolved)),
480
+ ];
220
481
  }
221
482
  }
222
- catch {
223
- return EXIT.failure;
224
- }
225
- const eof = fileSize(transcriptPath);
226
- if (eof === null)
227
- return EXIT.failure;
228
483
  // ── Deliver: promote what was pending, then sub-agents FIRST, then the main stream with the assertion. ──
229
484
  // ⛔ NO PENDING-LINE PROMOTION HERE, deliberately. Every hook fire of this session already ran it (`hooks/entry.ts`), and
230
485
  // the session necessarily has a state file (required above), so a first-turn commit was promoted by the first
231
486
  // `Stop` that wrote one. Promoting from this verb would add a second call site to a function whose only call site
232
487
  // is a pinned property (`post-commit-spool.test.ts`, D208): a dead session must never reach it.
233
- const closed = await closeSession({
234
- home: ctx.home,
235
- env: ctx.env,
236
- nodeVersion: ctx.nodeVersion,
237
- agentId: ctx.agentId ?? DEFAULT_AGENT_ID,
238
- cwd: ctx.cwd,
239
- url,
240
- credential: load.credential,
241
- projectKey,
242
- redactionRoots: confinementRoots(keys),
243
- repoSlug: slugOf(),
244
- sessionId,
245
- transcriptPath,
246
- eof,
247
- seal: true,
248
- allowNeverCaptured: false,
249
- completeSubagents: false,
250
- });
488
+ const outcomes = [];
489
+ // ⛔ LEAST RECENTLY SENT FIRST (C18 #8), as the hook orders its targets: resolver order is discovery order, and a session with many
490
+ // targets would always serve the same first ones if the credential or the clock ran out part-way. Never-sent targets (0) go first.
491
+ const lastSentAt = (spec) => {
492
+ const state = loadSessionState(ctx.home, { repoKey: spec.projectKey, sessionId });
493
+ return Math.max(0, ...Object.values(state.files).map((file) => file.lastSentAt));
494
+ };
495
+ specs = specs
496
+ .map((spec, index) => ({ spec, index, at: lastSentAt(spec) }))
497
+ .sort((a, b) => a.at - b.at || a.index - b.index)
498
+ .map((entry) => entry.spec);
499
+ for (const spec of specs) {
500
+ const closedOne = await closeSession({
501
+ home: ctx.home,
502
+ env: ctx.env,
503
+ nodeVersion: ctx.nodeVersion,
504
+ agentId: ctx.agentId ?? DEFAULT_AGENT_ID,
505
+ cwd: ctx.cwd,
506
+ url,
507
+ credential: load.credential,
508
+ projectKey: spec.projectKey,
509
+ redactionRoots: spec.redactionRoots,
510
+ repoSlug: spec.repoSlug,
511
+ sessionId,
512
+ transcriptPath,
513
+ eof: spec.sizeCap === undefined ? eof : Math.min(eof, spec.sizeCap.main ?? 0),
514
+ seal: true,
515
+ allowNeverCaptured: spec.allowNeverCaptured,
516
+ completeSubagents: false,
517
+ worktrees: spec.worktrees,
518
+ ...(spec.sizeCap === undefined ? {} : { sizeCap: spec.sizeCap }),
519
+ ...(spec.isolation === undefined ? {} : { isolation: spec.isolation }),
520
+ ...(heartbeat === undefined ? {} : { heartbeat }),
521
+ });
522
+ // C25: the session's lock was taken from us. Stop BEFORE the next target's write; this outcome is the verb's answer.
523
+ if (closedOne.kind === "lock-lost") {
524
+ outcomes.push(closedOne);
525
+ break;
526
+ }
527
+ // C3: the server refused THIS target for good. It is stopped (recorded, never retried), and it does not fail the others.
528
+ if (spec.target !== null &&
529
+ "delivery" in closedOne &&
530
+ closedOne.delivery.kind === "attempted" &&
531
+ closedOne.delivery.detail?.code === FOLDER_REPOSITORY_FORBIDDEN) {
532
+ // C35 (iii): recording the stop is a WRITE, so only while the session is still ours. A lost lock reports the refusal and leaves it
533
+ // for the next holder, who meets the same 403.
534
+ if (heartbeat === undefined || heartbeat())
535
+ stopTarget(ctx.home, sessionId, spec.target, FOLDER_REPOSITORY_FORBIDDEN, Date.now());
536
+ continue;
537
+ }
538
+ outcomes.push(closedOne);
539
+ if (closedOne.kind === "stopped")
540
+ break; // a fatal class for the credential: no other target can send either
541
+ }
542
+ // ONE answer: the first target that did not close cleanly, else one line for all of them.
543
+ const closed = outcomes.find((outcome) => outcome.kind !== "sealed" && outcome.kind !== "already") ??
544
+ (outcomes.length === 0
545
+ ? { kind: "never-captured" }
546
+ : {
547
+ ...(outcomes.find((outcome) => outcome.kind === "sealed") ?? outcomes[0]),
548
+ setAsidePairs: outcomes.reduce((sum, outcome) => sum + ("setAsidePairs" in outcome ? outcome.setAsidePairs : 0), 0),
549
+ });
251
550
  // ── What the server said. ──
252
551
  if (closed.kind === "stopped") {
253
552
  writeLines(ctx.stderr, renderErrorBlock({
@@ -282,7 +581,8 @@ export async function finalize(ctx, argv) {
282
581
  writeLines(ctx.stderr, renderErrorBlock({ kind: "warn", what: FINALIZE.notAcknowledged, why: [] }, ctx.colour));
283
582
  return EXIT.unacknowledged;
284
583
  }
285
- // Anything else did not deliver (`not-delivered`; `finalize` never asks for the outcomes only a backfill can get).
584
+ // Anything else did not deliver (`not-delivered`, or `lock-lost` (C25: another writer took the session; the retry command is the
585
+ // right advice and no new line is needed); `finalize` never asks for the outcomes only a backfill can get).
286
586
  writeLines(ctx.stderr, renderErrorBlock({
287
587
  kind: "bad",
288
588
  code: "DELIVERY_FAILED",
@@ -332,9 +632,14 @@ export async function closeSession(input) {
332
632
  if (state === null || !existsSync(state))
333
633
  return { kind: "never-captured" };
334
634
  }
335
- const spooled = capSpool(readSpool(home, spoolKey));
336
- const rewritten = capSuccessors(readRewrites(home, spoolKey));
337
- const inferred = capInferred(readInferred(home, spoolKey)); // `TODOS[177]`
635
+ // ⛔ THE SPOOL UNION (any-folder capture, O7): the commit, rewrite and inferred spools of every work tree of this repo for the
636
+ // session, acknowledged per source. With one work tree this is exactly the three reads it always was.
637
+ const worktrees = input.worktrees ?? [projectKey];
638
+ const isolation = input.isolation ?? { kind: "repo", ownRoots: redactionRoots };
639
+ const union = readSpoolUnion(home, sessionId, projectKey, worktrees);
640
+ const spooled = union.commits;
641
+ const rewritten = union.rewrites;
642
+ const inferred = union.inferred; // `TODOS[177]`
338
643
  const hookEnv = {
339
644
  env,
340
645
  home,
@@ -344,7 +649,11 @@ export async function closeSession(input) {
344
649
  };
345
650
  const hookInput = { sessionId, transcriptPath, cwd: input.cwd, eventName: "Stop" };
346
651
  const budget = input.sendBudgetMs ?? SEND_BUDGET_MS;
652
+ const beat = input.heartbeat;
653
+ const holding = () => beat === undefined || beat();
347
654
  let startedAt = Date.now();
655
+ if (!holding())
656
+ return { kind: "lock-lost" };
348
657
  if (!input.completeSubagents) {
349
658
  await deliverSubagents({
350
659
  ctx: hookEnv,
@@ -357,10 +666,17 @@ export async function closeSession(input) {
357
666
  startedAt,
358
667
  sendBudget: budget,
359
668
  byHook: false,
669
+ ...(input.sizeCap === undefined ? {} : { sizeCap: input.sizeCap }),
670
+ isolation,
671
+ ...(beat === undefined ? {} : { heartbeat: beat }),
360
672
  });
673
+ if (!holding())
674
+ return { kind: "lock-lost" };
361
675
  }
362
676
  else {
363
677
  for (let round = 0; round < SUBAGENT_ROUNDS_MAX; round += 1) {
678
+ if (!holding())
679
+ return { kind: "lock-lost" };
364
680
  const sent = await deliverSubagents({
365
681
  ctx: hookEnv,
366
682
  input: hookInput,
@@ -373,7 +689,12 @@ export async function closeSession(input) {
373
689
  startedAt: Date.now(),
374
690
  sendBudget: budget,
375
691
  byHook: false,
692
+ ...(input.sizeCap === undefined ? {} : { sizeCap: input.sizeCap }),
693
+ isolation,
694
+ ...(beat === undefined ? {} : { heartbeat: beat }),
376
695
  });
696
+ if (!holding())
697
+ return { kind: "lock-lost" };
377
698
  const blocked = sent.deliveries.find(blocksMain);
378
699
  if (blocked !== undefined)
379
700
  return { kind: "subagents-held", delivery: blocked };
@@ -386,6 +707,8 @@ export async function closeSession(input) {
386
707
  // the clock it always had.
387
708
  startedAt = Date.now();
388
709
  }
710
+ if (!holding())
711
+ return { kind: "lock-lost" };
389
712
  const delivery = await deliver({
390
713
  home,
391
714
  env,
@@ -397,17 +720,30 @@ export async function closeSession(input) {
397
720
  fileKey: "main",
398
721
  transcriptPath,
399
722
  timeoutMs: sendTimeoutMs(budget, Date.now() - startedAt),
723
+ // ⛔ NO `deadlineAtMs` HERE (C21 #2, retracting C18 #8's deadline): `finalize` and `backfill` are interactive and "set no deadline
724
+ // and run on" (post.ts). A deadline would stop a main stream that needs longer part-way, never send the final request, and
725
+ // report a session 0.8.0 completed as failed.
400
726
  nowMs: Date.now(),
401
727
  head: headRef(projectKey),
402
728
  commits: spooled,
729
+ // E8: only a REPO target (the same resolver as the hook returns granted targets only); a folder has no commits to describe.
730
+ commitFiles: isolation.kind === "repo",
403
731
  rewrites: rewritten.pairs,
404
732
  inferredRewrites: inferred.pairs,
405
733
  inferredGroups: inferred.groups,
734
+ spoolSources: union.sources,
406
735
  ...(input.seal ? { final: true } : {}),
407
736
  byHook: false,
408
- }, eof, (from, to) => readSpan(transcriptPath, from, to, redactionRoots, home, env), recordCutter(transcriptPath));
737
+ ...(beat === undefined ? {} : { beforeRequest: beat }),
738
+ }, eof, (from, to) => readSpan(transcriptPath, from, to, redactionRoots, home, env, isolation), recordCutter(transcriptPath));
409
739
  if (delivery.kind === "stopped")
410
740
  return { kind: "stopped" };
741
+ // C25: `deliver` makes no request once the lock is gone. It answers `nothing-to-send` (which a close that does not seal reads as
742
+ // "already delivered") or, after some deltas went, the last good one. Ask again: a lock that is not ours, with bytes still unsent,
743
+ // is not a delivery. (Everything sent and acknowledged is reported as it happened, whoever holds the session now.)
744
+ if (!holding() && (delivery.kind === "nothing-to-send" || fileState(loadSessionState(home, spoolKey), "main").sentOffset < eof)) {
745
+ return { kind: "lock-lost" };
746
+ }
411
747
  if (!input.seal) {
412
748
  return delivery.kind === "nothing-to-send" || (delivery.kind === "attempted" && delivery.disposition === "ok")
413
749
  ? { kind: "delivered", delivery }
@@ -416,7 +752,9 @@ export async function closeSession(input) {
416
752
  if (delivery.kind !== "attempted" || delivery.disposition !== "ok")
417
753
  return { kind: "not-delivered", delivery };
418
754
  if (delivery.finalAck === "sealed" || delivery.finalAck === "already") {
419
- clearDeliveredHold(home, spoolKey);
755
+ // C25: a write, so only while the session is still ours. The close itself DID happen (the server said so), and is reported.
756
+ if (holding())
757
+ clearDeliveredHold(home, spoolKey);
420
758
  // ⭐ `TODOS[177]` follow-up (VL, 2026-10-08) — DRAIN what the first delivery's caps left spooled. A session
421
759
  // over 16 exact pairs or 64 inferred pairs (one squash of 64 is already the server's own cap; several
422
760
  // squashes in one session is what overflows this) would otherwise leave the rest spooled FOREVER: nobody
@@ -439,8 +777,11 @@ export async function closeSession(input) {
439
777
  // existing "absent ack, stop asking" guard below never fires either).
440
778
  const lean = fileState(loadSessionState(home, spoolKey), "main").lean;
441
779
  for (let i = 0; !lean && i < FINALIZE_DRAIN_MAX_ITERATIONS; i += 1) {
442
- const moreRewritten = capSuccessors(readRewrites(home, spoolKey));
443
- const moreInferred = capInferred(readInferred(home, spoolKey));
780
+ if (!holding())
781
+ break;
782
+ const more = readSpoolUnion(home, sessionId, projectKey, worktrees);
783
+ const moreRewritten = more.rewrites;
784
+ const moreInferred = more.inferred;
444
785
  if (moreRewritten.pairs.length === 0 && moreInferred.pairs.length === 0)
445
786
  break;
446
787
  const groupId = moreInferred.groups.length > 0 ? `${moreInferred.groups[0].successor}:${moreInferred.groups[0].patchId}` : null;
@@ -463,9 +804,11 @@ export async function closeSession(input) {
463
804
  rewrites: moreRewritten.pairs,
464
805
  inferredRewrites: moreInferred.pairs,
465
806
  inferredGroups: moreInferred.groups,
807
+ spoolSources: more.sources,
466
808
  final: true,
467
809
  byHook: false,
468
- }, eof, (from, to) => readSpan(transcriptPath, from, to, redactionRoots, home, env), recordCutter(transcriptPath));
810
+ ...(beat === undefined ? {} : { beforeRequest: beat }),
811
+ }, eof, (from, to) => readSpan(transcriptPath, from, to, redactionRoots, home, env, isolation), recordCutter(transcriptPath));
469
812
  if (drain.kind !== "attempted" || drain.disposition !== "ok")
470
813
  break;
471
814
  setAsidePairs += drain.inferredSetAsidePairs;
@@ -498,8 +841,11 @@ export async function closeSession(input) {
498
841
  // ⛔⛔ Fine to delete AFTER `setAsidePairs` is computed above, NEVER before (VL, 2026-10-08, VG's silent-loss
499
842
  // finding B1): `setAsidePairs` is read from `deliver()`'s own return values — which were captured before this
500
843
  // point — not re-derived from the spool afterward, which is the file this line is about to delete.
501
- if (inferredFullyResolved(home, spoolKey))
502
- deleteInferredSpool(home, spoolKey);
844
+ for (const source of holding() ? [projectKey, ...worktrees.filter((worktree) => worktree !== projectKey)] : []) {
845
+ const sourceKey = { repoKey: source, sessionId };
846
+ if (inferredFullyResolved(home, sourceKey))
847
+ deleteInferredSpool(home, sourceKey);
848
+ }
503
849
  return { kind: delivery.finalAck, delivery, setAsidePairs };
504
850
  }
505
851
  if (delivery.finalAck === "refused")
@@ -553,14 +899,6 @@ function findTranscript(home, env, sessionId) {
553
899
  }
554
900
  /** After a successful close, forget end-of-session holds whose bytes have now been delivered. */
555
901
  function clearDeliveredHold(home, key) {
556
- const session = loadSessionState(home, key);
557
- if (session.endHold === null)
558
- return;
559
- const eof = {};
560
- for (const [fileKey, size] of Object.entries(session.endHold.eof)) {
561
- if (fileState(session, fileKey).sentOffset < size)
562
- eof[fileKey] = size;
563
- }
564
- saveSessionState(home, key, { ...session, endHold: Object.keys(eof).length > 0 ? { at: session.endHold.at, eof } : null });
902
+ pruneDeliveredEndHold(home, key);
565
903
  }
566
904
  //# sourceMappingURL=finalize.js.map