dsh-hashline-edit 0.1.0 → 0.2.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 (131) hide show
  1. package/README.md +72 -70
  2. package/cordis.patch.yml +1 -1
  3. package/lib/index.js +4494 -4813
  4. package/lib/types/dsh/contracts.d.ts +69 -6
  5. package/lib/types/dsh/fs.d.ts +38 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/hide.d.ts +78 -0
  8. package/lib/types/dsh/host-compat.d.ts +7 -78
  9. package/lib/types/dsh/index.d.ts +17 -7
  10. package/lib/types/dsh/metrics/events.d.ts +8 -1
  11. package/lib/types/dsh/metrics/instrument.d.ts +20 -14
  12. package/lib/types/dsh/metrics/observer.d.ts +28 -0
  13. package/lib/types/dsh/sandbox.d.ts +2 -2
  14. package/lib/types/dsh/shell-hook.d.ts +17 -8
  15. package/lib/types/dsh/shell-view.d.ts +65 -3
  16. package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
  17. package/lib/types/dsh/tools/context.d.ts +19 -1
  18. package/lib/types/dsh/tools/output.d.ts +32 -4
  19. package/lib/types/dsh/tools/read.d.ts +12 -15
  20. package/lib/types/dsh/tools/undo.d.ts +5 -0
  21. package/lib/types/dsh/write-hook.d.ts +8 -22
  22. package/lib/types/src/apply-patch/commit.d.ts +49 -0
  23. package/lib/types/src/apply-patch/grammar.d.ts +29 -0
  24. package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
  25. package/lib/types/src/apply-patch/plan.d.ts +50 -0
  26. package/lib/types/src/apply-patch/render.d.ts +51 -0
  27. package/lib/types/src/apply-patch/resolve.d.ts +129 -0
  28. package/lib/types/src/apply-patch/tool.d.ts +54 -0
  29. package/lib/types/src/apply-patch/types.d.ts +362 -0
  30. package/lib/types/src/bash-view-banner.d.ts +20 -0
  31. package/lib/types/src/domain-errors.d.ts +16 -2
  32. package/lib/types/src/edit-undo.d.ts +11 -2
  33. package/lib/types/src/file-content/detection.d.ts +17 -0
  34. package/lib/types/src/file-content/index.d.ts +2 -2
  35. package/lib/types/src/file-content/preview.d.ts +1 -1
  36. package/lib/types/src/fs-lifecycle.d.ts +44 -0
  37. package/lib/types/src/hashline/resolve.d.ts +6 -0
  38. package/lib/types/src/host-utils.d.ts +63 -0
  39. package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
  40. package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
  41. package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
  42. package/lib/types/src/mutation-engine/types.d.ts +50 -58
  43. package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
  44. package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
  45. package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
  46. package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
  47. package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
  48. package/lib/types/src/served-session/serve-render.d.ts +9 -3
  49. package/lib/types/src/undo-store.d.ts +17 -1
  50. package/package.json +10 -22
  51. package/prompts/apply-patch-guidelines.md +10 -0
  52. package/prompts/apply-patch.md +1 -0
  53. package/prompts/read-guidelines.md +5 -5
  54. package/prompts/read.md +1 -1
  55. package/prompts/undo-last-edit-guidelines.md +2 -2
  56. package/prompts/undo-last-edit-snippet.md +1 -1
  57. package/prompts/undo-last-edit.md +1 -1
  58. package/src/apply-patch/commit.ts +190 -0
  59. package/src/apply-patch/grammar.ts +620 -0
  60. package/src/apply-patch/index.ts +17 -0
  61. package/src/apply-patch/lifecycle.ts +195 -0
  62. package/src/apply-patch/plan.ts +800 -0
  63. package/src/apply-patch/render.ts +137 -0
  64. package/src/apply-patch/resolve.ts +470 -0
  65. package/src/apply-patch/tool.ts +257 -0
  66. package/src/apply-patch/types.ts +387 -0
  67. package/src/cut-repair.ts +77 -11
  68. package/src/domain-errors.ts +58 -10
  69. package/src/edit-undo.ts +379 -25
  70. package/src/file-content/detection.ts +34 -0
  71. package/src/file-content/index.ts +2 -2
  72. package/src/file-content/preview.ts +1 -1
  73. package/src/file-content/served-header.ts +1 -1
  74. package/src/fs-lifecycle.ts +201 -0
  75. package/src/hash-store.ts +18 -1
  76. package/src/hashline/lease-resolve.ts +14 -8
  77. package/src/hashline/resolve.ts +30 -27
  78. package/src/host-utils.ts +224 -0
  79. package/src/mutation-engine/batch-span-gate.ts +17 -4
  80. package/src/mutation-engine/edit-request.ts +39 -0
  81. package/src/mutation-engine/pipeline.ts +535 -63
  82. package/src/mutation-engine/types.ts +49 -65
  83. package/src/mutation-queue.ts +1 -1
  84. package/src/prompts.ts +0 -28
  85. package/src/read-expansion/ast-provider.ts +4 -0
  86. package/src/read-expansion/grammar-manifest.ts +21 -2
  87. package/src/read-expansion/java-decoration.ts +34 -0
  88. package/src/read-expansion/span-expansion.ts +6 -2
  89. package/src/read-expansion/span-kind.ts +3 -1
  90. package/src/read-expansion/vue-decoration.ts +43 -0
  91. package/src/served-session/serve-render.ts +5 -5
  92. package/src/undo-store.ts +74 -5
  93. package/lib/types/dsh/encoding-bridge.d.ts +0 -25
  94. package/lib/types/dsh/encoding.d.ts +0 -144
  95. package/lib/types/dsh/tools/edit.d.ts +0 -10
  96. package/lib/types/src/edit-tool.d.ts +0 -41
  97. package/lib/types/src/mutation-engine/engine.d.ts +0 -31
  98. package/lib/types/src/payload-contract.d.ts +0 -124
  99. package/prompts/edit-guidelines.md +0 -10
  100. package/prompts/edit-snippet.md +0 -1
  101. package/prompts/edit.md +0 -1
  102. package/src/bash-classifier.ts +0 -1626
  103. package/src/bash-conversion/decide.ts +0 -812
  104. package/src/bash-display-transform.ts +0 -50
  105. package/src/bash-multifile-config.ts +0 -239
  106. package/src/bash-view-config.ts +0 -158
  107. package/src/edit-normalize.ts +0 -8
  108. package/src/edit-render.ts +0 -161
  109. package/src/edit-tool.ts +0 -116
  110. package/src/edit.ts +0 -136
  111. package/src/integrations/pi-lens/command.ts +0 -712
  112. package/src/integrations/pi-lens/config.ts +0 -148
  113. package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
  114. package/src/integrations/pi-lens/io-bridge.ts +0 -112
  115. package/src/integrations/pi-lens/settings-overlay.ts +0 -272
  116. package/src/lifecycle-hooks/index.ts +0 -1992
  117. package/src/lifecycle-hooks/types.ts +0 -64
  118. package/src/mutated-files.ts +0 -75
  119. package/src/mutation-engine/engine.ts +0 -173
  120. package/src/mutation-engine/index.ts +0 -17
  121. package/src/payload-contract.ts +0 -637
  122. package/src/preview-controller.ts +0 -84
  123. package/src/read.ts +0 -865
  124. package/src/search-hook.ts +0 -41
  125. package/src/store-config.ts +0 -73
  126. package/src/store-janitor-warning.ts +0 -169
  127. package/src/store-janitor.ts +0 -539
  128. package/src/store-kernel/settings.ts +0 -233
  129. package/src/store.ts +0 -154
  130. package/src/tui-presenter.ts +0 -134
  131. package/src/write-hook.ts +0 -130
@@ -1,1992 +0,0 @@
1
- import { lstat, readdir } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
4
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import { initHasher as defaultInitHasher } from "../hashline/hash-identity.js";
6
- import {
7
- findServedHashEcho,
8
- findServedPrefixMismatches,
9
- buildServedWritePrefixNote,
10
- LITERAL_BYPASS_NOTICE,
11
- clearServedRefusals,
12
- } from "../served-session/serve-render.js";
13
- import { assertNever, splitLines } from "../utils.js";
14
- import { denseServeRows, fmtServedRows, type ServedRow } from "../served-session/serve-render.js";
15
- import { notifyServedSpans, servedRowsToSpans, type ServedSpan } from "../served-spans.js";
16
- import { notifyMutatedFile } from "../mutated-files.js";
17
- import { applySliceOps, isSliceOp } from "../bash-classifier.js";
18
- import { resolveBashViewMode } from "../bash-view-config.js";
19
- import { resolveBashMultiFileSettings } from "../bash-multifile-config.js";
20
- import { pruneMissingAll as defaultPruneMissingAll } from "../snapshot-store";
21
- import { clearUndo as defaultClearUndo } from "../edit-undo.js";
22
- import { stripBOM, toLF } from "../edit-diff.js";
23
- import {
24
- createSessionHandle,
25
- sessionKeyFor as defaultSessionKeyFor,
26
- } from "../served-session/session.js";
27
- import { snapshotHashFor } from "../snapshot-store";
28
- import { BASH_AUTO_READ_LABEL, BASH_VIEW_LABEL, bashBanner } from "../bash-view-banner.js";
29
- import type { Decision } from "../bash-conversion/decide.js";
30
- import { formatPageHeader } from "../file-content/served-header.js";
31
-
32
- async function defaultRecordDiffServes(input: {
33
- sessionKey: string;
34
- path: string;
35
- servedRows: import("../served-session/serve-render.js").ServedRow[];
36
- contentHash: string;
37
- resultLineCount?: number;
38
- firstChangedLine?: number;
39
- lastChangedLine?: number;
40
- }): Promise<void> {
41
- await createSessionHandle(input.sessionKey, input.path).recordDiff(input.servedRows, {
42
- contentHash: input.contentHash,
43
- ...(input.resultLineCount !== undefined ? { resultLineCount: input.resultLineCount } : {}),
44
- ...(input.firstChangedLine !== undefined ? { firstChangedLine: input.firstChangedLine } : {}),
45
- ...(input.lastChangedLine !== undefined ? { lastChangedLine: input.lastChangedLine } : {}),
46
- });
47
- }
48
- import { readNormFile as defaultReadNormFile } from "../file-reader.js";
49
- import { loadFileKindAndText as defaultLoadFileKindAndText } from "../file-kind.js";
50
- import { toCwd as defaultToCwd } from "../paths.js";
51
- import { resolveTarget as defaultResolveTarget } from "../fs-write.js";
52
- import { valAccess as defaultValAccess } from "../validation.js";
53
- import { visLines as defaultVisLines } from "../utils.js";
54
- import { fmtReadPreview as defaultFmtReadPreview } from "../read.js";
55
- import { runDisplayTransform as defaultRunDisplayTransform } from "../bash-display-transform.js";
56
- import { finalizeToolResult as defaultFinalizeToolResult } from "../edit-response.js";
57
- import { reportDiagnostic, reportInfo, reportWarning } from "../diagnostics.js";
58
- import { DomainError } from "../domain-errors.js";
59
- import {
60
- AUTO_READ_MAX,
61
- BASH_SEARCH_MAX_FILES,
62
- BASH_SEARCH_WALK_MAX_FILES,
63
- MAX_READ_WINDOWS,
64
- SEARCH_MAX_MATCHES,
65
- SEARCH_MAX_TOTAL_ROWS,
66
- SERVED_MAX_LINES,
67
- } from "../constants.js";
68
- import type {
69
- BashFilesList,
70
- BashMultiSearch,
71
- BashMultiView,
72
- BashRecursiveSearch,
73
- BashSearch,
74
- BashSearchContext,
75
- CompoundSegment,
76
- ContentLine,
77
- DisplayTransform,
78
- SliceOp,
79
- ViewOp,
80
- } from "../bash-classifier.js";
81
-
82
- import type { LifecycleDeps, ToolContext, ToolResultEvent } from "./types.js";
83
-
84
- export type { ToolContext, ToolResultEvent, LifecycleDeps } from "./types.js";
85
-
86
- function defaultDeps(): LifecycleDeps {
87
- return {
88
- initHasher: defaultInitHasher,
89
- pruneMissingAll: defaultPruneMissingAll,
90
- clearUndo: defaultClearUndo,
91
- resolveTarget: defaultResolveTarget,
92
- toCwd: defaultToCwd,
93
- valAccess: defaultValAccess,
94
- loadFileKindAndText: defaultLoadFileKindAndText,
95
- readNormFile: defaultReadNormFile,
96
- fmtReadPreview: defaultFmtReadPreview,
97
- recordDiffServes: defaultRecordDiffServes,
98
- sessionKeyFor: defaultSessionKeyFor,
99
- finalizeToolResult: defaultFinalizeToolResult,
100
- visLines: defaultVisLines,
101
- runDisplayTransform: defaultRunDisplayTransform,
102
- };
103
- }
104
-
105
- /** WHY: (item 10) one `FILE:LINE:content` row of a multi-file / recursive search. The file
106
- * field is non-greedy, so a path that itself contains `:` still parses (`a:b.txt:3:x`). */
107
- const MULTI_SEARCH_STDOUT_LINE = /^(.+?):(\d+):([\s\S]*)$/;
108
-
109
- /**
110
- * WHY: (item 10) parse the `FILE:LINE:content` triples of a multi-file or recursive search.
111
- * Fails closed (`undefined`) unless EVERY row is a triple with a 1-indexed line — the same
112
- * all-or-nothing geometry rule `parseSearchStdout` applies — so a single-geometry row, a
113
- * heading, or a match count is never mistaken for the admitted shape.
114
- */
115
- function parseTripleStdout(
116
- observed: string,
117
- ): Array<{ file: string; line: number; content: string }> | undefined {
118
- const lines = observed.split("\n");
119
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
120
- if (lines.length === 0) return undefined;
121
- const matches: Array<{ file: string; line: number; content: string }> = [];
122
- for (const text of lines) {
123
- const parsed = MULTI_SEARCH_STDOUT_LINE.exec(text);
124
- if (!parsed) return undefined;
125
- const line = Number(parsed[2]);
126
- if (!Number.isSafeInteger(line) || line < 1) return undefined;
127
- matches.push({ file: parsed[1] ?? "", line, content: parsed[3] ?? "" });
128
- }
129
- return matches;
130
- }
131
-
132
- /** WHY: (item 10) exactly one text block, the attribution rule every seam applies. */
133
- function singleTextBlock(event: ToolResultEvent): string | undefined {
134
- const block = Array.isArray(event.content) ? event.content : undefined;
135
- return block?.length === 1 && block[0]?.type === "text" ? block[0].text : undefined;
136
- }
137
-
138
- /**
139
- * WHY: (item 11) the header states the order whenever a consumer reordered the stream — a sorted
140
- * WHY: or adjacent-deduped render is not the file's own order, and the model reads the header to
141
- * WHY: know which coordinates it holds. The order suffix is empty for every order-preserving fold
142
- * WHY: (so the item-9/item-10 headers stay byte-identical), and `count` names itself instead.
143
- */
144
- function viewHeader(filePath: string, ops: readonly ViewOp[], count: number): string {
145
- if (ops.some((op) => op.kind === "count")) {
146
- return `[${BASH_VIEW_LABEL}: ${filePath} (count: ${count})]`;
147
- }
148
- const markers: string[] = [];
149
- if (ops.some((op) => op.kind === "sortUnique")) markers.push("sorted");
150
- if (ops.some((op) => op.kind === "uniq")) markers.push("adjacent-dedup");
151
- const order = markers.length === 0 ? "" : `, ${markers.join(", ")}`;
152
- return `[${BASH_VIEW_LABEL}: ${filePath} (${count} ${count === 1 ? "line" : "lines"}${order})]`;
153
- }
154
-
155
- /**
156
- * WHY: (item 10) the recursive-walk exclusion list, stated rather than inferred: no directory
157
- * whose name is hidden (leading `.`, which covers `.git`) and none named `node_modules` is ever
158
- * descended into. Exclusions gate DESCENT only — an explicitly named operand is still honored,
159
- * because the operator asked for that path.
160
- */
161
- const SEARCH_WALK_EXCLUDED_DIR_NAMES: ReadonlySet<string> = new Set(["node_modules"]);
162
-
163
- function isWalkableDirName(name: string): boolean {
164
- if (name.startsWith(".")) return false;
165
- return !SEARCH_WALK_EXCLUDED_DIR_NAMES.has(name);
166
- }
167
-
168
- /** `grep -n`/`rg --line-number` stdout row: a 1-indexed line number, a colon, the matched text. */
169
- const SEARCH_STDOUT_LINE = /^(\d+):([\s\S]*)$/;
170
- /** WHY: (ADR-0048 D3) one parsed search row and which geometry printed it. */
171
- interface SearchStdoutRow {
172
- line: number;
173
- kind: "match" | "context";
174
- content: string;
175
- }
176
- /** `grep -n -C`/`rg --line-number -C` CONTEXT row: line number, `-`, the line's own bytes. */
177
- const SEARCH_STDOUT_CONTEXT_LINE = /^(\d+)-([\s\S]*)$/;
178
- /** The group separator `grep -C`/`rg -C` print between non-contiguous context groups. */
179
- const SEARCH_STDOUT_GROUP_SEPARATOR = "--";
180
- /** `cat -n`/`nl -ba` stdout row: blank-padded 1-indexed number, a tab, the line content. */
181
- const NUMBERED_STDOUT_LINE = /^\s*(\d+)\t([\s\S]*)$/;
182
- /**
183
- * WHY: (item 11) `wc -l` stdout is the count ALONE. GNU pads to a fixed width when it
184
- * WHY: names a file (` 12 f`), so both `12\n` and ` 12\n` are accepted
185
- * WHY: for a piped count; a second field, a non-number or an empty stdout fails the gate.
186
- */
187
- const COUNT_STDOUT_LINE = /^\s*(\d+)\s*$/;
188
-
189
- /**
190
- * Parse the rows of a line-numbered single-file search. Fails closed (`undefined`) unless every row is
191
- * the admitted geometry, so a filename prefix, a match count, a heading or any other `grep`/`rg` shape
192
- * is never mistaken for it. The only empty line tolerated is the trailing newline every search emits.
193
- *
194
- * WHY: (ADR-0048 D3 — context searches) a row is a MATCH (`LINE:content`) or — only when the command
195
- * asked for a window — one of that match's CONTEXT lines (`LINE-content`), and non-contiguous groups
196
- * are separated by a bare `--`. The first field is the maximal leading digit run and the very next byte
197
- * decides the kind, so the split is deterministic: unlike `FILE:LINE:content`, whose prefix may itself
198
- * contain `:`, there is no second reading to enumerate. Context geometry is admitted ONLY when it was
199
- * asked for — a `--` or `LINE-content` row in a plain search's stdout is foreign output, not evidence.
200
- */
201
- function parseSearchStdout(
202
- observed: string,
203
- options?: { context: true },
204
- ): SearchStdoutRow[] | undefined {
205
- const context = options?.context === true;
206
- const lines = observed.split("\n");
207
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
208
- if (lines.length === 0) return undefined;
209
- const rows: SearchStdoutRow[] = [];
210
- for (const text of lines) {
211
- // WHY: a separator carries no file byte — it only marks a gap grep did not merge — so it is
212
- // WHY: dropped here and the surviving line numbers keep that gap visible in the render.
213
- if (context && text === SEARCH_STDOUT_GROUP_SEPARATOR) continue;
214
- const matched = SEARCH_STDOUT_LINE.exec(text);
215
- if (matched !== null) {
216
- const line = Number(matched[1]);
217
- if (!Number.isSafeInteger(line) || line < 1) return undefined;
218
- rows.push({ line, kind: "match", content: matched[2] ?? "" });
219
- continue;
220
- }
221
- if (!context) return undefined;
222
- const surrounding = SEARCH_STDOUT_CONTEXT_LINE.exec(text);
223
- if (surrounding === null) return undefined;
224
- const line = Number(surrounding[1]);
225
- if (!Number.isSafeInteger(line) || line < 1) return undefined;
226
- rows.push({ line, kind: "context", content: surrounding[2] ?? "" });
227
- }
228
- return rows.length === 0 ? undefined : rows;
229
- }
230
-
231
- /**
232
- * WHY: (ADR-0048 D3 — the search's own prediction half of D1) a CONTEXT row is witnessable only when
233
- * some match could have printed it: it must lie inside that match's window, and it must not BE a match
234
- * line (grep prints a matched line as a match row, never as context). A match row is witnessed by its
235
- * own byte equality, so it needs no window. A row that fails this is not this command's output.
236
- */
237
- function contextRowIsWitnessable(
238
- row: SearchStdoutRow,
239
- matchLines: ReadonlySet<number>,
240
- window: BashSearchContext,
241
- ): boolean {
242
- if (row.kind === "match") return true;
243
- if (matchLines.has(row.line)) return false;
244
- for (const matchLine of matchLines) {
245
- if (row.line >= matchLine - window.before && row.line <= matchLine + window.after) return true;
246
- }
247
- return false;
248
- }
249
-
250
- /**
251
- * WHY: (ADR-0048 D3) the row-budget and prediction gates of one search, all BEFORE the first lease.
252
- * Two INDEPENDENT caps: `SEARCH_MAX_MATCHES` still bounds the pattern hits — the bytes-cost argument
253
- * behind it is about matches — while `SEARCH_MAX_TOTAL_ROWS` bounds match+context rows, which one
254
- * `-C N` window inflates far past the match count. Both are all-or-nothing: over either one the whole
255
- * command passes through raw, because a truncated window names lines bash never printed. With a window
256
- * requested, every context row must also be witnessable (see `contextRowIsWitnessable`).
257
- *
258
- * Returns the MATCH line set the seam reuses for the header counts, or `undefined` to pass through.
259
- */
260
- function searchRowsWithinBudget(
261
- rows: readonly SearchStdoutRow[],
262
- window: BashSearchContext | undefined,
263
- ): ReadonlySet<number> | undefined {
264
- const matchLines = new Set<number>();
265
- for (const row of rows) if (row.kind === "match") matchLines.add(row.line);
266
- if (matchLines.size > SEARCH_MAX_MATCHES) return undefined;
267
- if (rows.length > SEARCH_MAX_TOTAL_ROWS) return undefined;
268
- if (window !== undefined) {
269
- for (const row of rows) {
270
- if (!contextRowIsWitnessable(row, matchLines, window)) return undefined;
271
- }
272
- }
273
- return matchLines;
274
- }
275
-
276
- /**
277
- * WHY: (ADR-0049 M3) one selected row of the concatenated stream: which page it belongs to, its
278
- * file-relative line, its stream line, and the bytes the re-read file holds there.
279
- */
280
- interface MultiViewStreamRow {
281
- readonly fileIndex: number;
282
- readonly fileLine: number;
283
- readonly streamLine: number;
284
- readonly text: string;
285
- }
286
-
287
- /**
288
- * WHY: (ADR-0049 M3) boundaries are LINE COUNTS. The operands are laid end to end; each file owns
289
- * `[start, start + count - 1]` of the stream, and a stream line maps to a file line by subtracting
290
- * that start. No byte arithmetic and no content matching is involved, so a `sed` range spanning files
291
- * (M5) is just interval intersection. A file with no selected line keeps an empty page (M4).
292
- *
293
- * `undefined` means the selection is not provable as ONE page per file: a stream line outside every
294
- * file, or a DISJOINT selection inside one file (one page header names one contiguous range, M5).
295
- */
296
- function multiViewSelection(
297
- lineCounts: readonly number[],
298
- intervals: ReadonlyArray<{ lo: number; hi: number }>,
299
- ): number[][] | undefined {
300
- const starts: number[] = [];
301
- let next = 1;
302
- for (const count of lineCounts) {
303
- starts.push(next);
304
- next += count;
305
- }
306
- const selected: number[][] = lineCounts.map(() => []);
307
- for (const interval of intervals) {
308
- for (let streamLine = interval.lo; streamLine <= interval.hi; streamLine++) {
309
- const index = starts.findIndex((start, fileIndex) => {
310
- const count = lineCounts[fileIndex] ?? 0;
311
- return count > 0 && streamLine >= start && streamLine < start + count;
312
- });
313
- if (index === -1) return undefined;
314
- selected[index]!.push(streamLine - starts[index]! + 1);
315
- }
316
- }
317
- // WHY: (M5) one page header names one contiguous range, so a file whose selected lines are not a
318
- // WHY: single run has no honest header: pass the whole command through instead of inventing one.
319
- for (const lines of selected) {
320
- const first = lines[0];
321
- const last = lines[lines.length - 1];
322
- if (first === undefined || last === undefined) continue;
323
- if (last - first + 1 !== lines.length) return undefined;
324
- }
325
- return selected;
326
- }
327
-
328
- /**
329
- * WHY: (ADR-0049 M3) the stream rows in stream order, built from the RE-READ files — the observed
330
- * stdout is compared against these bytes, so every served row is byte-identical to its disk slice by
331
- * construction. `undefined` when the layout and the bytes disagree (an impossible state, refused).
332
- */
333
- function multiViewStreamRows(
334
- files: ReadonlyArray<{ allLines: readonly string[] }>,
335
- selected: ReadonlyArray<readonly number[]>,
336
- ): MultiViewStreamRow[] | undefined {
337
- const rows: MultiViewStreamRow[] = [];
338
- let streamStart = 1;
339
- for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
340
- const allLines = files[fileIndex]?.allLines ?? [];
341
- for (const fileLine of selected[fileIndex] ?? []) {
342
- const text = allLines[fileLine - 1];
343
- if (text === undefined) return undefined;
344
- rows.push({ fileIndex, fileLine, streamLine: streamStart + fileLine - 1, text });
345
- }
346
- streamStart += allLines.length;
347
- }
348
- return rows;
349
- }
350
-
351
- /**
352
- * The candidate observed renders of one multi-file view, byte-exact and trailing-newline free.
353
- *
354
- * WHY two numbering candidates (ADR-0049 M3): the numbering transform is `cat -n` / `nl -ba`, and the
355
- * STREAM numbering is continuous while GNU `cat -n`/`nl` implement exactly that — but a per-file
356
- * restart is what BSD `cat -n` prints, and `nl` there refuses multiple operands. Both renders are
357
- * pure functions of line counts, so admitting either is still a length-derived proof: the observed
358
- * bytes choose which convention ran, and a stream that matches neither fails closed.
359
- */
360
- function multiViewExpectedTexts(
361
- rows: readonly MultiViewStreamRow[],
362
- lineNumbered: boolean,
363
- ): string[] {
364
- const plain = rows.map((row) => row.text).join("\n");
365
- if (!lineNumbered) return [plain];
366
- const numbered = (number: (row: MultiViewStreamRow) => number): string =>
367
- rows.map((row) => `${String(number(row)).padStart(6)}\t${row.text}`).join("\n");
368
- const continuous = numbered((row) => row.streamLine);
369
- const perFile = numbered((row) => row.fileLine);
370
- return perFile === continuous ? [continuous] : [continuous, perFile];
371
- }
372
-
373
- /**
374
- * Parse one `cat -n` / `nl -ba` full-file stdout into content lines. Fails closed
375
- * (`undefined`) unless EVERY non-trailing-newline row matches the shared numbered
376
- * geometry — ONE parser for both spellings (they are the same view). The numeric
377
- * field is display geometry: it is validated only for strict `1..L` sequencing and
378
- * never used as a lease coordinate (leases come from re-read positions).
379
- */
380
- function parseNumberedStdout(observed: string): string[] | undefined {
381
- const lines = observed.split("\n");
382
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
383
- if (lines.length === 0) return undefined;
384
- const out: string[] = [];
385
- for (let i = 0; i < lines.length; i++) {
386
- const parsed = NUMBERED_STDOUT_LINE.exec(lines[i]);
387
- if (!parsed) return undefined;
388
- // WHY: strict 1-based sequencing — `cat -n`/`nl -ba` number every line from 1.
389
- if (Number(parsed[1]) !== i + 1) return undefined;
390
- out.push(parsed[2]);
391
- }
392
- return out;
393
- }
394
-
395
- /**
396
- * The changed line span of one mutated file, or an empty list meaning "whole file".
397
- *
398
- * WHY empty means whole file: the io-bridge adapter maps an empty list to a whole-file write, and
399
- * the bridge resolves a whole-file write the same way — so an unnamed span stays honest instead of
400
- * inventing a line. WHY the validation: a non-integer, a zero, or an inverted pair would be refused
401
- * downstream (`isValidRange` in the v2 contract) or would name lines the file never had.
402
- */
403
- function toChangedRanges(first: number | undefined, last: number | undefined): ServedSpan[] {
404
- if (first === undefined || last === undefined) return [];
405
- if (!Number.isInteger(first) || !Number.isInteger(last) || first < 1 || last < first) return [];
406
- return [{ startLine: first, lineCount: last - first + 1 }];
407
- }
408
-
409
- /**
410
- * Sessions already shown the first-interception notice, keyed by `deps.sessionKeyFor`.
411
- * Module scope, not per hooks instance: a session outlives any one hooks object, and the
412
- * notice is once per session. Cleared in `handleSessionStart`, which re-arms it.
413
- */
414
- const warnedBashViewSessions = new Set<string>();
415
-
416
- /** First-interception notice: names the round saved and the off-ramp. */
417
- const BASH_VIEW_FIRST_NOTICE =
418
- "Bash view replaced this output with anchored lines, saving a re-read round trip. " +
419
- "Turn it off with /pi-better-edit bash-view off, or pick a scope via /pi-better-edit bash-view.";
420
-
421
- /**
422
- * Warn once per session about the interception. A context without a notify surface falls
423
- * back to the console — the same degradation the command's notify uses, inlined here so
424
- * the editing path imports no command module for three lines.
425
- */
426
- function notifyBashViewOnce(ctx: ToolContext, sessionKey: string): void {
427
- if (warnedBashViewSessions.has(sessionKey)) return;
428
- warnedBashViewSessions.add(sessionKey);
429
- const ui = ctx.ui as { notify: (message: string, level: string) => void } | undefined;
430
- if (ui !== undefined) {
431
- ui.notify(BASH_VIEW_FIRST_NOTICE, "warning");
432
- return;
433
- }
434
- reportInfo("lifecycle-hooks", BASH_VIEW_FIRST_NOTICE);
435
- }
436
- export function createLifecycleHooks(overrides: Partial<LifecycleDeps> = {}): {
437
- onSessionStart: (event: unknown, ctx: ToolContext) => Promise<void>;
438
- onToolResult: (
439
- event: ToolResultEvent,
440
- ctx: ToolContext,
441
- ) => Promise<{ content: Array<{ type: string; text: string }>; details?: unknown } | undefined>;
442
- onWrite: (
443
- event: ToolResultEvent,
444
- ctx: ToolContext,
445
- ) => Promise<{ content: Array<{ type: string; text: string }>; details?: unknown } | undefined>;
446
- onEdit: (
447
- event: ToolResultEvent,
448
- ctx: ToolContext,
449
- ) => Promise<{ content: Array<{ type: string; text: string }> } | undefined>;
450
- onBash: (
451
- event: ToolResultEvent,
452
- ctx: ToolContext,
453
- ) => Promise<{ content: Array<{ type: string; text: string }>; details?: unknown } | undefined>;
454
- } {
455
- const deps: LifecycleDeps = { ...defaultDeps(), ...overrides };
456
-
457
- async function recordServesBestEffort(input: {
458
- sessionKey: string;
459
- path: string;
460
- servedRows: import("../served-session/serve-render.js").ServedRow[];
461
- contentHash: string;
462
- resultLineCount?: number;
463
- firstChangedLine?: number;
464
- lastChangedLine?: number;
465
- }): Promise<void> {
466
- if (input.servedRows.length === 0) return;
467
- try {
468
- await deps.recordDiffServes(input);
469
- } catch (error) {
470
- // SAFETY: best-effort serve recording — failures are ignored; file edit already succeeded and next read will re-establish serves, no data loss.
471
- reportDiagnostic("lifecycle-hooks", "Failed to record served rows:", error);
472
- }
473
- }
474
-
475
- async function handleSessionStart(_event: unknown, ctx: ToolContext): Promise<void> {
476
- // WHY: a new session re-arms the first-interception notice the last one consumed.
477
- warnedBashViewSessions.clear();
478
- await deps.initHasher();
479
- try {
480
- await deps.pruneMissingAll();
481
- } catch (err) {
482
- // SAFETY: best-effort startup cleanup — pruneMissingAll failures are ignored; hash store remains usable and stale entries will be retried next startup, no user data loss.
483
- reportDiagnostic("lifecycle-hooks", "Failed to load or prune hash store:", err);
484
- }
485
- const debugValue = process.env.PI_HASHLINE_DEBUG;
486
- if (debugValue === "1" || debugValue === "true") {
487
- ctx.ui.notify("Hashline Edit mode active", "info");
488
- }
489
- }
490
-
491
- async function handleWrite(
492
- event: ToolResultEvent,
493
- ctx: ToolContext,
494
- ): Promise<{ content: Array<{ type: string; text: string }>; details?: unknown } | undefined> {
495
- const rawInput = event.input as Record<string, unknown> | undefined;
496
- const writtenPath = rawInput?.file ?? rawInput?.path;
497
- if (typeof writtenPath === "string") {
498
- try {
499
- await deps.clearUndo(await deps.resolveTarget(deps.toCwd(writtenPath, ctx.cwd)));
500
- } catch (error) {
501
- // SAFETY: best-effort undo cleanup after write — clearUndo failures are ignored; stale undo history will be overwritten on next edit or pruned, no data loss.
502
- reportDiagnostic("lifecycle-hooks", "Failed to clear undo after write:", error);
503
- }
504
- }
505
- if (typeof writtenPath !== "string") return undefined;
506
- try {
507
- const resolvedPath = await deps.resolveTarget(deps.toCwd(writtenPath, ctx.cwd));
508
- await deps.valAccess(resolvedPath, writtenPath);
509
- const file = await deps.loadFileKindAndText(resolvedPath, {
510
- maxLines: SERVED_MAX_LINES,
511
- displayPath: writtenPath,
512
- });
513
- if (file.kind !== "text") return undefined;
514
- const { normalized, fileHashes, absolutePath } = await deps.readNormFile(
515
- writtenPath,
516
- ctx.cwd,
517
- {
518
- maxLines: SERVED_MAX_LINES,
519
- preloadedFile: file,
520
- },
521
- );
522
- const preview = await deps.fmtReadPreview(
523
- normalized,
524
- {},
525
- fileHashes,
526
- absolutePath,
527
- DEFAULT_MAX_BYTES,
528
- AUTO_READ_MAX,
529
- );
530
- // WHY: literal declaration audit for `write` (ADR-0009 revision): the pre-write
531
- // WHY: guard allowed `mode: "literal"` through, so re-evaluate the written bytes
532
- // WHY: against the pre-auto-read served mirror (still the pre-write mirror here)
533
- // WHY: and append the dimmed human line when the bytes reproduce a served row.
534
- let literalBypass = false;
535
- // WHY: middle tier for `write`: a written line opening with a served anchor
536
- // WHY: whose remainder canon digest matches none of the digests the leases recorded for that
537
- // WHY: The bytes are already on disk; each note only informs the model channel,
538
- // WHY: never alters bytes, never blocks, keeps no state, fires per line.
539
- let prefixNotes: string[] = [];
540
- // WHY: one session key for the whole post-write handler: the literal audit, the
541
- // WHY: serve recording it triggers, and the refusal clear all belong to the session
542
- // WHY: that wrote the bytes, never to another session sharing the path (#132).
543
- const sessionKey = deps.sessionKeyFor(ctx);
544
- try {
545
- const rawContent = (event.input as Record<string, unknown> | undefined)?.content;
546
- const rawMode = (event.input as Record<string, unknown> | undefined)?.mode;
547
- if (typeof rawContent === "string") {
548
- const handle = createSessionHandle(sessionKey, absolutePath);
549
- const served = await handle.load();
550
- let canonDigests: (string | null)[] = [];
551
- try {
552
- canonDigests = await handle.loadCanonDigests();
553
- } catch {
554
- canonDigests = [];
555
- }
556
- if (rawMode === "literal") {
557
- const reproduction = findServedHashEcho(
558
- splitLines(rawContent),
559
- served,
560
- canonDigests,
561
- 1,
562
- );
563
- if (reproduction) literalBypass = true;
564
- }
565
- const mismatches = findServedPrefixMismatches(
566
- splitLines(rawContent),
567
- served,
568
- canonDigests,
569
- 1,
570
- );
571
- prefixNotes = mismatches.map((mismatch) =>
572
- buildServedWritePrefixNote({
573
- line: mismatch.line,
574
- anchor: mismatch.anchor,
575
- servedLine: mismatch.servedLine,
576
- }),
577
- );
578
- }
579
- } catch (error) {
580
- reportDiagnostic(
581
- "lifecycle-hooks",
582
- "Failed to evaluate served prefix notes after write:",
583
- error,
584
- );
585
- prefixNotes = [];
586
- }
587
- const resultLineCount = deps.visLines(normalized).length;
588
- await recordServesBestEffort({
589
- sessionKey,
590
- path: absolutePath,
591
- servedRows: denseServeRows(fileHashes),
592
- contentHash: snapshotHashFor(normalized),
593
- resultLineCount,
594
- firstChangedLine: 1,
595
- });
596
- // WHY: the bytes are on disk and the auto-read re-served the whole file, so the honest shape
597
- // WHY: is whole-file authorship: an empty range list means the whole file changed, which keeps
598
- // WHY: this hot path free of any line counting.
599
- notifyMutatedFile({ filePath: absolutePath, kind: "write", ranges: [], sourceTool: "write" });
600
- // WHY: an auto-read re-serves the whole file, so one full-file span is the honest shape here;
601
- // WHY: a zero-line result sends empty spans, which notifies nobody. The mutation is notified
602
- // WHY: first, so a mirror that reads the file observes the bytes now on disk.
603
- notifyServedSpans({
604
- filePath: absolutePath,
605
- spans: resultLineCount > 0 ? [{ startLine: 1, lineCount: resultLineCount }] : [],
606
- source: "auto-read",
607
- // WHY: the auto-read holds the verbatim bytes, so the mirror hashes caller evidence in
608
- // WHY: memory; the anchored preview is never the source of a line hash.
609
- content: normalized,
610
- });
611
- // WHY: the clear side of the tally: this runs only after the write's bytes are on disk
612
- // WHY: (the auto-read above re-served this session's rows), never on the pre-write
613
- // WHY: verification, which must keep the count for a resubmission.
614
- try {
615
- clearServedRefusals(sessionKey, absolutePath);
616
- } catch {
617
- // SAFETY: best-effort counter clear — a missed clear only sharpens the next refusal message, never blocks a write.
618
- }
619
- // WHY: the auto-read is a whole-file page, so its header states the file's total (and, when the
620
- // WHY: byte budget cut it, where it stopped) — the same grammar the read tool uses (read-chrome).
621
- const autoReadHeader = formatPageHeader({
622
- label: BASH_AUTO_READ_LABEL,
623
- file: writtenPath,
624
- totalLines: resultLineCount,
625
- ...(preview.truncation === undefined
626
- ? {}
627
- : { shownRange: { startLine: 1, endLine: preview.truncation.outputLines } }),
628
- ...(preview.truncation?.truncatedBy === "bytes"
629
- ? { stoppedAtBytes: preview.truncation.maxBytes }
630
- : {}),
631
- });
632
- return {
633
- content: [
634
- ...(event.content ?? []),
635
- {
636
- type: "text",
637
- text: `\n\n${autoReadHeader}\n${preview.text}`,
638
- },
639
- ...(literalBypass ? [{ type: "text" as const, text: LITERAL_BYPASS_NOTICE }] : []),
640
- ...prefixNotes.map((note) => ({ type: "text" as const, text: note })),
641
- ],
642
- ...(literalBypass
643
- ? {
644
- details: {
645
- metrics: {
646
- classification: "applied" as const,
647
- edits_attempted: 0,
648
- edits_noop: 0,
649
- warnings: 0,
650
- literalDeclarations: 1,
651
- },
652
- },
653
- }
654
- : {}),
655
- };
656
- } catch (error) {
657
- reportDiagnostic("lifecycle-hooks", "Auto-read after write failed:", error);
658
- const message = error instanceof Error ? error.message : String(error);
659
- return {
660
- content: [
661
- ...(event.content ?? []),
662
- { type: "text", text: `\n\n[Auto-read failed: ${message}]` },
663
- ],
664
- };
665
- }
666
- }
667
-
668
- async function handleEdit(
669
- event: ToolResultEvent,
670
- ctx: ToolContext,
671
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
672
- if (event.toolName !== "edit" && event.toolName !== "undo_last_edit") return undefined;
673
- const details = event.details as import("../edit-response.js").EditDetails | undefined;
674
- if (details?.metrics?.classification === "noop") return undefined;
675
- if (!details?.diff) return undefined;
676
-
677
- const { content, servedRows } = deps.finalizeToolResult(details);
678
- if (details.servedByPath && details.servedByPath.length > 0) {
679
- for (const entry of details.servedByPath) {
680
- if (entry.servedRows.length === 0) continue;
681
- const resolvedPath = await deps.resolveTarget(deps.toCwd(entry.path, ctx.cwd));
682
- await recordServesBestEffort({
683
- sessionKey: deps.sessionKeyFor(ctx),
684
- path: resolvedPath,
685
- servedRows: entry.servedRows,
686
- contentHash: entry.contentHash,
687
- ...(entry.resultLineCount !== undefined
688
- ? { resultLineCount: entry.resultLineCount }
689
- : {}),
690
- ...(entry.firstChangedLine !== undefined
691
- ? { firstChangedLine: entry.firstChangedLine }
692
- : {}),
693
- ...(entry.lastChangedLine !== undefined
694
- ? { lastChangedLine: entry.lastChangedLine }
695
- : {}),
696
- });
697
- // WHY: a served diff means the bytes changed on disk, and the committed span is this
698
- // WHY: file's changed range. A `reject-and-serve` payload never reaches here (it carries an
699
- // WHY: error, not a diff), so a read-only refusal can never be reported as a mutation.
700
- notifyMutatedFile({
701
- filePath: resolvedPath,
702
- kind: "edit",
703
- ranges: toChangedRanges(entry.firstChangedLine, entry.lastChangedLine),
704
- sourceTool: event.toolName === "undo_last_edit" ? "undo_last_edit" : "edit",
705
- });
706
- // WHY: the mutation is notified before the rows it produced, so a mirror that reads the file
707
- // WHY: observes the post-edit bytes. A diff holds no verbatim text, so the read is text-less
708
- // WHY: and falls back to disk evidence.
709
- notifyServedSpans({
710
- filePath: resolvedPath,
711
- spans:
712
- entry.resultLineCount !== undefined && entry.resultLineCount > 0
713
- ? [{ startLine: 1, lineCount: entry.resultLineCount }]
714
- : servedRowsToSpans(entry.servedRows),
715
- source: "diff",
716
- });
717
- }
718
- } else if (servedRows && servedRows.length > 0) {
719
- const rawInput = event.input as Record<string, unknown> | undefined;
720
- const rawPath = rawInput?.file ?? rawInput?.path;
721
- if (typeof rawPath === "string") {
722
- const resolvedPath = await deps.resolveTarget(deps.toCwd(rawPath, ctx.cwd));
723
- await recordServesBestEffort({
724
- sessionKey: deps.sessionKeyFor(ctx),
725
- path: resolvedPath,
726
- servedRows,
727
- // WHY: undo_last_edit names the restored snapshot on its details; legacy synthesized
728
- // WHY: details may not, in which case "" names no snapshot and no lease is granted
729
- // WHY: (fail-closed rather than binding to whatever was materialized most recently).
730
- contentHash: details.contentHash ?? "",
731
- ...(details.resultLineCount !== undefined
732
- ? { resultLineCount: details.resultLineCount }
733
- : {}),
734
- ...(details.firstChangedLine !== undefined
735
- ? { firstChangedLine: details.firstChangedLine }
736
- : {}),
737
- ...(details.lastChangedLine !== undefined
738
- ? { lastChangedLine: details.lastChangedLine }
739
- : {}),
740
- });
741
- // WHY: same contract as the servedByPath branch: the undo or edit landed on disk, and the
742
- // WHY: details' first/last changed lines bound the changed span (empty when unnamed).
743
- notifyMutatedFile({
744
- filePath: resolvedPath,
745
- kind: "edit",
746
- ranges: toChangedRanges(details.firstChangedLine, details.lastChangedLine),
747
- sourceTool: event.toolName === "undo_last_edit" ? "undo_last_edit" : "edit",
748
- });
749
- // WHY: mutation first, then the rows it produced, so a mirror that reads the file observes
750
- // WHY: the post-edit bytes; a diff holds no verbatim text and falls back to disk evidence.
751
- notifyServedSpans({
752
- filePath: resolvedPath,
753
- spans:
754
- details.resultLineCount !== undefined && details.resultLineCount > 0
755
- ? [{ startLine: 1, lineCount: details.resultLineCount }]
756
- : servedRowsToSpans(servedRows),
757
- source: "diff",
758
- });
759
- }
760
- }
761
- return { content };
762
- }
763
-
764
- /**
765
- /**
766
- * WHY deferred module load: `bash-classifier` statically pulls `unbash`, which must
767
- * WHY: never join the extension entry graph — entry import cost is budgeted
768
- * WHY: (`measure-import --max-ratio 0.75`). The handler therefore reaches the
769
- * WHY: classifier only through a dynamic `import()` on the first bash result.
770
- * WHY:
771
- * WHY: Load-shape record (ADR-0033): esbuild bundles the relative specifier into
772
- * WHY: the entry and hoists the external `unbash` import to a top-level static
773
- * WHY: import in the shipped artifact, so the artifact is NOT lazy — only the
774
- * WHY: source entry is. Measured cost is negligible (`unbash` 52 KB parser,
775
- * WHY: no transitive deps, cold import ~0.01-0.03 ms), and a resolve/eval failure
776
- * WHY: of the lazily imported classifier still fails closed via the catch below,
777
- * WHY: which passes the original output through untouched (in the shipped artifact
778
- * WHY: the hoisted top-level import is instead fatal at entry — same class as the
779
- * WHY: other entry imports).
780
- */
781
- async function handleBash(
782
- event: ToolResultEvent,
783
- ctx: ToolContext,
784
- ): Promise<{ content: Array<{ type: string; text: string }>; details?: unknown } | undefined> {
785
- if (event.toolName !== "bash") return undefined;
786
- // WHY: the off switch bypasses EVERY seam below — view, search, content filter,
787
- // WHY: multi-file, recursive walk and `rg --files` — so raw output passes through
788
- // WHY: byte-identical. The default is on, so an unconfigured session never reaches this
789
- // WHY: return.
790
- if (resolveBashViewMode(ctx.cwd) === "off") return undefined;
791
- if (event.isError) return undefined;
792
- const rawInput = event.input as Record<string, unknown> | undefined;
793
- const command = rawInput?.command;
794
- if (typeof command !== "string" || command.trim() === "") return undefined;
795
- // WHY: `ToolResultEvent.details` is typed as `EditDetails`, but the pi runtime
796
- // WHY: delivers `BashToolDetails` (`truncation`, `fullOutputPath`) for bash —
797
- // WHY: read it structurally. A truncated stdout means the model did NOT see
798
- // WHY: the whole file, so replacing it with anchored lines would invent
799
- // WHY: viewed lines: fail closed.
800
- // SAFETY: the runtime hands bash a `BashToolDetails`, while the event type
801
- // SAFETY: declares `EditDetails` for every tool. The assertion only widens the
802
- // SAFETY: read to an optional `truncation`; the guard below re-checks the shape
803
- // SAFETY: structurally before either field is touched.
804
-
805
- const bashDetails = event.details as unknown as { truncation?: unknown } | undefined;
806
- if (bashDetails && typeof bashDetails === "object" && bashDetails.truncation != null) {
807
- return undefined;
808
- }
809
- try {
810
- const { classifyBashCommand, applySliceOps, isContentOp, isSliceOp } =
811
- await import("../bash-classifier.js");
812
- const conversion: Decision = classifyBashCommand(command);
813
- /**
814
- * WHY (#151): the single decision switch — every conversion shape routes by
815
- * WHY: `decision.kind`; no shape-sniffing, no stringified reasons downstream (the
816
- * WHY: debug seam below records the reason in the diagnostics file, never in the model's
817
- * WHY: text (ADR-0033 D6: observability is never model-visible). Banners and headers stay
818
- * WHY: in the shape seams this calls; this is where the routing decision lands.
819
- */
820
- async function renderDecision(
821
- decision: Decision,
822
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
823
- if (decision.kind === "pureSearch") {
824
- return await handleBashSearch(event, ctx, decision.search);
825
- }
826
- // WHY: (item 10) the three new geometries each get their own seam; every one of them
827
- // WHY: returns `undefined` (raw output, zero leases) the moment a gate fails.
828
- if (decision.kind === "pureMultiSearch") {
829
- return await handleBashMultiSearch(event, ctx, decision.search);
830
- }
831
- if (decision.kind === "pureRecursiveSearch") {
832
- return await handleBashRecursiveSearch(event, ctx, decision.search);
833
- }
834
- if (decision.kind === "pureFilesList") {
835
- return await handleBashFilesList(event, ctx, decision.search);
836
- }
837
- // WHY: (ADR-0049 M2) the multi-file view is N pages in one result: its own seam, because the
838
- // WHY: proof is per-file line-count boundaries and its leases are per file.
839
- if (decision.kind === "pureMultiView") {
840
- return await handleBashMultiView(event, ctx, decision.view);
841
- }
842
- if (decision.kind === "pureCompound") {
843
- return await handleBashCompound(event, ctx, decision);
844
- }
845
- if (decision.kind !== "pureView") {
846
- // WHY: (ADR-0033 observability) a systematic regression (parser drift, a
847
- // WHY: filtering wrapper, a new view shape) is otherwise indistinguishable
848
- // WHY: from "no views issued". Log the internal reason behind the existing
849
- // WHY: debug seam — the diagnostics log, never the model's text (D6 holds).
850
- const debugValue = process.env.PI_HASHLINE_DEBUG;
851
- if (debugValue === "1" || debugValue === "true") {
852
- reportDiagnostic("lifecycle-hooks", `[bash-view] pass-through (${decision.reason})`);
853
- }
854
- return undefined;
855
- }
856
- /**
857
- * WHY: shared view serving (single views call it with whole stdout; the envelope calls it
858
- * WHY: per verified slice). Pure relocation of the branch body — `event`, `deps` and helpers
859
- * WHY: resolve via closure. Throws propagate to the caller SAFETY catch.
860
- */
861
- /**
862
- * WHY: (ADR-0039 D1/D2/D4) sequential peeling + hybrid envelope. Each segment's expected raw
863
- * WHY: bytes come from re-read disk (never observed stdout); the peel consumes exact prefixes
864
- * WHY: left-to-right and halts at the first mismatch, keeping verified prefix rendering and
865
- * WHY: leases with the rest raw. Glue contributes verified bytes verbatim; views/searches
866
- * WHY: render anchored blocks via the shared paths on verified slices. Zero blocks, nothing
867
- * WHY: verified, or a trailing remainder beyond lone-newline leniency returns undefined (F2).
868
- */
869
- async function handleBashCompound(
870
- event: ToolResultEvent,
871
- ctx: ToolContext,
872
- compound: { kind: "pureCompound"; segments: CompoundSegment[]; baseDir?: string },
873
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
874
- const dbg = (...a: unknown[]) => {
875
- if (process.env.PI_HASHLINE_DEBUG === "1")
876
- reportDiagnostic("lifecycle-hooks", "[peel]", ...a);
877
- };
878
- dbg("segments", compound.segments.map((x) => x.kind).join(","));
879
- const blocks = compound.segments.filter(
880
- (segment) => segment.kind === "view" || segment.kind === "search",
881
- );
882
- if (blocks.length === 0) return undefined;
883
- const effCwd =
884
- compound.baseDir === undefined ? ctx.cwd : deps.toCwd(compound.baseDir, ctx.cwd);
885
- const stdoutText = singleTextBlock(event);
886
- if (stdoutText === undefined) return undefined;
887
- const observed = toLF(stripBOM(stdoutText).text);
888
- const parts: string[] = [];
889
- let renderedBlocks = 0;
890
- let pos = 0;
891
- try {
892
- for (const segment of compound.segments) {
893
- const candidates = await segmentExpectedVariants(segment, effCwd);
894
- dbg(
895
- segment.kind,
896
- "candidates",
897
- candidates === undefined ? "UNDEF" : candidates.map((c) => c.length),
898
- );
899
- if (candidates === undefined) break;
900
- let consumed: string | undefined;
901
- for (const candidate of candidates) {
902
- if (observed.startsWith(candidate, pos)) {
903
- consumed = candidate;
904
- break;
905
- }
906
- }
907
- if (consumed === undefined) {
908
- dbg(segment.kind, "NO-CONSUME halt");
909
- break;
910
- }
911
- if (segment.kind === "glue") {
912
- if (consumed !== "") parts.push(consumed);
913
- pos += consumed.length;
914
- continue;
915
- }
916
- const rendered = await renderVerifiedBlock(
917
- segment,
918
- observed.slice(pos, pos + consumed.length),
919
- compound.baseDir,
920
- ctx,
921
- );
922
- if (rendered === undefined) {
923
- dbg(segment.kind, "RENDER-UNDEF halt");
924
- break;
925
- }
926
- parts.push(rendered);
927
- renderedBlocks++;
928
- pos += consumed.length;
929
- }
930
- } catch {
931
- // fall through to partial halt below (SAFETY: never break the view).
932
- }
933
- dbg("done", { renderedBlocks, pos, len: observed.length });
934
- if (renderedBlocks === 0) return undefined;
935
- const rest = observed.slice(pos);
936
- if (rest !== "" && rest !== "\n") {
937
- return { content: [{ type: "text", text: parts.join("") + rest }] };
938
- }
939
- return {
940
- content: [{ type: "text", text: parts.join("") + (rest === "\n" ? "\n" : "") }],
941
- };
942
- }
943
- /**
944
- * WHY: (ADR-0039 D2/D4) render one verified envelope block (single trailing newline; glue
945
- * WHY: stays byte-exact). Views/searches render through the shared serving paths on their
946
- * WHY: verified slice (their internal gates re-verify defensively); multi/recursive/file-list
947
- * WHY: searches never reach here (no expected variants — the peel halts first). An empty
948
- * WHY: search renders its banner with zero rows and zero leases rather than halting the rest.
949
- */
950
- async function renderVerifiedBlock(
951
- segment: CompoundSegment,
952
- slice: string,
953
- baseDir: string | undefined,
954
- ctx: ToolContext,
955
- ): Promise<string | undefined> {
956
- const withBase = baseDir === undefined ? {} : { baseDir };
957
- if (segment.kind === "view") {
958
- const res = await servePureViewBlock({ ...segment, ...withBase }, slice, ctx);
959
- if (res === undefined) return undefined;
960
- const text = res.content[0]?.text;
961
- if (typeof text !== "string") return undefined;
962
- return text.endsWith("\n") ? text : `${text}\n`;
963
- }
964
- if (segment.kind !== "search" || segment.family.kind !== "pureSearch") return undefined;
965
- if (slice === "") {
966
- const file = segment.family.search.filePath;
967
- return `${bashBanner("search")}\n[${file} (0 matches)]\n`;
968
- }
969
- const res = await handleBashSearch(
970
- event,
971
- ctx,
972
- { ...segment.family.search, ...withBase },
973
- slice,
974
- );
975
- if (res === undefined) return undefined;
976
- const text = res.content[0]?.text;
977
- if (typeof text !== "string") return undefined;
978
- return text.endsWith("\n") ? text : `${text}\n`;
979
- }
980
- /**
981
- * WHY (ADR-0049 M2/M3): a served multi-file view is ONE result with ONE page section per file,
982
- * in operand order, under the existing page header grammar — no new root
983
- * banner and no second chrome dialect. Every page's rows come from its own re-read file and are
984
- * leased under the existing `(session, path, anchor)` identity: no anchor is ever shared between files.
985
- *
986
- * WHY the whole-command gate (M8): boundaries are line counts, so the observed STREAM must
987
- * equal the render built from the re-read files before the first lease — one bad file, page,
988
- * transform or cap passes the entire command through raw with zero leases. The zero-row page
989
- * (M4) has a zero-length observed interval, which is why it cannot weaken that proof.
990
- */
991
- async function handleBashMultiView(
992
- event: ToolResultEvent,
993
- ctx: ToolContext,
994
- view: BashMultiView,
995
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
996
- const caps = resolveBashMultiFileSettings(ctx.cwd, (reason) =>
997
- reportWarning("lifecycle-hooks", reason),
998
- );
999
- // WHY: (M6) the file cap counts EVERY operand, a zero-row page included; the row cap counts
1000
- // WHY: served rows only, never page headers. Over either cap the whole command is raw.
1001
- if (view.filePaths.length < 2 || view.filePaths.length > caps.maxFiles) return undefined;
1002
- const effCwd = view.baseDir === undefined ? ctx.cwd : deps.toCwd(view.baseDir, ctx.cwd);
1003
- const files: Array<{
1004
- display: string;
1005
- absolutePath: string;
1006
- normalized: string;
1007
- allLines: string[];
1008
- fileHashes: string[];
1009
- }> = [];
1010
- for (const display of view.filePaths) {
1011
- const target = await loadTextTarget(display, effCwd);
1012
- if (target === undefined) return undefined;
1013
- // WHY: (M1) a page boundary is proved from line counts, so an unterminated final line has
1014
- // WHY: no provable boundary; a 0-byte file is the zero-line case, not an unterminated one.
1015
- if (target.normalized !== "" && !target.normalized.endsWith("\n")) return undefined;
1016
- files.push({ display, ...target });
1017
- }
1018
- const lineCounts = files.map((file) => file.allLines.length);
1019
- const totalLines = lineCounts.reduce((sum, count) => sum + count, 0);
1020
- const selected = multiViewSelection(lineCounts, applySliceOps(view.ops, totalLines));
1021
- if (selected === undefined) return undefined;
1022
- const rows = multiViewStreamRows(files, selected);
1023
- if (rows === undefined) return undefined;
1024
- // WHY: (M4) an all-empty command stays raw: serving only synthetic headers would show the
1025
- // WHY: model no observed byte at all, and every page would lease nothing.
1026
- if (rows.length === 0) return undefined;
1027
- if (rows.length > caps.maxRows) return undefined;
1028
- const stdoutText = singleTextBlock(event);
1029
- if (stdoutText === undefined) return undefined;
1030
- const observed = toLF(stripBOM(stdoutText).text);
1031
- // WHY: (M3 + ADR-0033 D9 + ADR-0048 D1) the stream the shell printed must equal the render
1032
- // WHY: built from the re-read files, with the same lone-trailing-newline leniency every other
1033
- // WHY: view seam applies. A missing newline, a numbering mismatch, TOCTOU drift or a reordered
1034
- // WHY: row fails closed here, before any lease exists.
1035
- const expected = multiViewExpectedTexts(rows, view.lineNumbered === true);
1036
- if (!expected.some((text) => observed === text || observed === `${text}\n`)) {
1037
- return undefined;
1038
- }
1039
-
1040
- // WHY: (M8) build every page's rows FIRST — after this loop there is no failure path, so a
1041
- // WHY: refusal can never leave a partial grant behind.
1042
- const pages: Array<{
1043
- header: string;
1044
- servedRows: ServedRow[];
1045
- file: (typeof files)[number];
1046
- }> = [];
1047
- for (let index = 0; index < files.length; index++) {
1048
- const file = files[index]!;
1049
- const lines = selected[index] ?? [];
1050
- const servedRows: ServedRow[] = [];
1051
- for (const line of lines) {
1052
- const hash = file.fileHashes[line - 1];
1053
- if (hash === undefined) return undefined;
1054
- servedRows.push({ position: line - 1, hash });
1055
- }
1056
- const first = lines[0];
1057
- const last = lines[lines.length - 1];
1058
- const wholeFile =
1059
- first === 1 && last === file.allLines.length && lines.length === file.allLines.length;
1060
- pages.push({
1061
- file,
1062
- servedRows,
1063
- header: formatPageHeader({
1064
- label: BASH_VIEW_LABEL,
1065
- file: file.display,
1066
- // WHY: (M4) a page that serves nothing reports `0 lines total`, so it claims nothing
1067
- // WHY: about the file's length — an empty FILE and a file with no selected row are the
1068
- // WHY: same page, exactly as M4/M5 prescribe.
1069
- totalLines: servedRows.length === 0 ? 0 : file.allLines.length,
1070
- ...(wholeFile || first === undefined || last === undefined
1071
- ? {}
1072
- : { shownRange: { startLine: first, endLine: last } }),
1073
- }),
1074
- });
1075
- }
1076
-
1077
- const sessionKey = deps.sessionKeyFor(ctx);
1078
- const sections: string[] = [];
1079
- for (const page of pages) {
1080
- if (page.servedRows.length > 0) {
1081
- await recordServesBestEffort({
1082
- sessionKey,
1083
- path: page.file.absolutePath,
1084
- servedRows: page.servedRows,
1085
- contentHash: snapshotHashFor(page.file.normalized),
1086
- });
1087
- notifyServedSpans({
1088
- filePath: page.file.absolutePath,
1089
- spans: servedRowsToSpans(page.servedRows),
1090
- source: "auto-read",
1091
- content: page.file.normalized,
1092
- });
1093
- }
1094
- const rowsText = fmtServedRows(page.servedRows, page.file.allLines);
1095
- sections.push(rowsText === "" ? page.header : `${page.header}\n${rowsText}`);
1096
- }
1097
- notifyBashViewOnce(ctx, sessionKey);
1098
- return { content: [{ type: "text", text: sections.join("\n") }] };
1099
- }
1100
-
1101
- async function servePureViewBlock(
1102
- view: {
1103
- filePath: string;
1104
- baseDir?: string;
1105
- ops: ViewOp[];
1106
- lineNumbered?: boolean;
1107
- display?: DisplayTransform;
1108
- },
1109
- rawText: string | undefined,
1110
- ctx: ToolContext,
1111
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
1112
- const { filePath, baseDir, ops, lineNumbered, display } = view;
1113
- // WHY: (item 8) numbered and unnumbered are the same view — full-file, leased.
1114
- // WHY: `lineNumbered` only selects the stdout-verification parser; leases and render
1115
- // WHY: always come from re-read positions, never from parsed stdout numbers.
1116
- const isNumbered = lineNumbered === true;
1117
- // WHY: (item 8) numbered views admit no slice ops — the classifier only emits
1118
- // WHY: `ops: []` with the marker, but defense-in-depth fails closed on any drift.
1119
- if (isNumbered && ops.length !== 0) return undefined;
1120
- if (display !== undefined && (isNumbered || ops.some(isContentOp))) return undefined;
1121
- // WHY: the single pre-view literal `cd` re-bases relative resolution; an absolute
1122
- // WHY: view target ignores it (`toCwd` returns absolutes unchanged). Multi-`cd`
1123
- // WHY: and post-view-`cd` chains never reach here — the classifier fails them
1124
- // WHY: closed to pass-through (ADR-0033 D1).
1125
- const effCwd = baseDir === undefined ? ctx.cwd : deps.toCwd(baseDir, ctx.cwd);
1126
- const resolvedPath = await deps.resolveTarget(deps.toCwd(filePath, effCwd));
1127
- await deps.valAccess(resolvedPath, filePath);
1128
- const file = await deps.loadFileKindAndText(resolvedPath, {
1129
- maxLines: SERVED_MAX_LINES,
1130
- displayPath: filePath,
1131
- });
1132
- if (file.kind !== "text") return undefined;
1133
- const { normalized, fileHashes, absolutePath, originalEnding } = await deps.readNormFile(
1134
- filePath,
1135
- effCwd,
1136
- {
1137
- maxLines: SERVED_MAX_LINES,
1138
- preloadedFile: file,
1139
- },
1140
- );
1141
- // WHY: intervals fold over the re-read line count, so an empty selection
1142
- // WHY: (e.g. `head -n 0`, out-of-range `sed` addresses) serves nothing and
1143
- // WHY: passes through — zero leases, zero output change (ADR-0033 D3).
1144
- const allLines = deps.visLines(normalized);
1145
- // WHY: (item 9) a content stage needs the line bytes, so it takes its own seam (row
1146
- // WHY: leases over the survivors, bounded like a search). The predicate is the type
1147
- // WHY: refinement that keeps the selector-only path structurally `SliceOp[]`, so the
1148
- // WHY: interval algebra keeps its exact contract.
1149
- // WHY: (item 11) the line-set consumers take this same seam: the predicate is the single
1150
- // WHY: split over `ViewOp`, so a new content op routes here by construction.
1151
- if (ops.some(isContentOp)) {
1152
- return await serveContentView(event, ctx, {
1153
- filePath,
1154
- absolutePath,
1155
- normalized,
1156
- fileHashes,
1157
- allLines,
1158
- ops,
1159
- observedText: rawText,
1160
- });
1161
- }
1162
- const intervals = applySliceOps(ops.filter(isSliceOp), allLines.length);
1163
- if (intervals.length === 0 || intervals.length > MAX_READ_WINDOWS) return undefined;
1164
- // WHY: [ADR-0033 D9] the observed stdout must byte-match the re-read slice —
1165
- // WHY: this is what makes transparent wrappers (e.g. `rtk`) safe by
1166
- // WHY: construction instead of by reputation. A filtering wrapper, a
1167
- // WHY: numbering wrapper, TOCTOU drift between exec and re-read, or any
1168
- // WHY: skew the re-read does not normalize (undecodable bytes, a reordered
1169
- // WHY: row, an edited line) still fails closed to pass-through. Line-ending
1170
- // WHY: and BOM skew is normalized instead of rejected: `cat` emits the disk
1171
- // WHY: bytes while `readNormFile` — and therefore the anchors below —
1172
- // WHY: describes the LF/no-BOM text, so comparing raw stdout would drop
1173
- // WHY: anchors on exactly the CRLF and BOM files the model views via bash.
1174
- // WHY: Exactly one text block is required — anything else (multi-block,
1175
- // WHY: non-text) cannot be attributed to the view.
1176
- // WHY: Trailing-newline-only leniency: `joined` vs `joined + "\n"`.
1177
- if (typeof rawText !== "string") return undefined;
1178
- const stdoutText: string = rawText;
1179
- const sliceLines: string[] = [];
1180
- for (const iv of intervals) sliceLines.push(...allLines.slice(iv.lo - 1, iv.hi));
1181
- const joinedSlice = sliceLines.join("\n");
1182
- // D1/D9, amended 2026-10-09 (commit 7b78278): See ADR-0033. This site
1183
- // WHY: implements D9's symmetric CRLF/BOM normalization here; D1's
1184
- // WHY: `;`-chain gate lives in the classifier.
1185
- const observedText = toLF(stripBOM(stdoutText).text);
1186
- if (display !== undefined) {
1187
- // WHY: (ADR-0048 D2) the observed bytes are the transform, not the file. Prove the
1188
- // WHY: exact raw head slice against the same transform; only then serve the raw rows.
1189
- if (originalEnding === "\r") return undefined;
1190
- const rawContent = file.text;
1191
- // WHY: a CRLF file is supported; a lone CR (or a mixed-in CR) is not, because `toLF`
1192
- // WHY: would change the line count and the raw-head bytes would no longer map to intervals.
1193
- if (rawContent.replace(/\r\n/g, "").includes("\r")) return undefined;
1194
- const rawLines = rawContent.split("\n");
1195
- const rawSelected: string[] = [];
1196
- for (const iv of intervals) {
1197
- rawSelected.push(...rawLines.slice(iv.lo - 1, iv.hi));
1198
- }
1199
- if (rawSelected.length === 0) return undefined;
1200
- const lastSelected = intervals[intervals.length - 1];
1201
- const trailingNewline =
1202
- lastSelected !== undefined &&
1203
- (lastSelected.hi < rawLines.length - 1 || rawContent.endsWith("\n"));
1204
- const rawHead = rawSelected.join("\n") + (trailingNewline ? "\n" : "");
1205
- const transformed = await deps.runDisplayTransform(display, rawHead);
1206
- if (transformed === undefined) return undefined;
1207
- // WHY: (ADR-0033 D9 / ADR-0048 D1) match the observed normalization. BSD `cat -vet`
1208
- // WHY: leaves a UTF-8 BOM byte-for-byte, so stripping it on only one side would refuse
1209
- // WHY: exactly the BOM files the display tail is meant to inspect.
1210
- if (toLF(stripBOM(transformed).text) !== observedText) return undefined;
1211
- } else if (isNumbered) {
1212
- // WHY: (item 8) strip display numbers, then the SAME byte-equality gate as below.
1213
- // WHY: the numbers are never trusted as coordinates — positions come from `intervals`
1214
- // WHY: (full-file here), and a row-count or content mismatch fails closed with no lease.
1215
- const deNumbered = parseNumberedStdout(observedText);
1216
- if (deNumbered === undefined) return undefined;
1217
- if (deNumbered.length !== allLines.length) return undefined;
1218
- for (let i = 0; i < allLines.length; i++) {
1219
- if (deNumbered[i] !== allLines[i]) return undefined;
1220
- }
1221
- } else if (observedText !== joinedSlice && observedText !== `${joinedSlice}\n`) {
1222
- return undefined;
1223
- }
1224
- const preview = await deps.fmtReadPreview(
1225
- normalized,
1226
- {
1227
- windows: intervals.map((iv) => ({ offset: iv.lo, limit: iv.hi - iv.lo + 1 })),
1228
- },
1229
- fileHashes,
1230
- absolutePath,
1231
- DEFAULT_MAX_BYTES,
1232
- AUTO_READ_MAX,
1233
- );
1234
- // WHY: (ADR-0033 D3) anchors add bytes per row, so a view whose raw stdout
1235
- // WHY: fits the bash byte budget can still exceed the preview budget — the
1236
- // WHY: shortened preview would then silently drop lines while the header
1237
- // WHY: claims the full range. A truncated preview is never slice-accurate:
1238
- // WHY: pass through with the original output untouched.
1239
- if (preview.truncation !== undefined) return undefined;
1240
- if (preview.served.length === 0) return undefined;
1241
- const sessionKey = deps.sessionKeyFor(ctx);
1242
- // WHY: plain mode (no `resultLineCount`/`firstChangedLine`): truncated mode
1243
- // WHY: belongs to diffs and would corrupt the mirror. The grant leases
1244
- // WHY: exactly the served rows against the re-read snapshot (ADR-0033 D4).
1245
- await recordServesBestEffort({
1246
- sessionKey,
1247
- path: absolutePath,
1248
- servedRows: preview.served,
1249
- contentHash: snapshotHashFor(normalized),
1250
- });
1251
- // WHY: `servedRowsToSpans` compresses the served window rows into honest
1252
- // WHY: per-run spans — a slice view notifies its slice, never whole-file. The bash
1253
- // WHY: view already re-read the file, so the mirror gets those bytes rather than
1254
- // WHY: re-reading the slice itself.
1255
- notifyServedSpans({
1256
- filePath: absolutePath,
1257
- spans: servedRowsToSpans(preview.served),
1258
- source: "auto-read",
1259
- content: normalized,
1260
- });
1261
- // WHY: FULL replacement (not append): the raw stdout is unanchored bytes the
1262
- // WHY: model already paid for once — swapping it for the anchored slice is
1263
- // WHY: the zero-bloat interception point (ADR-0033 D4).
1264
- // WHY: FIRST-notice: the interception landed, so the session is told once what saved
1265
- // WHY: the round trip and where the off switch lives; later interceptions stay silent.
1266
- notifyBashViewOnce(ctx, sessionKey);
1267
- // WHY: the view's page names itself (read-chrome): one interval is a page, so its header
1268
- // WHY: carries the range; several intervals keep the total and let each section name its own.
1269
- const [firstInterval] = intervals;
1270
- const lastInterval = intervals[intervals.length - 1];
1271
- const header = formatPageHeader({
1272
- label: BASH_VIEW_LABEL,
1273
- file: filePath,
1274
- totalLines: allLines.length,
1275
- ...(intervals.length === 1 && firstInterval !== undefined && lastInterval !== undefined
1276
- ? { shownRange: { startLine: firstInterval.lo, endLine: lastInterval.hi } }
1277
- : {}),
1278
- });
1279
- const disclosure =
1280
- display === undefined ? "" : `[${display.label} rendering dropped; raw rows served]\n`;
1281
- return { content: [{ type: "text", text: `${header}\n${disclosure}${preview.text}` }] };
1282
- }
1283
-
1284
- return await servePureViewBlock(decision.view, singleTextBlock(event), ctx);
1285
- }
1286
- return await renderDecision(conversion);
1287
- } catch (error) {
1288
- // SAFETY: every failure returns undefined so the original bash output reaches
1289
- // SAFETY: the model untouched — interception must never break a view.
1290
- // WHY: the three-way split — a DomainError code means the file the shell just read is no
1291
- // WHY: longer statable/readable (deleted, chmod, rotating symlink); that is expected in a live
1292
- // WHY: workspace and the view already passes through, so it must not read as a defect, while
1293
- // WHY: anything else is a classifier/serve bug and must read as one.
1294
- const failureCode = error instanceof DomainError ? error.code : undefined;
1295
- if (failureCode === "E_NOT_FOUND" || failureCode === "E_ACCESS") {
1296
- reportWarning(
1297
- "lifecycle-hooks",
1298
- `bash view declined (${failureCode}) — source changed after exec:`,
1299
- command,
1300
- error,
1301
- );
1302
- } else {
1303
- reportDiagnostic("lifecycle-hooks", "Bash view interception failed:", command, error);
1304
- }
1305
- return undefined;
1306
- }
1307
- }
1308
-
1309
- /**
1310
- * WHY: (item 10) resolve + read one candidate file for a triple serve. The classifier is pure
1311
- * WHY: and zero-fs, so the file kind is re-checked here: anything that is not decodable text
1312
- * WHY: fails the WHOLE serve closed rather than being silently skipped.
1313
- */
1314
- async function loadTextTarget(
1315
- displayPath: string,
1316
- effCwd: string,
1317
- ): Promise<
1318
- | { absolutePath: string; normalized: string; fileHashes: string[]; allLines: string[] }
1319
- | undefined
1320
- > {
1321
- const resolvedPath = await deps.resolveTarget(deps.toCwd(displayPath, effCwd));
1322
- await deps.valAccess(resolvedPath, displayPath);
1323
- const file = await deps.loadFileKindAndText(resolvedPath, {
1324
- maxLines: SERVED_MAX_LINES,
1325
- displayPath,
1326
- });
1327
- if (file.kind !== "text") return undefined;
1328
- const { normalized, fileHashes, absolutePath } = await deps.readNormFile(displayPath, effCwd, {
1329
- maxLines: SERVED_MAX_LINES,
1330
- preloadedFile: file,
1331
- });
1332
- return { absolutePath, normalized, fileHashes, allLines: deps.visLines(normalized) };
1333
- }
1334
-
1335
- /**
1336
- * WHY: (ADR-0048 D1 — bash serve admission) never content matching, no
1337
- * WHY: other-file anchor bleed. The triple regex splits `FILE:LINE:content` non-greedily, so
1338
- * WHY: when two splits of one observed row both resolve into the admitted set, the row is
1339
- * WHY: served under a coordinate bash never printed. Concrete twin: file `a` line 1 ==
1340
- * WHY: `b.txt:3:x` and file `a:1:b.txt` line 3 == `x` produce the identical stdout row
1341
- * WHY: `a:1:b.txt:3:x`. Enumerate EVERY split whose suffix is itself a line-number shape
1342
- * WHY: (`^\d+:` with a safe integer >= 1, the parser's own line rule) and resolve the prefix —
1343
- * WHY: candidates are resolved, never display-matched, because `admitted` displays are
1344
- * WHY: `path.join`-normalized (`sub/a`) while bash prints the operand spelling (`./sub/a`
1345
- * WHY: for a `.` root). A prefix that fails to resolve is not a file and is skipped, never a
1346
- * WHY: refusal: matched content beginning `digits:` yields phantom candidates (e.g.
1347
- * WHY: `sub/ok.txt:2:5:foo` yields `sub/ok.txt:2`), and refusing on throw would make the
1348
- * WHY: common content-with-colons case unservable. More than one distinct resolved path
1349
- * WHY: fails the WHOLE serve closed before the first lease — several display aliases of one
1350
- * WHY: resolved path are not ambiguous. Residual, still fail-closed: `resolveTarget` catches
1351
- * WHY: ENOENT and resolves a deleted admitted file lexically, so deletion cannot blind this
1352
- * WHY: guard — the serve then fails at `valAccess` (`E_NOT_FOUND`) in `loadTextTarget` before
1353
- * WHY: the first lease, and the outer `handleBash` catch passes the command through raw.
1354
- */
1355
- async function tripleSplitAmbiguous(
1356
- rawLines: readonly string[],
1357
- effCwd: string,
1358
- admitted: ReadonlyMap<string, string>,
1359
- ): Promise<boolean> {
1360
- // WHY: ambiguity needs two admitted paths — a single-operand search skips the fs cost whole.
1361
- if (admitted.size < 2) return false;
1362
- for (const raw of rawLines) {
1363
- const resolved = new Set<string>();
1364
- let idx = raw.indexOf(":");
1365
- while (idx !== -1) {
1366
- const prefix = raw.slice(0, idx);
1367
- const rest = raw.slice(idx + 1);
1368
- const numbered = /^(\d+):[\s\S]*$/.exec(rest);
1369
- const line = numbered === null ? NaN : Number(numbered[1]);
1370
- if (Number.isSafeInteger(line) && line >= 1) {
1371
- try {
1372
- const abs = await deps.resolveTarget(deps.toCwd(prefix, effCwd));
1373
- if (admitted.has(abs)) {
1374
- resolved.add(abs);
1375
- if (resolved.size > 1) return true;
1376
- }
1377
- } catch {
1378
- // SAFETY: an unresolvable prefix is not a file, therefore not admitted — skip.
1379
- }
1380
- }
1381
- idx = raw.indexOf(":", idx + 1);
1382
- }
1383
- }
1384
- return false;
1385
- }
1386
-
1387
- /**
1388
- * WHY: (item 10) the shared multi-file / recursive serve seam. Every named file must resolve
1389
- * INTO the admitted set (explicit operands or the walked set), every row's bytes must equal
1390
- * the disk line at that number, and the total row count must fit `SEARCH_MAX_MATCHES` — all
1391
- * verified BEFORE the first lease, so a refusal grants nothing (invariant 2) and an over-cap
1392
- * search passes through raw instead of truncating (invariant 3). Sections render in operand /
1393
- * first-seen order under the admitted display (which differs from bash's spelling for a `.`
1394
- * root: `sub/a` renders where bash printed `./sub/a`), so coordinates are stable.
1395
- */
1396
- async function serveMatchTriples(
1397
- event: ToolResultEvent,
1398
- ctx: ToolContext,
1399
- input: { effCwd: string; admitted: ReadonlyMap<string, string> },
1400
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
1401
- const stdoutText = singleTextBlock(event);
1402
- if (stdoutText === undefined) return undefined;
1403
- const normalizedObserved = toLF(stripBOM(stdoutText).text);
1404
- const parsed = parseTripleStdout(normalizedObserved);
1405
- if (parsed === undefined || parsed.length === 0) return undefined;
1406
- if (parsed.length > SEARCH_MAX_MATCHES) return undefined;
1407
- // WHY: (ADR-0048 D1 — bash serve admission) the ambiguity gate below runs
1408
- // WHY: on raw lines, not parsed rows, so every candidate split is enumerated before the
1409
- // WHY: first lease is recorded.
1410
- const rawLines = normalizedObserved.split("\n");
1411
- if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") rawLines.pop();
1412
- if (await tripleSplitAmbiguous(rawLines, input.effCwd, input.admitted)) return undefined;
1413
- const grouped = new Map<string, Array<{ line: number; content: string }>>();
1414
- for (const row of parsed) {
1415
- const resolved = await deps.resolveTarget(deps.toCwd(row.file, input.effCwd));
1416
- // WHY: a triple may only name a file this command could have read — `../` escapes, alias
1417
- // WHY: spellings that resolve outside the set, and unwalked subtrees all fail closed.
1418
- if (!input.admitted.has(resolved)) return undefined;
1419
- const rows = grouped.get(resolved) ?? [];
1420
- // WHY: (review F3) a repeated `(file, line)` coordinate renders and leases ONCE: the
1421
- // WHY: coordinate set is a set, and `grep -n pat a.txt a.txt` prints the same line twice
1422
- // WHY: only because the operand was repeated. Nothing new is witnessed by a second copy.
1423
- if (!rows.some((existing) => existing.line === row.line)) {
1424
- rows.push({ line: row.line, content: row.content });
1425
- }
1426
- grouped.set(resolved, rows);
1427
- }
1428
- // WHY: (serve-seam policy: one row per file line, rows in stream order) the repeat dedupe
1429
- // WHY: above already yields one row per `(file, line)`, so a reordering would keep the
1430
- // WHY: bijection — what this refuses is an unproven render order from a reordering consumer
1431
- // WHY: (`grep -n pat a.txt a.txt` repeats a coordinate legitimately and still serves). A
1432
- // WHY: reordered file's rows fail the whole serve closed here, with zero leases.
1433
- for (const rows of grouped.values()) {
1434
- for (let i = 1; i < rows.length; i++) {
1435
- if (rows[i]!.line <= rows[i - 1]!.line) return undefined;
1436
- }
1437
- }
1438
- if (grouped.size === 0 || grouped.size > BASH_SEARCH_MAX_FILES) return undefined;
1439
- const verified: Array<{
1440
- display: string;
1441
- absolutePath: string;
1442
- normalized: string;
1443
- allLines: string[];
1444
- servedRows: ServedRow[];
1445
- }> = [];
1446
- for (const [resolved, rows] of grouped) {
1447
- const display = input.admitted.get(resolved) ?? resolved;
1448
- const target = await loadTextTarget(display, input.effCwd);
1449
- if (target === undefined) return undefined;
1450
- const servedRows: ServedRow[] = [];
1451
- for (const row of rows) {
1452
- // WHY: [ADR-0033 D9] byte equality per matched line against the re-read snapshot — the
1453
- // WHY: witness that the anchored row is the line bash printed. A stale or forged row
1454
- // WHY: fails the WHOLE serve, so no partial grant can ever be recorded.
1455
- if (target.allLines[row.line - 1] !== row.content) return undefined;
1456
- const hash = target.fileHashes[row.line - 1];
1457
- if (hash === undefined) return undefined;
1458
- servedRows.push({ position: row.line - 1, hash });
1459
- }
1460
- verified.push({ display, ...target, servedRows });
1461
- }
1462
- const sessionKey = deps.sessionKeyFor(ctx);
1463
- const blocks: string[] = [];
1464
- for (const group of verified) {
1465
- // WHY: the lease key is the existing `(session, file_path, anchor)` triple, so per-file
1466
- // WHY: leases ride the path Tier 1 already uses — no new identity, no #24 revival.
1467
- await recordServesBestEffort({
1468
- sessionKey,
1469
- path: group.absolutePath,
1470
- servedRows: group.servedRows,
1471
- contentHash: snapshotHashFor(group.normalized),
1472
- });
1473
- notifyServedSpans({
1474
- filePath: group.absolutePath,
1475
- spans: servedRowsToSpans(group.servedRows),
1476
- source: "auto-read",
1477
- content: group.normalized,
1478
- });
1479
- const count = group.servedRows.length;
1480
- blocks.push(
1481
- `[${group.display} (${count} ${count === 1 ? "match" : "matches"})]\n${fmtServedRows(group.servedRows, group.allLines)}`,
1482
- );
1483
- }
1484
- notifyBashViewOnce(ctx, sessionKey);
1485
- return {
1486
- content: [{ type: "text", text: `${bashBanner("search")}\n${blocks.join("\n")}` }],
1487
- };
1488
- }
1489
-
1490
- /**
1491
- * WHY: (item 10) the walk behind a recursive search and `rg --files`: breadth-first, hidden and
1492
- * `node_modules` directories never descended into, symlinks never followed (a `Dirent`'s
1493
- * `isFile()` does not follow one), and the visit count bounded — past
1494
- * `BASH_SEARCH_WALK_MAX_FILES` the WHOLE command passes through raw.
1495
- */
1496
- async function walkSearchFiles(
1497
- rootAbsolute: string,
1498
- rootDisplay: string,
1499
- ): Promise<Map<string, string> | undefined> {
1500
- const found = new Map<string, string>();
1501
- let root;
1502
- try {
1503
- root = await lstat(rootAbsolute);
1504
- } catch {
1505
- return undefined;
1506
- }
1507
- if (root.isSymbolicLink()) return undefined;
1508
- if (root.isFile()) {
1509
- found.set(rootAbsolute, rootDisplay);
1510
- return found;
1511
- }
1512
- if (!root.isDirectory()) return undefined;
1513
- const queue: Array<{ abs: string; display: string }> = [
1514
- { abs: rootAbsolute, display: rootDisplay },
1515
- ];
1516
- let visited = 0;
1517
- while (queue.length > 0) {
1518
- const current = queue.shift();
1519
- if (current === undefined) break;
1520
- let entries;
1521
- try {
1522
- entries = await readdir(current.abs, { withFileTypes: true });
1523
- } catch {
1524
- return undefined;
1525
- }
1526
- for (const entry of entries) {
1527
- if (entry.isDirectory()) {
1528
- if (isWalkableDirName(entry.name)) {
1529
- queue.push({
1530
- abs: join(current.abs, entry.name),
1531
- display: join(current.display, entry.name),
1532
- });
1533
- // WHY: (review F4) the visit bound must cover DIRECTORIES too: a forest of empty
1534
- // WHY: directories has no files to count, so an unbounded queue would walk it whole.
1535
- if (queue.length > BASH_SEARCH_WALK_MAX_FILES) return undefined;
1536
- }
1537
- continue;
1538
- }
1539
- if (!entry.isFile()) continue;
1540
- visited += 1;
1541
- if (visited > BASH_SEARCH_WALK_MAX_FILES) return undefined;
1542
- found.set(join(current.abs, entry.name), join(current.display, entry.name));
1543
- }
1544
- }
1545
- return found;
1546
- }
1547
-
1548
- /**
1549
- * WHY: (item 10) the multi-file seam. Triples require a numbered spelling, and the `tool_call`
1550
- * rewrite supplies it; a command that somehow reaches here without it fails closed instead of
1551
- * serving an unverifiable geometry.
1552
- */
1553
- async function handleBashMultiSearch(
1554
- event: ToolResultEvent,
1555
- ctx: ToolContext,
1556
- search: BashMultiSearch,
1557
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
1558
- if (!search.lineNumbered) return undefined;
1559
- const effCwd = search.baseDir === undefined ? ctx.cwd : deps.toCwd(search.baseDir, ctx.cwd);
1560
- const admitted = new Map<string, string>();
1561
- for (const filePath of search.filePaths) {
1562
- admitted.set(await deps.resolveTarget(deps.toCwd(filePath, effCwd)), filePath);
1563
- }
1564
- return await serveMatchTriples(event, ctx, { effCwd, admitted });
1565
- }
1566
-
1567
- /**
1568
- * WHY: (item 10) the recursive seam over one explicit root (`grep -r PATTERN DIR`, and an `rg`
1569
- * directory operand). Only files the walk actually visited can be leased, so the exclusion list
1570
- * is what keeps `node_modules`/`.git`/hidden subtrees out of the anchor budget.
1571
- */
1572
- async function handleBashRecursiveSearch(
1573
- event: ToolResultEvent,
1574
- ctx: ToolContext,
1575
- search: BashRecursiveSearch,
1576
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
1577
- if (!search.lineNumbered) return undefined;
1578
- const effCwd = search.baseDir === undefined ? ctx.cwd : deps.toCwd(search.baseDir, ctx.cwd);
1579
- const rootResolved = await deps.resolveTarget(deps.toCwd(search.dirPath, effCwd));
1580
- const walked = await walkSearchFiles(rootResolved, search.dirPath);
1581
- if (walked === undefined) return undefined;
1582
- return await serveMatchTriples(event, ctx, { effCwd, admitted: walked });
1583
- }
1584
-
1585
- /**
1586
- * WHY: (item 10) `rg --files`: every entry is existence-checked against the walk (invariant 2)
1587
- * inside the same count cap, and NOTHING is leased — a path list has no lines to anchor, so
1588
- * listing grants no edit rights.
1589
- */
1590
- async function handleBashFilesList(
1591
- event: ToolResultEvent,
1592
- ctx: ToolContext,
1593
- search: BashFilesList,
1594
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
1595
- const effCwd = search.baseDir === undefined ? ctx.cwd : deps.toCwd(search.baseDir, ctx.cwd);
1596
- const rootDisplay = search.dirPath ?? ".";
1597
- const rootResolved = await deps.resolveTarget(deps.toCwd(rootDisplay, effCwd));
1598
- const walked = await walkSearchFiles(rootResolved, rootDisplay);
1599
- if (walked === undefined) return undefined;
1600
- // WHY: (review F5) no `valAccess`/`loadFileKindAndText` here on purpose: a path list has
1601
- // WHY: no bytes to witness and grants no anchors, so the walk's membership is the whole
1602
- // WHY: witness chain. The cap below is the scope bound `constants.ts` states for this
1603
- // WHY: constant: how many paths one listing may name — the brief's "same count cap",
1604
- // WHY: not an anchor budget, because nothing here is leased. A listing larger than it
1605
- // WHY: therefore passes through raw, which ADR-0033 D10 records.
1606
- const stdoutText = singleTextBlock(event);
1607
- if (stdoutText === undefined) return undefined;
1608
- const lines = toLF(stripBOM(stdoutText).text).split("\n");
1609
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
1610
- if (lines.length === 0 || lines.length > BASH_SEARCH_MAX_FILES) return undefined;
1611
- for (const entry of lines) {
1612
- if (entry === "" || !walked.has(await deps.resolveTarget(deps.toCwd(entry, effCwd)))) {
1613
- return undefined;
1614
- }
1615
- }
1616
- return {
1617
- content: [{ type: "text", text: `${bashBanner("file-list")}\n${lines.join("\n")}` }],
1618
- };
1619
- }
1620
-
1621
- /**
1622
- * WHY: (item 9) the content stage's serve seam. A filter's survivors are sparse, so they are
1623
- * WHY: leased row by row exactly like a search's matches — the same `fmtServedRows` renderer
1624
- * WHY: and `SEARCH_MAX_MATCHES` bound — rather than borrowing the read tool's window budget,
1625
- * WHY: which would pass a realistic sparse filter through raw. The whole-stdout byte gate is
1626
- * WHY: the witness: a literal fold that diverges from what bash printed (a regex pattern, an
1627
- * WHY: `-i` spelling) fails closed here instead of being served as if it were the command's
1628
- * WHY: output. Empty survivors pass through, so a filter that matched nothing leases nothing.
1629
- *
1630
- * WHY: (item 11) the same seam serves the line-SET consumers. `sort -u`/`uniq` reorder the
1631
- * WHY: rows, so the header names the order while every lease keeps the true file coordinate;
1632
- * WHY: `wc -l` is terminal and takes the count gate instead of the byte gate, leasing EVERY
1633
- * WHY: counted row in file order so the number it printed is checkable by editing a row.
1634
- */
1635
- async function serveContentView(
1636
- event: ToolResultEvent,
1637
- ctx: ToolContext,
1638
- input: {
1639
- filePath: string;
1640
- absolutePath: string;
1641
- normalized: string;
1642
- fileHashes: string[];
1643
- allLines: string[];
1644
- ops: ViewOp[];
1645
- observedText?: string | undefined;
1646
- },
1647
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
1648
- const { applyViewOps } = await import("../bash-classifier.js");
1649
- const stdoutText = input.observedText ?? singleTextBlock(event);
1650
- if (stdoutText === undefined) return undefined;
1651
- const observedText = toLF(stripBOM(stdoutText).text);
1652
- const survivors = applyViewOps(
1653
- input.ops,
1654
- input.allLines.map((text, index) => ({ line: index + 1, text })),
1655
- );
1656
- // WHY: empty survivors and over-cap output both pass the WHOLE stdout through with zero
1657
- // WHY: leases: `SEARCH_MAX_MATCHES` bounds a content stage exactly as it bounds a search, and
1658
- // WHY: an empty fold has no row to lease — never a truncated or invented render.
1659
- if (survivors.length === 0 || survivors.length > SEARCH_MAX_MATCHES) return undefined;
1660
- let rows: ContentLine[];
1661
- if (input.ops.some((op) => op.kind === "count")) {
1662
- // WHY: (item 11) `wc -l` is terminal and its observed stdout is the NUMBER, not the
1663
- // WHY: content, so the count REPLACES the whole-stdout byte gate. A count the fold does
1664
- // WHY: not produce refuses the whole serve with zero leases.
1665
- const observed = COUNT_STDOUT_LINE.exec(observedText);
1666
- if (observed === null || Number(observed[1]) !== survivors.length) return undefined;
1667
- // WHY: the counted rows render in FILE order — the number is checkable only against stable
1668
- // WHY: coordinates, not against the stream order a preceding consumer established.
1669
- rows = [...survivors].sort((left, right) => left.line - right.line);
1670
- } else {
1671
- // WHY: [ADR-0033 D9] the whole-stdout byte gate against the disk-derived fold; a fold
1672
- // WHY: that diverges from what bash printed fails closed here.
1673
- const joined = survivors.map((entry) => entry.text).join("\n");
1674
- if (observedText !== joined && observedText !== `${joined}\n`) return undefined;
1675
- rows = survivors;
1676
- }
1677
- const servedRows: ServedRow[] = [];
1678
- for (const entry of rows) {
1679
- const hash = input.fileHashes[entry.line - 1];
1680
- if (hash === undefined) return undefined;
1681
- servedRows.push({ position: entry.line - 1, hash });
1682
- }
1683
- const sessionKey = deps.sessionKeyFor(ctx);
1684
- await recordServesBestEffort({
1685
- sessionKey,
1686
- path: input.absolutePath,
1687
- servedRows,
1688
- contentHash: snapshotHashFor(input.normalized),
1689
- });
1690
- notifyServedSpans({
1691
- filePath: input.absolutePath,
1692
- spans: servedRowsToSpans(servedRows),
1693
- source: "auto-read",
1694
- content: input.normalized,
1695
- });
1696
- notifyBashViewOnce(ctx, sessionKey);
1697
- const count = servedRows.length;
1698
- return {
1699
- content: [
1700
- {
1701
- type: "text",
1702
- text: `${viewHeader(input.filePath, input.ops, count)}\n${fmtServedRows(servedRows, input.allLines)}`,
1703
- },
1704
- ],
1705
- };
1706
- }
1707
-
1708
- /**
1709
- * [spec §3.4] The `tool_result` half of search interception: parse the `LINE:content` stdout of an
1710
- * already-rewritten search, verify every match against the disk bytes (D9), then swap the raw
1711
- * match list for anchored rows and synchronously enroll their leases.
1712
- *
1713
- * WHY the stdout is normalized rather than rejected: `grep` emits the disk bytes, so a CRLF file
1714
- * yields `5:line5\r` while `readNormFile` — and the anchors served here — describe LF/no-BOM text.
1715
- * `toLF(stripBOM(...))` is exactly the D9 normalization `handleBash` applies to a view, which
1716
- * keeps both seams consistent on the files the model inspects through bash.
1717
- *
1718
- * Every gate fails closed to `undefined` (raw bash output untouched): a non-zero exit or zero
1719
- * matches, an unparsable row, more than `SEARCH_MAX_MATCHES` rows, a byte mismatch on any line, or
1720
- * a line the hash array does not cover. Truncating or partially leasing would serve anchors for
1721
- * lines the model never saw.
1722
- */
1723
- /**
1724
- * WHY: (ADR-0039 D1) expected raw bytes for one envelope segment, computed from re-read disk
1725
- * WHY: content (never from observed stdout). Each entry is an exact byte candidate, longest
1726
- * WHY: first: glue is a single predicted literal; views cover the two D9-accepted trailing
1727
- * WHY: forms; searches cover theirs. A candidate that matches as a prefix is consumed; if none
1728
- * WHY: matches, peeling halts. No candidate invents bytes — mismatch fails closed, never leases.
1729
- */
1730
- async function segmentExpectedVariants(
1731
- segment: CompoundSegment,
1732
- effCwd: string,
1733
- ): Promise<string[] | undefined> {
1734
- // WHY: (ADR-0039 D10) a raw tail has no predicted bytes — its already-observed stdout
1735
- // WHY: passes through via the peel loop's `rest` (the loop breaks on this `undefined`).
1736
- // WHY: Nothing is leased or anchored for it; the block above carries the only leases.
1737
- if (segment.kind === "raw") return undefined;
1738
-
1739
- if (segment.kind === "glue") {
1740
- return [segment.glue.expectedText];
1741
- }
1742
- if (segment.kind === "view") {
1743
- const resolved = await resolveSegmentFile(segment.filePath, effCwd);
1744
- if (resolved === undefined) return undefined;
1745
- const intervals = applySliceOps(segment.ops.filter(isSliceOp), resolved.allLines.length);
1746
- if (segment.lineNumbered === true) {
1747
- if (intervals.length === 0) return [""];
1748
- const numbered = resolved.allLines.map(
1749
- (line, index) => `${String(index + 1).padStart(6)}\t${line}`,
1750
- );
1751
- return variantsOf(numbered.join("\n"));
1752
- }
1753
- if (intervals.length === 0) return [""];
1754
- const selected: string[] = [];
1755
- for (const iv of intervals) selected.push(...resolved.allLines.slice(iv.lo - 1, iv.hi));
1756
- return variantsOf(selected.join("\n"));
1757
- }
1758
- const family = segment.family;
1759
- if (family.kind !== "pureSearch") return undefined;
1760
- // WHY: (ADR-0048 D3 scope) predicting a context block byte-exactly would mean reproducing grep's
1761
- // WHY: window-merge and `--` separator rules from disk. That prediction is not part of D3, so a
1762
- // WHY: context search segment halts the peel here: the rest rides through raw with zero leases for
1763
- // WHY: it — never a guessed block, and never a regression for the plain search segments beside it.
1764
- if (family.search.context !== undefined) return undefined;
1765
- const resolved = await resolveSegmentFile(family.search.filePath, effCwd);
1766
- if (resolved === undefined) return undefined;
1767
- const rows: ServedRow[] = [];
1768
- for (let i = 0; i < resolved.diskLines.length; i++) {
1769
- const content = resolved.diskLines[i];
1770
- if (content === undefined || !content.includes(family.search.pattern)) continue;
1771
- const hash = resolved.fileHashes[i];
1772
- if (hash === undefined) return undefined;
1773
- rows.push({ position: i, hash });
1774
- }
1775
- const shown = applySliceToServedRows(rows, family.search.sliceOps ?? []);
1776
- if (shown.length === 0) return [""];
1777
- const text = shown
1778
- .map((row) => `${row.position + 1}:${resolved.diskLines[row.position]}`)
1779
- .join("\n");
1780
- return variantsOf(text);
1781
- }
1782
- function variantsOf(base: string): string[] {
1783
- return base === "" ? [""] : [`${base}\n`, base];
1784
- }
1785
- async function resolveSegmentFile(
1786
- filePath: string,
1787
- effCwd: string,
1788
- ): Promise<
1789
- | { normalized: string; allLines: string[]; fileHashes: string[]; diskLines: string[] }
1790
- | undefined
1791
- > {
1792
- const resolvedPath = await deps.resolveTarget(deps.toCwd(filePath, effCwd));
1793
- await deps.valAccess(resolvedPath, filePath);
1794
- const file = await deps.loadFileKindAndText(resolvedPath, {
1795
- maxLines: SERVED_MAX_LINES,
1796
- displayPath: filePath,
1797
- });
1798
- if (file.kind !== "text") return undefined;
1799
- const { normalized, fileHashes } = await deps.readNormFile(filePath, effCwd, {
1800
- maxLines: SERVED_MAX_LINES,
1801
- preloadedFile: file,
1802
- });
1803
- const allLines = deps.visLines(normalized);
1804
- return { normalized, allLines, fileHashes, diskLines: allLines };
1805
- }
1806
- async function handleBashSearch(
1807
- event: ToolResultEvent,
1808
- ctx: ToolContext,
1809
- search: BashSearch,
1810
- observedText?: string,
1811
- ): Promise<{ content: Array<{ type: string; text: string }> } | undefined> {
1812
- // WHY: the single pre-search literal `cd` re-bases relative resolution under the same rule as a
1813
- // WHY: view (ADR-0033 D1); multi-`cd` and post-search-`cd` chains never reach here.
1814
- const effCwd = search.baseDir === undefined ? ctx.cwd : deps.toCwd(search.baseDir, ctx.cwd);
1815
- const resolvedPath = await deps.resolveTarget(deps.toCwd(search.filePath, effCwd));
1816
- await deps.valAccess(resolvedPath, search.filePath);
1817
- // WHY: the pure classifier cannot see the filesystem, so the single-operand target is re-checked
1818
- // WHY: FIRST — before any stdout geometry is parsed, because a directory operand's stdout is
1819
- // WHY: `FILE:LINE:content` triples, not the `LINE:content` rows below.
1820
- const file = await deps.loadFileKindAndText(resolvedPath, {
1821
- maxLines: SERVED_MAX_LINES,
1822
- displayPath: search.filePath,
1823
- });
1824
- if (file.kind === "directory") {
1825
- // WHY: (item 10) rg recurses by default, so a directory operand is a recursive search and
1826
- // WHY: delegates to the walk seam. grep needs its explicit `-r`, so the same operand for
1827
- // WHY: grep stays raw — native grep errors on a directory without the marker.
1828
- if (search.program !== "rg") return undefined;
1829
- return await handleBashRecursiveSearch(event, ctx, {
1830
- program: "rg",
1831
- pattern: search.pattern,
1832
- dirPath: search.filePath,
1833
- lineNumbered: search.lineNumbered,
1834
- ...(search.baseDir !== undefined ? { baseDir: search.baseDir } : {}),
1835
- });
1836
- }
1837
- if (file.kind !== "text") return undefined;
1838
- // WHY: exactly one text block, the same attribution rule the view path applies — a search whose
1839
- // WHY: stdout is split or non-text cannot be proven to be this command's output.
1840
- const stdoutText = observedText ?? singleTextBlock(event);
1841
- if (stdoutText === undefined) return undefined;
1842
- const rows = parseSearchStdout(
1843
- toLF(stripBOM(stdoutText).text),
1844
- search.context === undefined ? undefined : { context: true },
1845
- );
1846
- if (rows === undefined || rows.length === 0) return undefined;
1847
- const matchLines = searchRowsWithinBudget(rows, search.context);
1848
- if (matchLines === undefined) return undefined;
1849
- // WHY: (serve-seam policy: one row per file line, rows in stream order — ADR-0048, bash
1850
- // WHY: serve admission, states the no-bleed invariant, not an ordering clause)
1851
- // WHY: the printed number is a label whose ORDER is proven by the stream, not by matching
1852
- // WHY: content. A descending or repeated label breaks stream order, so the WHOLE serve fails
1853
- // WHY: closed with zero leases. Real `grep`/`rg` on one file always print ascending, so
1854
- // WHY: nothing served regresses.
1855
- for (let i = 1; i < rows.length; i++) {
1856
- if (rows[i]!.line <= rows[i - 1]!.line) return undefined;
1857
- }
1858
- const { normalized, fileHashes, absolutePath } = await deps.readNormFile(
1859
- search.filePath,
1860
- effCwd,
1861
- {
1862
- maxLines: SERVED_MAX_LINES,
1863
- preloadedFile: file,
1864
- },
1865
- );
1866
- const diskLines = deps.visLines(normalized);
1867
- const servedRows: ServedRow[] = [];
1868
- for (const { line, content } of rows) {
1869
- // WHY: [ADR-0033 D9] byte equality per matched line — the witness that the anchored row is the
1870
- // WHY: line bash printed. A stale/forged/stdout-only line fails closed with no lease.
1871
- if (diskLines[line - 1] !== content) return undefined;
1872
- const hash = fileHashes[line - 1];
1873
- if (hash === undefined) return undefined;
1874
- servedRows.push({ position: line - 1, hash });
1875
- }
1876
- // WHY: [ADR-0039 D3] a pipelined search (`grep -n pat f | head -2`) slices the resolved rows,
1877
- // WHY: not the file. Only survivors are leased, rendered and counted; an empty survivor set
1878
- // WHY: declines rather than leasing nothing while claiming success (F4).
1879
- const shownRows = applySliceToServedRows(servedRows, search.sliceOps ?? []);
1880
- if (shownRows.length === 0) return undefined;
1881
- // WHY: (ADR-0048 D3) the header's count names what the block HOLDS: the pattern hits, with the
1882
- // WHY: context rows named beside them once a window was served. A plain search has no context row,
1883
- // WHY: so its frozen `[file (N matches)]` literal is byte-identical to before.
1884
- const shownMatchRows = shownRows.filter((row) => matchLines.has(row.position + 1)).length;
1885
- const shownContextRows = shownRows.length - shownMatchRows;
1886
- const sessionKey = deps.sessionKeyFor(ctx);
1887
- await recordServesBestEffort({
1888
- sessionKey,
1889
- path: absolutePath,
1890
- servedRows: shownRows,
1891
- contentHash: snapshotHashFor(normalized),
1892
- });
1893
- notifyServedSpans({
1894
- filePath: absolutePath,
1895
- spans: servedRowsToSpans(shownRows),
1896
- source: "auto-read",
1897
- content: normalized,
1898
- });
1899
- // WHY: the header is frozen (spec §3.4): the same search banner literal from `bashBanner` names
1900
- // WHY: the interception seam, and the bracketed count uses the singular for one match.
1901
- const matchNoun = shownMatchRows === 1 ? "match" : "matches";
1902
- const contextNoun = shownContextRows === 1 ? "line" : "lines";
1903
- const matches = `${shownMatchRows} ${matchNoun}`;
1904
- const count =
1905
- shownContextRows === 0 ? matches : `${matches}, ${shownContextRows} context ${contextNoun}`;
1906
- const header = `${bashBanner("search")}\n[${search.filePath} (${count})]`;
1907
- notifyBashViewOnce(ctx, sessionKey);
1908
- return {
1909
- content: [{ type: "text", text: `${header}\n${fmtServedRows(shownRows, diskLines)}` }],
1910
- };
1911
- }
1912
-
1913
- /**
1914
- * WHY: [ADR-0039 D3] positional fold over resolved search rows. `first`/`last` (and their
1915
- * WHY: `from`/`dropLast`/`range` siblings, kept total so a new kind breaks typecheck here)
1916
- * WHY: select by stream position; each row already carries its original file line, so surviving
1917
- * WHY: rows keep original numbers by construction. Uses `slice`, never a line split.
1918
- */
1919
- function applySliceToServedRows(rows: ServedRow[], ops: SliceOp[]): ServedRow[] {
1920
- let shown = rows;
1921
- for (const op of ops) {
1922
- switch (op.kind) {
1923
- case "first":
1924
- shown = shown.slice(0, Math.max(0, op.n));
1925
- break;
1926
- case "last":
1927
- shown = op.n <= 0 ? [] : shown.slice(Math.max(0, shown.length - op.n));
1928
- break;
1929
- case "from":
1930
- shown = shown.slice(Math.max(0, op.n - 1));
1931
- break;
1932
- case "dropLast":
1933
- shown = shown.slice(0, Math.max(0, shown.length - op.n));
1934
- break;
1935
- case "range":
1936
- shown = shown.slice(Math.max(0, op.from - 1), Math.max(0, op.to));
1937
- break;
1938
- default:
1939
- // WHY: `assertNever` carries the union's exhaustiveness to the compiler: a sixth SliceOp
1940
- // WHY: kind fails the build here instead of passing silently through the fold.
1941
- return assertNever(op);
1942
- }
1943
- }
1944
- return shown;
1945
- }
1946
-
1947
- async function handleToolResult(
1948
- event: ToolResultEvent,
1949
- ctx: ToolContext,
1950
- ): Promise<{ content: Array<{ type: string; text: string }>; details?: unknown } | undefined> {
1951
- if (event.isError) return undefined;
1952
- if (event.toolName === "write") {
1953
- return handleWrite(event, ctx);
1954
- }
1955
- if (event.toolName === "edit" || event.toolName === "undo_last_edit") {
1956
- return handleEdit(event, ctx);
1957
- }
1958
- if (event.toolName === "bash") {
1959
- return handleBash(event, ctx);
1960
- }
1961
- return undefined;
1962
- }
1963
-
1964
- return {
1965
- onSessionStart: handleSessionStart,
1966
- onToolResult: handleToolResult,
1967
- onWrite: handleWrite,
1968
- onEdit: handleEdit,
1969
- onBash: handleBash,
1970
- };
1971
- }
1972
-
1973
- export function registerLifecycleHooks(
1974
- pi: ExtensionAPI,
1975
- overrides: Partial<LifecycleDeps> = {},
1976
- ): ReturnType<typeof createLifecycleHooks> {
1977
- const hooks = createLifecycleHooks(overrides);
1978
- // SAFETY: ExtensionAPI on() typed for known events — string-key widening validated by lifecycle hook contract
1979
- (pi as unknown as { on: (e: string, h: unknown) => void }).on(
1980
- // SAFETY: string-key widening for session_start
1981
- "session_start",
1982
- // SAFETY: onSessionStart handler tuple overload — cast to never validated by handleSessionStart signature
1983
- hooks.onSessionStart as unknown as never,
1984
- );
1985
- // SAFETY: ExtensionAPI on() typed for known events — string-key widening for tool_result delegation
1986
- (pi as unknown as { on: (e: string, h: unknown) => void }).on(
1987
- "tool_result",
1988
- // SAFETY: onToolResult handler tuple overload — cast to never validated by handleToolResult signature
1989
- hooks.onToolResult as unknown as never,
1990
- );
1991
- return hooks;
1992
- }