@vibe-commit/capture 0.1.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/agents/claude_code.d.ts +11 -0
  4. package/dist/agents/claude_code.js +162 -0
  5. package/dist/agents/claude_code.js.map +1 -0
  6. package/dist/agents/codex_cli.d.ts +15 -0
  7. package/dist/agents/codex_cli.js +67 -0
  8. package/dist/agents/codex_cli.js.map +1 -0
  9. package/dist/agents/cursor.d.ts +21 -0
  10. package/dist/agents/cursor.js +89 -0
  11. package/dist/agents/cursor.js.map +1 -0
  12. package/dist/agents/registry.d.ts +184 -0
  13. package/dist/agents/registry.js +212 -0
  14. package/dist/agents/registry.js.map +1 -0
  15. package/dist/agents/types.d.ts +279 -0
  16. package/dist/agents/types.js +67 -0
  17. package/dist/agents/types.js.map +1 -0
  18. package/dist/commands/connect.d.ts +36 -0
  19. package/dist/commands/connect.js +499 -0
  20. package/dist/commands/connect.js.map +1 -0
  21. package/dist/commands/context.d.ts +45 -0
  22. package/dist/commands/context.js +14 -0
  23. package/dist/commands/context.js.map +1 -0
  24. package/dist/commands/report.d.ts +105 -0
  25. package/dist/commands/report.js +430 -0
  26. package/dist/commands/report.js.map +1 -0
  27. package/dist/commands/status.d.ts +29 -0
  28. package/dist/commands/status.js +236 -0
  29. package/dist/commands/status.js.map +1 -0
  30. package/dist/commands/why.d.ts +349 -0
  31. package/dist/commands/why.js +937 -0
  32. package/dist/commands/why.js.map +1 -0
  33. package/dist/consent.d.ts +59 -0
  34. package/dist/consent.js +111 -0
  35. package/dist/consent.js.map +1 -0
  36. package/dist/copy/claims.d.ts +79 -0
  37. package/dist/copy/claims.js +343 -0
  38. package/dist/copy/claims.js.map +1 -0
  39. package/dist/copy/grades.d.ts +117 -0
  40. package/dist/copy/grades.js +96 -0
  41. package/dist/copy/grades.js.map +1 -0
  42. package/dist/copy/index.d.ts +12 -0
  43. package/dist/copy/index.js +13 -0
  44. package/dist/copy/index.js.map +1 -0
  45. package/dist/copy/strings.d.ts +1328 -0
  46. package/dist/copy/strings.js +1243 -0
  47. package/dist/copy/strings.js.map +1 -0
  48. package/dist/copy/time.d.ts +27 -0
  49. package/dist/copy/time.js +43 -0
  50. package/dist/copy/time.js.map +1 -0
  51. package/dist/credential.d.ts +211 -0
  52. package/dist/credential.js +298 -0
  53. package/dist/credential.js.map +1 -0
  54. package/dist/exit.d.ts +59 -0
  55. package/dist/exit.js +60 -0
  56. package/dist/exit.js.map +1 -0
  57. package/dist/git.d.ts +299 -0
  58. package/dist/git.js +653 -0
  59. package/dist/git.js.map +1 -0
  60. package/dist/hooks/entry.d.ts +277 -0
  61. package/dist/hooks/entry.js +1092 -0
  62. package/dist/hooks/entry.js.map +1 -0
  63. package/dist/hooks/post_commit.d.ts +59 -0
  64. package/dist/hooks/post_commit.js +160 -0
  65. package/dist/hooks/post_commit.js.map +1 -0
  66. package/dist/hooks/post_rewrite.d.ts +59 -0
  67. package/dist/hooks/post_rewrite.js +161 -0
  68. package/dist/hooks/post_rewrite.js.map +1 -0
  69. package/dist/hooks/session_end.d.ts +101 -0
  70. package/dist/hooks/session_end.js +120 -0
  71. package/dist/hooks/session_end.js.map +1 -0
  72. package/dist/index.d.ts +34 -0
  73. package/dist/index.js +360 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/install.d.ts +153 -0
  76. package/dist/install.js +284 -0
  77. package/dist/install.js.map +1 -0
  78. package/dist/json.d.ts +100 -0
  79. package/dist/json.js +94 -0
  80. package/dist/json.js.map +1 -0
  81. package/dist/oauth/discovery.d.ts +96 -0
  82. package/dist/oauth/discovery.js +163 -0
  83. package/dist/oauth/discovery.js.map +1 -0
  84. package/dist/oauth/lock.d.ts +100 -0
  85. package/dist/oauth/lock.js +287 -0
  86. package/dist/oauth/lock.js.map +1 -0
  87. package/dist/oauth/loopback.d.ts +85 -0
  88. package/dist/oauth/loopback.js +124 -0
  89. package/dist/oauth/loopback.js.map +1 -0
  90. package/dist/oauth/pkce.d.ts +58 -0
  91. package/dist/oauth/pkce.js +77 -0
  92. package/dist/oauth/pkce.js.map +1 -0
  93. package/dist/oauth/read.d.ts +120 -0
  94. package/dist/oauth/read.js +144 -0
  95. package/dist/oauth/read.js.map +1 -0
  96. package/dist/oauth/session.d.ts +80 -0
  97. package/dist/oauth/session.js +112 -0
  98. package/dist/oauth/session.js.map +1 -0
  99. package/dist/oauth/signin.d.ts +178 -0
  100. package/dist/oauth/signin.js +225 -0
  101. package/dist/oauth/signin.js.map +1 -0
  102. package/dist/oauth/token.d.ts +139 -0
  103. package/dist/oauth/token.js +183 -0
  104. package/dist/oauth/token.js.map +1 -0
  105. package/dist/paths.d.ts +366 -0
  106. package/dist/paths.js +460 -0
  107. package/dist/paths.js.map +1 -0
  108. package/dist/policy.d.ts +195 -0
  109. package/dist/policy.js +244 -0
  110. package/dist/policy.js.map +1 -0
  111. package/dist/post.d.ts +302 -0
  112. package/dist/post.js +384 -0
  113. package/dist/post.js.map +1 -0
  114. package/dist/project.d.ts +99 -0
  115. package/dist/project.js +192 -0
  116. package/dist/project.js.map +1 -0
  117. package/dist/redact.d.ts +175 -0
  118. package/dist/redact.js +700 -0
  119. package/dist/redact.js.map +1 -0
  120. package/dist/runtime.d.ts +30 -0
  121. package/dist/runtime.js +37 -0
  122. package/dist/runtime.js.map +1 -0
  123. package/dist/spawn_budget.d.ts +101 -0
  124. package/dist/spawn_budget.js +124 -0
  125. package/dist/spawn_budget.js.map +1 -0
  126. package/dist/spool.d.ts +390 -0
  127. package/dist/spool.js +556 -0
  128. package/dist/spool.js.map +1 -0
  129. package/dist/state.d.ts +109 -0
  130. package/dist/state.js +217 -0
  131. package/dist/state.js.map +1 -0
  132. package/dist/system_message.d.ts +101 -0
  133. package/dist/system_message.js +141 -0
  134. package/dist/system_message.js.map +1 -0
  135. package/dist/term.d.ts +112 -0
  136. package/dist/term.js +134 -0
  137. package/dist/term.js.map +1 -0
  138. package/dist/version.d.ts +16 -0
  139. package/dist/version.js +17 -0
  140. package/dist/version.js.map +1 -0
  141. package/package.json +50 -0
package/dist/paths.js ADDED
@@ -0,0 +1,460 @@
1
+ /**
2
+ * The `~/.vibecommit/` layout.
3
+ *
4
+ * One module so the fifteen tasks that build on this one do not each invent a
5
+ * path. Every function takes `home` explicitly rather than calling `os.homedir()`
6
+ * internally — the contract test runs the real binary against a temp HOME, and a
7
+ * module that reads the real one cannot be tested without touching the
8
+ * developer's own credentials.
9
+ *
10
+ * `CR-017d` added the repo to the binding key and hangs per-repo send state off
11
+ * `stateDir` — see `sessionStatePath` and `SessionKey` below.
12
+ *
13
+ * @provenance vibecommit-schema 20260809150000_cr017a_repo_binding_key.sql — PK shape, mirrored
14
+ * @provenance vibecommit-schema ingest_sessions.file_key — CHECK bound 1..128, retyped
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { realpathSync } from "node:fs";
18
+ import { join, sep } from "node:path";
19
+ /** Root of the client's own state. Never Claude Code's — see the plan's keychain note. */
20
+ export function rootDir(home) {
21
+ return join(home, ".vibecommit");
22
+ }
23
+ /** The ingest credential envelope. Mode 0600, enforced on read (`credential.ts`). */
24
+ export function credentialsPath(home) {
25
+ return join(rootDir(home), "credentials.json");
26
+ }
27
+ /** The per-project consent allow list. Consulted BEFORE any transcript read (D56 §D19). */
28
+ export function projectsPath(home) {
29
+ return join(rootDir(home), "projects.json");
30
+ }
31
+ /**
32
+ * The HUMAN lane's signed-in session — `CR-084d`. Mode 0600, enforced on read by
33
+ * the same `readSecretFile` the ingest credential goes through.
34
+ *
35
+ * A SECOND FILE and not a second field in `credentials.json`, deliberately. The
36
+ * two hold different privilege classes on different lanes: `credentials.json`
37
+ * holds the machine's opaque ingest credential and is read by every hook, and
38
+ * this holds a user-principal refresh token read only by interactive verbs. One
39
+ * file would mean the hook path opens the more powerful secret on every turn for
40
+ * no reason, and `VIBECOMMIT_TOKEN` (which overrides the ingest credential and
41
+ * nothing else) would acquire a meaning it does not have.
42
+ */
43
+ export function sessionPath(home) {
44
+ return join(rootDir(home), "session.json");
45
+ }
46
+ /**
47
+ * Client state. `CR-023`'s capture-confirmed key lives here under `(repo, day)`;
48
+ * the three negative `systemMessage` states use a per-session file.
49
+ */
50
+ export function stateDir(home) {
51
+ return join(rootDir(home), "state");
52
+ }
53
+ /**
54
+ * Once-per-session marker for a `systemMessage` state.
55
+ *
56
+ * Keyed on `(session, state)` and NOT on the transcript file: `ER23` keys client
57
+ * state per file, which would fire the same message once for `main` and once for
58
+ * every `subagents/agent-*.jsonl` in the same session.
59
+ */
60
+ export function sessionNoticePath(home, sessionId, state) {
61
+ return join(stateDir(home), "notices", sanitise(sessionId), `${sanitise(state)}.json`);
62
+ }
63
+ /**
64
+ * Once-per-`(repo, day)` marker for the capture-confirmed notice — `CR-023d`.
65
+ *
66
+ * **The key is neither the session nor "ever", and both alternatives are wrong
67
+ * in a way you can feel.** `PreCompact` mints a fresh `session_id` mid-task, so
68
+ * a per-session key fires the one positive message twice in an afternoon and
69
+ * reads as a bug. A key with no day component fires it exactly once ever, which
70
+ * under-communicates on a tool used daily — and this is the single positive beat
71
+ * in a channel that is otherwise silent by design (style guide §10.1).
72
+ *
73
+ * A per-FILE key would be wrong too, once `CR-124` (W6) starts producing
74
+ * `agent-*` file keys: it would fire once for `main` and once per sub-agent
75
+ * transcript in the same session. The key is deliberately chosen so that landing
76
+ * `CR-124` cannot change how often this message appears.
77
+ *
78
+ * `repo-day/` namespaces this away from `sessionNoticePath`'s
79
+ * `notices/<session>/<state>.json`: the two live at different depths under
80
+ * `notices/`, so no session id can ever resolve onto a repo-day file.
81
+ *
82
+ * The repo is the same SHA-256 digest `sessionStatePath` uses, for the same
83
+ * reason — `sanitise` truncates at 128 characters, so two deep checkouts sharing
84
+ * a prefix would land on one key and the second repo would never see its
85
+ * confirmation. `day` is supplied by the caller as a UTC `YYYY-MM-DD`
86
+ * (`system_message.ts`'s `utcDay`), never read from a local clock here.
87
+ */
88
+ export function repoDayNoticePath(home, repoKey, day, state) {
89
+ return join(stateDir(home), "notices", "repo-day", repoDigest(repoKey), sanitise(day), `${sanitise(state)}.json`);
90
+ }
91
+ /**
92
+ * Per-`(repo, session)` send state — `CR-018`'s offset ledger, backlog and
93
+ * terminal stop, keyed by `CR-017d`.
94
+ *
95
+ * **Returns null when there is no repo identity, and that is the fix.** D58: *"The
96
+ * server key gains repo; the client offset state did not, so a `prefix_sha256`
97
+ * resync would duplicate repo 1's turns into repo 2."* Keyed on the session alone
98
+ * this file was machine-wide, so one Claude Code session whose `cwd` moved between
99
+ * two repos read and wrote the same offsets for both. A caller with no repo must
100
+ * therefore get NO path at all rather than a shared bucket — a placeholder key is
101
+ * the bleed with an extra step (D72's "required from day one", mirrored).
102
+ *
103
+ * The repo is a SHA-256 digest and not the sanitised path: `sanitise` truncates at
104
+ * 128 characters, and two work trees sharing a long prefix would land in one
105
+ * directory — a silent collision at exactly the boundary being defended.
106
+ *
107
+ * Still one file per session rather than one per `(session, file)`, because `seq`
108
+ * is monotonic per SESSION across the main transcript and every sub-agent's, so
109
+ * splitting the store would split the counter that has to stay single.
110
+ */
111
+ export function sessionStatePath(home, key) {
112
+ const dir = repoSessionsDir(home, key.repoKey);
113
+ if (dir === null)
114
+ return null;
115
+ return join(dir, `${sanitise(key.sessionId)}.json`);
116
+ }
117
+ /**
118
+ * Every session file for ONE repo — the directory `sessionStatePath` writes into.
119
+ *
120
+ * `status` needs this and a hook does not: a hook is handed a `session_id` on
121
+ * stdin, but `vibecommit status` is interactive and has none, so the only way it
122
+ * can answer "when did this repo last send?" is to look at every session under
123
+ * the repo (`CR-021`). Exposed rather than duplicated so there is still exactly
124
+ * one place that knows the layout, and the digest stays private.
125
+ *
126
+ * Null on no repo, for the same reason `sessionStatePath` is: there is no
127
+ * repo-less bucket, and a caller with no repo has nothing to scan.
128
+ */
129
+ export function repoSessionsDir(home, repoKey) {
130
+ if (repoKey === "")
131
+ return null;
132
+ return join(stateDir(home), "sessions", repoDigest(repoKey));
133
+ }
134
+ /** A fixed-length, collision-free filename component for a work-tree path. */
135
+ function repoDigest(repoKey) {
136
+ return createHash("sha256").update(repoKey).digest("hex").slice(0, 32);
137
+ }
138
+ /**
139
+ * Where Claude Code writes transcripts.
140
+ *
141
+ * `CLAUDE_CONFIG_DIR` is honoured because Claude Code honours it; hard-coding
142
+ * `~/.claude` would silently capture nothing for anyone who relocated it.
143
+ */
144
+ export function transcriptRoot(home, env) {
145
+ const configured = env.CLAUDE_CONFIG_DIR;
146
+ const root = configured !== undefined && configured.trim() !== ""
147
+ ? configured.trim()
148
+ : join(home, ".claude");
149
+ return join(root, "projects");
150
+ }
151
+ /**
152
+ * Where Codex CLI writes transcripts — `CR-183`, D164.
153
+ *
154
+ * BESIDE `transcriptRoot` rather than inside it, because the two roots are two
155
+ * facts about two products: `CODEX_HOME ?? ~/.codex` plus `sessions`, against
156
+ * `CLAUDE_CONFIG_DIR ?? ~/.claude` plus `projects`. Folding them into one
157
+ * function taking a discriminant would make the union below read as a branch on
158
+ * a flag rather than as an enumeration of the registry, which is the property
159
+ * the confinement check depends on.
160
+ *
161
+ * ⚠ **The env var is honoured on the same evidence the Claude one is: because
162
+ * the product honours it.** Hard-coding `~/.codex` would silently capture
163
+ * nothing for anyone who relocated it.
164
+ */
165
+ export function codexTranscriptRoot(home, env) {
166
+ const configured = env.CODEX_HOME;
167
+ const root = configured !== undefined && configured.trim() !== ""
168
+ ? configured.trim()
169
+ : join(home, ".codex");
170
+ return join(root, "sessions");
171
+ }
172
+ /**
173
+ * Where Cursor writes transcripts — `CR-193`, D177 §2.
174
+ *
175
+ * ⛔ **`projects`, and NOT the `agent-transcripts` directory D177 §2 names —
176
+ * because that root IS NOT EXPRESSIBLE HERE, and the reason is measured rather
177
+ * than argued.**
178
+ *
179
+ * One conversation's transcript lives at
180
+ * `~/.cursor/projects/<munged>/agent-transcripts/<cid>/<cid>.jsonl`, where
181
+ * `<munged>` is the WORKSPACE fsPath with every non-alphanumeric run collapsed
182
+ * to `-`. A `transcriptRoot` member is handed `home` and `env` and nothing else,
183
+ * and `<munged>` is derivable from neither: this machine already carries two of
184
+ * them, `Users-acohan-Documents-VibeCommit` and `empty-window`.
185
+ *
186
+ * ⚠ **And the obvious rescue — derive it from the consented project key — is
187
+ * measured WRONG, not merely awkward.** `resolveProjectKey` is
188
+ * `git rev-parse --show-toplevel`, and the workspace that produced the D177 §10
189
+ * corpus, `/Users/acohan/Documents/VibeCommit`, is **not a git repository at
190
+ * all**. The repository that IS one sits a directory below it, so its project
191
+ * key munges to that workspace's name with the child directory appended — a
192
+ * name with no directory under `projects/` — and a project-key-derived root
193
+ * would refuse the one real transcript there is.
194
+ *
195
+ * ⇒ ⛔ **The interior shape is enforced by `isCursorTranscript` instead**, which
196
+ * runs AFTER the registry-computed boundary and can only refuse. Read that
197
+ * function's docblock for what this changes about D177 §2's stated reason: it
198
+ * inverts it.
199
+ *
200
+ * ⚠ **No environment override, and that is an absence rather than an
201
+ * oversight.** `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honoured because those
202
+ * products honour them. No Cursor equivalent has been measured, and inventing
203
+ * one would be a configuration surface this client reads and nothing writes.
204
+ */
205
+ export function cursorTranscriptRoot(home) {
206
+ return join(home, ".cursor", "projects");
207
+ }
208
+ /**
209
+ * The one child of a Cursor workspace directory that holds transcripts.
210
+ *
211
+ * Its three measured siblings are `agent-tools/` (scraped web content),
212
+ * `canvases/` (a React/TypeScript scratch project, `node_modules` and all) and
213
+ * `mcps/` (per-server tool schemas). D177 §2.
214
+ */
215
+ const CURSOR_TRANSCRIPTS_DIR = "agent-transcripts";
216
+ /**
217
+ * ⛔ **THE `.jsonl` GATE — `CR-193`, D177 §2**, which supersedes `CR-183`'s
218
+ * exclusion of `transcriptExtension`.
219
+ *
220
+ * The lane's file gate: `transport: "ndjson"` says every member appends NDJSON
221
+ * to a file, and `.jsonl` is what that file is called. Confinement answers
222
+ * *"may this path be read at all"*; this answers *"is this a transcript"*, and
223
+ * the two are different questions — a root can be exactly right and still
224
+ * contain files that are not transcripts.
225
+ *
226
+ * ⚠ **`root` is unused HERE and is a parameter of the member, because Cursor's
227
+ * answer needs it.** Under Claude's and Codex's roots every file is a
228
+ * transcript, so the suffix is the whole test; under Cursor's it is not.
229
+ *
230
+ * ⚠ **No `realpath`, deliberately, and only here.** A symlink whose name ends
231
+ * `.jsonl` and whose target does not is a real shape, and `isCursorTranscript`
232
+ * resolves for exactly that reason — but under these two roots there is nothing
233
+ * to point at: every file under them is a transcript already. Paying two
234
+ * syscalls per hook to defend a set with no members is the cost this note exists
235
+ * to decline.
236
+ */
237
+ export function isTranscriptFile(_root, path) {
238
+ return path.endsWith(TRANSCRIPT_SUFFIX);
239
+ }
240
+ /**
241
+ * The lane's gate PLUS the interior shape Cursor's root cannot declare —
242
+ * `CR-193`, D177 §2.
243
+ *
244
+ * ⛔ **AND THIS IS WHERE D177 §2's STATED REASON INVERTS. Say it in these
245
+ * words, because the polarity is the whole point.** §2 records that the ROOT is
246
+ * what excludes the 32 KB scraped-web-content `.txt`, and that the gate
247
+ * therefore ships as fail-closed defence with **no corpus witness** —
248
+ * `agent-tools/` being a SIBLING of `agent-transcripts/`. That is true of the
249
+ * root §2 names. It is **false of the root that is expressible** (see
250
+ * `cursorTranscriptRoot`): rooted at `projects`, `find -type f` returns **113
251
+ * files, exactly 1 of them the transcript** — 66 `.json`, 34 `.ts`, 8 `.md`,
252
+ * 3 extensionless, 1 `.txt`. ⇒ ⛔ **the gate has 112 corpus witnesses and IS
253
+ * what excludes the `.txt`.** It is not defence in depth here; it is the
254
+ * mechanism.
255
+ *
256
+ * ⚠ **THOSE FIGURES ARE A CORRECTION.** This docblock first said 112/111. The
257
+ * total was summed from a per-extension HISTOGRAM rather than read from
258
+ * `find … | wc -l`, and the histogram's own `.jsonl` row was dropped in the
259
+ * addition — `feedback_bounded_output_is_not_a_count`, on the very measurement
260
+ * that motivates this function. Re-measured three ways that must agree:
261
+ * `-type f` = 113, `-name '*.jsonl'` = 1, `-not -name '*.jsonl'` = 112.
262
+ *
263
+ * ⛔ **The segment test is what puts D177 §2's boundary back.** A `.jsonl`
264
+ * anywhere under `projects` would otherwise be admitted for upload, and
265
+ * `agent-tools/` is precisely where Cursor spills whatever the agent
266
+ * downloaded. Requiring `<root>/<workspace>/agent-transcripts/…` reconstructs
267
+ * the root §2 asked for, as a narrowing applied AFTER the registry-computed
268
+ * boundary — so it can only refuse, never widen (D177 §7's direction test).
269
+ *
270
+ * ⚠ **`M0.3` measured ZERO non-`.jsonl` files INSIDE `agent-transcripts/`** (the
271
+ * whole directory holds one file). So the suffix half of this test still has no
272
+ * witness under the segment half, and absence from a corpus is not absence from
273
+ * the product — `D172 §2`'s `notebook_path` reasoning, unchanged.
274
+ *
275
+ * ⛔ **Resolved, because the segment names are the thing being trusted.**
276
+ * `isInside` already resolves both sides, so a path claiming to be under this
277
+ * root cannot be outside it; what a string test would still miss is a symlink
278
+ * INSIDE the root — `…/agent-transcripts/x.jsonl` pointing at
279
+ * `…/agent-tools/scrape.txt`, which passes confinement on the real path and
280
+ * passes a naive segment test on the claimed one. `isInside` is reused rather
281
+ * than re-implemented (`CR-183`); the resolution below exists only to locate the
282
+ * segments within it.
283
+ */
284
+ export function isCursorTranscript(root, path) {
285
+ if (!isInside(root, path))
286
+ return false;
287
+ let realRoot;
288
+ let realPath;
289
+ try {
290
+ realRoot = realpathSync(root);
291
+ realPath = realpathSync(path);
292
+ }
293
+ catch {
294
+ return false;
295
+ }
296
+ if (!realPath.endsWith(TRANSCRIPT_SUFFIX))
297
+ return false;
298
+ const segments = realPath.slice(realRoot.length + 1).split(sep);
299
+ // `<workspace>` / `agent-transcripts` / at least one thing under it.
300
+ return segments.length >= 3 && segments[1] === CURSOR_TRANSCRIPTS_DIR;
301
+ }
302
+ /**
303
+ * Where Claude Code writes DELEGATED work — `CR-124`, D76.
304
+ *
305
+ * `<dir>/<session-id>/subagents/`, beside `<dir>/<session-id>.jsonl`. Derived
306
+ * from `transcript_path` because there is no environment variable for it and
307
+ * the hook is never handed one: the session id is the transcript's own basename.
308
+ *
309
+ * Measured on this machine rather than read from documentation — and the
310
+ * measurement carries a trap. The directory holds an `agent-<id>.meta.json`
311
+ * sidecar beside every `agent-<id>.jsonl`, **636 of each**, exactly 1:1. A
312
+ * `agent-*` glob would therefore double every upload and send metadata as if it
313
+ * were transcript content. The caller globs `agent-*.jsonl`.
314
+ *
315
+ * Returns null for a path that is not a `.jsonl`, rather than inventing a
316
+ * directory next to something that was never a transcript.
317
+ */
318
+ export function subagentsDir(transcriptPath) {
319
+ if (!transcriptPath.endsWith(TRANSCRIPT_SUFFIX))
320
+ return null;
321
+ return join(transcriptPath.slice(0, -TRANSCRIPT_SUFFIX.length), "subagents");
322
+ }
323
+ const TRANSCRIPT_SUFFIX = ".jsonl";
324
+ /**
325
+ * The `file_key` a sub-agent transcript travels under: its basename, minus
326
+ * `.jsonl`.
327
+ *
328
+ * From the FILENAME, deliberately. Every record inside also carries `agentId`,
329
+ * but reading a value out of a file to name that file is analysis this client is
330
+ * not licensed to do (D60 §D6 / §D1a) and does not need.
331
+ *
332
+ * Null when the key would breach the server's 1..128 bound
333
+ * (`ingest_sessions.file_key`'s CHECK, `CR-008`): a delta the server 400s is
334
+ * classed `never` and would advance the offset past bytes it never stored.
335
+ * Longest key measured on this machine is 49 characters, so this is a bound
336
+ * being respected rather than one being hit.
337
+ */
338
+ export function subagentFileKey(fileName) {
339
+ if (!fileName.startsWith("agent-") || !fileName.endsWith(TRANSCRIPT_SUFFIX))
340
+ return null;
341
+ const key = fileName.slice(0, -TRANSCRIPT_SUFFIX.length);
342
+ return key.length >= 1 && key.length <= 128 ? key : null;
343
+ }
344
+ /**
345
+ * The `file_key` an ANNOUNCED sub-agent transcript travels under — `CR-196`,
346
+ * D177 §9. ⛔ **`agent-<childId>`, SYNTHESIZED HERE, CLIENT-SIDE.**
347
+ *
348
+ * ## ⛔ Why this sits BESIDE `subagentFileKey` and not inside it
349
+ *
350
+ * They are opposite jobs on opposite layouts, and one function doing both would
351
+ * be strictly worse at each:
352
+ *
353
+ * `subagentFileKey` a FILTER. Claude's `subagents/` directory is globbed, so
354
+ * the caller sees `agent-<id>.jsonl`, `agent-<id>.meta.json`
355
+ * and anything else that is in there — and the prefix test
356
+ * is what REFUSES the sidecar and `main.jsonl`. Relaxing it
357
+ * to "mint a prefix if one is missing" would make the
358
+ * discover path accept every one of those.
359
+ * this a CONSTRUCTOR. Cursor's children are `<childId>.jsonl`
360
+ * with **no prefix at all**, and the path was HANDED over
361
+ * rather than found, so there is nothing to filter — the
362
+ * only question is what to call the stream.
363
+ *
364
+ * ## ⛔ The key stays FILENAME-derived, which is what keeps D60 §D6 intact
365
+ *
366
+ * `<childId>` is the announced path's BASENAME. It is not read out of any
367
+ * record inside the file, so *"do not read a value out of a file to name that
368
+ * file"* still holds — the same rule `subagentFileKey`'s own docblock states
369
+ * about `agentId`. What arrives on stdin is a PATH; this reads its name.
370
+ *
371
+ * ## ⛔ WIDENING `ingest_sessions.file_key` WAS CONSIDERED AND DECLINED (D177 §9)
372
+ *
373
+ * Five independent layers refuse a bare `<childId>`, and a DB-only widening
374
+ * would not have worked at all: `subagentFileKey`'s own prefix test, `post.ts`'s
375
+ * `FileKey` type, `cr008`'s CHECK (whose C5/C6/C7 cells pin more than the CHECK
376
+ * line shows), the server's family check — where a 400 classes `never`, advances
377
+ * the offset and stamps a PERMANENT gap — and the health classifier, which
378
+ * would silently reclassify every sub-agent stream as a MAIN stream. Synthesis
379
+ * touches none of them: no migration, no production deploy.
380
+ *
381
+ * ## The prefix is added UNCONDITIONALLY, and that is what makes it injective
382
+ *
383
+ * A child literally named `agent-x.jsonl` becomes `agent-agent-x`, which is
384
+ * ugly and is the only answer that cannot collide: `x` → `agent-x` and
385
+ * `agent-x` → `agent-agent-x` stay distinct, where "prefix only if missing"
386
+ * maps both onto `agent-x` — two streams sharing one offset ledger.
387
+ *
388
+ * Null when the key would breach the server's 1..128 bound, exactly as
389
+ * `subagentFileKey` is, and null for an empty `<childId>`: a bare `agent-` is
390
+ * REJECTED by `cr008`'s CHECK (`like 'agent-_%'`), and its C5 cell pins that.
391
+ */
392
+ export function announcedSubagentFileKey(fileName) {
393
+ if (!fileName.endsWith(TRANSCRIPT_SUFFIX))
394
+ return null;
395
+ const childId = fileName.slice(0, -TRANSCRIPT_SUFFIX.length);
396
+ if (childId === "")
397
+ return null;
398
+ const key = `agent-${childId}`;
399
+ return key.length <= 128 ? key : null;
400
+ }
401
+ /**
402
+ * Is `candidate` really inside `root`?
403
+ *
404
+ * `/cso` finding 1 (2026-08-09), HIGH. `transcript_path` arrives on hook stdin
405
+ * and was read and uploaded verbatim under the user's ingest credential, with no
406
+ * check that it was a transcript at all. Anything that could put a hook
407
+ * definition in front of Claude Code could therefore point the client at
408
+ * `~/.ssh/id_rsa` and have it shipped off the machine — and the hook contract's
409
+ * exit-0 silence meant nothing would show on any stream. The silence that makes
410
+ * the hook safe for the developer's turn is what made that quiet.
411
+ *
412
+ * `realpathSync` on BOTH sides, so a symlink cannot walk out of the root: a
413
+ * string prefix test on the unresolved path is exactly the check that misses
414
+ * `~/.claude/projects/x -> /etc`. A missing path resolves to "outside", because
415
+ * a transcript we cannot resolve is not one we should read.
416
+ */
417
+ export function isInside(root, candidate) {
418
+ let realRoot;
419
+ let realCandidate;
420
+ try {
421
+ realRoot = realpathSync(root);
422
+ realCandidate = realpathSync(candidate);
423
+ }
424
+ catch {
425
+ return false;
426
+ }
427
+ return realCandidate === realRoot || realCandidate.startsWith(realRoot + sep);
428
+ }
429
+ /**
430
+ * Is `candidate` inside ANY of `roots`? — `CR-183`, D164 §5.
431
+ *
432
+ * ⛔ **A WRAPPER, and `isInside` above is byte-unchanged.** The union is a
433
+ * second agent's root being added to the SET the check runs over; it is not a
434
+ * loosening of what "inside" means, and rewriting the resolved-path comparison
435
+ * to take a list would have put the `/cso` finding-1 remediation back on the
436
+ * table for a change that is about membership.
437
+ *
438
+ * ⛔ **`roots` comes from the REGISTRY. It must never be derived from input.**
439
+ * A caller that built this list out of anything on hook stdin would have handed
440
+ * the confinement boundary to the value the boundary exists to constrain.
441
+ *
442
+ * Empty `roots` refuses everything, which is the right direction: no known root
443
+ * means nothing is known to be inside one.
444
+ */
445
+ export function isInsideAny(roots, candidate) {
446
+ return roots.some((root) => isInside(root, candidate));
447
+ }
448
+ /**
449
+ * Reduce an untrusted id to a filename component.
450
+ *
451
+ * `session_id` arrives on hook stdin and is attacker-influenced in the sense that
452
+ * matters here: it reaches `path.join`. Whitelisting is the only safe direction —
453
+ * a blacklist of `..` and `/` misses NUL, backslash on Windows, and the empty
454
+ * string, each of which resolves somewhere other than intended.
455
+ */
456
+ function sanitise(value) {
457
+ const cleaned = value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128);
458
+ return cleaned === "" || /^\.+$/.test(cleaned) ? "_" : cleaned;
459
+ }
460
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The failure policy — CR-018, D58 (plan §ER22/§ER10/§ER11/§ER4).
3
+ *
4
+ * The defect this replaces, verbatim from the register: *"5xx was unclassified
5
+ * and fell through to advance-and-discard; 401/413 never succeed."* Every failed
6
+ * POST used to reach the same code, so the offset moved forward as if the bytes
7
+ * had been delivered. They had not.
8
+ *
9
+ * ## Three classes, and why `never` and `fatal` are not the same class
10
+ *
11
+ * later Hold the offset. The NEXT hook invocation retries from the same
12
+ * point. Transient: 5xx, no-response, 429, and 403 — a 403 here is
13
+ * org-approval-pending, which means later, not failed (D60 §D12).
14
+ * never Advance PAST the failing bytes and stamp a gap, so the record shows
15
+ * an honest hole instead of pretending nothing happened. Per-PAYLOAD:
16
+ * resending the identical bytes cannot work (413, structurally
17
+ * malformed 4xx).
18
+ * fatal Stop attempting further sends for this session until the user
19
+ * reconnects. Per-CREDENTIAL, which is the whole difference from
20
+ * `never`: 401 does not describe these bytes, it describes the token.
21
+ *
22
+ * **`later` never means a synchronous retry inside one hook invocation.** D56
23
+ * plan §D8 is a founder override that is not to be re-argued: the hook must never
24
+ * block or slow a developer's turn. "Later" means the bytes stay in the
25
+ * transcript file — which is where they already are — and the hook that fires on
26
+ * the next turn picks them up from the same offset.
27
+ *
28
+ * ## The table is data on purpose
29
+ *
30
+ * The title's word "table" is load-bearing. The classification is a literal data
31
+ * structure, so it is auditable by reading it and testable without exercising a
32
+ * real network failure. The original bug was an UNCLASSIFIED status silently
33
+ * defaulting to lossy behaviour, so the default here is explicit and it is
34
+ * `later` — the safe direction. A status nobody enumerated holds its bytes; it
35
+ * never discards them.
36
+ *
37
+ * This module is PURE. No fs, no network, no clock — `state.ts` persists, and the
38
+ * caller supplies `nowMs`. That is what lets both backlog caps be tested at their
39
+ * boundaries without waiting a day for one of them.
40
+ */
41
+ import type { SendOutcome } from "./post.js";
42
+ export type FailureClass = "later" | "never" | "fatal";
43
+ /** What a completed attempt means. `ok` is the only non-failure. */
44
+ export type Disposition = "ok" | FailureClass;
45
+ /**
46
+ * THE TABLE. Status codes named one by one, because a family rule that swept up
47
+ * a whole `4xx` would be the implicit fallthrough this task exists to remove.
48
+ *
49
+ * Adding a row is how a new structurally-invalid status becomes `never`. Until
50
+ * someone does, an unnamed `4xx` resolves to `DEFAULT_CLASS` and HOLDS its bytes:
51
+ * a status we have not reasoned about is not one we discard data for.
52
+ */
53
+ export declare const STATUS_CLASSES: ReadonlyMap<number, FailureClass>;
54
+ /** `5xx` — the server's problem, not the payload's. */
55
+ export declare const SERVER_ERROR_CLASS: FailureClass;
56
+ /** No HTTP response at all: DNS, refused, TLS, timeout, abort. */
57
+ export declare const NETWORK_CLASS: FailureClass;
58
+ /**
59
+ * The explicit default. **It is `later` and it must stay `later`.** An implicit
60
+ * fallthrough to advance-and-discard is the original defect at a smaller scale.
61
+ */
62
+ export declare const DEFAULT_CLASS: FailureClass;
63
+ /** Classify one HTTP status. `2xx` is the only success. */
64
+ export declare function classifyStatus(status: number): Disposition;
65
+ /** Classify one attempt, response or not. */
66
+ export declare function classify(outcome: SendOutcome): Disposition;
67
+ /**
68
+ * The §13.6 copy for a class.
69
+ *
70
+ * `ERRORS` was scaffolded for this task and carries exactly these three lines, so
71
+ * this is the one place a class becomes a sentence. Nothing in the HOOK path
72
+ * calls it — a hook speaks only through `systemMessage` (§13.7) — but `CR-021`'s
73
+ * `status` needs a class-to-copy mapping and this is where it belongs rather than
74
+ * as a fourth `switch` in a verb body.
75
+ */
76
+ export declare function failureCopy(cls: FailureClass): string;
77
+ /**
78
+ * One undelivered span of a transcript file.
79
+ *
80
+ * The bytes themselves are not copied anywhere: the transcript is append-only and
81
+ * still on disk, so a span is a coordinate pair, not a spool. `at` is when this
82
+ * span was FIRST left undelivered, which is what the age cap measures.
83
+ */
84
+ export interface BacklogEntry {
85
+ /** Byte offset, inclusive. */
86
+ readonly from: number;
87
+ /** Byte offset, exclusive. */
88
+ readonly to: number;
89
+ /** Epoch ms of the first failed attempt covering this span. */
90
+ readonly at: number;
91
+ }
92
+ /** Per-(session, file) send state. `state.ts` persists it; this module decides it. */
93
+ export interface FileState {
94
+ /** Everything below this is delivered, or deliberately gapped. */
95
+ readonly sentOffset: number;
96
+ /** Contiguous undelivered spans, oldest first. `[0].from === sentOffset`. */
97
+ readonly backlog: readonly BacklogEntry[];
98
+ /** Bytes we advanced past without delivering. Counters, never a growing list. */
99
+ readonly gapBytes: number;
100
+ readonly gapCount: number;
101
+ /**
102
+ * Epoch ms of the last DELIVERED span, or 0 for "never" — `CR-021`.
103
+ *
104
+ * The only success clock in the store. `BacklogEntry.at` stamps a HELD span,
105
+ * i.e. a failure, so `status` had nothing honest to render before this.
106
+ *
107
+ * **0 means unknown, not the epoch.** A state file written before this field
108
+ * existed parses to 0 (`nonNegative`), and rendering that as 1 January 1970
109
+ * would be a confident lie about a value we simply do not have.
110
+ */
111
+ readonly lastSentAt: number;
112
+ }
113
+ export declare const EMPTY_FILE_STATE: FileState;
114
+ /**
115
+ * The caps.
116
+ *
117
+ * ⚠ **Neither the register nor `DECISIONS.md` specifies an eviction policy once a
118
+ * cap is hit.** What is decided is only that both caps exist. The rule below is
119
+ * this task's choice, stated rather than smuggled: **drop the oldest span,
120
+ * advance past it, stamp a gap, continue.** It is the `never` class's own shape
121
+ * applied to our own storage, and it is the only option that neither grows
122
+ * unbounded nor blocks — the two things D56 §D8 forbids outright.
123
+ *
124
+ * `entries` is not a third policy. It is the same drop-oldest rule guarding the
125
+ * degenerate shape where a file grows one byte at a time: the byte cap alone
126
+ * would permit `MAX_BACKLOG_BYTES` single-byte entries.
127
+ */
128
+ export declare const BACKLOG_CAPS: {
129
+ readonly bytes: number;
130
+ readonly ageMs: number;
131
+ readonly entries: 512;
132
+ };
133
+ export interface BacklogCaps {
134
+ readonly bytes: number;
135
+ readonly ageMs: number;
136
+ readonly entries: number;
137
+ }
138
+ /**
139
+ * Read the caps from the environment.
140
+ *
141
+ * Same shape as `hookBudgetMs`: an unset or unparseable value takes the default
142
+ * rather than disabling the cap, because a cap that an empty string can switch
143
+ * off is not a cap. Present for operators with unusual transcript volumes and
144
+ * for the tests, which have to reach a boundary they cannot otherwise reach.
145
+ */
146
+ export declare function resolveCaps(env: NodeJS.ProcessEnv): BacklogCaps;
147
+ /** Undelivered bytes currently held. */
148
+ export declare function pendingBytes(state: FileState): number;
149
+ /**
150
+ * The span the next attempt should carry: everything from the offset to `eof`.
151
+ *
152
+ * A file SHORTER than the offset means the transcript was replaced or truncated —
153
+ * a new file at a path we have state for. Resync from 0 rather than reading a
154
+ * negative-length slice; the server is idempotent on `(session, offset)`.
155
+ */
156
+ export declare function nextSpan(state: FileState, eof: number): {
157
+ from: number;
158
+ to: number;
159
+ } | null;
160
+ /**
161
+ * Everything up to `to` arrived. The backlog clears, and the success is STAMPED.
162
+ *
163
+ * `nowMs` is a parameter rather than a `Date.now()` call because this module is
164
+ * pure — that is what lets both backlog caps be tested at their boundaries
165
+ * without waiting a day, and the same property now covers the `last sent` row.
166
+ */
167
+ export declare function markDelivered(state: FileState, to: number, nowMs: number): FileState;
168
+ /**
169
+ * `never` — advance PAST the attempted span exactly once, stamping a gap.
170
+ *
171
+ * Exactly once is the point: the offset lands at `to`, so the next invocation
172
+ * starts on the next, unrelated chunk. It neither loops on the poisoned bytes nor
173
+ * swallows what comes after them.
174
+ */
175
+ export declare function markSkipped(state: FileState, from: number, to: number): FileState;
176
+ /**
177
+ * `later` — hold the offset and remember when this span first failed.
178
+ *
179
+ * The span is appended only when the file has actually grown past what is already
180
+ * held. A hook that fires twice on an unchanged file retries the same span rather
181
+ * than minting a second entry with a newer timestamp, which would keep resetting
182
+ * the age cap and make it unreachable.
183
+ */
184
+ export declare function markHeld(state: FileState, span: {
185
+ from: number;
186
+ to: number;
187
+ }, nowMs: number, caps: BacklogCaps): FileState;
188
+ /**
189
+ * Shed the oldest spans until every cap holds.
190
+ *
191
+ * Never blocks, never grows. Each shed span advances the offset past itself and
192
+ * stamps a gap, so what the server sees is a discontinuity in `byteOffset` — an
193
+ * honest hole — and not a silent renumbering.
194
+ */
195
+ export declare function enforceCaps(state: FileState, nowMs: number, caps: BacklogCaps): FileState;