@vibe-commit/capture 0.6.1 → 0.8.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 (172) hide show
  1. package/README.md +191 -9
  2. package/dist/account_not_here.d.ts +15 -0
  3. package/dist/account_not_here.js +25 -0
  4. package/dist/account_not_here.js.map +1 -0
  5. package/dist/agent_hooks.d.ts +8 -0
  6. package/dist/agent_hooks.js +31 -1
  7. package/dist/agent_hooks.js.map +1 -1
  8. package/dist/agents/claude_code.d.ts +34 -0
  9. package/dist/agents/claude_code.js +141 -8
  10. package/dist/agents/claude_code.js.map +1 -1
  11. package/dist/agents/codex_cli.d.ts +13 -0
  12. package/dist/agents/codex_cli.js +241 -2
  13. package/dist/agents/codex_cli.js.map +1 -1
  14. package/dist/agents/cursor.d.ts +34 -0
  15. package/dist/agents/cursor.js +115 -1
  16. package/dist/agents/cursor.js.map +1 -1
  17. package/dist/agents/session_listing.d.ts +29 -0
  18. package/dist/agents/session_listing.js +49 -0
  19. package/dist/agents/session_listing.js.map +1 -0
  20. package/dist/agents/types.d.ts +118 -8
  21. package/dist/agents/types.js +17 -3
  22. package/dist/agents/types.js.map +1 -1
  23. package/dist/commands/approval_pending.d.ts +9 -0
  24. package/dist/commands/approval_pending.js +23 -0
  25. package/dist/commands/approval_pending.js.map +1 -0
  26. package/dist/commands/auth.d.ts +7 -0
  27. package/dist/commands/auth.js +52 -11
  28. package/dist/commands/auth.js.map +1 -1
  29. package/dist/commands/backfill.d.ts +160 -0
  30. package/dist/commands/backfill.js +748 -0
  31. package/dist/commands/backfill.js.map +1 -0
  32. package/dist/commands/browser_credential.d.ts +6 -2
  33. package/dist/commands/browser_credential.js +34 -14
  34. package/dist/commands/browser_credential.js.map +1 -1
  35. package/dist/commands/connect.d.ts +146 -0
  36. package/dist/commands/connect.js +216 -117
  37. package/dist/commands/connect.js.map +1 -1
  38. package/dist/commands/connect_account.d.ts +135 -0
  39. package/dist/commands/connect_account.js +321 -0
  40. package/dist/commands/connect_account.js.map +1 -0
  41. package/dist/commands/connect_picker.d.ts +27 -0
  42. package/dist/commands/connect_picker.js +549 -0
  43. package/dist/commands/connect_picker.js.map +1 -0
  44. package/dist/commands/context.d.ts +16 -0
  45. package/dist/commands/context.js +27 -0
  46. package/dist/commands/context.js.map +1 -1
  47. package/dist/commands/credential_problem.d.ts +15 -4
  48. package/dist/commands/credential_problem.js +29 -22
  49. package/dist/commands/credential_problem.js.map +1 -1
  50. package/dist/commands/device_credential.d.ts +5 -1
  51. package/dist/commands/device_credential.js +17 -14
  52. package/dist/commands/device_credential.js.map +1 -1
  53. package/dist/commands/finalize.d.ts +95 -0
  54. package/dist/commands/finalize.js +249 -74
  55. package/dist/commands/finalize.js.map +1 -1
  56. package/dist/commands/finish_mint.d.ts +30 -6
  57. package/dist/commands/finish_mint.js +26 -6
  58. package/dist/commands/finish_mint.js.map +1 -1
  59. package/dist/commands/report.js +38 -30
  60. package/dist/commands/report.js.map +1 -1
  61. package/dist/commands/scan.d.ts +162 -0
  62. package/dist/commands/scan.js +507 -0
  63. package/dist/commands/scan.js.map +1 -0
  64. package/dist/commands/status.d.ts +19 -1
  65. package/dist/commands/status.js +232 -26
  66. package/dist/commands/status.js.map +1 -1
  67. package/dist/commands/status_hooks.d.ts +14 -0
  68. package/dist/commands/status_hooks.js +81 -0
  69. package/dist/commands/status_hooks.js.map +1 -0
  70. package/dist/commands/status_machine.d.ts +27 -0
  71. package/dist/commands/status_machine.js +210 -0
  72. package/dist/commands/status_machine.js.map +1 -0
  73. package/dist/commands/sync_reason.d.ts +17 -0
  74. package/dist/commands/sync_reason.js +25 -0
  75. package/dist/commands/sync_reason.js.map +1 -0
  76. package/dist/commands/why.js +44 -31
  77. package/dist/commands/why.js.map +1 -1
  78. package/dist/consent.d.ts +217 -0
  79. package/dist/consent.js +400 -1
  80. package/dist/consent.js.map +1 -1
  81. package/dist/copy/error_codes.d.ts +222 -0
  82. package/dist/copy/error_codes.js +105 -0
  83. package/dist/copy/error_codes.js.map +1 -0
  84. package/dist/copy/index.d.ts +2 -2
  85. package/dist/copy/index.js +2 -2
  86. package/dist/copy/index.js.map +1 -1
  87. package/dist/copy/strings.d.ts +617 -30
  88. package/dist/copy/strings.js +741 -36
  89. package/dist/copy/strings.js.map +1 -1
  90. package/dist/copy/time.d.ts +11 -0
  91. package/dist/copy/time.js +18 -0
  92. package/dist/copy/time.js.map +1 -1
  93. package/dist/credential.d.ts +5 -1
  94. package/dist/credential.js +2 -2
  95. package/dist/credential.js.map +1 -1
  96. package/dist/git.d.ts +43 -0
  97. package/dist/git.js +95 -0
  98. package/dist/git.js.map +1 -1
  99. package/dist/grants_sync.d.ts +165 -0
  100. package/dist/grants_sync.js +289 -0
  101. package/dist/grants_sync.js.map +1 -0
  102. package/dist/grants_verb.d.ts +70 -0
  103. package/dist/grants_verb.js +121 -0
  104. package/dist/grants_verb.js.map +1 -0
  105. package/dist/hooks/entry.d.ts +18 -1
  106. package/dist/hooks/entry.js +117 -14
  107. package/dist/hooks/entry.js.map +1 -1
  108. package/dist/hooks/post_commit.js +3 -3
  109. package/dist/hooks/post_commit.js.map +1 -1
  110. package/dist/hooks/post_rewrite.js +6 -4
  111. package/dist/hooks/post_rewrite.js.map +1 -1
  112. package/dist/hooks/session_grants.d.ts +59 -0
  113. package/dist/hooks/session_grants.js +91 -0
  114. package/dist/hooks/session_grants.js.map +1 -0
  115. package/dist/hooks/session_start.d.ts +114 -0
  116. package/dist/hooks/session_start.js +243 -0
  117. package/dist/hooks/session_start.js.map +1 -0
  118. package/dist/index.d.ts +1 -1
  119. package/dist/index.js +76 -11
  120. package/dist/index.js.map +1 -1
  121. package/dist/install.d.ts +57 -0
  122. package/dist/install.js +54 -20
  123. package/dist/install.js.map +1 -1
  124. package/dist/json.d.ts +32 -9
  125. package/dist/json.js +35 -8
  126. package/dist/json.js.map +1 -1
  127. package/dist/machine_state.d.ts +52 -0
  128. package/dist/machine_state.js +63 -0
  129. package/dist/machine_state.js.map +1 -0
  130. package/dist/oauth/retire.d.ts +10 -0
  131. package/dist/oauth/retire.js +13 -0
  132. package/dist/oauth/retire.js.map +1 -1
  133. package/dist/oauth/session.d.ts +1 -0
  134. package/dist/org_pending.d.ts +52 -0
  135. package/dist/org_pending.js +109 -0
  136. package/dist/org_pending.js.map +1 -0
  137. package/dist/paths.d.ts +70 -0
  138. package/dist/paths.js +90 -0
  139. package/dist/paths.js.map +1 -1
  140. package/dist/pending_revoke.d.ts +117 -0
  141. package/dist/pending_revoke.js +233 -0
  142. package/dist/pending_revoke.js.map +1 -0
  143. package/dist/pending_revoke_file.d.ts +55 -0
  144. package/dist/pending_revoke_file.js +74 -0
  145. package/dist/pending_revoke_file.js.map +1 -0
  146. package/dist/picker.d.ts +208 -0
  147. package/dist/picker.js +526 -0
  148. package/dist/picker.js.map +1 -0
  149. package/dist/picker_checklist.d.ts +141 -0
  150. package/dist/picker_checklist.js +411 -0
  151. package/dist/picker_checklist.js.map +1 -0
  152. package/dist/policy.d.ts +70 -1
  153. package/dist/policy.js +68 -2
  154. package/dist/policy.js.map +1 -1
  155. package/dist/post.d.ts +91 -18
  156. package/dist/post.js +259 -27
  157. package/dist/post.js.map +1 -1
  158. package/dist/state.d.ts +6 -0
  159. package/dist/state.js +38 -1
  160. package/dist/state.js.map +1 -1
  161. package/dist/system_message.d.ts +32 -2
  162. package/dist/system_message.js +47 -5
  163. package/dist/system_message.js.map +1 -1
  164. package/dist/term.d.ts +28 -2
  165. package/dist/term.js +23 -2
  166. package/dist/term.js.map +1 -1
  167. package/dist/verb_help.d.ts +13 -0
  168. package/dist/verb_help.js +40 -0
  169. package/dist/verb_help.js.map +1 -0
  170. package/dist/version.d.ts +1 -1
  171. package/dist/version.js +1 -1
  172. package/package.json +5 -9
@@ -0,0 +1,748 @@
1
+ /**
2
+ * `vibecommit backfill` — style guide §10.14, design review D14 · 8A, D15 · 9A; capture 0.7.0, PR 5.
3
+ *
4
+ * Claude Code keeps about 30 days of transcripts and then deletes them. This uploads the ones still on this machine,
5
+ * for the repositories the user chose, once they say yes — and says plainly that a backfilled session arrives WITHOUT
6
+ * commit links. It is offered by `connect` (`backfillForConnect`, for its seam) and is a verb in its own right.
7
+ *
8
+ * ## What it is made of
9
+ *
10
+ * - **Which sessions** come from the scan engine (`scanMachine`): folder names and Codex's index, no transcript opened.
11
+ * Only repos the consent gate admits (`isCaptureAllowed`) are offered, and only sessions with unsent bytes. A session in
12
+ * a clone with no reflog, over the 50 MiB cap, or from Cursor is left OUT of the count and named in a muted line, so the
13
+ * offer never promises an upload the run would skip.
14
+ * - **How one is sent** is `closeSession` (the code `finalize` runs): sub-agent streams first, then the main stream, then
15
+ * — only for a session quiet for 30 minutes — the seal. Through `deliver()` with `byHook: false`, each clone's own
16
+ * confinement roots, and the hooks' own offset ledger, so a re-run sends only what is new.
17
+ * - **Each session goes through its OWN agent's dialect** (transport, redaction profile, confinement root); the file is
18
+ * admitted only if it lies under that agent's transcript root (`admitsTranscript`), which for Codex is
19
+ * `~/.codex/sessions`.
20
+ * - **The consent gate runs again before EACH session.** The offer is a plan; `vibecommit off` in another terminal during a
21
+ * long run must stop the rest of that repo, so `uploadOne` asks `isCaptureAllowed` first.
22
+ *
23
+ * ## ⛔ The one transcript read before an upload (exception 1)
24
+ *
25
+ * Alexander ruled (2026-10-24 10:53 EST) that the deleted-sibling case — a folder whose original path is gone and whose
26
+ * encoded name now resolves to a different surviving repo — is CLOSED, not accepted. So, after both the grant y/N and the
27
+ * backfill y/N, one session at a time just before its upload, a Claude Code session has ONE value read from its
28
+ * transcript: the `cwd` of the first record that carries one (`firstRecordedCwd`). It uploads only if that path is
29
+ * absolute, exists, is a git work tree, and is the same clone (common dir) the folder was matched to. This is an exception
30
+ * to D60 §D6 (the client may display transcripts but not analyze them), and it is the ONLY one: the scan, the picker and
31
+ * the offer open no transcript, and nothing else is read from this one. Codex needs no check (its index already holds the
32
+ * real `cwd`). Cursor records no folder at all (measured, STATIC), so its sessions are never uploaded and are counted instead.
33
+ */
34
+ import { closeSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
35
+ import { basename, isAbsolute } from "node:path";
36
+ import { admitsTranscript, DIALECTS } from "../agents/registry.js";
37
+ import { BACKFILL, COMMANDS, SCAN, URLS, shortDate } from "../copy/index.js";
38
+ import { grantsUnreadable, isCaptureAllowed } from "../consent.js";
39
+ import { loadCredential } from "../credential.js";
40
+ import { EXIT } from "../exit.js";
41
+ import { firstReflogSeconds, resolveRepoSlug } from "../git.js";
42
+ import { confinementRoots } from "../hooks/entry.js";
43
+ import { CAPTURE_NOT_APPROVED, ingestUrl } from "../post.js";
44
+ import { resolveProjectKeys } from "../project.js";
45
+ import { SESSION_LIVE_WINDOW_MS } from "../spool.js";
46
+ import { fileState, loadSessionState } from "../state.js";
47
+ import { glyph, paint, renderErrorBlock, tildePath, truncateMiddle, truncatePath, wrap, WRAP_COLUMNS } from "../term.js";
48
+ import { writeLines } from "./context.js";
49
+ import { credentialProblem } from "./credential_problem.js";
50
+ import { authCommandFor, channelCommand, connectCommandFor } from "../install.js";
51
+ import { closeSession, ingestUrlUnavailableBlock } from "./finalize.js";
52
+ import { scanMachine } from "./scan.js";
53
+ import { renderVerbHelp } from "../verb_help.js";
54
+ /** A transcript larger than this on disk is not offered (the 50 MiB policy cap). Sub-agent streams are not capped. */
55
+ const MAX_SESSION_BYTES = 50 * 1024 * 1024;
56
+ /** How long a repo's upload runs before the static `…` line appears (§10.14: "over 4 s"). */
57
+ const SLOW_REPO_MS = 4_000;
58
+ /** The folder check gives up on a transcript that records no `cwd` in its first few MiB. Fails closed. */
59
+ const FOLDER_CHECK_MAX_BYTES = 4 * 1024 * 1024;
60
+ /** The widest a repo name is drawn before it is cut in the middle (D27). */
61
+ const SLUG_MAX = 36;
62
+ const ROW_INDENT = 4;
63
+ const FOLDER_INDENT = 6;
64
+ const COLUMN_GAP = 3;
65
+ /** The least a folder path is cut to, so a very wide agents column never leaves it a stub. */
66
+ const PATH_MIN = 12;
67
+ // ---------------------------------------------------------------------------
68
+ // Which sessions
69
+ // ---------------------------------------------------------------------------
70
+ /** The session id the agent's own hook would report for this file, so the offset ledger is the same one. */
71
+ function sessionIdOf(agent, path) {
72
+ const stem = basename(path, ".jsonl");
73
+ if (agent === "codex-cli") {
74
+ // `rollout-<timestamp>-<thread id>.jsonl`: the thread id is the trailing UUID (measured: it is the hook's `session_id`).
75
+ const match = /([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/i.exec(stem);
76
+ return match === null ? null : match[1];
77
+ }
78
+ // Claude Code: `<session id>.jsonl`. Cursor: `<conversation id>/<conversation id>.jsonl`.
79
+ return stem === "" ? null : stem;
80
+ }
81
+ /** A regular file's size and time. `lstat`, so a symlink is not a session; metadata only. */
82
+ function statFile(path) {
83
+ try {
84
+ const stat = lstatSync(path);
85
+ return stat.isFile() ? { size: stat.size, mtimeMs: stat.mtimeMs } : null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ /**
92
+ * Plan the offer from a scan. ⛔ Opens no transcript: a session's size is its `lstat`, and "unsent" is a comparison with the
93
+ * offset ledger the hooks already keep — a state file, not a transcript.
94
+ */
95
+ export function planBackfill(ctx, scanFn = scanMachine) {
96
+ const scanned = scanFn(ctx.home, ctx.env);
97
+ // Asked of git here and not read from the scan: `connect`'s seam hands this the picker's repos and not the scan's whole answer.
98
+ const reflogOf = new Map();
99
+ const hasReflog = (clone) => {
100
+ if (!reflogOf.has(clone))
101
+ reflogOf.set(clone, firstReflogSeconds(clone) !== null);
102
+ return reflogOf.get(clone) === true;
103
+ };
104
+ const keysOf = new Map();
105
+ const keysFor = (clone) => {
106
+ if (!keysOf.has(clone))
107
+ keysOf.set(clone, resolveProjectKeys(clone));
108
+ return keysOf.get(clone) ?? null;
109
+ };
110
+ const repos = [];
111
+ const noReflog = new Map();
112
+ let total = 0;
113
+ let cursorSessions = 0;
114
+ let overSize = 0;
115
+ for (const repo of scanned.repos) {
116
+ const candidates = [];
117
+ const admitted = new Map();
118
+ for (const member of repo.members ?? []) {
119
+ const keys = keysFor(member.clone);
120
+ if (keys === null)
121
+ continue;
122
+ if (!admitted.has(member.clone)) {
123
+ admitted.set(member.clone, isCaptureAllowed(ctx.home, keys, () => repo.slug));
124
+ }
125
+ if (admitted.get(member.clone) !== true)
126
+ continue;
127
+ const found = statFile(member.session.path);
128
+ if (found === null)
129
+ continue;
130
+ const sessionId = sessionIdOf(member.agent, member.session.path);
131
+ const sent = sessionId === null
132
+ ? 0
133
+ : fileState(loadSessionState(ctx.home, { repoKey: member.clone, sessionId }), "main").sentOffset;
134
+ if (sent >= found.size)
135
+ continue;
136
+ if (member.agent === "cursor") {
137
+ cursorSessions += 1;
138
+ continue;
139
+ }
140
+ // A clone with no reflog skips backfill entirely: its transcripts cannot be told from a previous repo's at the same path.
141
+ if (!hasReflog(member.clone)) {
142
+ noReflog.set(member.clone, (noReflog.get(member.clone) ?? 0) + 1);
143
+ continue;
144
+ }
145
+ if (found.size > MAX_SESSION_BYTES) {
146
+ overSize += 1;
147
+ continue;
148
+ }
149
+ candidates.push(candidateOf(member, repo.slug, sessionId, found));
150
+ }
151
+ if (candidates.length === 0)
152
+ continue;
153
+ // Oldest first: the order a re-run resumes in.
154
+ candidates.sort((a, b) => a.mtimeMs - b.mtimeMs || (a.path < b.path ? -1 : 1));
155
+ repos.push({ slug: repo.slug, candidates });
156
+ total += candidates.length;
157
+ }
158
+ return {
159
+ repos,
160
+ total,
161
+ cursorSessions,
162
+ overSize,
163
+ noReflog: [...noReflog.entries()].map(([path, sessions]) => ({ path, sessions })).sort((a, b) => (a.path < b.path ? -1 : 1)),
164
+ };
165
+ }
166
+ function candidateOf(member, slug, sessionId, found) {
167
+ return {
168
+ agent: member.agent,
169
+ slug,
170
+ sessionId,
171
+ path: member.session.path,
172
+ dir: member.dir,
173
+ clone: member.clone,
174
+ commonDir: member.commonDir,
175
+ mtimeMs: found.mtimeMs,
176
+ startedMs: member.session.startedMs ?? member.session.mtimeMs,
177
+ size: found.size,
178
+ };
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // The offer
182
+ // ---------------------------------------------------------------------------
183
+ const AGENT_ORDER = DIALECTS.map((dialect) => dialect.id);
184
+ function folderRows(repo) {
185
+ const byDir = new Map();
186
+ for (const candidate of repo.candidates) {
187
+ const counts = byDir.get(candidate.dir) ?? { "claude-code": 0, "codex-cli": 0, cursor: 0 };
188
+ counts[candidate.agent] += 1;
189
+ byDir.set(candidate.dir, counts);
190
+ }
191
+ const sum = (counts) => AGENT_ORDER.reduce((n, id) => n + counts[id], 0);
192
+ return [...byDir.entries()]
193
+ .map(([dir, counts]) => ({ dir, counts }))
194
+ // The folder with the most sessions first, then by path, so the order is the same on every run.
195
+ .sort((a, b) => sum(b.counts) - sum(a.counts) || (a.dir < b.dir ? -1 : 1));
196
+ }
197
+ /** The muted lines under the repo list: what is NOT offered, and why. Each is a claims row. */
198
+ function notOfferedLines(plan, colour, home) {
199
+ const lines = [];
200
+ if (plan.cursorSessions > 0)
201
+ lines.push(...wrap(BACKFILL.cursorNotUploaded(plan.cursorSessions), ROW_INDENT));
202
+ if (plan.overSize > 0)
203
+ lines.push(...wrap(BACKFILL.overSizeNote(plan.overSize), ROW_INDENT));
204
+ for (const clone of plan.noReflog) {
205
+ // Left-cut to leave room for the sentence around it (§13.4); the clone's last segments identify it.
206
+ lines.push(...wrap(BACKFILL.noReflogNote(clone.sessions, truncatePath(tildePath(clone.path, home), 40)), ROW_INDENT));
207
+ }
208
+ return lines.map((line) => paint(colour, "muted", line));
209
+ }
210
+ /**
211
+ * The offer, as lines (§10.14, as VQ redrew it after the PR 5 review). SGR roles: `text` the repo rows and the question,
212
+ * `muted` the folder rows and the not-offered lines. `nowMs` is an argument so a golden file pins the clock.
213
+ *
214
+ * ## Widths
215
+ *
216
+ * ⛔ A folder path is cut from the LEFT to fit 80 columns whatever the slug width (§13.4, D27): the folder column is never
217
+ * narrowed to the slug column. The right-hand text (counts, agents) starts at one shared column: the slug column, or further
218
+ * right if a path needs it, and never so far that a line passes 80.
219
+ */
220
+ export function renderOffer(plan, options) {
221
+ const { colour, home, nowMs } = options;
222
+ const countsOf = (repo) => `${BACKFILL.notYetUploaded(repo.candidates.length)} · ${SCAN.since(shortDate(Math.min(...repo.candidates.map((candidate) => candidate.startedMs)), nowMs))}`;
223
+ const agentsOf = (counts) => AGENT_ORDER.filter((id) => counts[id] > 0)
224
+ .map((id) => `${SCAN.agent[id]} ${counts[id]}`)
225
+ .join(" · ");
226
+ const folders = plan.repos.map((repo) => folderRows(repo));
227
+ const widestRight = Math.max(...plan.repos.map(countsOf).map((text) => text.length), ...folders.flatMap((rows) => rows.map((row) => agentsOf(row.counts).length)));
228
+ // The column the right-hand text may start at without passing 80.
229
+ const limit = WRAP_COLUMNS - widestRight;
230
+ const slugWidth = Math.max(8, Math.min(SLUG_MAX, ...[Math.max(...plan.repos.map((repo) => repo.slug.length))], limit - ROW_INDENT - COLUMN_GAP));
231
+ const pathCap = Math.max(PATH_MIN, limit - FOLDER_INDENT - 2);
232
+ const pathOf = (dir) => truncatePath(tildePath(dir, home), pathCap);
233
+ const longestPath = Math.max(...folders.flatMap((rows) => rows.map((row) => pathOf(row.dir).length)));
234
+ const column = Math.max(ROW_INDENT + slugWidth + COLUMN_GAP, FOLDER_INDENT + longestPath + 2);
235
+ const lines = ["", ...wrap(BACKFILL.offerHeading(plan.repos.length, options.standalone !== false), 2), ""];
236
+ for (const [index, repo] of plan.repos.entries()) {
237
+ lines.push(`${" ".repeat(ROW_INDENT)}${paint(colour, "text", `${truncateMiddle(repo.slug, slugWidth).padEnd(column - ROW_INDENT)}${countsOf(repo)}`)}`);
238
+ for (const row of folders[index]) {
239
+ lines.push(`${" ".repeat(FOLDER_INDENT)}${paint(colour, "muted", `${pathOf(row.dir).padEnd(column - FOLDER_INDENT)}${agentsOf(row.counts)}`)}`);
240
+ }
241
+ }
242
+ const notOffered = notOfferedLines(plan, colour, home);
243
+ if (notOffered.length > 0)
244
+ lines.push("", ...notOffered);
245
+ lines.push("", ...BACKFILL.uploadNote.map((line) => ` ${line}`), "");
246
+ return lines;
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // The folder check — exception 1. Everything between the markers is the ONLY code that reads a transcript.
250
+ // ---------------------------------------------------------------------------
251
+ // folder-check:begin
252
+ /**
253
+ * The `cwd` of the transcript's first record that has one. ⛔ ONE VALUE. The record is parsed to read its top-level `cwd`
254
+ * and nothing else is looked at, kept, returned or sent; a line that does not mention `"cwd"` is not parsed at all.
255
+ * `none` (no record carries one in the first `FOLDER_CHECK_MAX_BYTES`) and `unreadable` (the file cannot be opened) both
256
+ * fail closed — the session is skipped — but they are different facts and are told apart.
257
+ */
258
+ export function firstRecordedCwd(path) {
259
+ let fd;
260
+ try {
261
+ fd = openSync(path, "r");
262
+ }
263
+ catch {
264
+ return { kind: "unreadable" };
265
+ }
266
+ try {
267
+ const block = Buffer.alloc(64 * 1024);
268
+ let pending = Buffer.alloc(0);
269
+ let readBytes = 0;
270
+ while (readBytes < FOLDER_CHECK_MAX_BYTES) {
271
+ const n = readSync(fd, block, 0, block.length, readBytes);
272
+ if (n === 0)
273
+ break;
274
+ readBytes += n;
275
+ pending = Buffer.concat([pending, block.subarray(0, n)]);
276
+ for (let end = pending.indexOf(0x0a); end >= 0; end = pending.indexOf(0x0a)) {
277
+ const line = pending.subarray(0, end).toString("utf8");
278
+ pending = pending.subarray(end + 1);
279
+ if (!line.includes('"cwd"'))
280
+ continue;
281
+ const cwd = cwdOfLine(line);
282
+ if (cwd !== null)
283
+ return { kind: "found", cwd };
284
+ }
285
+ }
286
+ const last = cwdOfLine(pending.toString("utf8"));
287
+ return last === null ? { kind: "none" } : { kind: "found", cwd: last };
288
+ }
289
+ catch {
290
+ return { kind: "unreadable" };
291
+ }
292
+ finally {
293
+ try {
294
+ closeSync(fd);
295
+ }
296
+ catch {
297
+ // Nothing useful to do.
298
+ }
299
+ }
300
+ }
301
+ function cwdOfLine(line) {
302
+ if (!line.includes('"cwd"'))
303
+ return null;
304
+ try {
305
+ const record = JSON.parse(line);
306
+ if (record === null || typeof record !== "object" || Array.isArray(record))
307
+ return null;
308
+ const cwd = record.cwd;
309
+ return typeof cwd === "string" && cwd !== "" ? cwd : null;
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ }
315
+ // folder-check:end
316
+ /**
317
+ * Does the folder the session recorded still resolve to the clone it was matched to?
318
+ *
319
+ * ⛔ The recorded value must be ABSOLUTE. A relative one (`.`) would resolve against whatever directory this process runs in,
320
+ * and a crafted transcript could then name the clone it is run from.
321
+ */
322
+ function folderMatches(recorded, commonDir) {
323
+ if (!isAbsolute(recorded))
324
+ return false;
325
+ try {
326
+ const keys = resolveProjectKeys(realpathSync(recorded));
327
+ return keys !== null && keys.consent === commonDir;
328
+ }
329
+ catch {
330
+ // Deleted, or not reachable: a folder that is not this clone.
331
+ return false;
332
+ }
333
+ }
334
+ // ---------------------------------------------------------------------------
335
+ // One session
336
+ // ---------------------------------------------------------------------------
337
+ /** The sessions running right now, by the agents' own environment variables — never sealed (§6). */
338
+ function runningSessionIds(env) {
339
+ const ids = new Set();
340
+ for (const dialect of DIALECTS) {
341
+ const id = dialect.sessionIdFromEnv(env);
342
+ if (id !== null)
343
+ ids.add(id);
344
+ }
345
+ return ids;
346
+ }
347
+ /** The disposition classes of §10.14, from what the close returned. */
348
+ function classify(outcome) {
349
+ switch (outcome.kind) {
350
+ case "stopped":
351
+ return { kind: "rejected" };
352
+ case "delivered":
353
+ case "sealed":
354
+ case "already":
355
+ case "refused":
356
+ case "unacknowledged":
357
+ return { kind: "uploaded" };
358
+ case "never-captured":
359
+ return { kind: "failed", reason: BACKFILL.refusedByServer };
360
+ case "not-delivered":
361
+ case "subagents-held": {
362
+ const delivery = outcome.delivery;
363
+ if (delivery.kind === "stopped")
364
+ return { kind: "rejected" };
365
+ if (delivery.kind === "nothing-to-send")
366
+ return { kind: "uploaded" };
367
+ if (delivery.kind === "no-repo")
368
+ return { kind: "failed", reason: BACKFILL.noRepoIdentity };
369
+ if (delivery.disposition === "ok")
370
+ return { kind: "uploaded" };
371
+ if (delivery.disposition === "fatal")
372
+ return { kind: "rejected" };
373
+ if (delivery.disposition === "later") {
374
+ // A 403 is `later` by status (`policy.ts`), so the two refusals that share it are told apart by their CODE:
375
+ // an org that has not approved capture HOLDS the repo; a repo another org owns FAILS the session with its reason
376
+ // (`resolve_repository`'s ownership oracle) — neither is "the server is busy", which is what stops a run.
377
+ if (delivery.detail?.code === CAPTURE_NOT_APPROVED)
378
+ return { kind: "held" };
379
+ if (delivery.detail?.code === "repository_forbidden") {
380
+ return { kind: "failed", reason: BACKFILL.repositoryForbidden };
381
+ }
382
+ return { kind: "stopped", unreachable: delivery.unreachable === true };
383
+ }
384
+ return { kind: "failed", reason: BACKFILL.refusedByServer };
385
+ }
386
+ }
387
+ }
388
+ async function uploadOne(run, candidate) {
389
+ const { ctx } = run;
390
+ const found = statFile(candidate.path);
391
+ if (found === null)
392
+ return { kind: "skipped", reason: BACKFILL.fileGone };
393
+ // The plan leaves these out; the file may have grown since, so the run still holds the line.
394
+ if (found.size > MAX_SESSION_BYTES)
395
+ return { kind: "skipped", reason: BACKFILL.overSize };
396
+ if (!admitsTranscript(ctx.home, ctx.env, candidate.path)) {
397
+ return { kind: "skipped", reason: BACKFILL.outsideAgentFolder };
398
+ }
399
+ if (candidate.sessionId === null)
400
+ return { kind: "failed", reason: BACKFILL.unrecognisedFileName };
401
+ const keys = resolveProjectKeys(candidate.clone);
402
+ if (keys === null)
403
+ return { kind: "failed", reason: BACKFILL.noRepoIdentity };
404
+ // ⛔ THE CONSENT GATE AGAIN, BEFORE ANYTHING IS READ. The offer was planned a while ago; `vibecommit off` (or a removed grant)
405
+ // since then must stop the rest of this repo, not just the ones not yet offered.
406
+ if (!isCaptureAllowed(ctx.home, keys, () => candidate.slug))
407
+ return { kind: "skipped", reason: BACKFILL.turnedOff };
408
+ // Exception 1. A Claude Code session says where it started inside its own transcript; Codex's index already did.
409
+ if (candidate.agent === "claude-code") {
410
+ const recorded = run.readCwd(candidate.path);
411
+ if (recorded.kind === "unreadable")
412
+ return { kind: "skipped", reason: BACKFILL.couldntBeRead };
413
+ if (recorded.kind === "none")
414
+ return { kind: "skipped", reason: BACKFILL.folderNotRecorded };
415
+ if (!folderMatches(recorded.cwd, candidate.commonDir))
416
+ return { kind: "skipped", reason: BACKFILL.folderDiffers };
417
+ }
418
+ let slug = run.slugs.get(candidate.clone);
419
+ if (slug === undefined) {
420
+ slug = resolveRepoSlug(keys.worktree);
421
+ run.slugs.set(candidate.clone, slug);
422
+ }
423
+ const quiet = ctx.now().getTime() - found.mtimeMs >= SESSION_LIVE_WINDOW_MS;
424
+ const outcome = await run.close({
425
+ home: ctx.home,
426
+ env: ctx.env,
427
+ nodeVersion: ctx.nodeVersion,
428
+ agentId: candidate.agent,
429
+ cwd: candidate.dir,
430
+ url: run.url,
431
+ credential: run.credential,
432
+ projectKey: keys.worktree,
433
+ redactionRoots: confinementRoots(keys),
434
+ repoSlug: slug,
435
+ sessionId: candidate.sessionId,
436
+ transcriptPath: candidate.path,
437
+ eof: found.size,
438
+ // Sealed only when the transcript has been quiet for 30 minutes and it is not the session running this command.
439
+ seal: quiet && !run.running.has(candidate.sessionId),
440
+ allowNeverCaptured: true,
441
+ completeSubagents: true,
442
+ });
443
+ return classify(outcome);
444
+ }
445
+ const newTally = () => ({
446
+ uploaded: 0,
447
+ held: 0,
448
+ stopped: false,
449
+ unreachable: false,
450
+ rejected: false,
451
+ skipped: new Map(),
452
+ failed: new Map(),
453
+ });
454
+ const bump = (into, key) => void into.set(key, (into.get(key) ?? 0) + 1);
455
+ function tallyLine(tally) {
456
+ const parts = [];
457
+ if (tally.uploaded > 0)
458
+ parts.push(BACKFILL.uploaded(tally.uploaded));
459
+ for (const [reason, count] of tally.skipped)
460
+ parts.push(BACKFILL.skipped(count, reason));
461
+ if (tally.held > 0)
462
+ parts.push(BACKFILL.held(tally.held));
463
+ for (const [reason, count] of tally.failed)
464
+ parts.push(BACKFILL.failed(count, reason));
465
+ if (tally.stopped)
466
+ parts.push(BACKFILL.stopped);
467
+ return parts.join(" · ");
468
+ }
469
+ const sumOf = (map) => [...map.values()].reduce((n, v) => n + v, 0);
470
+ /** "an acme-corp owner", "an owner of acme-corp or other-org". */
471
+ function ownersPhrase(slugs) {
472
+ const owners = [...new Set(slugs.map((slug) => slug.split("/")[0]))].sort();
473
+ if (owners.length === 1)
474
+ return `${/^[aeiou]/i.test(owners[0]) ? "an" : "a"} ${owners[0]} owner`;
475
+ const head = owners.slice(0, -1).join(", ");
476
+ return `an owner of ${head} or ${owners[owners.length - 1]}`;
477
+ }
478
+ /** Ctrl+C for the run: the injected signal, or a real SIGINT listener that is removed on the way out. */
479
+ function interruption(deps) {
480
+ if (deps.signal !== undefined)
481
+ return { signal: deps.signal, dispose: () => undefined };
482
+ const controller = new AbortController();
483
+ const onSigint = () => {
484
+ // A second Ctrl+C is "now": the first one waits for the session in flight.
485
+ if (controller.signal.aborted)
486
+ process.exit(EXIT.ok);
487
+ controller.abort();
488
+ };
489
+ process.on("SIGINT", onSigint);
490
+ return { signal: controller.signal, dispose: () => void process.off("SIGINT", onSigint) };
491
+ }
492
+ /**
493
+ * Ask, then upload. Everything `backfill` and `connect` share. `connect` calls it AFTER its own grant y/N and its
494
+ * "Capture is on" block (through `backfillForConnect`), and chooses its closing URL from `end`.
495
+ */
496
+ export async function runBackfill(ctx, deps = {}) {
497
+ const stop = (exit, end, uploaded = 0) => ({ exit, end, uploaded });
498
+ // Not connected is a fact about the machine, so it is answered first; then the terminal (nothing to ask on).
499
+ const load = loadCredential({ env: ctx.env, home: ctx.home });
500
+ if (load.kind === "absent") {
501
+ writeLines(ctx.stderr, renderErrorBlock({
502
+ kind: "bad",
503
+ code: "NO_CREDENTIAL",
504
+ what: BACKFILL.notConnectedWhat,
505
+ why: [BACKFILL.notConnectedWhy],
506
+ fixLabel: BACKFILL.notConnectedFixLabel,
507
+ fixes: [connectCommandFor(ctx.env, ctx.selfPath)],
508
+ }, ctx.colour));
509
+ return stop(EXIT.notConnected, "refused");
510
+ }
511
+ if (load.kind !== "ok") {
512
+ writeLines(ctx.stderr, credentialProblem(load, ctx.colour, authCommandFor(ctx.env, ctx.selfPath)));
513
+ return stop(EXIT.notConnected, "refused");
514
+ }
515
+ if (!ctx.stdinIsTty) {
516
+ writeLines(ctx.stderr, renderErrorBlock({
517
+ kind: "bad",
518
+ code: "BACKFILL_NEEDS_TTY",
519
+ what: BACKFILL.nonInteractiveWhat,
520
+ why: [BACKFILL.nonInteractiveWhy],
521
+ fixLabel: BACKFILL.nonInteractiveFix,
522
+ fixes: [channelCommand(ctx.env, ctx.selfPath, COMMANDS.backfill)],
523
+ }, ctx.colour));
524
+ return stop(EXIT.usage, "refused");
525
+ }
526
+ const url = ingestUrl(ctx.env);
527
+ if (url === null) {
528
+ writeLines(ctx.stderr, ingestUrlUnavailableBlock(ctx));
529
+ return stop(EXIT.failure, "refused");
530
+ }
531
+ // An unreadable `grants.json` denies every repo that has a remote, so "nothing to upload" would be a false answer.
532
+ if (grantsUnreadable(ctx.home)) {
533
+ writeLines(ctx.stderr, renderErrorBlock({
534
+ kind: "bad",
535
+ code: "GRANTS_UNREADABLE",
536
+ what: BACKFILL.grantsUnreadableWhat,
537
+ why: [BACKFILL.grantsUnreadableWhy],
538
+ fixLabel: BACKFILL.grantsUnreadableFixLabel,
539
+ fixes: [channelCommand(ctx.env, ctx.selfPath, COMMANDS.backfill)],
540
+ }, ctx.colour));
541
+ return stop(EXIT.failure, "refused");
542
+ }
543
+ const plan = planBackfill(ctx, deps.scanMachine ?? scanMachine);
544
+ if (plan.total === 0) {
545
+ const notes = notOfferedLines(plan, ctx.colour, ctx.home);
546
+ writeLines(ctx.stdout, ["", ...wrap(BACKFILL.nothingToUpload, 2), ...(notes.length > 0 ? ["", ...notes] : [])]);
547
+ return stop(EXIT.empty, "nothing");
548
+ }
549
+ writeLines(ctx.stdout, renderOffer(plan, {
550
+ colour: ctx.colour,
551
+ home: ctx.home,
552
+ nowMs: ctx.now().getTime(),
553
+ standalone: deps.fromConnect !== true,
554
+ }));
555
+ const { signal, dispose } = interruption(deps);
556
+ try {
557
+ let answer;
558
+ try {
559
+ answer = await ctx.ask(` ${paint(ctx.colour, "text", BACKFILL.question(plan.total))}`);
560
+ }
561
+ catch {
562
+ // Ctrl+C at the question rejects the prompt. Capture is on, so this is a stop and not a failure.
563
+ writeLines(ctx.stdout, ["", ...wrap(BACKFILL.interrupted(channelCommand(ctx.env, ctx.selfPath, COMMANDS.backfill)), 2)]);
564
+ return stop(EXIT.ok, "interrupted");
565
+ }
566
+ if (!/^\s*y(es)?\s*$/i.test(answer)) {
567
+ writeLines(ctx.stdout, ["", ...wrap(BACKFILL.declined(channelCommand(ctx.env, ctx.selfPath, COMMANDS.backfill)), 2)]);
568
+ return stop(EXIT.ok, "declined");
569
+ }
570
+ return await upload(ctx, plan, { url, credential: load.credential }, deps, signal);
571
+ }
572
+ finally {
573
+ dispose();
574
+ }
575
+ }
576
+ async function upload(ctx, plan, connection, deps, signal) {
577
+ const run = {
578
+ ctx,
579
+ url: connection.url,
580
+ credential: connection.credential,
581
+ close: deps.closeSession ?? closeSession,
582
+ readCwd: deps.readCwd ?? firstRecordedCwd,
583
+ running: runningSessionIds(ctx.env),
584
+ slugs: new Map(),
585
+ };
586
+ const column = ROW_INDENT + Math.min(SLUG_MAX, Math.max(...plan.repos.map((repo) => repo.slug.length))) + COLUMN_GAP;
587
+ const repoLine = (repo, text) => `${" ".repeat(ROW_INDENT)}${truncateMiddle(repo.slug, column - ROW_INDENT - COLUMN_GAP).padEnd(column - ROW_INDENT)}${text}`;
588
+ writeLines(ctx.stdout, [...wrap(BACKFILL.runHeading(plan.total), 2)]);
589
+ let uploaded = 0;
590
+ let skipped = 0;
591
+ let held = 0;
592
+ let failed = 0;
593
+ const heldRepos = [];
594
+ for (const repo of plan.repos) {
595
+ const tally = newTally();
596
+ let slowShown = false;
597
+ const timer = setTimeout(() => {
598
+ // A static line, overwritten once when the repo finishes — and only where an overwrite is possible (§13.8).
599
+ slowShown = true;
600
+ ctx.stdout.write(`${paint(ctx.colour, "muted", repoLine(repo, "…"))}`);
601
+ }, deps.slowAfterMs ?? SLOW_REPO_MS);
602
+ let heldRepo = false;
603
+ try {
604
+ for (const candidate of repo.candidates) {
605
+ if (signal.aborted)
606
+ break;
607
+ // A repo whose owner has not approved capture is held whole: the rest of it is not even tried.
608
+ const result = heldRepo ? { kind: "held" } : await uploadOne(run, candidate);
609
+ if (result.kind === "uploaded")
610
+ tally.uploaded += 1;
611
+ else if (result.kind === "skipped")
612
+ bump(tally.skipped, result.reason);
613
+ else if (result.kind === "failed")
614
+ bump(tally.failed, result.reason);
615
+ else if (result.kind === "held") {
616
+ tally.held += 1;
617
+ heldRepo = true;
618
+ }
619
+ else {
620
+ // `stopped` (the server asked to try later, or could not be reached) and `rejected` (the credential was refused)
621
+ // end the whole run.
622
+ tally.stopped = true;
623
+ tally.rejected = result.kind === "rejected";
624
+ tally.unreachable = result.kind === "stopped" && result.unreachable;
625
+ break;
626
+ }
627
+ }
628
+ }
629
+ finally {
630
+ clearTimeout(timer);
631
+ }
632
+ if (signal.aborted && !tally.stopped) {
633
+ if (slowShown)
634
+ ctx.stdout.write("\n");
635
+ writeLines(ctx.stdout, ["", ...wrap(BACKFILL.interrupted(channelCommand(ctx.env, ctx.selfPath, COMMANDS.backfill)), 2)]);
636
+ return { exit: EXIT.ok, end: "interrupted", uploaded: uploaded + tally.uploaded };
637
+ }
638
+ uploaded += tally.uploaded;
639
+ skipped += sumOf(tally.skipped);
640
+ failed += sumOf(tally.failed);
641
+ held += tally.held;
642
+ if (tally.held > 0)
643
+ heldRepos.push(repo.slug);
644
+ const role = tally.held > 0 || tally.stopped ? "warn" : "text";
645
+ const line = paint(ctx.colour, role, repoLine(repo, tallyLine(tally)));
646
+ // Overwrite the `…` line in place on a terminal; elsewhere it stays and the result prints beneath it.
647
+ ctx.stdout.write(slowShown && ctx.colour ? `\r${line}\n` : `${slowShown ? "\n" : ""}${line}\n`);
648
+ if (tally.stopped)
649
+ return stopRun(ctx, plan, uploaded, tally);
650
+ }
651
+ const clean = held === 0 && failed === 0;
652
+ const summary = BACKFILL.summary(uploaded, plan.total, skipped, held, failed);
653
+ const kind = clean ? "ok" : "warn";
654
+ writeLines(ctx.stdout, ["", ` ${paint(ctx.colour, kind, glyph(ctx.colour, kind))} ${summary}`]);
655
+ if (held > 0) {
656
+ writeLines(ctx.stdout, [
657
+ ...wrap(BACKFILL.heldBlock(ownersPhrase(heldRepos)), 4).map((l) => paint(ctx.colour, "muted", l)),
658
+ ` ${paint(ctx.colour, "accent", URLS.captureAccess)}`,
659
+ ` ${BACKFILL.heldThenRun} ${paint(ctx.colour, "accent", channelCommand(ctx.env, ctx.selfPath, COMMANDS.backfill))}`,
660
+ ]);
661
+ }
662
+ return { exit: EXIT.ok, end: "uploaded", uploaded };
663
+ }
664
+ /**
665
+ * A server `later` ended the run, the server could not be reached, or the credential was refused: the resume fix goes last
666
+ * (§13.6). ⛔ The block states only what landed: `uploaded` is sessions the server took.
667
+ */
668
+ function stopRun(ctx, plan, uploaded, tally) {
669
+ const notUploaded = plan.total - uploaded;
670
+ const block = tally.rejected
671
+ ? renderErrorBlock({
672
+ kind: "bad",
673
+ code: "CREDENTIAL_REJECTED",
674
+ what: BACKFILL.credentialStoppedWhat,
675
+ why: [BACKFILL.credentialStoppedWhy],
676
+ fixLabel: BACKFILL.credentialStoppedFixLabel,
677
+ fixes: [authCommandFor(ctx.env, ctx.selfPath), channelCommand(ctx.env, ctx.selfPath, COMMANDS.backfill)],
678
+ }, ctx.colour)
679
+ : renderErrorBlock({
680
+ kind: "warn",
681
+ what: BACKFILL.stopWhat(uploaded, plan.total),
682
+ why: [tally.unreachable ? BACKFILL.stopWhyUnreachable(notUploaded) : BACKFILL.stopWhy(notUploaded)],
683
+ fixLabel: BACKFILL.stopFixLabel,
684
+ fixes: [channelCommand(ctx.env, ctx.selfPath, COMMANDS.backfill)],
685
+ }, ctx.colour);
686
+ writeLines(ctx.stdout, ["", ...block]);
687
+ return { exit: EXIT.failure, end: "stopped", uploaded };
688
+ }
689
+ const NOTHING_NOT_LISTED = {
690
+ noRemote: [],
691
+ ambiguousSessions: 0,
692
+ missingFolderSessions: 0,
693
+ nonRepoSessions: 0,
694
+ pathReusedSessions: 0,
695
+ noReflogClones: [],
696
+ unindexedSessions: 0,
697
+ unreadableAgents: [],
698
+ };
699
+ /**
700
+ * `connect`'s backfill step. It is handed the repos `connect`'s own scan found (the picker's), asks the backfill y/N
701
+ * and runs, so the picker's scan is not repeated. It owns its Ctrl+C: stopping a backfill is `ran`, and the run has
702
+ * already printed the stop line. A no, nothing waiting, and a refusal are all `declined`: `connect` ends on `/app`.
703
+ *
704
+ * ⚠ The consent gate runs HERE, over what `connect` just granted, not over what the scan saw before the grant: a repo the
705
+ * user did not choose is dropped by `planBackfill` however it got into `repos`; so is a clone with no reflog, which the plan asks
706
+ * git about itself.
707
+ */
708
+ export async function backfillForConnect(ctx, repos, deps = {}) {
709
+ const result = await runBackfill(ctx, { ...deps, fromConnect: true, scanMachine: () => ({ repos, notListed: NOTHING_NOT_LISTED }) });
710
+ switch (result.end) {
711
+ case "declined":
712
+ case "nothing":
713
+ case "refused":
714
+ return { kind: "declined" };
715
+ case "stopped":
716
+ return { kind: "stopped", uploaded: result.uploaded };
717
+ default:
718
+ return { kind: "ran", uploaded: result.uploaded };
719
+ }
720
+ }
721
+ // ---------------------------------------------------------------------------
722
+ // The verb
723
+ // ---------------------------------------------------------------------------
724
+ /**
725
+ * `vibecommit backfill`. Exit `0` done (held sessions included: org-pending is 0, as in §10.2), `1` stopped with the resume
726
+ * fix, `2` no terminal, `3` not connected, `4` nothing to upload. It ends on the dashboard URL after a completed run or a
727
+ * no, and on its resume fix after a stop.
728
+ */
729
+ export async function backfill(ctx, argv = [], deps = {}) {
730
+ if (argv.includes("--help") || argv.includes("-h")) {
731
+ const helpVerb = "backfill"; // an identifier, not a literal, at the writer: `copy-inline-literals` follows argument positions
732
+ writeLines(ctx.stdout, renderVerbHelp(helpVerb));
733
+ return EXIT.ok;
734
+ }
735
+ if (argv.length > 0) {
736
+ writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "UNRECOGNIZED_ARGUMENT", what: BACKFILL.usageWhat, why: [BACKFILL.usageWhy] }, ctx.colour));
737
+ return EXIT.usage;
738
+ }
739
+ const result = await runBackfill(ctx, deps);
740
+ if (result.end === "uploaded" || result.end === "interrupted") {
741
+ writeLines(ctx.stdout, ["", ` ${paint(ctx.colour, "accent", result.uploaded > 0 ? URLS.conversations : URLS.dashboard)}`]);
742
+ }
743
+ else if (result.end === "declined") {
744
+ writeLines(ctx.stdout, ["", ` ${paint(ctx.colour, "accent", URLS.dashboard)}`]);
745
+ }
746
+ return result.exit;
747
+ }
748
+ //# sourceMappingURL=backfill.js.map