diff --git a/docs/tools/edit.md b/docs/tools/edit.md index 29a11e4c3..99e77f5ec 100644 --- a/docs/tools/edit.md +++ b/docs/tools/edit.md @@ -31,16 +31,16 @@ Patch language inside `input`: - Section header: `¶PATH#HASH` for anchored edits, `¶PATH` for BOF/EOF-only inserts -- Insert after: `LINE↓` -- Insert before: `LINE↑` -- Replace range: `A-B:` -- Single-line replace sugar: `A:` means `A-A:` +- Insert after: `LINE↓[payload]` +- Insert before: `LINE↑[payload]` +- Replace range: `A-B:[payload]` +- Single-line replace sugar: `A:[payload]` means `A-A:[payload]` - Delete range: `A-B!` - Single-line delete sugar: `A!` means `A-A!` -- `:` / `↑` / `↓` payload may be inline after the sigil and/or on subsequent payload lines. Bare `A↑` / `A↓` insert one blank line; bare `A:` / `A-B:` (no payload) replaces the line/range with a single blank line. Use `A!` / `A-B!` to delete entirely. +- **Payload semantics:** the first payload line is whatever follows the sigil on the op line itself; additional payload lines follow on subsequent lines and append after the inline first line. An empty inline (just the sigil followed by a newline) means the first payload line is empty. So bare `A↑` / `A↓` insert one blank line, and bare `A:` / `A-B:` replace the line/range with one blank line. But `A↓\nfoo` inserts blank-then-`foo`, not just `foo` — for a single-line insert, put `foo` inline as `A↓foo`. - `!` deletes and forbids payload. -- Inline payload: content after `↓`/`↑`/`:` on the same line is the first payload line; subsequent lines append to it. Read lines like `84:content` are already valid single-line replacements. -- Special anchors: `BOF`, `EOF` +- Read lines like `84:content` are already valid single-line replacements. +- Special anchors: `BOF`, `EOF` (both support inline payload, e.g. `BOF↓export const done = true;`). - Anchor token: bare line number, for example `41` - File binding: 4-hex hash in the section header, for example `¶src/a.ts#1a2b` @@ -79,7 +79,7 @@ Warnings: - ignores blank lines and optional `*** Begin Patch` - stops at `*** End Patch` - stops at `*** Abort` and emits `ABORT_WARNING` - - turns `↓` / `↑` payload runs into one `insert` edit per payload line + - turns `↓` / `↑` payload runs (inline plus subsequent lines) into one `insert` edit per payload line - turns `A-B:` with payload into inserts before `A`, then deletes for `A-B` - turns `A-B!` into one `delete` edit per line in the range; payload is forbidden 6. `executeHashlineSingle()` computes the current file hash before applying anchored edits. If it differs from the section `#HASH`, recovery tries the read/search snapshot cache before any write. @@ -103,51 +103,44 @@ Warnings: - `patch` — structured JSON diff-hunk mode (`packages/coding-agent/src/edit/modes/patch.ts`). - `apply_patch` — freeform Codex-style `*** Begin Patch` envelope, internally expanded into patch-mode entries (`packages/coding-agent/src/edit/modes/apply-patch.ts`). -Hashline op examples: +Hashline op examples (single-line payloads are inline; multi-line payloads continue on subsequent lines): ```text ¶src/a.ts#1a2b -4↓ -const added = true; +4↓const added = true; ``` ```text ¶src/a.ts#1a2b -4↑ -const addedBefore = true; +4↑const addedBefore = true; ``` ```text ¶src/a.ts#1a2b -4-6: -const replacement = true; +4-6:const replacement = true; ``` ```text ¶src/a.ts#1a2b -4-5: -const clean = (name || DEF).trim(); +4-5:const clean = (name || DEF).trim(); return clean.length === 0 ? DEF : clean.toUpperCase(); ``` ```text ¶src/a.ts#1a2b -4: -const clean = (name || DEF).trim(); +4:const clean = (name || DEF).trim(); ``` BOF/EOF examples: ```text ¶src/a.ts -BOF↓ -const HEADER = true; +BOF↓const HEADER = true; ``` ```text ¶src/a.ts -EOF↓ -export const done = true; +EOF↓export const done = true; ``` Delete / blank examples: @@ -160,7 +153,6 @@ Delete / blank examples: ```text ¶src/a.ts#1a2b 4: - ``` ```text @@ -172,9 +164,7 @@ Multi-file example: ```text ¶src/a.ts#1a2b -4: -const enabled = true; - +4:const enabled = true; ¶src/b.ts#3c4d 20! ``` @@ -238,6 +228,7 @@ const enabled = true; - Multi-op patches are parsed against the original file snapshot. Do not renumber later anchors after earlier ops; `applyHashlineEdits()` buckets and applies them bottom-up. - Failed hand-edits often come from sequentially shifting later anchors inside the same patch. Treat every op as using the line numbers from the original section header. - `A-B:` is not a primitive replace in the parser. With payload, it expands to inserts before `A` plus deletes for `A-B`. `A-B!` is the direct delete form. Bare `A:` / `A-B:` (no payload) replaces with a single blank line; bare `↑` / `↓` insert a blank line. +- Inline payload tip: trailing whitespace on the op line is trimmed. To preserve trailing spaces in the inserted/replacement content, put that content on the next line instead of inline. - `computeFileHash()` normalizes CR characters and trailing whitespace before hashing. The section survives line-ending and trailing-space-only changes, but not substantive file edits. - `splitHashlineInputs()` normalizes absolute `¶PATH#HASH` headers back to a cwd-relative path when the file is inside the current working tree. Headers with any run of leading `¶` chars (e.g. `¶foo.ts`, `¶¶foo.ts`, `¶¶¶foo.ts`) are accepted; the canonical form is `¶PATH#HASH` for anchored edits. - Optional `*** Begin Patch` / `*** End Patch` markers are accepted in hashline mode, but the file sections are still `¶PATH#HASH`-based, not Codex `*** Update File:` hunks. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7c8249277..431ae4ac5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Breaking Changes - The `vim` edit mode option is no longer available; configurations using `edit.mode: vim` will be automatically mapped to `hashline` mode +- Hashline payload semantics are now strictly inline-first: the first payload line is whatever follows the sigil on the op line itself, and subsequent lines append after it. A newline immediately after `↑`/`↓`/`:` is no longer a free separator — it produces a blank first payload line. Use `LINE↓content` for a one-line insert, `LINE↓firstline\nsecondline` for two lines; bare `LINE↓` / `LINE↑` / `LINE:` (no inline payload) still insert/replace with one blank line as before. ### Added diff --git a/packages/coding-agent/src/hashline/executor.ts b/packages/coding-agent/src/hashline/executor.ts index 8f7b85afa..4c6849762 100644 --- a/packages/coding-agent/src/hashline/executor.ts +++ b/packages/coding-agent/src/hashline/executor.ts @@ -105,7 +105,7 @@ export class HashlineExecutor { this.#flushPending(false); this.#pending = { op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum }, - payload: token.inlineBody === undefined ? [] : [token.inlineBody], + payload: [token.inlineBody ?? ""], pendingBlanks: 0, }; return; @@ -114,7 +114,7 @@ export class HashlineExecutor { validateRangeOrder(token.range, token.lineNum); this.#pending = { op: { kind: "replace", range: token.range, lineNum: token.lineNum }, - payload: token.inlineBody === undefined ? [] : [token.inlineBody], + payload: [token.inlineBody ?? ""], pendingBlanks: 0, }; return; @@ -186,7 +186,7 @@ export class HashlineExecutor { if (includeTrailingBlanks) this.#flushPendingBlanks(); const { op, payload } = pending; - const linesToInsert = payload.length === 0 ? [""] : payload; + const linesToInsert = payload; if (op.kind === "insert") { for (const text of linesToInsert) { diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index ef9efc57a..b0887c968 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -1,61 +1,40 @@ Your patch language is a compact, line-anchored edit format. -A patch contains one or more file sections. The first non-blank line of an anchored edit section MUST be `¶PATH#HASH`, copied from the latest `read`/`search` output for that file. `HASH` is a 4-hex file hash. -Operations reference lines by bare line number, e.g. `5`, `123`. +A patch contains one or more file sections. Each anchored section starts with `¶PATH#HASH`, copied verbatim from the latest `read`/`search` output. `HASH` is a 4-hex file hash; `¶PATH` without `#HASH` is allowed only for new-file / `BOF` / `EOF` boundary inserts. -`¶PATH` without `#HASH` is allowed ONLY for new-file / `BOF` / `EOF` boundary inserts. Anchored line ops without a header hash are rejected. - -Purely textual format. The tool has NO awareness of language, indentation, brackets, fences, or table widths. You MUST emit valid syntax in replacements/insertions. +Operations reference lines by bare line number (`5`, `123`). Payload text is verbatim — NEVER escape unicode. The tool has NO awareness of language, indentation, brackets, fences, or table widths. Emit valid syntax in replacements/insertions. -¶PATH#HASH header: subsequent anchored ops apply to PATH at file hash HASH -¶PATH unbound header: only BOF/EOF boundary inserts -Each op line is ONE of: -LINE↑ insert ABOVE the anchored line (or BOF); payload may follow inline after `↑` and/or on subsequent lines -LINE↓ insert BELOW the anchored line (or EOF); payload may follow inline after `↓` and/or on subsequent lines -A-B: replace the inclusive range A..B with payload -A: shorthand for A-A: -A-B! delete the inclusive range A..B; payload forbidden -A! shorthand for A-A! +¶PATH#HASH header: subsequent anchored ops apply to PATH at file hash HASH +¶PATH unbound header: only BOF/EOF boundary inserts +LINE↑PAYLOAD insert ABOVE the anchored line (or BOF) +LINE↓PAYLOAD insert BELOW the anchored line (or EOF) +A-B:PAYLOAD replace the inclusive range A..B with PAYLOAD +A:PAYLOAD shorthand for A-A:PAYLOAD +A-B! delete the inclusive range A..B (payload forbidden) +A! shorthand for A-A! + +- The first payload line is whatever follows the sigil on the op line. Additional payload lines follow on the next lines and append after the first. +- An empty inline IS an empty first line. So bare `A↓` / `A↑` insert one blank line; bare `A:` / `A-B:` replace with one blank line. `A↓\nfoo` inserts blank-then-`foo`, NOT just `foo`. +- Payload ends at the next op, next `¶PATH`, envelope marker, or EOF. Blank lines immediately before a next op or `¶PATH` are dropped; blank lines between content lines are preserved. + + - The sigil tells where content lands: `↑` above, `↓` below, `:` replaces, `!` deletes. -- Payload text is verbatim — NEVER escape unicode. -- Op line shape: `ANCHOR[INLINE_PAYLOAD]`. -- Payload ends at next op, next `¶PATH`, envelope marker, or EOF. Blank lines immediately before a next op or `¶PATH` are treated as separators (dropped); blank lines between two content payload lines, or trailing at EOF, are preserved. -- `:` / `↑` / `↓` payload may be inline after the sigil and/or on subsequent lines. -- A bare `A↑` / `A↓` (no payload) inserts one blank line. A bare `A:` / `A-B:` (no payload, no inline body) replaces the line/range with a single blank line. -- `!` delete ops NEVER include payload. -- Blank a line with bare `A:`, or remove it entirely with `A!`. Insert a blank line with bare `A↑` / `A↓`. -- **Payload is only what's NEW relative to your range:** - - `:` replaces inside; NEVER include lines outside. - - `↑`/`↓` adds at anchor; NEVER repeat line A or neighbors. - - Payload matching nearby content duplicates — drop it or widen. -- **Pick a self-contained unit first.** Touching multiline construct? Widen to it. -- Then smallest op: add with `↑`/`↓`; replace with `:`; delete with `!`. +- **Payload is only what's NEW relative to your range.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat the anchor line or neighbors — that duplicates them. +- **Pick a self-contained unit.** Touching a multiline construct (return, array, brace block, JSX element)? Widen the range to span it. Don't bisect. +- Smallest op wins: add with `↑`/`↓`; replace with `:`; delete with `!`. +- Anchors reference the file as last read. ONE patch, ONE coordinate space — later ops still use original line numbers. - -When braces bound your edit, you SHOULD prefer these shapes: -- **Whole block**: range spans `{` through matching `}`. -- **Signature only**: one-line `:` on opener; body untouched. -- **Insert inside**: anchor on `{` or last interior line; NEVER repeat braces. -- **End on `}`**: only when that `}` is part of the change. Otherwise extend or stop earlier. - - - **NEVER replay past your range.** Stop before B+1; extend B if it must go. -- **NEVER duplicate chunks inside one payload.** Caught re-emitting? Rewrite. -- **Anchor only inside visible content.** B+1 truncated? Re-`read` first. -- **Use the section hash from latest output.** Missing/stale? Re-`read`. -- **You SHOULD prefer the narrowest self-contained edit.** Narrow range beats wide range. -- **Anchors reference the file as last read.** NEVER shift for prior ops. -- **One patch, one coordinate space.** Later ops still use original line numbers. -- **Read lines already look like replace ops.** `84:content` already means “make line 84 equal to content”. Do not echo a second context line before it. -- **One `↓`/`↑` op per block, NOT per line.** N lines = ONE op, N payloads. +- **NEVER duplicate chunks inside one payload.** +- **Read lines look like replace ops.** `84:content` already means "make line 84 equal to content" — don't echo a context line before it. - **NEVER fabricate file hashes.** Missing? Re-`read`. -- **`A!` deletes silently.** Deleting a line that closes/opens a block (`}`, `} else {`, `})`, `*/`) breaks structure with no parse error. If you misfired an earlier edit and reach for `A!` to clean up, re-read first — you'll get a warning if the deleted line was a structural boundary, but the warning only fires after the fact. +- **`A!` deletes silently.** Deleting a line that closes/opens a block (`}`, `} else {`, `})`, `*/`) breaks structure with no parse error. @@ -70,47 +49,37 @@ When braces bound your edit, you SHOULD prefer these shapes: -# Replace one line (payload must re-emit original indentation) +# Replace one line (inline payload preserves original indentation) ¶mod.ts#1a2b -{{hrefr 1}}: -const TITLE = "Mrs"; +{{hrefr 1}}:const TITLE = "Mrs"; -# Replace a full multiline statement (widen to self-contained boundary) +# Replace a multiline statement — first line inline, rest below ¶mod.ts#1a2b -{{hrefr 3}}-{{hrefr 6}}: - return [ +{{hrefr 3}}-{{hrefr 6}}: return [ "Mrs", name?.trim() || "guest", ].join(" "); -# Delete one line +# Insert ABOVE / BELOW a line +¶mod.ts#1a2b +{{hrefr 4}}↓ "Dr", +{{hrefr 5}}↑ "Dr", + +# Delete one line / blank a line / insert a blank line ¶mod.ts#1a2b {{hrefr 5}}! +{{hrefr 6}}: +{{hrefr 7}}↑ -# Blank a line -¶mod.ts#1a2b -{{hrefr 5}}: -# Insert ABOVE/BELOW a line -¶mod.ts#1a2b -{{hrefr 4}}↓ - "Dr", -{{hrefr 5}}↑ - "Dr", - -# Append to existing file; hash optional because EOF is a boundary insert -¶mod.ts -EOF↓ -export const done = true; - -# Create a file +# Create a file / append to one (hash optional for boundary-only inserts) ¶new.ts -BOF↓ -export const done = true; +BOF↓export const done = true; +¶mod.ts +EOF↓export const done = true; # Multi-file patch ¶src/a.ts#1a2b -12: -const enabled = true; +12:const enabled = true; ¶src/b.ts#3c4d 20! @@ -118,36 +87,23 @@ const enabled = true; # WRONG — replaces 2 lines just to add one. ¶mod.ts#1a2b -{{hrefr 1}}-{{hrefr 2}}: -const TITLE = "Mr"; +{{hrefr 1}}-{{hrefr 2}}:const TITLE = "Mr"; const DEBUG = false; export function greet(name) { -# RIGHT — same effect, one-line insert -¶mod.ts#1a2b -{{hrefr 1}}↓ -const DEBUG = false; -# WRONG — replace from the middle of a larger statement (error-prone) +# RIGHT — one-line insert ¶mod.ts#1a2b -{{hrefr 4}}-{{hrefr 5}}: - "Dr", +{{hrefr 1}}↓const DEBUG = false; + +# WRONG — bisects a multiline statement +¶mod.ts#1a2b +{{hrefr 4}}-{{hrefr 5}}: "Dr", name?.trim() || "guest", + # RIGHT — widen to the full statement ¶mod.ts#1a2b -{{hrefr 3}}-{{hrefr 6}}: - return [ +{{hrefr 3}}-{{hrefr 6}}: return [ "Dr", name?.trim() || "guest", ].join(" "); - - -- Copy the `¶PATH#HASH` header verbatim for anchored edits. -- Copy only line numbers into ops; NEVER include `:TEXT` body unless you are intentionally using `LINE:TEXT` as replace syntax. -- NEVER write unified diff syntax. Ops put `↑`/`↓`/`:`/`!` AFTER the anchor. -- `:` replaces; bare `A:` blanks the line. `↑` / `↓` insert; bare `A↑` / `A↓` insert one blank line. Use `A!` to delete entirely. -- `!` deletes and forbids payload. -- Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `:`. - - Before `A-B:` or `A-B!`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct. -- NEVER use this tool to reformat code. Run the project's formatter instead. -