@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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/agents/claude_code.d.ts +11 -0
- package/dist/agents/claude_code.js +162 -0
- package/dist/agents/claude_code.js.map +1 -0
- package/dist/agents/codex_cli.d.ts +15 -0
- package/dist/agents/codex_cli.js +67 -0
- package/dist/agents/codex_cli.js.map +1 -0
- package/dist/agents/cursor.d.ts +21 -0
- package/dist/agents/cursor.js +89 -0
- package/dist/agents/cursor.js.map +1 -0
- package/dist/agents/registry.d.ts +184 -0
- package/dist/agents/registry.js +212 -0
- package/dist/agents/registry.js.map +1 -0
- package/dist/agents/types.d.ts +279 -0
- package/dist/agents/types.js +67 -0
- package/dist/agents/types.js.map +1 -0
- package/dist/commands/connect.d.ts +36 -0
- package/dist/commands/connect.js +499 -0
- package/dist/commands/connect.js.map +1 -0
- package/dist/commands/context.d.ts +45 -0
- package/dist/commands/context.js +14 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/report.d.ts +105 -0
- package/dist/commands/report.js +430 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/status.d.ts +29 -0
- package/dist/commands/status.js +236 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/why.d.ts +349 -0
- package/dist/commands/why.js +937 -0
- package/dist/commands/why.js.map +1 -0
- package/dist/consent.d.ts +59 -0
- package/dist/consent.js +111 -0
- package/dist/consent.js.map +1 -0
- package/dist/copy/claims.d.ts +79 -0
- package/dist/copy/claims.js +343 -0
- package/dist/copy/claims.js.map +1 -0
- package/dist/copy/grades.d.ts +117 -0
- package/dist/copy/grades.js +96 -0
- package/dist/copy/grades.js.map +1 -0
- package/dist/copy/index.d.ts +12 -0
- package/dist/copy/index.js +13 -0
- package/dist/copy/index.js.map +1 -0
- package/dist/copy/strings.d.ts +1328 -0
- package/dist/copy/strings.js +1243 -0
- package/dist/copy/strings.js.map +1 -0
- package/dist/copy/time.d.ts +27 -0
- package/dist/copy/time.js +43 -0
- package/dist/copy/time.js.map +1 -0
- package/dist/credential.d.ts +211 -0
- package/dist/credential.js +298 -0
- package/dist/credential.js.map +1 -0
- package/dist/exit.d.ts +59 -0
- package/dist/exit.js +60 -0
- package/dist/exit.js.map +1 -0
- package/dist/git.d.ts +299 -0
- package/dist/git.js +653 -0
- package/dist/git.js.map +1 -0
- package/dist/hooks/entry.d.ts +277 -0
- package/dist/hooks/entry.js +1092 -0
- package/dist/hooks/entry.js.map +1 -0
- package/dist/hooks/post_commit.d.ts +59 -0
- package/dist/hooks/post_commit.js +160 -0
- package/dist/hooks/post_commit.js.map +1 -0
- package/dist/hooks/post_rewrite.d.ts +59 -0
- package/dist/hooks/post_rewrite.js +161 -0
- package/dist/hooks/post_rewrite.js.map +1 -0
- package/dist/hooks/session_end.d.ts +101 -0
- package/dist/hooks/session_end.js +120 -0
- package/dist/hooks/session_end.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +360 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +153 -0
- package/dist/install.js +284 -0
- package/dist/install.js.map +1 -0
- package/dist/json.d.ts +100 -0
- package/dist/json.js +94 -0
- package/dist/json.js.map +1 -0
- package/dist/oauth/discovery.d.ts +96 -0
- package/dist/oauth/discovery.js +163 -0
- package/dist/oauth/discovery.js.map +1 -0
- package/dist/oauth/lock.d.ts +100 -0
- package/dist/oauth/lock.js +287 -0
- package/dist/oauth/lock.js.map +1 -0
- package/dist/oauth/loopback.d.ts +85 -0
- package/dist/oauth/loopback.js +124 -0
- package/dist/oauth/loopback.js.map +1 -0
- package/dist/oauth/pkce.d.ts +58 -0
- package/dist/oauth/pkce.js +77 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/read.d.ts +120 -0
- package/dist/oauth/read.js +144 -0
- package/dist/oauth/read.js.map +1 -0
- package/dist/oauth/session.d.ts +80 -0
- package/dist/oauth/session.js +112 -0
- package/dist/oauth/session.js.map +1 -0
- package/dist/oauth/signin.d.ts +178 -0
- package/dist/oauth/signin.js +225 -0
- package/dist/oauth/signin.js.map +1 -0
- package/dist/oauth/token.d.ts +139 -0
- package/dist/oauth/token.js +183 -0
- package/dist/oauth/token.js.map +1 -0
- package/dist/paths.d.ts +366 -0
- package/dist/paths.js +460 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy.d.ts +195 -0
- package/dist/policy.js +244 -0
- package/dist/policy.js.map +1 -0
- package/dist/post.d.ts +302 -0
- package/dist/post.js +384 -0
- package/dist/post.js.map +1 -0
- package/dist/project.d.ts +99 -0
- package/dist/project.js +192 -0
- package/dist/project.js.map +1 -0
- package/dist/redact.d.ts +175 -0
- package/dist/redact.js +700 -0
- package/dist/redact.js.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.js +37 -0
- package/dist/runtime.js.map +1 -0
- package/dist/spawn_budget.d.ts +101 -0
- package/dist/spawn_budget.js +124 -0
- package/dist/spawn_budget.js.map +1 -0
- package/dist/spool.d.ts +390 -0
- package/dist/spool.js +556 -0
- package/dist/spool.js.map +1 -0
- package/dist/state.d.ts +109 -0
- package/dist/state.js +217 -0
- package/dist/state.js.map +1 -0
- package/dist/system_message.d.ts +101 -0
- package/dist/system_message.js +141 -0
- package/dist/system_message.js.map +1 -0
- package/dist/term.d.ts +112 -0
- package/dist/term.js +134 -0
- package/dist/term.js.map +1 -0
- package/dist/version.d.ts +16 -0
- package/dist/version.js +17 -0
- package/dist/version.js.map +1 -0
- 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
|