dsh-hashline-edit 0.2.1 → 0.2.2
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 +60 -56
- package/cordis.patch.yml +3 -3
- package/lib/index.js +7224 -6454
- package/lib/types/dsh/contracts.d.ts +3 -12
- package/lib/types/dsh/fs.d.ts +0 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/host-compat.d.ts +78 -7
- package/lib/types/dsh/index.d.ts +9 -21
- package/lib/types/dsh/metrics/events.d.ts +1 -1
- package/lib/types/dsh/metrics/observer.d.ts +1 -1
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +1 -1
- package/lib/types/dsh/tools/edit.d.ts +10 -0
- package/lib/types/dsh/tools/read.d.ts +3 -3
- package/lib/types/src/domain-errors.d.ts +2 -16
- package/lib/types/src/edit-tool.d.ts +41 -0
- package/lib/types/src/edit-undo.d.ts +2 -11
- package/lib/types/src/file-content/index.d.ts +1 -1
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/hash-store.d.ts +2 -2
- package/lib/types/src/hashline/alphabet.d.ts +16 -1
- package/lib/types/src/hashline/hash-identity.d.ts +10 -4
- package/lib/types/src/mutation-engine/engine.d.ts +31 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +1 -8
- package/lib/types/src/mutation-engine/types.d.ts +50 -40
- package/lib/types/src/payload-contract.d.ts +135 -0
- package/lib/types/src/served-session/serve-render.d.ts +0 -6
- package/lib/types/src/undo-store.d.ts +1 -17
- package/package.json +21 -18
- package/prompts/edit-guidelines.md +10 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +1 -0
- package/prompts/read-guidelines.md +4 -4
- 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/bash-classifier.ts +1626 -0
- package/src/bash-conversion/decide.ts +812 -0
- package/src/bash-display-transform.ts +50 -0
- package/src/bash-multifile-config.ts +239 -0
- package/src/bash-view-config.ts +158 -0
- package/src/cut-repair.ts +11 -77
- package/src/domain-errors.ts +3 -35
- package/src/edit-normalize.ts +8 -0
- package/src/edit-render.ts +161 -0
- package/src/edit-tool.ts +118 -0
- package/src/edit-undo.ts +25 -379
- package/src/edit.ts +136 -0
- package/src/file-content/index.ts +1 -1
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/hash-store.ts +1 -18
- package/src/hashline/alphabet.ts +42 -1
- package/src/hashline/hash-identity.ts +12 -3
- package/src/integrations/pi-lens/command.ts +712 -0
- package/src/integrations/pi-lens/config.ts +148 -0
- package/src/integrations/pi-lens/io-bridge-adapter.ts +234 -0
- package/src/integrations/pi-lens/io-bridge.ts +112 -0
- package/src/integrations/pi-lens/settings-overlay.ts +272 -0
- package/src/lifecycle-hooks/index.ts +1992 -0
- package/src/lifecycle-hooks/types.ts +64 -0
- package/src/mutated-files.ts +75 -0
- package/src/mutation-engine/engine.ts +239 -0
- package/src/mutation-engine/index.ts +17 -0
- package/src/mutation-engine/pipeline.ts +27 -198
- package/src/mutation-engine/types.ts +57 -39
- package/src/mutation-queue.ts +1 -1
- package/src/payload-contract.ts +685 -0
- package/src/preview-controller.ts +84 -0
- package/src/prompts.ts +43 -10
- package/src/read.ts +865 -0
- package/src/search-hook.ts +41 -0
- package/src/served-session/serve-render.ts +2 -2
- package/src/store-config.ts +73 -0
- package/src/store-janitor-warning.ts +169 -0
- package/src/store-janitor.ts +539 -0
- package/src/store-kernel/settings.ts +233 -0
- package/src/store.ts +154 -0
- package/src/tui-presenter.ts +134 -0
- package/src/undo-store.ts +5 -74
- package/src/write-hook.ts +130 -0
- package/lib/types/dsh/hide.d.ts +0 -79
- package/lib/types/dsh/tools/apply-patch.d.ts +0 -38
- package/lib/types/src/apply-patch/commit.d.ts +0 -49
- package/lib/types/src/apply-patch/grammar.d.ts +0 -29
- package/lib/types/src/apply-patch/lifecycle.d.ts +0 -32
- package/lib/types/src/apply-patch/plan.d.ts +0 -50
- package/lib/types/src/apply-patch/render.d.ts +0 -51
- package/lib/types/src/apply-patch/resolve.d.ts +0 -129
- package/lib/types/src/apply-patch/tool.d.ts +0 -54
- package/lib/types/src/apply-patch/types.d.ts +0 -362
- package/lib/types/src/fs-lifecycle.d.ts +0 -44
- package/lib/types/src/host-utils.d.ts +0 -63
- package/lib/types/src/mutation-engine/edit-request.d.ts +0 -34
- package/prompts/apply-patch-guidelines.md +0 -10
- package/prompts/apply-patch.md +0 -1
- package/src/apply-patch/commit.ts +0 -190
- package/src/apply-patch/grammar.ts +0 -620
- package/src/apply-patch/index.ts +0 -17
- package/src/apply-patch/lifecycle.ts +0 -195
- package/src/apply-patch/plan.ts +0 -800
- package/src/apply-patch/render.ts +0 -137
- package/src/apply-patch/resolve.ts +0 -470
- package/src/apply-patch/tool.ts +0 -257
- package/src/apply-patch/types.ts +0 -387
- package/src/fs-lifecycle.ts +0 -201
- package/src/host-utils.ts +0 -224
- package/src/mutation-engine/edit-request.ts +0 -39
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import type { DisplayTransform } from "./bash-classifier.js";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* WHY: (ADR-0048 D2) the transform is a closed display set. The re-run is the oracle: the same
|
|
12
|
+
* executable and flags run on a temporary copy of the exact raw head bytes, so platform-specific
|
|
13
|
+
* `od`/`xxd`/`hexdump` layouts cannot drift between what bash printed and what this proves.
|
|
14
|
+
* `cat -A` is GNU-only; `cat -vet` is the BSD/GNU-portable equivalent. A missing command, a
|
|
15
|
+
* timeout, or over-budget output returns undefined and the serve seam passes the whole command
|
|
16
|
+
* through raw with zero leases.
|
|
17
|
+
*/
|
|
18
|
+
const COMMANDS: Record<DisplayTransform["kind"], readonly [string, ...string[]]> = {
|
|
19
|
+
"cat-A": ["cat", "-A"],
|
|
20
|
+
"cat-vet": ["cat", "-vet"],
|
|
21
|
+
"od-c": ["od", "-c"],
|
|
22
|
+
xxd: ["xxd"],
|
|
23
|
+
"hexdump-C": ["hexdump", "-C"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
27
|
+
const TIMEOUT_MS = 5_000;
|
|
28
|
+
|
|
29
|
+
export async function runDisplayTransform(
|
|
30
|
+
transform: DisplayTransform,
|
|
31
|
+
input: string,
|
|
32
|
+
): Promise<string | undefined> {
|
|
33
|
+
const [program, ...args] = COMMANDS[transform.kind];
|
|
34
|
+
let dir: string | undefined;
|
|
35
|
+
try {
|
|
36
|
+
dir = await mkdtemp(join(tmpdir(), "pi-bash-display-"));
|
|
37
|
+
const file = join(dir, "slice");
|
|
38
|
+
await writeFile(file, input);
|
|
39
|
+
const { stdout } = await execFileAsync(program, [...args, file], {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
42
|
+
timeout: TIMEOUT_MS,
|
|
43
|
+
});
|
|
44
|
+
return stdout;
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
} finally {
|
|
48
|
+
if (dir !== undefined) await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bash.multiFile` — the multi-file view caps (ADR-0049 M6), the sibling of `bash.view`.
|
|
3
|
+
*
|
|
4
|
+
* WHY the caps are settings: they are operator policy over how much one bash result may spend, not
|
|
5
|
+
* a correctness bound (the ADR's safety bound IS the allowed range — a config can only lower a cap,
|
|
6
|
+
* never raise it past the hard maximum). They resolve per KEY through the shared project/global
|
|
7
|
+
* ladder so "the project file is set" never stands in for "this key came from the project file".
|
|
8
|
+
*
|
|
9
|
+
* WHY an out-of-range value warns instead of falling through silently: ADR-0049 M6 requires the
|
|
10
|
+
* warning, and a dropped cap changes how much the session serves — the silent drop this ladder
|
|
11
|
+
* refuses to make at file level is the same drop one level down. A MISSING key still warns about
|
|
12
|
+
* nothing, and a broken file keeps warning exactly once through `readScopeConfig`.
|
|
13
|
+
*
|
|
14
|
+
* This module never touches the filesystem itself: reads go through `readScopeConfig`, writes
|
|
15
|
+
* through `updateConfigFile`, so the config file keeps one owner.
|
|
16
|
+
*/
|
|
17
|
+
import { DomainError } from "./domain-errors.js";
|
|
18
|
+
import {
|
|
19
|
+
configFilePath,
|
|
20
|
+
isConfigScope,
|
|
21
|
+
isRecord,
|
|
22
|
+
readScopeConfig,
|
|
23
|
+
updateConfigFile,
|
|
24
|
+
type ConfigOnWarn,
|
|
25
|
+
type ConfigRung,
|
|
26
|
+
type ConfigScope,
|
|
27
|
+
} from "./json-config.js";
|
|
28
|
+
|
|
29
|
+
/** The two caps, both integers in their allowed range. */
|
|
30
|
+
export interface BashMultiFileSettings {
|
|
31
|
+
readonly maxFiles: number;
|
|
32
|
+
readonly maxRows: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One scope file's accepted keys; every key is optional and absent means "not set here". */
|
|
36
|
+
export type BashMultiFilePatch = Partial<BashMultiFileSettings>;
|
|
37
|
+
|
|
38
|
+
/** The key ADR-0049 M6 puts under `bash`. */
|
|
39
|
+
const BASH_MULTI_FILE_SUBTREE = "multiFile";
|
|
40
|
+
|
|
41
|
+
/** The per-key names, in report order. */
|
|
42
|
+
const BASH_MULTI_FILE_KEYS = {
|
|
43
|
+
maxFiles: "maxFiles",
|
|
44
|
+
maxRows: "maxRows",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/** The declared defaults: an unconfigured session gets both. */
|
|
48
|
+
export const BASH_MULTI_FILE_DEFAULTS: BashMultiFileSettings = { maxFiles: 10, maxRows: 2000 };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* WHY the ranges are closed at the top: ADR-0049 M6 fixes them, and the upper bound is the safety
|
|
52
|
+
* bound (the row cap fits the built-in bash result cap). Lowering is allowed — an operator may want
|
|
53
|
+
* a tighter page — raising is not a supported way to exceed it.
|
|
54
|
+
*/
|
|
55
|
+
export const BASH_MULTI_FILE_LIMITS = {
|
|
56
|
+
maxFiles: { min: 2, max: 10 },
|
|
57
|
+
maxRows: { min: 1, max: 2000 },
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
type BashMultiFileKey = keyof BashMultiFileSettings;
|
|
61
|
+
|
|
62
|
+
const KEY_ORDER: readonly BashMultiFileKey[] = [
|
|
63
|
+
BASH_MULTI_FILE_KEYS.maxFiles,
|
|
64
|
+
BASH_MULTI_FILE_KEYS.maxRows,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/** One cap's rung: the value the session will use and where it came from. */
|
|
68
|
+
interface ResolvedBashMultiFileCap {
|
|
69
|
+
readonly value: number;
|
|
70
|
+
readonly source: ConfigRung;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Both caps with their rungs — the shape `/pi-better-edit settings` reports. */
|
|
74
|
+
export interface ResolvedBashMultiFileSettings {
|
|
75
|
+
readonly maxFiles: ResolvedBashMultiFileCap;
|
|
76
|
+
readonly maxRows: ResolvedBashMultiFileCap;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function withinRange(value: unknown, limit: { min: number; max: number }): value is number {
|
|
80
|
+
return (
|
|
81
|
+
typeof value === "number" && Number.isInteger(value) && value >= limit.min && value <= limit.max
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Runtime guard for `bash.multiFile.maxFiles` (integer, 2–10). */
|
|
86
|
+
export function isBashMultiFileMaxFiles(value: unknown): value is number {
|
|
87
|
+
return withinRange(value, BASH_MULTI_FILE_LIMITS.maxFiles);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Runtime guard for `bash.multiFile.maxRows` (integer, 1–2000). */
|
|
91
|
+
export function isBashMultiFileMaxRows(value: unknown): value is number {
|
|
92
|
+
return withinRange(value, BASH_MULTI_FILE_LIMITS.maxRows);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const GUARDS: Readonly<Record<BashMultiFileKey, (value: unknown) => value is number>> = {
|
|
96
|
+
maxFiles: isBashMultiFileMaxFiles,
|
|
97
|
+
maxRows: isBashMultiFileMaxRows,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Runtime guard for the two config scopes. */
|
|
101
|
+
export function isBashMultiFileScope(value: unknown): value is ConfigScope {
|
|
102
|
+
return isConfigScope(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `2-10` / `1-2000`, the range text every warning and guard message uses. */
|
|
106
|
+
function rangeText(key: BashMultiFileKey): string {
|
|
107
|
+
const limit = BASH_MULTI_FILE_LIMITS[key];
|
|
108
|
+
return `${limit.min}-${limit.max}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One scope file's accepted caps. Reads only; a key that is present but unusable is dropped WITH a
|
|
113
|
+
* warning naming the file, the key, the value and the allowed range, then falls through the ladder.
|
|
114
|
+
*/
|
|
115
|
+
function readScopeSettings(
|
|
116
|
+
scope: ConfigScope,
|
|
117
|
+
cwd: string,
|
|
118
|
+
onWarn?: ConfigOnWarn,
|
|
119
|
+
): BashMultiFilePatch {
|
|
120
|
+
const config = readScopeConfig(scope, cwd, onWarn);
|
|
121
|
+
if (config === undefined) return {};
|
|
122
|
+
const bash = config.bash;
|
|
123
|
+
if (!isRecord(bash)) return {};
|
|
124
|
+
const subtree = bash[BASH_MULTI_FILE_SUBTREE];
|
|
125
|
+
if (subtree === undefined) return {};
|
|
126
|
+
const path = configFilePath(scope, cwd);
|
|
127
|
+
if (!isRecord(subtree)) {
|
|
128
|
+
onWarn?.(`config ${path}: ignoring bash.${BASH_MULTI_FILE_SUBTREE} (not an object)`);
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
const accepted: Record<string, number> = {};
|
|
132
|
+
for (const key of KEY_ORDER) {
|
|
133
|
+
const raw = subtree[key];
|
|
134
|
+
if (raw === undefined) continue;
|
|
135
|
+
if (!GUARDS[key](raw)) {
|
|
136
|
+
onWarn?.(
|
|
137
|
+
`config ${path}: ignoring bash.${BASH_MULTI_FILE_SUBTREE}.${key} ${JSON.stringify(
|
|
138
|
+
raw,
|
|
139
|
+
)} (allowed ${rangeText(key)})`,
|
|
140
|
+
);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
accepted[key] = raw;
|
|
144
|
+
}
|
|
145
|
+
return accepted;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Both scopes' accepted caps. A scope is absent (rather than an empty object) when its file is
|
|
150
|
+
* missing, carries no `bash.multiFile` object, or holds no accepted key.
|
|
151
|
+
*/
|
|
152
|
+
export function readBashMultiFileConfig(
|
|
153
|
+
cwd: string,
|
|
154
|
+
onWarn?: ConfigOnWarn,
|
|
155
|
+
): { project?: BashMultiFilePatch; global?: BashMultiFilePatch } {
|
|
156
|
+
const project = readScopeSettings("project", cwd, onWarn);
|
|
157
|
+
const global = readScopeSettings("global", cwd, onWarn);
|
|
158
|
+
return {
|
|
159
|
+
...(Object.keys(project).length > 0 ? { project } : {}),
|
|
160
|
+
...(Object.keys(global).length > 0 ? { global } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The effective caps WITH their rungs: project, then global, then the declared default — per key, so
|
|
166
|
+
* one key can come from a different rung than its sibling. Never throws.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveBashMultiFileSettingsWithSources(
|
|
169
|
+
cwd: string,
|
|
170
|
+
onWarn?: ConfigOnWarn,
|
|
171
|
+
): ResolvedBashMultiFileSettings {
|
|
172
|
+
const config = readBashMultiFileConfig(cwd, onWarn);
|
|
173
|
+
const pick = (key: BashMultiFileKey): ResolvedBashMultiFileCap => {
|
|
174
|
+
const fromProject = config.project?.[key];
|
|
175
|
+
if (fromProject !== undefined) return { value: fromProject, source: "project" };
|
|
176
|
+
const fromGlobal = config.global?.[key];
|
|
177
|
+
if (fromGlobal !== undefined) return { value: fromGlobal, source: "global" };
|
|
178
|
+
return { value: BASH_MULTI_FILE_DEFAULTS[key], source: "default" };
|
|
179
|
+
};
|
|
180
|
+
return { maxFiles: pick("maxFiles"), maxRows: pick("maxRows") };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Effective caps only — the ladder's value half. Never throws. */
|
|
184
|
+
export function resolveBashMultiFileSettings(
|
|
185
|
+
cwd: string,
|
|
186
|
+
onWarn?: ConfigOnWarn,
|
|
187
|
+
): BashMultiFileSettings {
|
|
188
|
+
const resolved = resolveBashMultiFileSettingsWithSources(cwd, onWarn);
|
|
189
|
+
return { maxFiles: resolved.maxFiles.value, maxRows: resolved.maxRows.value };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Persist the given caps for `scope`, preserving `$schema`, unknown top-level keys, every other
|
|
194
|
+
* `bash.*` key (including `view`) and every `multiFile` key not being written. Throws
|
|
195
|
+
* `E_INTERNAL_INVARIANT` — and writes nothing — for an out-of-range value or an unknown scope: a
|
|
196
|
+
* defensive guard, unreachable from the command, because a silently dropped cap would read back as
|
|
197
|
+
* the default.
|
|
198
|
+
*/
|
|
199
|
+
export function saveBashMultiFileConfig(
|
|
200
|
+
patch: BashMultiFilePatch,
|
|
201
|
+
scope: ConfigScope,
|
|
202
|
+
cwd: string,
|
|
203
|
+
): { path: string } {
|
|
204
|
+
const keys = KEY_ORDER.filter((key) => patch[key] !== undefined);
|
|
205
|
+
if (keys.length === 0) {
|
|
206
|
+
throw new DomainError("E_INTERNAL_INVARIANT", {
|
|
207
|
+
message: `Missing bash.${BASH_MULTI_FILE_SUBTREE} setting to write.`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
for (const key of keys) {
|
|
211
|
+
const value = patch[key];
|
|
212
|
+
if (!GUARDS[key](value)) {
|
|
213
|
+
throw new DomainError("E_INTERNAL_INVARIANT", {
|
|
214
|
+
message: `Unknown bash.${BASH_MULTI_FILE_SUBTREE}.${key} "${String(value)}" (allowed ${rangeText(
|
|
215
|
+
key,
|
|
216
|
+
)}).`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (!isBashMultiFileScope(scope)) {
|
|
221
|
+
throw new DomainError("E_INTERNAL_INVARIANT", {
|
|
222
|
+
message: `Unknown bash.${BASH_MULTI_FILE_SUBTREE} scope "${String(scope)}".`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const path = configFilePath(scope, cwd);
|
|
227
|
+
updateConfigFile(path, (existing) => {
|
|
228
|
+
const bash = isRecord(existing.bash) ? existing.bash : {};
|
|
229
|
+
const subtree = isRecord(bash[BASH_MULTI_FILE_SUBTREE]) ? bash[BASH_MULTI_FILE_SUBTREE] : {};
|
|
230
|
+
return {
|
|
231
|
+
...existing,
|
|
232
|
+
bash: {
|
|
233
|
+
...bash,
|
|
234
|
+
[BASH_MULTI_FILE_SUBTREE]: { ...subtree, ...patch },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
return { path };
|
|
239
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash view setting — the `bash.view` key of the shared config file, resolved
|
|
3
|
+
* through the shared ladder: env override, then project file, then global file,
|
|
4
|
+
* then the default.
|
|
5
|
+
*
|
|
6
|
+
* WHY the interception is configurable at all: the view replacement swaps bash
|
|
7
|
+
* stdout for anchored lines, which is a display decision about the model's own
|
|
8
|
+
* session — an operator who would rather see raw output turns one key off, and
|
|
9
|
+
* every other session keeps the default without restating it.
|
|
10
|
+
*
|
|
11
|
+
* WHY a warning is part of the contract here: a dropped setting changes what the
|
|
12
|
+
* session shows, so an existing-but-unusable file is reported through `onWarn`
|
|
13
|
+
* while the value falls through the ladder. A missing file warns about nothing,
|
|
14
|
+
* and a value outside the accepted shape is ignored exactly as the sibling
|
|
15
|
+
* ladders ignore one. Nothing here throws on the read side.
|
|
16
|
+
*/
|
|
17
|
+
import { DomainError } from "./domain-errors.js";
|
|
18
|
+
import {
|
|
19
|
+
configFilePath,
|
|
20
|
+
isConfigScope,
|
|
21
|
+
isRecord,
|
|
22
|
+
readScopeConfig,
|
|
23
|
+
updateConfigFile,
|
|
24
|
+
type ConfigOnWarn,
|
|
25
|
+
type ConfigRung,
|
|
26
|
+
type ConfigScope,
|
|
27
|
+
} from "./json-config.js";
|
|
28
|
+
|
|
29
|
+
export type BashViewMode = "on" | "off";
|
|
30
|
+
|
|
31
|
+
/** Environment override; wins over both files. Values outside the enum are ignored, not fatal. */
|
|
32
|
+
export const BASH_VIEW_ENV_VAR = "PI_BETTER_EDIT_BASH_VIEW";
|
|
33
|
+
|
|
34
|
+
/** The mode an unconfigured session gets: interception stays on. */
|
|
35
|
+
export const BASH_VIEW_DEFAULT: BashViewMode = "on";
|
|
36
|
+
|
|
37
|
+
const BASH_VIEW_MODES: readonly BashViewMode[] = ["on", "off"];
|
|
38
|
+
|
|
39
|
+
/** The single key this owner reads and writes under `bash`. */
|
|
40
|
+
const BASH_VIEW_KEY = "view";
|
|
41
|
+
|
|
42
|
+
/** Runtime guard for values arriving from files, env vars and command arguments. */
|
|
43
|
+
export function isBashViewMode(value: unknown): value is BashViewMode {
|
|
44
|
+
return typeof value === "string" && BASH_VIEW_MODES.some((mode) => mode === value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Runtime guard for the two config scopes. */
|
|
48
|
+
export function isBashViewScope(value: unknown): value is ConfigScope {
|
|
49
|
+
return isConfigScope(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One scope file's mode, or `undefined` when the file carries no accepted value. */
|
|
53
|
+
function readScopeMode(
|
|
54
|
+
scope: ConfigScope,
|
|
55
|
+
cwd: string,
|
|
56
|
+
onWarn?: ConfigOnWarn,
|
|
57
|
+
): BashViewMode | undefined {
|
|
58
|
+
const config = readScopeConfig(scope, cwd, onWarn);
|
|
59
|
+
if (config === undefined) return undefined;
|
|
60
|
+
const bash = config.bash;
|
|
61
|
+
if (!isRecord(bash)) return undefined;
|
|
62
|
+
const mode = bash[BASH_VIEW_KEY];
|
|
63
|
+
return isBashViewMode(mode) ? mode : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Both scopes' configured modes. Pure read: never creates the directory or the file, never
|
|
68
|
+
* throws. A scope is absent (rather than `undefined`-valued) when its file is missing or holds a
|
|
69
|
+
* value outside the enum.
|
|
70
|
+
*/
|
|
71
|
+
export function readBashViewConfig(
|
|
72
|
+
cwd: string,
|
|
73
|
+
onWarn?: ConfigOnWarn,
|
|
74
|
+
): { project?: BashViewMode; global?: BashViewMode } {
|
|
75
|
+
const project = readScopeMode("project", cwd, onWarn);
|
|
76
|
+
const global = readScopeMode("global", cwd, onWarn);
|
|
77
|
+
return {
|
|
78
|
+
...(project !== undefined ? { project } : {}),
|
|
79
|
+
...(global !== undefined ? { global } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A trimmed environment value, or `undefined` when the variable is unset. */
|
|
84
|
+
function envText(name: string): string | undefined {
|
|
85
|
+
const raw = process.env[name];
|
|
86
|
+
return raw === undefined ? undefined : raw.trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readEnvMode(): BashViewMode | undefined {
|
|
90
|
+
const mode = envText(BASH_VIEW_ENV_VAR)?.toLowerCase();
|
|
91
|
+
return mode !== undefined && isBashViewMode(mode) ? mode : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One resolved setting: the value the session will use, and the rung of the ladder
|
|
96
|
+
* that supplied it.
|
|
97
|
+
*/
|
|
98
|
+
export interface ResolvedBashViewMode {
|
|
99
|
+
readonly value: BashViewMode;
|
|
100
|
+
readonly source: ConfigRung;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The effective mode WITH its rung: env override, then project, then global, then `"on"`.
|
|
105
|
+
* Never throws. This is the ladder itself, and `resolveBashViewMode` is its value half, so the
|
|
106
|
+
* two cannot disagree about which rung won — a caller that has to NAME the winner (the
|
|
107
|
+
* `/pi-better-edit` status text does) reads it here instead of re-walking the two files.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveBashViewModeWithSource(
|
|
110
|
+
cwd: string,
|
|
111
|
+
onWarn?: ConfigOnWarn,
|
|
112
|
+
): ResolvedBashViewMode {
|
|
113
|
+
const fromEnv = readEnvMode();
|
|
114
|
+
if (fromEnv !== undefined) return { value: fromEnv, source: "env" };
|
|
115
|
+
const config = readBashViewConfig(cwd, onWarn);
|
|
116
|
+
if (config.project !== undefined) return { value: config.project, source: "project" };
|
|
117
|
+
if (config.global !== undefined) return { value: config.global, source: "global" };
|
|
118
|
+
return { value: BASH_VIEW_DEFAULT, source: "default" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Effective mode: env override, then project, then global, then `"on"`. Never throws. */
|
|
122
|
+
export function resolveBashViewMode(cwd: string, onWarn?: ConfigOnWarn): BashViewMode {
|
|
123
|
+
return resolveBashViewModeWithSource(cwd, onWarn).value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Persist `mode` for `scope`, preserving unknown top-level keys and other `bash.*` keys already
|
|
128
|
+
* in the file. Creates the directory as needed and replaces the file atomically. Throws
|
|
129
|
+
* `E_INTERNAL_INVARIANT` — and writes nothing — for a value outside its accepted shape or a
|
|
130
|
+
* scope outside the enum: a defensive guard, unreachable from the command and the overlay,
|
|
131
|
+
* because a silently dropped value would read back as the default.
|
|
132
|
+
*/
|
|
133
|
+
export function saveBashViewConfig(
|
|
134
|
+
mode: BashViewMode,
|
|
135
|
+
scope: ConfigScope,
|
|
136
|
+
cwd: string,
|
|
137
|
+
): { path: string } {
|
|
138
|
+
if (!isBashViewMode(mode)) {
|
|
139
|
+
throw new DomainError("E_INTERNAL_INVARIANT", {
|
|
140
|
+
message: `Unknown bash view mode "${String(mode)}".`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (!isBashViewScope(scope)) {
|
|
144
|
+
throw new DomainError("E_INTERNAL_INVARIANT", {
|
|
145
|
+
message: `Unknown bash view scope "${String(scope)}".`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const path = configFilePath(scope, cwd);
|
|
150
|
+
// WHY: only `bash.view` is ours to set — an existing `$schema` is a deliberate editor-time
|
|
151
|
+
// WHY: choice and unknown top-level or `bash.*` keys belong to the user, so all of them survive.
|
|
152
|
+
updateConfigFile(path, (existing) => ({
|
|
153
|
+
...existing,
|
|
154
|
+
bash: { ...(isRecord(existing.bash) ? existing.bash : {}), [BASH_VIEW_KEY]: mode },
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
return { path };
|
|
158
|
+
}
|
package/src/cut-repair.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { restoreEndings, type LineEnding } from "./edit-diff.js";
|
|
2
2
|
import { readBytes, writeAtomic } from "./fs-write.js";
|
|
3
|
-
import { pathKind, removeFile, renameFile } from "./fs-lifecycle.js";
|
|
4
3
|
import { withSortedMutationQueues } from "./mutation-queue.js";
|
|
5
4
|
import { errCode } from "./utils.js";
|
|
6
5
|
import { loadHashStore, type HashStore } from "./hash-store.js";
|
|
@@ -74,87 +73,20 @@ function candidateFor(row: MemberRow, which: "pre" | "post"): Buffer | undefined
|
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
async function stateOf(row: MemberRow): Promise<MemberState> {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
return await readBytes(path);
|
|
84
|
-
} catch (error) {
|
|
85
|
-
if (errCode(error) === "ENOENT") return undefined;
|
|
86
|
-
throw error;
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
|
-
if (lifecycle === "add") {
|
|
90
|
-
const bytes = await readOrUndefined(row.path);
|
|
91
|
-
if (bytes === undefined) return "pre";
|
|
92
|
-
return post !== undefined && bytes.equals(post) ? "post" : "other";
|
|
93
|
-
}
|
|
94
|
-
if (lifecycle === "delete") {
|
|
95
|
-
const bytes = await readOrUndefined(row.path);
|
|
96
|
-
if (bytes === undefined) return "post";
|
|
97
|
-
return pre !== undefined && bytes.equals(pre) ? "pre" : "other";
|
|
98
|
-
}
|
|
99
|
-
if (lifecycle === "move") {
|
|
100
|
-
const to = await readOrUndefined(row.path);
|
|
101
|
-
const from = await readOrUndefined(row.fromPath);
|
|
102
|
-
if (to !== undefined && post !== undefined && to.equals(post)) return "post";
|
|
103
|
-
if (from !== undefined && pre !== undefined && from.equals(pre)) return "pre";
|
|
104
|
-
// WHY: crash after the rename but before the content rewrite — the destination holds the raw
|
|
105
|
-
// WHY: pre bytes and the source is gone. That is the PRE state of an as-yet-unapplied move.
|
|
106
|
-
if (to !== undefined && pre !== undefined && to.equals(pre) && from === undefined) return "pre";
|
|
107
|
-
return "other";
|
|
76
|
+
let bytes: Buffer;
|
|
77
|
+
try {
|
|
78
|
+
bytes = await readBytes(row.path);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (errCode(error) === "ENOENT") return "other";
|
|
81
|
+
throw error;
|
|
108
82
|
}
|
|
109
|
-
const
|
|
110
|
-
if (bytes === undefined) return "other";
|
|
83
|
+
const post = candidateFor(row, "post");
|
|
111
84
|
if (post !== undefined && bytes.equals(post)) return "post";
|
|
85
|
+
const pre = candidateFor(row, "pre");
|
|
112
86
|
if (pre !== undefined && bytes.equals(pre)) return "pre";
|
|
113
87
|
return "other";
|
|
114
88
|
}
|
|
115
89
|
|
|
116
|
-
/** Write one lifecycle row to its named state; returns false when the row cannot express it. */
|
|
117
|
-
async function writeState(row: MemberRow, which: "pre" | "post"): Promise<boolean> {
|
|
118
|
-
const lifecycle = row.lifecycle ?? "edit";
|
|
119
|
-
const bytes = candidateFor(row, which);
|
|
120
|
-
if (lifecycle === "add") {
|
|
121
|
-
if (which === "post") {
|
|
122
|
-
if (bytes === undefined) return false;
|
|
123
|
-
await writeAtomic(row.path, bytes);
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
if ((await pathKind(row.path)) !== "absent") await removeFile(row.path);
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
if (lifecycle === "delete") {
|
|
130
|
-
if (which === "post") {
|
|
131
|
-
if ((await pathKind(row.path)) !== "absent") await removeFile(row.path);
|
|
132
|
-
return true;
|
|
133
|
-
}
|
|
134
|
-
if (bytes === undefined) return false;
|
|
135
|
-
await writeAtomic(row.path, bytes);
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
if (lifecycle === "move") {
|
|
139
|
-
const source = row.fromPath;
|
|
140
|
-
if (source === null || source === undefined) return false;
|
|
141
|
-
if (which === "post") {
|
|
142
|
-
if ((await pathKind(row.path)) === "absent") {
|
|
143
|
-
if ((await pathKind(source)) === "absent") return false;
|
|
144
|
-
await renameFile(source, row.path);
|
|
145
|
-
}
|
|
146
|
-
if (bytes !== undefined) await writeAtomic(row.path, bytes);
|
|
147
|
-
return true;
|
|
148
|
-
}
|
|
149
|
-
if ((await pathKind(row.path)) !== "absent") await renameFile(row.path, source);
|
|
150
|
-
if (bytes !== undefined) await writeAtomic(source, bytes);
|
|
151
|
-
return true;
|
|
152
|
-
}
|
|
153
|
-
if (bytes === undefined) return false;
|
|
154
|
-
await writeAtomic(row.path, bytes);
|
|
155
|
-
return true;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
90
|
/** Returns true when the resolution completed (bytes written or nothing to write) and the intent retired. */
|
|
159
91
|
async function applyResolution(
|
|
160
92
|
store: HashStore,
|
|
@@ -174,7 +106,9 @@ async function applyResolution(
|
|
|
174
106
|
if (now !== expected) return false;
|
|
175
107
|
}
|
|
176
108
|
for (const row of toWrite) {
|
|
177
|
-
|
|
109
|
+
const bytes = candidateFor(row, which);
|
|
110
|
+
if (bytes === undefined) return false;
|
|
111
|
+
await writeAtomic(row.path, bytes);
|
|
178
112
|
}
|
|
179
113
|
deleteCutIntent(store, txnId);
|
|
180
114
|
return true;
|
package/src/domain-errors.ts
CHANGED
|
@@ -54,27 +54,19 @@ export type DomainErrorCode =
|
|
|
54
54
|
| "E_UNDO_REVERT_FAILED"
|
|
55
55
|
| "E_UNKNOWN"
|
|
56
56
|
| "E_LARGE_FILE"
|
|
57
|
-
| "E_INTERNAL_INVARIANT"
|
|
58
|
-
// WHY: apply_patch's two genuinely new codes (E_STALE_ANCHOR already exists above).
|
|
59
|
-
| "E_PATCH_SYNTAX"
|
|
60
|
-
| "E_UNIQUE_MATCH";
|
|
57
|
+
| "E_INTERNAL_INVARIANT";
|
|
61
58
|
|
|
62
59
|
/**
|
|
63
60
|
* The registered tool whose call produced an `E_BAD_PAYLOAD`. WHY the registered name: the model
|
|
64
61
|
* reads the tool list, so `undo_last_edit` names the call it must repair and a short form would
|
|
65
62
|
* name nothing. A tool belongs here only after a live path is measured, never by assumption.
|
|
66
63
|
*/
|
|
67
|
-
export type PayloadSubject = "read" | "edit" | "
|
|
64
|
+
export type PayloadSubject = "read" | "edit" | "undo_last_edit";
|
|
68
65
|
|
|
69
66
|
// WHY: this is the runtime companion of the `PayloadSubject` union, like `isDomainErrorCode`.
|
|
70
67
|
// WHY: Only a member may name a tool.
|
|
71
68
|
// WHY: A non-member renders the neutral wording instead.
|
|
72
|
-
const PAYLOAD_SUBJECTS: readonly PayloadSubject[] = [
|
|
73
|
-
"read",
|
|
74
|
-
"edit",
|
|
75
|
-
"apply_patch",
|
|
76
|
-
"undo_last_edit",
|
|
77
|
-
];
|
|
69
|
+
const PAYLOAD_SUBJECTS: readonly PayloadSubject[] = ["read", "edit", "undo_last_edit"];
|
|
78
70
|
|
|
79
71
|
export function isPayloadSubject(value: unknown): value is PayloadSubject {
|
|
80
72
|
return typeof value === "string" && (PAYLOAD_SUBJECTS as readonly string[]).includes(value);
|
|
@@ -260,20 +252,6 @@ export interface ErrorPayloadMap {
|
|
|
260
252
|
*/
|
|
261
253
|
subject?: PayloadSubject;
|
|
262
254
|
};
|
|
263
|
-
E_PATCH_SYNTAX: {
|
|
264
|
-
message: string;
|
|
265
|
-
path?: string;
|
|
266
|
-
patchLine?: number;
|
|
267
|
-
servedRows?: ServedRow[];
|
|
268
|
-
servedBlock?: string;
|
|
269
|
-
};
|
|
270
|
-
E_UNIQUE_MATCH: {
|
|
271
|
-
message: string;
|
|
272
|
-
path?: string;
|
|
273
|
-
patchLine?: number;
|
|
274
|
-
servedRows?: ServedRow[];
|
|
275
|
-
servedBlock?: string;
|
|
276
|
-
};
|
|
277
255
|
}
|
|
278
256
|
|
|
279
257
|
export interface CodeSpec<P> {
|
|
@@ -600,16 +578,6 @@ export const ERROR_REGISTRY: { [K in DomainErrorCode]: CodeSpec<ErrorPayloadMap[
|
|
|
600
578
|
// WHY: no options — read needs `mode: "verbatim"` and edit needs a split, but the payload
|
|
601
579
|
// WHY: names neither tool. Add `subject` before serving advice (ADR-0047).
|
|
602
580
|
},
|
|
603
|
-
E_PATCH_SYNTAX: {
|
|
604
|
-
audience: "MODEL",
|
|
605
|
-
// WHY: the parser already rendered every problem with its patch line; the envelope carries
|
|
606
|
-
// WHY: that aggregate so one resubmission can fix every syntax problem.
|
|
607
|
-
format: ({ message }) => message,
|
|
608
|
-
},
|
|
609
|
-
E_UNIQUE_MATCH: {
|
|
610
|
-
audience: "MODEL",
|
|
611
|
-
format: ({ message }) => message,
|
|
612
|
-
},
|
|
613
581
|
};
|
|
614
582
|
|
|
615
583
|
export interface WarningPayloadMap {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** SAFETY: @deprecated Import from "./payload-contract.js" instead — compatibility shim retained until next MAJOR per ADR-0007. Single source: src/payload-contract.ts. */
|
|
2
|
+
/** SAFETY: @deprecated Use `import { normReq } from "./payload-contract.js"` */
|
|
3
|
+
export {
|
|
4
|
+
/** SAFETY: @deprecated Use `import { normReq } from "./payload-contract.js"` */
|
|
5
|
+
normReq,
|
|
6
|
+
/** SAFETY: @deprecated Use `import { prepareEditArguments } from "./payload-contract.js"` */
|
|
7
|
+
prepareEditArguments,
|
|
8
|
+
} from "./payload-contract.js";
|