@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
@@ -0,0 +1,937 @@
1
+ /**
2
+ * `vibecommit why` — the screenshotted surface. `CR-086`, W9.
3
+ *
4
+ * *"Show the conversation turns recorded against the commit that last changed
5
+ * this line."* That sentence is VERBATIM-APPROVED (D61 §PS6), already in
6
+ * `strings.ts` and already pinned by `test/help.golden.txt`. This module is what
7
+ * makes it true.
8
+ *
9
+ * The screen is style guide §10.4; the copy is §R3's decision table in
10
+ * `docs/designs/claims-audit-why.md`, which §10.4 says must not be paraphrased.
11
+ * Layout is `DESIGN.md` §13 — six SGR roles, base-16 only, 80-column HARD WRAP,
12
+ * §13.6's error shape, §13.7's exit contract.
13
+ *
14
+ * ─────────────────────────────────────────────────────────────────────────────
15
+ * ## ⚠ WHAT THIS VERB SHIPS WITHOUT, AND WHY IT SHIPS ANYWAY
16
+ *
17
+ * `why` is *blame → commit → attribution*. The third arrow has no producer, and
18
+ * two more of this screen's inputs do not exist either. All four were
19
+ * re-measured for this task rather than taken on trust (every tracked blob at
20
+ * `vibecommit-capture` `4dcf47d`, `vibecommit-mcp` `eeda878`,
21
+ * and `vibecommit-schema` `2c77400`, read as utf8 in
22
+ * Python — never `grep`, because `src/redact.ts` carries literal NUL bytes and
23
+ * `grep -I` skips it silently):
24
+ *
25
+ * 1. **`observed` has no producer in any repo** — 0 client git probes on the
26
+ * write path, and `vibecommit-mcp/src/conversation/edge_derivation.ts:36-47`
27
+ * states it outright. This module does not build one and must not be read
28
+ * as building one: `git blame` here is an INTERACTIVE READ-PATH probe whose
29
+ * output never crosses the ingest wire. `src/hooks/`, `src/post.ts` and
30
+ * `src/state.ts` are untouched.
31
+ * 2. **Nothing writes `commit_file_attribution`** — the table `agent_id` lives
32
+ * on. `CR-070` created it; `CR-072` shipped the edge only and said so.
33
+ * 3. **`capture_turns.subagent_count` EXISTS AND STILL HAS NO PRODUCER.**
34
+ * `CR-129a` added it (`vibecommit-schema` `f3022c5`), `integer`, nullable,
35
+ * no default. Nothing can carry it: mcp's `insertCaptureTurns` is an
36
+ * explicit seven-key literal and `blame_commit` selects
37
+ * `position, turn_hash`, so the column never reaches this client. The
38
+ * measurement, with the file and line for each answer, lives in
39
+ * `test/subagent-count.test.ts`.
40
+ *
41
+ * ⚠ **A COLUMN LIST STOOD HERE AND IT WAS A TALLY — D101, WRITE THE
42
+ * PREDICATE, NEVER THE COUNT.** It read `capture_id, position,
43
+ * workspace_id, conversation_id, turn_hash, is_human_prompt`, and it was
44
+ * an exact transcription of the original `create table` when it was
45
+ * written. **Three later migrations emptied it, none of them wrong:**
46
+ * `20260610140000` added `human_prompt_hash`; `20260620180000` renamed
47
+ * `workspace_id` to `org_id` across fourteen tables; `20260820000000`
48
+ * added `subagent_count`. So the list decayed to naming a column that no
49
+ * longer exists — D97's species, a statement that was genuine when
50
+ * authored and was emptied later by someone else's correct change. **The
51
+ * live set is not restated here**, because restating it would start the
52
+ * same decay again; ask the catalog.
53
+ * 4. **`blame_commit` does not exist** — it is `CR-085`, being built in
54
+ * another repo in this same wave, and D106 §1 measured production mcp still
55
+ * serving release v4, which has no ingest route at all. **There is no
56
+ * deployed server to test this against.**
57
+ *
58
+ * (1) and (2) are recorded in D104 §2 as owed their own `CR-###` and are
59
+ * explicitly not this task's. (3) was `CR-129`: `CR-129a` shipped the column in
60
+ * W10 and `CR-129d` verified the consumer against it — **the gap is narrower
61
+ * than it was and it is not closed**, because a producer needs an mcp write
62
+ * path AND an mcp read path, and no row owns either.
63
+ *
64
+ * D81 governs the shape of the answer: *"A task can be un-shippable because of
65
+ * what it CLAIMS, not because of what it lacks… making the claim honest is
66
+ * usually cheaper than building the thing, and it is always the correct
67
+ * order."* So every one of those holes renders as an absence in this project's
68
+ * grammar — **state the absence, state the cause, state what would change it** —
69
+ * and none of them renders as a zero, a placeholder, or a silence.
70
+ *
71
+ * ─────────────────────────────────────────────────────────────────────────────
72
+ * ## THE THREE RULES THIS FILE IS BUILT AROUND
73
+ *
74
+ * **The payload is UNTRUSTED JSON.** `blame_commit` has no implementation to
75
+ * agree with, so `BlameCommitPayload` below is a contract this repo DECLARES and
76
+ * `CR-085` owns the other side of. Every field is validated; anything
77
+ * unrecognised is `malformed` and renders as such rather than being partly
78
+ * guessed at.
79
+ *
80
+ * **The client may render; it may not analyze** (D60 §D1a). The one authorised
81
+ * deviation in this package is `CR-024d`'s redaction parse, by name; there is no
82
+ * authorisation here. So the truncation totals come from the payload and this
83
+ * module never counts the array it was handed, never ranks it, and never
84
+ * reorders it.
85
+ *
86
+ * **`null` and ABSENT are different facts.** The schema comment on `agent_id`
87
+ * says *"NULL = the main thread"* — so a null we were SENT is knowledge, and a
88
+ * field that never arrived is not. An unmarked turn rendered from an absent
89
+ * field is the overclaim, so a turn is rendered unmarked only when the field was
90
+ * actually present.
91
+ *
92
+ * @provenance vibecommit-mcp src/read/envelope.ts — the one-text-part wire shape, retyped
93
+ * @provenance vibecommit-schema capture_turns + commit_file_attribution — MEASURED ABSENCE, cited
94
+ */
95
+ import { ABSENCE, COMMANDS, ERRORS, SIGNIN, WHY, renderCommitHeader, renderCommitIdentity, } from "../copy/index.js";
96
+ import { EXIT } from "../exit.js";
97
+ import { gitProbe, isShallowClone, resolveRepoSlug } from "../git.js";
98
+ import { emitJson } from "../json.js";
99
+ import { mcpUrl } from "../oauth/discovery.js";
100
+ import { readWithSession } from "../oauth/signin.js";
101
+ import { resolveProjectKey } from "../project.js";
102
+ import { WRAP_COLUMNS, paint, renderErrorBlock, truncatePath, wrap } from "../term.js";
103
+ import { writeLines } from "./context.js";
104
+ /** §10.4 indents the body two columns and the turn rows four under it. */
105
+ const BODY_INDENT = 2;
106
+ const TURN_INDENT = 4;
107
+ /** Excerpt continuation lines hang under the `>` (§10.4's drawing). */
108
+ const EXCERPT_INDENT = 13;
109
+ /** One read, one POST. Generous because it contains a server-side join. */
110
+ const READ_TIMEOUT_MS = 15_000;
111
+ /** `git blame` writes this SHA for a line that is not committed yet. */
112
+ const UNCOMMITTED_SHA = "0".repeat(40);
113
+ /** §10.4 renders the short form; the wire carries the full one. */
114
+ const ABBREV = 7;
115
+ const GRADES = new Set(["derived", "observed", "declared"]);
116
+ const MATCH_KINDS = new Set(["exact", "probable"]);
117
+ function parseMatchKind(value) {
118
+ if (value === undefined || value === null)
119
+ return { ok: true, value: null };
120
+ return typeof value === "string" && MATCH_KINDS.has(value)
121
+ ? { ok: true, value: value }
122
+ : { ok: false };
123
+ }
124
+ /** A 7-40 hex sha on the wire, or nothing. Blank is not a sha. */
125
+ function asSha(value) {
126
+ return typeof value === "string" && value.trim() !== "" ? value : null;
127
+ }
128
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
129
+ /** A count on the wire: a non-negative integer, or nothing. Never a coerced string. */
130
+ function asCount(value) {
131
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
132
+ }
133
+ function asStrings(value) {
134
+ if (!Array.isArray(value))
135
+ return null;
136
+ return value.every((v) => typeof v === "string") ? value : null;
137
+ }
138
+ /**
139
+ * Validate one turn. Returns null on anything unexpected, which makes the whole
140
+ * payload `malformed` — a partly-rendered turn list is the shape that reads as
141
+ * complete, which is §R3's L2 concern one level down.
142
+ */
143
+ function parseTurn(value) {
144
+ if (!isRecord(value))
145
+ return null;
146
+ const position = asCount(value.position);
147
+ // ⚠ PRESENT-NULL TOLERANCE, AND IT IS HARDENING RATHER THAN A FIX FOR A LIVE
148
+ // BREAK (D112 §4). mcp emits `files: []` and `excerpt: [...] ?? []` — arrays,
149
+ // never null, measured at `355a53e`. But its demonstrated HOUSE STYLE is
150
+ // present-null (`emptyPayload` sets every absent field to null "so no key is
151
+ // ever omitted"), so one `?? null` on that side would otherwise poison every
152
+ // document. `null` here means the same as `[]` already means: WE DO NOT KNOW.
153
+ const files = value.files === null ? [] : asStrings(value.files);
154
+ if (position === null || files === null)
155
+ return null;
156
+ // ⚠ `string | null | ABSENT`, and the two absence forms are ONE fact here —
157
+ // see the wire contract above. This is the field whose old
158
+ // `typeof !== "string" -> return null` killed the whole document (D110 §1);
159
+ // anything that is not a string and not an absence is still refused.
160
+ const rawTool = value.tool;
161
+ if (rawTool !== undefined && rawTool !== null && typeof rawTool !== "string")
162
+ return null;
163
+ const excerpt = value.excerpt === undefined || value.excerpt === null ? [] : asStrings(value.excerpt);
164
+ if (excerpt === null)
165
+ return null;
166
+ // ⚠ PRESENCE, not truthiness. `null` is a legitimate value that means "main
167
+ // thread"; a missing key means nothing is known, and the two must not collapse.
168
+ const agentIdPresent = Object.prototype.hasOwnProperty.call(value, "agent_id");
169
+ const rawAgentId = value.agent_id;
170
+ if (agentIdPresent && rawAgentId !== null && typeof rawAgentId !== "string")
171
+ return null;
172
+ // The schema refuses `''` for exactly this reason: it would render as an
173
+ // unnamed sub-agent, which is a plausible wrong answer rather than an error.
174
+ if (typeof rawAgentId === "string" && rawAgentId.trim() === "")
175
+ return null;
176
+ return {
177
+ position,
178
+ tool: typeof rawTool === "string" ? rawTool : null,
179
+ files,
180
+ excerpt,
181
+ agentId: typeof rawAgentId === "string" ? rawAgentId : null,
182
+ agentIdPresent,
183
+ subagentCount: value.subagent_count === undefined ? null : asCount(value.subagent_count),
184
+ };
185
+ }
186
+ function parseWindow(value) {
187
+ if (!isRecord(value))
188
+ return null;
189
+ const first = asCount(value.first);
190
+ const last = asCount(value.last);
191
+ const total = asCount(value.total);
192
+ if (first === null || last === null || total === null)
193
+ return null;
194
+ // A window that does not bound anything is not a window. Rendering "showing
195
+ // 12-16 of 3" would be arithmetic nobody checked.
196
+ if (first > last || last > total)
197
+ return null;
198
+ return { first, last, total };
199
+ }
200
+ /**
201
+ * ⛔ THE SKEW SENTINEL. Returned when the document is WELL-FORMED but names a
202
+ * `state` this build does not know — which means the server is newer than this
203
+ * client, not that the answer is broken.
204
+ *
205
+ * ⚠ A UNIQUE OBJECT, compared by identity. A string sentinel could collide with
206
+ * a future legitimate return, and `null` is already taken by "malformed" — the
207
+ * two must stay distinguishable, because they send the user to opposite
208
+ * remedies: "report this" versus "update the plugin".
209
+ */
210
+ export const PAYLOAD_VERSION_SKEW = Object.freeze({ skew: true });
211
+ /**
212
+ * ⚠ A TYPE GUARD RATHER THAN A BARE `===`. TypeScript does not narrow a union
213
+ * on object identity — the two members share no discriminant literal — so
214
+ * `payload === PAYLOAD_VERSION_SKEW` compiles but leaves `payload` wide, and
215
+ * the next line passing it to a renderer fails to typecheck. The guard makes
216
+ * the narrowing explicit and keeps the identity comparison as the actual test.
217
+ */
218
+ export function isVersionSkew(p) {
219
+ return p === PAYLOAD_VERSION_SKEW;
220
+ }
221
+ /**
222
+ * The whole document.
223
+ *
224
+ * - `null` — MALFORMED. The server sent something that is not a valid document
225
+ * in any version, and the remedy is to report it.
226
+ * - `PAYLOAD_VERSION_SKEW` — WELL-FORMED, UNKNOWN STATE. The server is newer
227
+ * than this client and the remedy is to upgrade. ⛔ Added by `U1`
228
+ * (2026-08-30): before it, this case returned `null` and told the user a
229
+ * perfectly good answer was broken. See `WHY.skewWhat` for why that mattered
230
+ * more here than in most protocols.
231
+ * - a payload — parsed.
232
+ */
233
+ export function parseBlameCommitPayload(document) {
234
+ if (!isRecord(document))
235
+ return null;
236
+ const state = document.state;
237
+ // ⚠ SKEW IS CHECKED BEFORE MALFORMEDNESS, and only for a STRING state. A
238
+ // non-string `state` is malformed in every version — it is not evidence of a
239
+ // newer server — so it must keep falling through to `null` below rather than
240
+ // being excused as skew. Excusing it would turn a genuine protocol break into
241
+ // a spurious "update the plugin".
242
+ if (typeof state === "string" &&
243
+ state !== "cold_start" &&
244
+ state !== "no_edge" &&
245
+ state !== "edge_unreadable" &&
246
+ state !== "squash_resolved" &&
247
+ state !== "turns") {
248
+ return PAYLOAD_VERSION_SKEW;
249
+ }
250
+ if (state !== "cold_start" &&
251
+ state !== "no_edge" &&
252
+ // ⛔ ADDED BY `U1`, founder-ratified 2026-08-31 (`D203`). THE ORDER OF THE
253
+ // TWO REPO CHANGES IS LOAD-BEARING. An unknown `state` returns null here, and null renders as
254
+ // MALFORMED — so if mcp had shipped `edge_unreadable` before this line
255
+ // existed, every such answer would have reached the user as a broken
256
+ // payload rather than as the state it is. That is strictly worse than the
257
+ // false negative `U1` set out to fix. This half lands FIRST, exactly as
258
+ // Lane A landed inert ahead of `CR-174`.
259
+ state !== "edge_unreadable" &&
260
+ state !== "squash_resolved" &&
261
+ state !== "turns") {
262
+ return null;
263
+ }
264
+ // ⚠ Parsed for EVERY state, because an unrecognised value means the two repos
265
+ // disagree about the enum and that is not a per-branch concern.
266
+ const matchKind = parseMatchKind(document.match_kind);
267
+ if (!matchKind.ok)
268
+ return null;
269
+ if (state === "squash_resolved") {
270
+ const recordedSha = asSha(document.recorded_sha);
271
+ if (recordedSha === null)
272
+ return null;
273
+ // ⚠ D120 §2 — A NULL `match_kind` ON THIS STATE IS MALFORMED, NOT AN
274
+ // ABSENCE. mcp ALWAYS emits the field here (`blame.ts`, the resolution
275
+ // return), so a null is the two repos disagreeing about the enum rather
276
+ // than a data state. And rendering it would make the STRONGER claim by
277
+ // default: §R3 defines the unqualified copy as the `exact` variant, so an
278
+ // unqualified render on unknown provenance asserts a recorded successor
279
+ // pair we do not hold — D67's folding, reached by a different road. Same
280
+ // reasoning as `grades` staying malformed, one field over.
281
+ if (matchKind.value === null)
282
+ return null;
283
+ return {
284
+ state,
285
+ recordedSha,
286
+ matchKind: matchKind.value,
287
+ grades: [],
288
+ turns: [],
289
+ window: null,
290
+ filesWithoutTurn: null,
291
+ };
292
+ }
293
+ if (state !== "turns") {
294
+ return {
295
+ state,
296
+ recordedSha: null,
297
+ matchKind: matchKind.value,
298
+ grades: [],
299
+ turns: [],
300
+ window: null,
301
+ filesWithoutTurn: null,
302
+ };
303
+ }
304
+ const rawGrades = asStrings(document.grades);
305
+ // `gradeFloor` THROWS on an empty set, deliberately — a commit with no edge has
306
+ // no header. A `turns` payload carrying no grade is therefore malformed rather
307
+ // than something to render with a default, which would silently pick the
308
+ // strongest claim available.
309
+ if (rawGrades === null || rawGrades.length === 0 || !rawGrades.every((g) => GRADES.has(g))) {
310
+ return null;
311
+ }
312
+ if (!Array.isArray(document.turns))
313
+ return null;
314
+ const turns = [];
315
+ for (const raw of document.turns) {
316
+ const turn = parseTurn(raw);
317
+ if (turn === null)
318
+ return null;
319
+ turns.push(turn);
320
+ }
321
+ // ⚠ `null` joins `undefined` here for the same reason as `files` above —
322
+ // hardening against mcp's present-null house style, not a live break.
323
+ const windowAbsent = document.window === undefined || document.window === null;
324
+ const window = windowAbsent ? null : parseWindow(document.window);
325
+ if (!windowAbsent && window === null)
326
+ return null;
327
+ // ⚠ `recorded_sha` ON THE TURNS BRANCH — the whole of `CR-138`, and absent is
328
+ // the ORDINARY case because most commits were never rewritten. Read here as
329
+ // well as on `squash_resolved`, because reading it on one branch only is what
330
+ // made the single-shot shape parse and deliver a null to the renderer: the
331
+ // screen then claimed the BLAMED sha with no error and no gate (D65 §DR5).
332
+ const rawRecorded = document.recorded_sha;
333
+ if (rawRecorded !== undefined && rawRecorded !== null && asSha(rawRecorded) === null)
334
+ return null;
335
+ return {
336
+ state,
337
+ recordedSha: asSha(rawRecorded),
338
+ matchKind: matchKind.value,
339
+ grades: rawGrades,
340
+ turns,
341
+ window,
342
+ filesWithoutTurn: asCount(document.files_without_turn),
343
+ };
344
+ }
345
+ /**
346
+ * `git blame` one line, via `--porcelain`.
347
+ *
348
+ * Porcelain rather than the human format: the human format embeds the author's
349
+ * NAME, which can contain spaces, parentheses and digits, so every field after
350
+ * it is positional guesswork. Porcelain puts the SHA first on its own line and
351
+ * the timestamp on a labelled one.
352
+ */
353
+ export function blameLine(dir, file, line) {
354
+ const out = gitProbe(dir, ["blame", "--porcelain", "-L", `${line},${line}`, "--", file]);
355
+ if (out === null)
356
+ return null;
357
+ return parseBlamePorcelain(out);
358
+ }
359
+ /** Exported so the parser can be driven against captured git output in the suite. */
360
+ export function parseBlamePorcelain(out) {
361
+ const lines = out.split("\n");
362
+ const header = /^([0-9a-f]{40}) \d+ (\d+)/.exec(lines[0] ?? "");
363
+ if (header === null)
364
+ return null;
365
+ const sha = header[1];
366
+ // A line that is not committed yet blames to the all-zero SHA. Rendering it
367
+ // would put `0000000` on screen as if it identified something.
368
+ if (sha === UNCOMMITTED_SHA)
369
+ return null;
370
+ let epoch = null;
371
+ let tzOffsetMinutes = 0;
372
+ for (const raw of lines) {
373
+ if (raw.startsWith("author-time "))
374
+ epoch = Number.parseInt(raw.slice(12).trim(), 10);
375
+ else if (raw.startsWith("author-tz ")) {
376
+ const tz = /^([+-])(\d{2})(\d{2})$/.exec(raw.slice(10).trim());
377
+ if (tz !== null) {
378
+ const magnitude = Number.parseInt(tz[2], 10) * 60 + Number.parseInt(tz[3], 10);
379
+ tzOffsetMinutes = tz[1] === "-" ? -magnitude : magnitude;
380
+ }
381
+ }
382
+ }
383
+ if (epoch === null || !Number.isFinite(epoch))
384
+ return null;
385
+ // The AUTHOR's own date, not this machine's: a commit written at 23:30 in
386
+ // Berlin is not a different day because the reader is in California.
387
+ const shifted = new Date((epoch + tzOffsetMinutes * 60) * 1000);
388
+ const date = shifted.toISOString().slice(0, 10);
389
+ return {
390
+ sha,
391
+ commit: { line: Number.parseInt(header[2], 10), sha: sha.slice(0, ABBREV), date },
392
+ };
393
+ }
394
+ /**
395
+ * `<file>:<line>`.
396
+ *
397
+ * `lastIndexOf` rather than a split, because a path may legitimately contain a
398
+ * colon and only the last one can be the line number. The tail must be all
399
+ * digits and at least 1 — `file:0` and `file:-3` are not lines, and `file:1e3`
400
+ * is the kind of input `parseInt` would quietly accept as 1.
401
+ */
402
+ export function parseTarget(argv) {
403
+ const positional = argv.filter((arg) => !arg.startsWith("-"));
404
+ if (positional.length !== 1)
405
+ return null;
406
+ const raw = positional[0];
407
+ const at = raw.lastIndexOf(":");
408
+ if (at <= 0 || at === raw.length - 1)
409
+ return null;
410
+ const tail = raw.slice(at + 1);
411
+ if (!/^[0-9]+$/.test(tail))
412
+ return null;
413
+ const line = Number.parseInt(tail, 10);
414
+ return line >= 1 ? { file: raw.slice(0, at), line } : null;
415
+ }
416
+ // ---------------------------------------------------------------------------
417
+ // Rendering.
418
+ // ---------------------------------------------------------------------------
419
+ /**
420
+ * Assemble a §13.6 block from four copy constants.
421
+ *
422
+ * ⚠ THIS IS A COPY SINK AND IT IS REGISTERED AS ONE. `test/copy-inline-literals`
423
+ * scans ARGUMENT POSITIONS, not values, so a literal handed to a helper the sink
424
+ * list does not name reaches `renderErrorBlock` invisibly. Its own docblock says
425
+ * *"a new writer needs a new entry here"*; `errorCopy: [0, 1, 2, 3]` is that
426
+ * entry, and the mutation that proves it fires is in `test/why.test.ts`.
427
+ */
428
+ function errorCopy(what, why, fixLabel, fix) {
429
+ return { what, why: [why], fixLabel, fixes: [fix] };
430
+ }
431
+ /** §13.6's shape, with the identity line above it when we have one. */
432
+ function fail(ctx, copy, code, commit) {
433
+ const identity = commit === undefined ? [] : [...wrap(renderCommitIdentity(commit), BODY_INDENT), ""];
434
+ writeLines(ctx.stderr, [
435
+ ...identity,
436
+ ...renderErrorBlock({ kind: "bad", ...copy }, ctx.colour),
437
+ ]);
438
+ return code;
439
+ }
440
+ /**
441
+ * An ABSENCE state — `CR-112`'s grammar, and the exit code rides WITH the copy.
442
+ *
443
+ * Rendered to STDOUT, not stderr: absence is an ANSWER. Exit 4 says so, and
444
+ * collapsing it into 1 is one `||` away from the permanently BLOCKED claim that
445
+ * absence of an edge means no agent was involved.
446
+ *
447
+ * ⚠ THE `fix` FIELD ALWAYS RENDERS, WITH OR WITHOUT A COMMAND. `renderErrorBlock`
448
+ * drops `fixLabel` when `fixes` is empty, and `noEdge`'s remedy is the sentence
449
+ * *"Nothing to fix. A record cannot be added to a commit after the fact"* — which
450
+ * has no command beside it and is the third of the three grammar fields. Letting
451
+ * it disappear would turn a whole state into absence-with-no-remedy, which is
452
+ * the failure `copy-absence.test.ts` guards the STRING against and nothing was
453
+ * guarding the SCREEN against.
454
+ */
455
+ function absence(ctx, state, fixes, commit) {
456
+ const lines = [
457
+ ...wrap(renderCommitIdentity(commit), BODY_INDENT),
458
+ "",
459
+ ...renderErrorBlock(fixes.length > 0
460
+ ? { kind: "warn", what: state.what, why: [state.why], fixLabel: state.fix, fixes }
461
+ : { kind: "warn", what: state.what, why: [state.why] }, ctx.colour),
462
+ ];
463
+ if (fixes.length === 0)
464
+ lines.push("", ...wrap(state.fix, 4));
465
+ writeLines(ctx.stdout, lines);
466
+ return state.exit;
467
+ }
468
+ /**
469
+ * One turn row: `turn 12 [Edit] session.ts · sub-agent a782e50`.
470
+ *
471
+ * ⚠ **THE ATTRIBUTION NEVER COLLAPSES** (§10.5, D65 §DR2): *"the turn header and
472
+ * the file-attribution table ALWAYS name the actor… Hiding a quote makes no
473
+ * claim about who wrote the code; hiding an actor does."* So the marker is on
474
+ * every row — including, when the field never arrived, the marker that says we
475
+ * cannot name one.
476
+ *
477
+ * ⚠ **NO COLOUR ON THE MARKER** (D65 §DR3). `agent_id` is an ACTOR IDENTITY, the
478
+ * same class as a branch name — status changes and needs attention, an actor
479
+ * identity never changes and needs none — and a colour ramp would imply a
480
+ * quality judgment about delegated work that the register permanently BLOCKS.
481
+ * The 2026-06-20 founder override permits colour for status and access state
482
+ * only, and this is neither.
483
+ *
484
+ * The file list is budgeted against what the row has left and truncated from the
485
+ * LEFT (§13.4: the filename identifies and the directory does not), the same
486
+ * hand-defence `status.ts` applies to its one runtime gutter row.
487
+ *
488
+ * ⚠ **ASSEMBLED FROM THE PARTS THAT ARE PRESENT — NO PART, NO SEPARATOR**
489
+ * (D112 §3). Two of the four parts can be missing on today's wire, and the
490
+ * gutters used to be hardcoded around them, so the hole rendered as whitespace.
491
+ * Measured at `8aa94d6` by driving this function, `JSON.stringify` per row:
492
+ *
493
+ * ```
494
+ * " turn 12 [Edit] " <- no files: TRAILING WHITESPACE
495
+ * " turn 13 [Write] · actor not recorded" <- no files: FOUR-SPACE GUTTER
496
+ * ```
497
+ *
498
+ * A trailing space is invisible in a terminal AND invisible in a diff, which is
499
+ * why the suite asserts `line === line.trimEnd()` rather than reading the code.
500
+ *
501
+ * ⚠ **`files: []` MEANS "WE DO NOT KNOW WHICH FILES", NOT "THIS TURN TOUCHED
502
+ * NONE"** — `commit_file_attribution` has no writer (D104 §2b) and mcp
503
+ * hardcodes the empty array. Every row sits under `WHY.turnsIntro`'s framing,
504
+ * so the row is UNDER-INFORMATIVE rather than false; a sentence naming that
505
+ * absence may be owed and is not written here (D112 §3).
506
+ */
507
+ function turnRow(turn) {
508
+ const marker = attributionMarker(turn);
509
+ const head = `${" ".repeat(TURN_INDENT)}${WHY.turnLabel} ${turn.position}${turn.tool === null ? "" : `${GAP}[${turn.tool}]`}`;
510
+ const tail = marker === "" ? "" : `${GAP}${marker}`;
511
+ const files = turn.files.join(", ");
512
+ if (files === "")
513
+ return `${head}${tail}`;
514
+ const budget = Math.max(8, WRAP_COLUMNS - head.length - tail.length - GAP.length);
515
+ return `${head}${GAP}${truncatePath(files, budget)}${tail}`;
516
+ }
517
+ /**
518
+ * The two columns §10.4 puts between the parts of a turn row.
519
+ *
520
+ * A separator, not copy — the same class as `ELLIPSIS` below and as the `": "`
521
+ * that `test/copy-inline-literals.test.ts` documents as correctly living in
522
+ * code ("prose means contains a letter"). Named because the row now has three
523
+ * places that need exactly this width and a fourth that must NOT have it.
524
+ */
525
+ const GAP = " ";
526
+ /**
527
+ * §R3's three-row attribution table, in its stated order.
528
+ *
529
+ * Row 3 — *"more than one distinct `agent_id` in the commit → header carries no
530
+ * agent id; attribution stays per turn"* — is satisfied STRUCTURALLY rather than
531
+ * by a branch: §R3's header is the identity line plus the evidence sentence, and
532
+ * neither can carry an agent id at any grade. The suite asserts that rather than
533
+ * assuming it.
534
+ */
535
+ function attributionMarker(turn) {
536
+ if (turn.agentId !== null)
537
+ return WHY.subAgentMarker(turn.agentId);
538
+ // ⚠ NO PRODUCER. Implemented so the row exists the day the column does; today
539
+ // nothing on the wire can set it, and it is NEVER rendered from a zero.
540
+ if (turn.subagentCount !== null && turn.subagentCount > 0) {
541
+ return WHY.subAgentCountMarker(turn.subagentCount);
542
+ }
543
+ // Present-and-null is knowledge: the main thread. Absent is not.
544
+ return turn.agentIdPresent ? "" : WHY.actorUnrecordedMarker;
545
+ }
546
+ /**
547
+ * Bound one excerpt line to what the screen has left.
548
+ *
549
+ * ⚠ FOUND BY THE 80-COLUMN CHECK ON RENDERED OUTPUT, not by reading. `wrap()`
550
+ * deliberately emits a single word longer than the width on its OWN LINE rather
551
+ * than breaking it — correct for a URL or a path, where breaking makes it
552
+ * uncopyable. **An excerpt is neither.** It is verbatim transcript, so it can
553
+ * carry a minified line or a base64 blob with no space in it for 200 columns,
554
+ * and that word then overflows §13.4's hard wrap. A transcript is not a thing
555
+ * anyone copies out of this screen whole, so the trade `wrap()` makes for URLs
556
+ * is the wrong one here.
557
+ *
558
+ * Truncated from the RIGHT, unlike `truncatePath`: the START of a quote is what
559
+ * identifies it, which is the mirror of §13.4's reasoning for paths (*the
560
+ * filename identifies and the directory does not*). §10.4's own drawing shows
561
+ * excerpts ending mid-sentence, so this is the shape it was drawn with.
562
+ */
563
+ function boundQuote(quote) {
564
+ // The `> ` marker and its trailing space come out of the same budget.
565
+ const budget = WRAP_COLUMNS - EXCERPT_INDENT - WHY.excerptPrefix.length - 1;
566
+ const chars = [...quote];
567
+ return chars.length <= budget ? quote : `${chars.slice(0, budget - 1).join("")}${ELLIPSIS}`;
568
+ }
569
+ /**
570
+ * A separator, not copy — the same class as `": "` and `"\n"`, which
571
+ * `test/copy-inline-literals.test.ts` documents as correctly living in code
572
+ * ("prose means contains a letter"). One column, three bytes, which is why
573
+ * every width check in this package counts columns.
574
+ */
575
+ const ELLIPSIS = "…";
576
+ /** The turn list, its excerpts, and the two counted notices §R3 requires. */
577
+ function turnLines(ctx, payload, file) {
578
+ const out = ["", ...wrap(WHY.turnsIntro(file), BODY_INDENT), ""];
579
+ for (const turn of payload.turns) {
580
+ out.push(turnRow(turn));
581
+ for (const quote of turn.excerpt) {
582
+ // Verbatim scrubbed transcript, never a summary (§10.4). `muted`, and the
583
+ // `>` prefix is the quote marker the drawing uses.
584
+ for (const line of wrap(`${WHY.excerptPrefix} ${boundQuote(quote)}`, EXCERPT_INDENT)) {
585
+ out.push(paint(ctx.colour, "muted", line));
586
+ }
587
+ }
588
+ }
589
+ // §R3's L2 — NO SILENT DROPS. Files in the commit whose `turn_hash` is NULL
590
+ // are COUNTED, because omitting them makes a partial set read as complete.
591
+ // The number is the server's; nothing here counts anything.
592
+ const missing = payload.filesWithoutTurn;
593
+ if (missing !== null && missing > 0) {
594
+ out.push("", ...wrap(missing === 1 ? WHY.fileWithoutTurn : WHY.filesWithoutTurn(String(missing)), BODY_INDENT));
595
+ }
596
+ // ⚠ The truncation notice renders only when the payload SAYS the list is
597
+ // partial, and every number in it is the payload's. D60 §D1a: windowing an
598
+ // already-ordered list for display is rendering; deriving the numbers is not.
599
+ const w = payload.window;
600
+ if (w !== null && (w.first > 1 || w.last < w.total)) {
601
+ out.push(...wrap(WHY.truncation(String(w.first), String(w.last), String(w.total)), BODY_INDENT));
602
+ }
603
+ return out;
604
+ }
605
+ /**
606
+ * The `CR-129` note — the attribution hole, held open and named on screen.
607
+ *
608
+ * ⚠ Rendered when ANY turn arrived without the field, in the absence grammar
609
+ * (absence · cause · remedy) and NOT as a sixth `ABSENCE` state: those five are
610
+ * whole-screen empty states that all exit 4, and this is a caveat on a screen
611
+ * that has content. `test/copy-absence.test.ts` asserts the set is exactly five.
612
+ *
613
+ * No glyph. §10.6 gives the `warn` glyph to state D alone, because only D is
614
+ * actionable, and there is nothing the reader of this note can do.
615
+ */
616
+ function attributionNote(ctx) {
617
+ return [
618
+ "",
619
+ ...wrap(WHY.attributionWhat, BODY_INDENT),
620
+ ...wrap(WHY.attributionWhy, BODY_INDENT).map((l) => paint(ctx.colour, "muted", l)),
621
+ ...wrap(WHY.attributionNotAFinding, BODY_INDENT).map((l) => paint(ctx.colour, "muted", l)),
622
+ ...wrap(WHY.attributionFix, BODY_INDENT).map((l) => paint(ctx.colour, "muted", l)),
623
+ ];
624
+ }
625
+ // ---------------------------------------------------------------------------
626
+ // The read lane. Every arm branches; none exits 0 and silent.
627
+ // ---------------------------------------------------------------------------
628
+ /**
629
+ * `authorizedAccessToken`'s six outcomes.
630
+ *
631
+ * ⚠ `busy` and `expired` are the two `SIGNIN`'s docblock deferred to this task
632
+ * BY NAME — *"the read verbs are `CR-086` (`why`, W9) and `CR-108` (`report`,
633
+ * W10)"* — and this is the first renderer of either.
634
+ *
635
+ * `no-session` and `expired` exit **3**, not 1: both mean "sign in", which
636
+ * §13.7 makes distinct precisely so a wrapper can branch on it without parsing
637
+ * copy. The rest are failures of this attempt and exit 1.
638
+ */
639
+ function notAuthorized(ctx, authorized, commit) {
640
+ switch (authorized.kind) {
641
+ case "no-session":
642
+ return fail(ctx, errorCopy(WHY.signedOutWhat, WHY.signedOutWhy, WHY.signedOutFix, COMMANDS.signIn), EXIT.notConnected, commit);
643
+ case "expired":
644
+ return fail(ctx, errorCopy(SIGNIN.expiredWhat, SIGNIN.expiredWhy, SIGNIN.expiredFix, COMMANDS.signIn), EXIT.notConnected, commit);
645
+ case "busy":
646
+ return fail(ctx, errorCopy(SIGNIN.busyWhat, SIGNIN.busyWhy, SIGNIN.busyFix, COMMANDS.why), EXIT.failure, commit);
647
+ case "unreachable":
648
+ return fail(ctx, errorCopy(WHY.unreachableWhat, WHY.unreachableWhy, WHY.readFix, COMMANDS.why), EXIT.failure, commit);
649
+ case "malformed":
650
+ return fail(ctx, errorCopy(WHY.malformedWhat, WHY.malformedWhy, WHY.readFix, COMMANDS.why), EXIT.failure, commit);
651
+ case "ok":
652
+ // Unreachable by construction: `readWithSession` only wraps a NON-ok
653
+ // outcome in `not-authorized`. Rendered rather than thrown, because a
654
+ // crash here would be a worse answer than an honest failure.
655
+ return fail(ctx, errorCopy(WHY.malformedWhat, WHY.malformedWhy, WHY.readFix, COMMANDS.why), EXIT.failure, commit);
656
+ }
657
+ }
658
+ export async function why(ctx, argv, deps = {}) {
659
+ // §13.3 — `--json` emits nothing but the JSON document on stdout, and D122 §2
660
+ // rules that §13.3 REQUIRES it here. Read once and carried, never re-derived
661
+ // per branch. Every FAILURE below is unchanged: §13.6's block to stderr,
662
+ // nothing on stdout. Only the four ANSWER states branch (`renderPayload`).
663
+ const json = argv.includes("--json");
664
+ const target = parseTarget(argv);
665
+ if (target === null) {
666
+ return fail(ctx, errorCopy(WHY.usageWhat, WHY.usageWhy, WHY.usageFix, COMMANDS.why), EXIT.usage);
667
+ }
668
+ const projectKey = resolveProjectKey(ctx.cwd);
669
+ if (projectKey === null) {
670
+ writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", what: ERRORS.notAGitRepo, why: [] }, ctx.colour));
671
+ return EXIT.failure;
672
+ }
673
+ // ⚠ BEFORE the blame, not after: on a shallow clone the answer would be
674
+ // plausible and wrong, and this is a REFUSAL, not an absence.
675
+ if (isShallowClone(projectKey)) {
676
+ return fail(ctx, errorCopy(WHY.shallowWhat, WHY.shallowWhy, WHY.shallowFix, WHY.shallowFixCommand), EXIT.failure);
677
+ }
678
+ const blamed = blameLine(projectKey, target.file, target.line);
679
+ if (blamed === null) {
680
+ return fail(ctx, errorCopy(WHY.blameWhat, WHY.blameWhy, WHY.blameFix, COMMANDS.why), EXIT.failure);
681
+ }
682
+ const endpoint = mcpUrl(ctx.env);
683
+ if (endpoint === null) {
684
+ return fail(ctx, errorCopy(SIGNIN.noServerWhat, SIGNIN.noServerRefusedWhy, WHY.readFix, COMMANDS.why), EXIT.failure, blamed.commit);
685
+ }
686
+ const read = deps.read ?? readWithSession;
687
+ const outcome = await read(endpoint, "blame_commit", {
688
+ repo: resolveRepoSlug(projectKey),
689
+ commit_sha: blamed.sha,
690
+ file_path: target.file,
691
+ }, {
692
+ home: ctx.home,
693
+ fetch,
694
+ nowMs: () => ctx.now().getTime(),
695
+ read: { fetch, timeoutMs: READ_TIMEOUT_MS },
696
+ });
697
+ return render(ctx, outcome, blamed.commit, target.file, json);
698
+ }
699
+ /** Every arm of `Read`, and every arm of `Authorized` inside one of them. */
700
+ function render(ctx, outcome, commit, file, json) {
701
+ switch (outcome.kind) {
702
+ case "not-authorized":
703
+ return notAuthorized(ctx, outcome.authorized, commit);
704
+ case "unauthorized":
705
+ // A 401 that survived one refresh and one retry. The bearer is dead.
706
+ return fail(ctx, errorCopy(WHY.signedOutWhat, WHY.signedOutWhy, WHY.signedOutFix, COMMANDS.signIn), EXIT.notConnected, commit);
707
+ case "unreachable":
708
+ return fail(ctx, errorCopy(WHY.unreachableWhat, WHY.unreachableWhy, WHY.readFix, COMMANDS.why), EXIT.failure, commit);
709
+ case "rpc-error":
710
+ // ⚠ Never a bare HTTP status or a raw JSON-RPC code on screen (§13.6).
711
+ return fail(ctx, errorCopy(WHY.serverErrorWhat, WHY.serverErrorWhy, WHY.readFix, COMMANDS.why), EXIT.failure, commit);
712
+ case "malformed":
713
+ return fail(ctx, errorCopy(WHY.malformedWhat, WHY.malformedWhy, WHY.readFix, COMMANDS.why), EXIT.failure, commit);
714
+ case "ok": {
715
+ // ⚠ `isError` rides the SAME envelope as a success and is a documented
716
+ // field of a contract with an external owner. A caller that only branched
717
+ // on the transport would read a tool failure as a record.
718
+ if (outcome.isError) {
719
+ return fail(ctx, errorCopy(WHY.serverErrorWhat, WHY.serverErrorWhy, WHY.readFix, COMMANDS.why), EXIT.failure, commit);
720
+ }
721
+ const payload = parseBlameCommitPayload(outcome.document);
722
+ if (payload === null) {
723
+ return fail(ctx, errorCopy(WHY.malformedWhat, WHY.malformedWhy, WHY.readFix, COMMANDS.why), EXIT.failure, commit);
724
+ }
725
+ // ⛔ SKEW IS NOT MALFORMEDNESS. The server answered correctly and this
726
+ // build is too old to render it, so the user is sent to an upgrade rather
727
+ // than told to report a bug that does not exist. `EXIT.failure` is kept:
728
+ // the command genuinely produced no answer, and a caller scripting `why`
729
+ // must not read a skew screen as success.
730
+ if (isVersionSkew(payload)) {
731
+ return fail(ctx, errorCopy(WHY.skewWhat, WHY.skewWhy, WHY.skewFix, COMMANDS.pluginInstall), EXIT.failure, commit);
732
+ }
733
+ return renderPayload(ctx, payload, commit, file, json);
734
+ }
735
+ }
736
+ }
737
+ /**
738
+ * §R3's squash disclosure, and THE ONE PLACE THE TWO CLAIMS ARE DISTINGUISHED.
739
+ *
740
+ * ⚠ **SHARED BY BOTH SCREENS DELIBERATELY.** The terminal `squash_resolved`
741
+ * state and the `turns` header make the same claim about the same pair, so
742
+ * D67's folding arrives the moment one of them learns to tell `exact` from
743
+ * `probable` and the other does not. At `8aa94d6` the terminal screen dropped
744
+ * `match_kind` entirely — **zero occurrences in this repo** — so a recorded
745
+ * successor and a patch-id guess rendered identically, live in the tree. One
746
+ * helper, both callers, and a cell on each.
747
+ *
748
+ * ⚠ `exact` renders UNQUALIFIED per §R3: the base copy states no provenance at
749
+ * all, so there is nothing further owed. Only `probable` earns a stated basis.
750
+ * A `null` `match_kind` also renders unqualified — mcp always emits the field
751
+ * on this state, so null means the two repos disagree, and whether THAT should
752
+ * be malformed is a decision rather than a builder's call.
753
+ */
754
+ function squashWhy(blamed, matchKind) {
755
+ const why = [ABSENCE.squashResolved.why(blamed)];
756
+ if (matchKind === "probable")
757
+ why.push(WHY.squashProbableBasis);
758
+ // ⚠ THE ASYMMETRY IS EXPLAINED, NOT REMOVED. Two builders read the differing
759
+ // sha lengths as a defect, and the answer is a clause rather than a
760
+ // truncation that would destroy the value — see THE SHA-LENGTH RULE above.
761
+ why.push(WHY.squashShaLengths);
762
+ return why;
763
+ }
764
+ /**
765
+ * The exit code of each absence state, read from the COPY rather than restated.
766
+ *
767
+ * ⚠ `CR-112`'s grammar puts the exit code beside the sentence deliberately — the
768
+ * `absence()` helper above takes both off one object — so the `--json` path
769
+ * reads the same field instead of hardcoding `4`. A document and a screen that
770
+ * disagreed about the exit code would be the plausible-wrong-answer class (D98)
771
+ * arriving through a second renderer.
772
+ */
773
+ const ABSENCE_EXIT = {
774
+ cold_start: ABSENCE.coldStart.exit,
775
+ no_edge: ABSENCE.noEdge.exit,
776
+ edge_unreadable: ABSENCE.edgeUnreadable.exit,
777
+ squash_resolved: ABSENCE.squashResolved.exit,
778
+ };
779
+ /**
780
+ * One turn, as the machine surface renders it — `src/json.ts`'s zero rule
781
+ * applied field by field.
782
+ *
783
+ * ⛔ **`files` IS `null` WHENEVER THE LIST IS EMPTY, AND NEVER `[]`.** This
784
+ * module's own `turnRow` docblock rules on the fact: *"`files: []` MEANS 'WE DO
785
+ * NOT KNOW WHICH FILES', NOT 'THIS TURN TOUCHED NONE'"* — `commit_file_
786
+ * attribution` has no writer (D104 §2b) and mcp hardcodes the empty array. So an
787
+ * empty list on today's wire is an ABSENCE OF KNOWLEDGE, and emitting `[]` would
788
+ * turn it into a measurement claim that this turn touched no files. `[]` stays
789
+ * legal in the schema and simply cannot be produced today; the day a writer
790
+ * lands, a wire that means "none" needs its own signal, not this silence.
791
+ *
792
+ * ⚠ `agent_id` alone is not enough and that is the whole of `agentIdPresent`:
793
+ * `null` MEANS the main thread, so a missing key and a null key are DIFFERENT
794
+ * facts and `agent_id_recorded` is what keeps them apart. Collapsing them would
795
+ * render an unattributed turn as main-thread work.
796
+ *
797
+ * `subagent_count` is echoed, never defaulted — the column has no producer
798
+ * (`CR-129`), so `null` is what the wire actually says and `0` would be a count
799
+ * nobody took.
800
+ */
801
+ function turnDocument(turn) {
802
+ return {
803
+ position: turn.position,
804
+ tool: turn.tool,
805
+ files: turn.files.length === 0 ? null : turn.files,
806
+ agent_id: turn.agentId,
807
+ agent_id_recorded: turn.agentIdPresent,
808
+ subagent_count: turn.subagentCount,
809
+ };
810
+ }
811
+ /**
812
+ * The `--json` document for every state — §13.3, and THE ONE PLACE THE THREE-WAY
813
+ * GRADE DISTINCTION IS MACHINE-READABLE.
814
+ *
815
+ * ⛔ **`grades` IS ECHOED, NEVER WIDENED.** `observed` has no producer in any
816
+ * repo (D104 §2a), so a document that listed the full enum — or filled the gap
817
+ * with a zero — would advertise a grade nothing can emit. What the wire sent is
818
+ * what appears; the empty array on the three non-`turns` states is the honest
819
+ * *no edge is held*, which is what those states mean.
820
+ *
821
+ * The shape is the SAME on all four states so a wrapper has one parse path. The
822
+ * fields a state cannot fill are `null` (not known) or `[]` (measured none), per
823
+ * `src/json.ts` — never absent, and never zero.
824
+ */
825
+ function whyDocument(payload, commit, file) {
826
+ return {
827
+ commit: { sha: commit.sha, date: commit.date, line: commit.line },
828
+ file,
829
+ recorded_sha: payload.recordedSha,
830
+ match_kind: payload.matchKind,
831
+ grades: payload.grades,
832
+ turns: payload.turns.map(turnDocument),
833
+ window: payload.window === null
834
+ ? null
835
+ : { first: payload.window.first, last: payload.window.last, total: payload.window.total },
836
+ files_without_turn: payload.filesWithoutTurn,
837
+ };
838
+ }
839
+ function renderPayload(ctx, payload, commit, file, json) {
840
+ // §13.3 — one document, nothing else, and the exit code is the SAME one the
841
+ // screen returns. Placed above the switch rather than inside each arm so no
842
+ // future state can be added that silently prints prose under the flag.
843
+ if (json) {
844
+ emitJson(ctx.stdout, "why", payload.state, whyDocument(payload, commit, file));
845
+ return payload.state === "turns" ? EXIT.ok : ABSENCE_EXIT[payload.state];
846
+ }
847
+ switch (payload.state) {
848
+ case "cold_start":
849
+ return absence(ctx, ABSENCE.coldStart, [COMMANDS.connect], commit);
850
+ case "no_edge":
851
+ // `fix` is "Nothing to fix…", so there is no command to offer beside it.
852
+ return absence(ctx, ABSENCE.noEdge, [], commit);
853
+ case "edge_unreadable":
854
+ // ⚠ THE ONE ARM THAT MUST NOT READ AS "NO SESSION". The payload says we
855
+ // HOLD a link and could not render the conversation behind it; `no_edge`
856
+ // above says the opposite. Rendering them alike would re-create, in the
857
+ // client, the exact conflation `U1` removed from the server — which is
858
+ // why this is its own arm and its own copy rather than a fall-through.
859
+ //
860
+ // No command beside it: `fix` states the two conditions that produce this
861
+ // (erased, or never uploaded) and neither is repaired by a verb we could
862
+ // offer here. Offering `connect` would imply a repair that does not exist.
863
+ return absence(ctx, ABSENCE.edgeUnreadable, [], commit);
864
+ case "squash_resolved": {
865
+ // ⚠ D65 §DR5 — BOTH SHAs render. `CR-109` owns this state and `CR-074`
866
+ // owns the successor lookup; this renders what the payload says and builds
867
+ // neither. The header may NOT claim the transcript recorded the blamed SHA.
868
+ //
869
+ // ⚠ THIS SCREEN IS THE DEAD END `CR-138` EXISTS TO CLOSE, and it remains
870
+ // only for a payload that carries no turns. It hands the reader a sha and
871
+ // `parseTarget` accepts no sha form, so there is no step two. When mcp
872
+ // starts putting `recorded_sha` on the `turns` branch (`CR-138b`), the
873
+ // arm below renders the same disclosure WITH the session under it.
874
+ //
875
+ // ⚠⚠ AND ITS REMEDY CLAUSE WAS SILENTLY VANISHING — `CR-109d`, found by
876
+ // planting a phrase in `ABSENCE.squashResolved.fix` and watching a screen
877
+ // scan stay GREEN. `renderErrorBlock` DROPS `fixLabel` when `fixes` is
878
+ // empty, this state has no command to offer, and it is the ONE absence
879
+ // state that does not go through `absence()` — so it shipped with two of
880
+ // the grammar's three fields. `absence()`'s own docblock had already
881
+ // ruled on exactly this: *"Letting it disappear would turn a whole state
882
+ // into absence-with-no-remedy, which is the failure `copy-absence.test.ts`
883
+ // guards the STRING against and nothing was guarding the SCREEN
884
+ // against."* The guard existed; this state was outside it.
885
+ // ⚠ FULL, NOT ABBREVIATED — see THE SHA-LENGTH RULE. The asymmetry with
886
+ // the blamed sha is information, and `squashWhy` explains it on screen.
887
+ const recorded = payload.recordedSha ?? "";
888
+ writeLines(ctx.stdout, [
889
+ ...wrap(renderCommitIdentity(commit), BODY_INDENT),
890
+ "",
891
+ ...renderErrorBlock({
892
+ kind: "warn",
893
+ what: ABSENCE.squashResolved.what(commit.sha, recorded),
894
+ why: squashWhy(commit.sha, payload.matchKind),
895
+ }, ctx.colour),
896
+ "",
897
+ ...wrap(ABSENCE.squashResolved.fix, 4),
898
+ ]);
899
+ return ABSENCE.squashResolved.exit;
900
+ }
901
+ case "turns": {
902
+ const lines = [];
903
+ // ⚠ THE SQUASH DISCLOSURE COMES FIRST, ABOVE THE HEADER PAIR — D65 §DR5
904
+ // and §10.4's *"anything below the fold is not a qualification"*.
905
+ //
906
+ // ⚠ AND `renderCommitHeader`'S TWO LINES WERE NOT SPLIT TO SLOT IT
907
+ // BETWEEN THEM, deliberately: its docblock rules that the identity line
908
+ // and the evidence sentence *"are one unit: rendering either without the
909
+ // other is what the structural rule exists to prevent."* So the
910
+ // qualification goes above the pair rather than through it, and the
911
+ // reader learns which sha the session belongs to before reading any
912
+ // sentence about evidence.
913
+ if (payload.recordedSha !== null) {
914
+ lines.push(...renderErrorBlock({
915
+ kind: "warn",
916
+ what: ABSENCE.squashResolved.what(commit.sha, payload.recordedSha),
917
+ why: squashWhy(commit.sha, payload.matchKind),
918
+ }, ctx.colour), "");
919
+ }
920
+ // ⚠ THE ONE HEADER RENDERER, taking the grade. `gradeFloor` governs: on a
921
+ // mixed-grade commit the header takes the FLOOR (D61 §PS6), because a
922
+ // header reporting the strongest grade would describe evidence we do not
923
+ // hold for every edge on that commit.
924
+ lines.push(...[...renderCommitHeader(payload.grades, commit)].flatMap((l) => wrap(l, BODY_INDENT)));
925
+ lines.push(...turnLines(ctx, payload, truncatePath(file, WRAP_COLUMNS - 40)));
926
+ // The hole, named — see `attributionNote`.
927
+ if (payload.turns.some((t) => !t.agentIdPresent))
928
+ lines.push(...attributionNote(ctx));
929
+ writeLines(ctx.stdout, lines);
930
+ // ⚠ EXIT 0, NOT 4, EVEN WITH THE DISCLOSURE. Exit 4 means "connected,
931
+ // nothing to report"; this screen HAS the session on it. The terminal
932
+ // `squash_resolved` state above keeps 4 because it genuinely has nothing.
933
+ return EXIT.ok;
934
+ }
935
+ }
936
+ }
937
+ //# sourceMappingURL=why.js.map