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.
- package/README.md +72 -70
- package/cordis.patch.yml +1 -1
- package/lib/index.js +4494 -4813
- package/lib/types/dsh/contracts.d.ts +69 -6
- package/lib/types/dsh/fs.d.ts +38 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/hide.d.ts +78 -0
- package/lib/types/dsh/host-compat.d.ts +7 -78
- package/lib/types/dsh/index.d.ts +17 -7
- package/lib/types/dsh/metrics/events.d.ts +8 -1
- package/lib/types/dsh/metrics/instrument.d.ts +20 -14
- package/lib/types/dsh/metrics/observer.d.ts +28 -0
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +17 -8
- package/lib/types/dsh/shell-view.d.ts +65 -3
- package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
- package/lib/types/dsh/tools/context.d.ts +19 -1
- package/lib/types/dsh/tools/output.d.ts +32 -4
- package/lib/types/dsh/tools/read.d.ts +12 -15
- package/lib/types/dsh/tools/undo.d.ts +5 -0
- package/lib/types/dsh/write-hook.d.ts +8 -22
- package/lib/types/src/apply-patch/commit.d.ts +49 -0
- package/lib/types/src/apply-patch/grammar.d.ts +29 -0
- package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
- package/lib/types/src/apply-patch/plan.d.ts +50 -0
- package/lib/types/src/apply-patch/render.d.ts +51 -0
- package/lib/types/src/apply-patch/resolve.d.ts +129 -0
- package/lib/types/src/apply-patch/tool.d.ts +54 -0
- package/lib/types/src/apply-patch/types.d.ts +362 -0
- package/lib/types/src/bash-view-banner.d.ts +20 -0
- package/lib/types/src/domain-errors.d.ts +16 -2
- package/lib/types/src/edit-undo.d.ts +11 -2
- package/lib/types/src/file-content/detection.d.ts +17 -0
- package/lib/types/src/file-content/index.d.ts +2 -2
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/fs-lifecycle.d.ts +44 -0
- package/lib/types/src/hashline/resolve.d.ts +6 -0
- package/lib/types/src/host-utils.d.ts +63 -0
- package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
- package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
- package/lib/types/src/mutation-engine/types.d.ts +50 -58
- package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
- package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
- package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
- package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
- package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
- package/lib/types/src/served-session/serve-render.d.ts +9 -3
- package/lib/types/src/undo-store.d.ts +17 -1
- package/package.json +10 -22
- package/prompts/apply-patch-guidelines.md +10 -0
- package/prompts/apply-patch.md +1 -0
- package/prompts/read-guidelines.md +5 -5
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/src/apply-patch/commit.ts +190 -0
- package/src/apply-patch/grammar.ts +620 -0
- package/src/apply-patch/index.ts +17 -0
- package/src/apply-patch/lifecycle.ts +195 -0
- package/src/apply-patch/plan.ts +800 -0
- package/src/apply-patch/render.ts +137 -0
- package/src/apply-patch/resolve.ts +470 -0
- package/src/apply-patch/tool.ts +257 -0
- package/src/apply-patch/types.ts +387 -0
- package/src/cut-repair.ts +77 -11
- package/src/domain-errors.ts +58 -10
- package/src/edit-undo.ts +379 -25
- package/src/file-content/detection.ts +34 -0
- package/src/file-content/index.ts +2 -2
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/fs-lifecycle.ts +201 -0
- package/src/hash-store.ts +18 -1
- package/src/hashline/lease-resolve.ts +14 -8
- package/src/hashline/resolve.ts +30 -27
- package/src/host-utils.ts +224 -0
- package/src/mutation-engine/batch-span-gate.ts +17 -4
- package/src/mutation-engine/edit-request.ts +39 -0
- package/src/mutation-engine/pipeline.ts +535 -63
- package/src/mutation-engine/types.ts +49 -65
- package/src/mutation-queue.ts +1 -1
- package/src/prompts.ts +0 -28
- package/src/read-expansion/ast-provider.ts +4 -0
- package/src/read-expansion/grammar-manifest.ts +21 -2
- package/src/read-expansion/java-decoration.ts +34 -0
- package/src/read-expansion/span-expansion.ts +6 -2
- package/src/read-expansion/span-kind.ts +3 -1
- package/src/read-expansion/vue-decoration.ts +43 -0
- package/src/served-session/serve-render.ts +5 -5
- package/src/undo-store.ts +74 -5
- package/lib/types/dsh/encoding-bridge.d.ts +0 -25
- package/lib/types/dsh/encoding.d.ts +0 -144
- package/lib/types/dsh/tools/edit.d.ts +0 -10
- package/lib/types/src/edit-tool.d.ts +0 -41
- package/lib/types/src/mutation-engine/engine.d.ts +0 -31
- package/lib/types/src/payload-contract.d.ts +0 -124
- package/prompts/edit-guidelines.md +0 -10
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -1
- package/src/bash-classifier.ts +0 -1626
- package/src/bash-conversion/decide.ts +0 -812
- package/src/bash-display-transform.ts +0 -50
- package/src/bash-multifile-config.ts +0 -239
- package/src/bash-view-config.ts +0 -158
- package/src/edit-normalize.ts +0 -8
- package/src/edit-render.ts +0 -161
- package/src/edit-tool.ts +0 -116
- package/src/edit.ts +0 -136
- package/src/integrations/pi-lens/command.ts +0 -712
- package/src/integrations/pi-lens/config.ts +0 -148
- package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
- package/src/integrations/pi-lens/io-bridge.ts +0 -112
- package/src/integrations/pi-lens/settings-overlay.ts +0 -272
- package/src/lifecycle-hooks/index.ts +0 -1992
- package/src/lifecycle-hooks/types.ts +0 -64
- package/src/mutated-files.ts +0 -75
- package/src/mutation-engine/engine.ts +0 -173
- package/src/mutation-engine/index.ts +0 -17
- package/src/payload-contract.ts +0 -637
- package/src/preview-controller.ts +0 -84
- package/src/read.ts +0 -865
- package/src/search-hook.ts +0 -41
- package/src/store-config.ts +0 -73
- package/src/store-janitor-warning.ts +0 -169
- package/src/store-janitor.ts +0 -539
- package/src/store-kernel/settings.ts +0 -233
- package/src/store.ts +0 -154
- package/src/tui-presenter.ts +0 -134
- 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
|
-
}
|