@mammothb/pi-hashline 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/LICENSE +18 -0
- package/index.ts +36 -0
- package/package.json +27 -0
- package/src/apply.ts +255 -0
- package/src/edit.ts +313 -0
- package/src/format.ts +132 -0
- package/src/grep.ts +451 -0
- package/src/input.ts +232 -0
- package/src/messages.ts +127 -0
- package/src/normalize.ts +44 -0
- package/src/parser.ts +415 -0
- package/src/prompt.md +110 -0
- package/src/prompt.ts +59 -0
- package/src/read.ts +239 -0
- package/src/recovery.ts +141 -0
- package/src/snapshots.ts +166 -0
- package/src/tokenizer.ts +394 -0
- package/src/types.ts +109 -0
- package/src/write.ts +120 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-driven parser that converts a stream of {@link Token}s into a flat
|
|
3
|
+
* list of {@link Edit}s.
|
|
4
|
+
*
|
|
5
|
+
* Sits between the {@link Tokenizer} and the applier. Block ops (`replace
|
|
6
|
+
* block N:`, `delete block N`) are parsed but not resolved — resolution
|
|
7
|
+
* requires file text + language detection and happens at apply time.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { HL_PAYLOAD_REPLACE } from "./format";
|
|
11
|
+
import type { BlockTarget, Token } from "./tokenizer";
|
|
12
|
+
import { Tokenizer } from "./tokenizer";
|
|
13
|
+
import type { Anchor, Cursor, Edit, ParsedRange } from "./types";
|
|
14
|
+
|
|
15
|
+
// ─── Warning / error message constants ───────────────────────────────
|
|
16
|
+
|
|
17
|
+
const BARE_BODY_AUTO_PIPED_WARNING =
|
|
18
|
+
"Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
|
|
19
|
+
|
|
20
|
+
const EMPTY_REPLACE =
|
|
21
|
+
"`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
|
|
22
|
+
|
|
23
|
+
const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
|
|
24
|
+
|
|
25
|
+
const EMPTY_BLOCK =
|
|
26
|
+
"`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range.";
|
|
27
|
+
|
|
28
|
+
const DELETE_TAKES_NO_BODY =
|
|
29
|
+
"`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
|
|
30
|
+
|
|
31
|
+
const DELETE_BLOCK_TAKES_NO_BODY =
|
|
32
|
+
"`delete block N` does not take body rows. Remove the body, or use `replace block N:` to replace the block.";
|
|
33
|
+
|
|
34
|
+
const MINUS_ROW_REJECTED =
|
|
35
|
+
"`-` rows are not valid; hashline ranges already name the lines being changed. To insert a literal line starting with `-`, write `+-…`.";
|
|
36
|
+
|
|
37
|
+
// ─── Internal payload row ────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
interface PayloadRow {
|
|
40
|
+
text: string;
|
|
41
|
+
lineNum: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Clone helpers ───────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function cloneAnchor(a: Anchor): Anchor {
|
|
47
|
+
return { line: a.line };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cloneCursor(c: Cursor): Cursor {
|
|
51
|
+
switch (c.kind) {
|
|
52
|
+
case "before_anchor":
|
|
53
|
+
return { kind: "before_anchor", anchor: cloneAnchor(c.anchor) };
|
|
54
|
+
case "after_anchor":
|
|
55
|
+
return { kind: "after_anchor", anchor: cloneAnchor(c.anchor) };
|
|
56
|
+
default:
|
|
57
|
+
return c;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Range expansion ─────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function expandRange(range: ParsedRange): Anchor[] {
|
|
64
|
+
const anchors: Anchor[] = [];
|
|
65
|
+
for (let line = range.start.line; line <= range.end.line; line++) {
|
|
66
|
+
anchors.push({ line });
|
|
67
|
+
}
|
|
68
|
+
return anchors;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Parse state ─────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
interface PendingOp {
|
|
74
|
+
target: BlockTarget;
|
|
75
|
+
lineNum: number;
|
|
76
|
+
payloads: PayloadRow[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ParseState {
|
|
80
|
+
edits: Edit[];
|
|
81
|
+
warnings: string[];
|
|
82
|
+
editIndex: number;
|
|
83
|
+
pending: PendingOp | undefined;
|
|
84
|
+
terminated: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createState(): ParseState {
|
|
88
|
+
return {
|
|
89
|
+
edits: [],
|
|
90
|
+
warnings: [],
|
|
91
|
+
editIndex: 0,
|
|
92
|
+
pending: undefined,
|
|
93
|
+
terminated: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Edit emission ───────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function pushInsert(
|
|
100
|
+
state: ParseState,
|
|
101
|
+
cursor: Cursor,
|
|
102
|
+
text: string,
|
|
103
|
+
lineNum: number,
|
|
104
|
+
mode?: "replacement",
|
|
105
|
+
): void {
|
|
106
|
+
state.edits.push({
|
|
107
|
+
kind: "insert",
|
|
108
|
+
cursor: cloneCursor(cursor),
|
|
109
|
+
text,
|
|
110
|
+
lineNum,
|
|
111
|
+
index: state.editIndex++,
|
|
112
|
+
...(mode === undefined ? {} : { mode }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pushDelete(state: ParseState, anchor: Anchor, lineNum: number): void {
|
|
117
|
+
state.edits.push({
|
|
118
|
+
kind: "delete",
|
|
119
|
+
anchor: cloneAnchor(anchor),
|
|
120
|
+
lineNum,
|
|
121
|
+
index: state.editIndex++,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pushBlock(
|
|
126
|
+
state: ParseState,
|
|
127
|
+
anchor: Anchor,
|
|
128
|
+
payloads: readonly PayloadRow[],
|
|
129
|
+
lineNum: number,
|
|
130
|
+
): void {
|
|
131
|
+
state.edits.push({
|
|
132
|
+
kind: "block",
|
|
133
|
+
anchor: cloneAnchor(anchor),
|
|
134
|
+
payloads: payloads.map((p) => p.text),
|
|
135
|
+
lineNum,
|
|
136
|
+
index: state.editIndex++,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function emitPayloads(
|
|
141
|
+
state: ParseState,
|
|
142
|
+
cursor: Cursor,
|
|
143
|
+
payloads: readonly PayloadRow[],
|
|
144
|
+
lineNum: number,
|
|
145
|
+
mode?: "replacement",
|
|
146
|
+
): void {
|
|
147
|
+
for (const payload of payloads) {
|
|
148
|
+
pushInsert(state, cursor, payload.text, lineNum, mode);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Flush pending op ────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function flushPending(state: ParseState): void {
|
|
155
|
+
const pending = state.pending;
|
|
156
|
+
if (!pending) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
state.pending = undefined;
|
|
160
|
+
|
|
161
|
+
const { target, lineNum, payloads } = pending;
|
|
162
|
+
|
|
163
|
+
switch (target.kind) {
|
|
164
|
+
case "delete": {
|
|
165
|
+
// Delete takes no body
|
|
166
|
+
for (const anchor of expandRange(target.range)) {
|
|
167
|
+
pushDelete(state, anchor, lineNum);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case "delete_block": {
|
|
173
|
+
// Block delete with no payloads
|
|
174
|
+
pushBlock(state, target.anchor, [], lineNum);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case "block": {
|
|
179
|
+
// Replace block needs body
|
|
180
|
+
if (payloads.length === 0) {
|
|
181
|
+
throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`);
|
|
182
|
+
}
|
|
183
|
+
pushBlock(state, target.anchor, payloads, lineNum);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case "replace": {
|
|
188
|
+
// Replace needs body
|
|
189
|
+
if (payloads.length === 0) {
|
|
190
|
+
throw new Error(`line ${lineNum}: ${EMPTY_REPLACE}`);
|
|
191
|
+
}
|
|
192
|
+
const cursor: Cursor = {
|
|
193
|
+
kind: "before_anchor",
|
|
194
|
+
anchor: cloneAnchor(target.range.start),
|
|
195
|
+
};
|
|
196
|
+
emitPayloads(state, cursor, payloads, lineNum, "replacement");
|
|
197
|
+
for (const anchor of expandRange(target.range)) {
|
|
198
|
+
pushDelete(state, anchor, lineNum);
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case "insert_before": {
|
|
204
|
+
if (payloads.length === 0) {
|
|
205
|
+
throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
|
206
|
+
}
|
|
207
|
+
emitPayloads(
|
|
208
|
+
state,
|
|
209
|
+
{ kind: "before_anchor", anchor: cloneAnchor(target.anchor) },
|
|
210
|
+
payloads,
|
|
211
|
+
lineNum,
|
|
212
|
+
);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case "insert_after": {
|
|
217
|
+
if (payloads.length === 0) {
|
|
218
|
+
throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
|
219
|
+
}
|
|
220
|
+
emitPayloads(
|
|
221
|
+
state,
|
|
222
|
+
{ kind: "after_anchor", anchor: cloneAnchor(target.anchor) },
|
|
223
|
+
payloads,
|
|
224
|
+
lineNum,
|
|
225
|
+
);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case "bof": {
|
|
230
|
+
if (payloads.length === 0) {
|
|
231
|
+
throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
|
232
|
+
}
|
|
233
|
+
emitPayloads(state, { kind: "bof" }, payloads, lineNum);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case "eof": {
|
|
238
|
+
if (payloads.length === 0) {
|
|
239
|
+
throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
|
240
|
+
}
|
|
241
|
+
emitPayloads(state, { kind: "eof" }, payloads, lineNum);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Validate range order ────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
function validateRangeOrder(range: ParsedRange, lineNum: number): void {
|
|
250
|
+
if (range.end.line < range.start.line) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`line ${lineNum}: range ${range.start.line}..${range.end.line} ends before it starts.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Overlapping delete detection ────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
function validateNoOverlappingDeletes(edits: Edit[]): void {
|
|
260
|
+
const sourceLinesByAnchor = new Map<number, number[]>();
|
|
261
|
+
for (const edit of edits) {
|
|
262
|
+
if (edit.kind !== "delete") {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
|
|
266
|
+
if (sourceLines === undefined) {
|
|
267
|
+
sourceLines = [];
|
|
268
|
+
sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
|
|
269
|
+
}
|
|
270
|
+
if (!sourceLines.includes(edit.lineNum)) {
|
|
271
|
+
sourceLines.push(edit.lineNum);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
|
|
275
|
+
if (sourceLines.length < 2) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b);
|
|
279
|
+
throw new Error(
|
|
280
|
+
`line ${secondBlock}: anchor line ${anchorLine} is already targeted by another hunk on line ${firstBlock}. Issue ONE hunk per range.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Token consumer ──────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
function feedToken(state: ParseState, token: Token): void {
|
|
288
|
+
if (state.terminated) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
switch (token.kind) {
|
|
293
|
+
case "envelope-begin":
|
|
294
|
+
case "envelope-separator":
|
|
295
|
+
case "blank":
|
|
296
|
+
// Silently consumed
|
|
297
|
+
return;
|
|
298
|
+
|
|
299
|
+
case "envelope-end":
|
|
300
|
+
case "abort":
|
|
301
|
+
state.terminated = true;
|
|
302
|
+
return;
|
|
303
|
+
|
|
304
|
+
case "header":
|
|
305
|
+
// A new section header flushes the previous section's pending op
|
|
306
|
+
flushPending(state);
|
|
307
|
+
return;
|
|
308
|
+
|
|
309
|
+
case "op-block": {
|
|
310
|
+
// Validate range order for literal replace/delete
|
|
311
|
+
if (token.target.kind === "replace" || token.target.kind === "delete") {
|
|
312
|
+
validateRangeOrder(token.target.range, token.lineNum);
|
|
313
|
+
}
|
|
314
|
+
flushPending(state);
|
|
315
|
+
state.pending = {
|
|
316
|
+
target: token.target,
|
|
317
|
+
lineNum: token.lineNum,
|
|
318
|
+
payloads: [],
|
|
319
|
+
};
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
case "payload-literal": {
|
|
324
|
+
if (!state.pending) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`line ${token.lineNum}: payload line has no preceding hunk header. Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${token.text}`)}.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (
|
|
330
|
+
state.pending.target.kind === "delete" ||
|
|
331
|
+
state.pending.target.kind === "delete_block"
|
|
332
|
+
) {
|
|
333
|
+
const msg =
|
|
334
|
+
state.pending.target.kind === "delete"
|
|
335
|
+
? DELETE_TAKES_NO_BODY
|
|
336
|
+
: DELETE_BLOCK_TAKES_NO_BODY;
|
|
337
|
+
throw new Error(`line ${token.lineNum}: ${msg}`);
|
|
338
|
+
}
|
|
339
|
+
state.pending.payloads.push({
|
|
340
|
+
text: token.text,
|
|
341
|
+
lineNum: token.lineNum,
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case "raw": {
|
|
347
|
+
// Raw line without a pending op is an error
|
|
348
|
+
if (!state.pending) {
|
|
349
|
+
const trimmed = token.text.trim();
|
|
350
|
+
if (trimmed.length === 0) {
|
|
351
|
+
return; // blank-like
|
|
352
|
+
}
|
|
353
|
+
throw new Error(
|
|
354
|
+
`line ${token.lineNum}: payload line has no preceding hunk header. Use \`replace N..M:\`, \`delete N..M\`, or \`insert before|after|head|tail:\` above the body. Got ${JSON.stringify(token.text)}.`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Delete ops can't have body
|
|
359
|
+
if (
|
|
360
|
+
state.pending.target.kind === "delete" ||
|
|
361
|
+
state.pending.target.kind === "delete_block"
|
|
362
|
+
) {
|
|
363
|
+
const msg =
|
|
364
|
+
state.pending.target.kind === "delete"
|
|
365
|
+
? DELETE_TAKES_NO_BODY
|
|
366
|
+
: DELETE_BLOCK_TAKES_NO_BODY;
|
|
367
|
+
throw new Error(`line ${token.lineNum}: ${msg}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Reject `-` rows (unified-diff contamination)
|
|
371
|
+
if (token.text.trimStart().startsWith("-")) {
|
|
372
|
+
throw new Error(`line ${token.lineNum}: ${MINUS_ROW_REJECTED}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Auto-pipe bare body rows
|
|
376
|
+
if (!state.warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) {
|
|
377
|
+
state.warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
|
|
378
|
+
}
|
|
379
|
+
state.pending.payloads.push({
|
|
380
|
+
text: token.text,
|
|
381
|
+
lineNum: token.lineNum,
|
|
382
|
+
});
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── Public API ──────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Parse hashline diff text into a flat list of edits and warnings.
|
|
392
|
+
*
|
|
393
|
+
* This is the core parsing entry point. It tokenizes the input, feeds
|
|
394
|
+
* tokens through the state machine, flushes any pending op, and validates
|
|
395
|
+
* the result.
|
|
396
|
+
*/
|
|
397
|
+
export function parsePatch(diff: string): {
|
|
398
|
+
edits: Edit[];
|
|
399
|
+
warnings: string[];
|
|
400
|
+
} {
|
|
401
|
+
const tokenizer = new Tokenizer();
|
|
402
|
+
const state = createState();
|
|
403
|
+
|
|
404
|
+
for (const token of tokenizer.tokenizeAll(diff)) {
|
|
405
|
+
feedToken(state, token);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Flush any remaining pending op
|
|
409
|
+
flushPending(state);
|
|
410
|
+
|
|
411
|
+
// Validate no overlapping deletes
|
|
412
|
+
validateNoOverlappingDeletes(state.edits);
|
|
413
|
+
|
|
414
|
+
return { edits: state.edits, warnings: state.warnings };
|
|
415
|
+
}
|
package/src/prompt.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Hashline Edit Grammar
|
|
2
|
+
|
|
3
|
+
You are editing files using **hashline anchoring**. Every file you read has a `¶PATH#TAG` header. Every edit you make must include that same header so the tool can validate you're working against the version you read.
|
|
4
|
+
|
|
5
|
+
## Section Headers
|
|
6
|
+
|
|
7
|
+
Every file section starts with `¶PATH#TAG`. Copy the entire header from the `read` or `grep` output. **The tag is REQUIRED** — there is no hashless form. To create a new file, use the `write` tool.
|
|
8
|
+
|
|
9
|
+
## Operations
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
replace N..M: Replace original lines N–M with the body rows below.
|
|
13
|
+
Single line: `replace N..N:`.
|
|
14
|
+
Body length is irrelevant — replacing 1 line with 10 is still `replace N..N:`.
|
|
15
|
+
|
|
16
|
+
delete N..M Delete original lines N–M. No body, no colon.
|
|
17
|
+
Single line: `delete N`.
|
|
18
|
+
|
|
19
|
+
insert before N: Insert body rows immediately before line N.
|
|
20
|
+
insert after N: Insert body rows immediately after line N.
|
|
21
|
+
insert head: Insert body rows at the very start of the file.
|
|
22
|
+
insert tail: Insert body rows at the very end of the file.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Body Rows
|
|
26
|
+
|
|
27
|
+
Body rows appear only under a `:` header. Every body row is:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
+TEXT Add a new literal line TEXT, verbatim. Leading whitespace is kept.
|
|
31
|
+
Use `+` alone to add a blank line.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
There is NO other body row kind. **Never write `-old` rows or bare context lines.** To keep a line, leave it out of every range. To insert a literal line starting with `-`, prefix it: `+-text`.
|
|
35
|
+
|
|
36
|
+
## Critical Rules
|
|
37
|
+
|
|
38
|
+
1. **Re-ground after every edit.** Each applied edit mints a fresh `#TAG` and renumbers the file. The tag and line numbers you just used are dead. Take the next edit's `¶PATH#TAG` and line numbers from the edit response or a fresh `read`, never from pre-edit memory.
|
|
39
|
+
|
|
40
|
+
2. **Ranges are tight.** Cover only lines whose content actually changes. Never widen a range to swallow an unchanged signature, brace, or statement. A stale single-line replace corrupts one line; a stale block replace shreds everything.
|
|
41
|
+
|
|
42
|
+
3. **The body is the final content.** Only `+TEXT` rows under a `:` header. The range does the deleting — never include both old and new lines.
|
|
43
|
+
|
|
44
|
+
4. **One hunk per range.** The body is the final desired content, never an old/new pair. To change lines 2 and 5 while keeping 3–4, issue two separate hunks.
|
|
45
|
+
|
|
46
|
+
5. **Never format code with this tool.** Use the project's formatter (e.g. `bash: npm run format`) for reordering imports, re-indenting, or mechanical restyling.
|
|
47
|
+
|
|
48
|
+
## Examples
|
|
49
|
+
|
|
50
|
+
Read returns:
|
|
51
|
+
```
|
|
52
|
+
¶src/greet.ts#A1B2
|
|
53
|
+
1:function greet(name: string) {
|
|
54
|
+
2: console.log("Hello, " + name);
|
|
55
|
+
3:}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Replace line 2:
|
|
59
|
+
```
|
|
60
|
+
¶src/greet.ts#A1B2
|
|
61
|
+
replace 2..2:
|
|
62
|
+
+ console.log(`Hello, ${name}`);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Insert after line 1:
|
|
66
|
+
```
|
|
67
|
+
¶src/greet.ts#A1B2
|
|
68
|
+
insert after 1:
|
|
69
|
+
+ if (!name) name = "world";
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Delete line 2:
|
|
73
|
+
```
|
|
74
|
+
¶src/greet.ts#A1B2
|
|
75
|
+
delete 2
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Add header and footer:
|
|
79
|
+
```
|
|
80
|
+
¶src/greet.ts#A1B2
|
|
81
|
+
insert head:
|
|
82
|
+
+// Auto-generated
|
|
83
|
+
insert tail:
|
|
84
|
+
+export default greet;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Anti-Patterns (WRONG)
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
# WRONG — empty replace to delete. Use delete 4 instead.
|
|
91
|
+
replace 4..4:
|
|
92
|
+
|
|
93
|
+
# WRONG — range describes post-edit size. Use replace 1..1: (body length is irrelevant).
|
|
94
|
+
replace 1..2:
|
|
95
|
+
+function greet(name: string) {
|
|
96
|
+
|
|
97
|
+
# WRONG — `-` rows do not exist. The range deletes; the body is only new content.
|
|
98
|
+
replace 3..3:
|
|
99
|
+
old line
|
|
100
|
+
- removed line
|
|
101
|
+
+ new line
|
|
102
|
+
|
|
103
|
+
# RIGHT
|
|
104
|
+
replace 3..3:
|
|
105
|
+
+ new line
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## On Stale-Tag Rejection
|
|
109
|
+
|
|
110
|
+
If the edit tool says the tag is stale ("file changed between read and edit"), re-`read` the file to get the current tag and line numbers. Never stack more edits onto stale numbers.
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt injection for hashline — loads the LLM-facing grammar reference
|
|
3
|
+
* and injects it into the system prompt before each agent turn.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
// ─── Prompt loading ──────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
let _cachedPrompt: string | undefined;
|
|
13
|
+
let _cachedPath: string | undefined;
|
|
14
|
+
|
|
15
|
+
function getPromptPath(): string {
|
|
16
|
+
if (_cachedPath === undefined) {
|
|
17
|
+
_cachedPath = join(dirname(fileURLToPath(import.meta.url)), "prompt.md");
|
|
18
|
+
}
|
|
19
|
+
return _cachedPath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Load the hashline grammar prompt (cached after first read). */
|
|
23
|
+
export function loadPrompt(): string {
|
|
24
|
+
if (_cachedPrompt === undefined) {
|
|
25
|
+
_cachedPrompt = readFileSync(getPromptPath(), "utf-8");
|
|
26
|
+
}
|
|
27
|
+
return _cachedPrompt;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Injection helpers ───────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const PROMPT_MARKER = "<!-- HASHLINE_GRAMMAR -->";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Inject the hashline grammar prompt into the system message.
|
|
36
|
+
* Appends after the marker if present, otherwise appends to the end.
|
|
37
|
+
*/
|
|
38
|
+
export function injectPrompt(messages: unknown[]): void {
|
|
39
|
+
const prompt = loadPrompt();
|
|
40
|
+
if (messages.length === 0) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Find the system message (first message with role "system").
|
|
45
|
+
const systemMsg = messages[0] as { role?: string; content?: string };
|
|
46
|
+
if (
|
|
47
|
+
systemMsg !== null &&
|
|
48
|
+
typeof systemMsg === "object" &&
|
|
49
|
+
systemMsg.role === "system" &&
|
|
50
|
+
typeof systemMsg.content === "string"
|
|
51
|
+
) {
|
|
52
|
+
// If there's a marker, replace it. Otherwise append.
|
|
53
|
+
if (systemMsg.content.includes(PROMPT_MARKER)) {
|
|
54
|
+
systemMsg.content = systemMsg.content.replace(PROMPT_MARKER, prompt);
|
|
55
|
+
} else {
|
|
56
|
+
systemMsg.content += `\n\n${prompt}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|