pi-hashline-edit-pro 6.1.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +22 -16
  2. package/index.ts +48 -25
  3. package/package.json +3 -2
  4. package/src/auto-read-all.ts +39 -21
  5. package/src/batch.ts +2 -2
  6. package/src/config-ui.ts +135 -55
  7. package/src/config.ts +48 -12
  8. package/src/copy-move.ts +4 -4
  9. package/src/grep.ts +3 -3
  10. package/src/insert.ts +3 -3
  11. package/src/outline/index.ts +45 -0
  12. package/src/outline/parsers.ts +42 -0
  13. package/src/outline/render.ts +166 -0
  14. package/src/outline/vendor/LICENSE +201 -0
  15. package/src/outline/vendor/README.md +17 -0
  16. package/src/outline/vendor/parsers/blade.ts +138 -0
  17. package/src/outline/vendor/parsers/index.ts +75 -0
  18. package/src/outline/vendor/parsers/java.ts +501 -0
  19. package/src/outline/vendor/parsers/manager.ts +1721 -0
  20. package/src/outline/vendor/parsers/php.ts +332 -0
  21. package/src/outline/vendor/parsers/sass.ts +416 -0
  22. package/src/outline/vendor/parsers/scss.ts +566 -0
  23. package/src/outline/vendor/types.ts +82 -0
  24. package/src/outline/vendor/wasm/LICENSES.md +37 -0
  25. package/src/outline/vendor/wasm/tree-sitter-blade.wasm +0 -0
  26. package/src/outline/vendor/wasm/tree-sitter-go.wasm +0 -0
  27. package/src/outline/vendor/wasm/tree-sitter-java.wasm +0 -0
  28. package/src/outline/vendor/wasm/tree-sitter-javascript.wasm +0 -0
  29. package/src/outline/vendor/wasm/tree-sitter-php.wasm +0 -0
  30. package/src/outline/vendor/wasm/tree-sitter-python.wasm +0 -0
  31. package/src/outline/vendor/wasm/tree-sitter-rust.wasm +0 -0
  32. package/src/outline/vendor/wasm/tree-sitter-sass.wasm +0 -0
  33. package/src/outline/vendor/wasm/tree-sitter-scss.wasm +0 -0
  34. package/src/outline/vendor/wasm/tree-sitter-solidity.wasm +0 -0
  35. package/src/outline/vendor/wasm/tree-sitter-tsx.wasm +0 -0
  36. package/src/outline/vendor/wasm/tree-sitter-typescript.wasm +0 -0
  37. package/src/payload-contract.ts +7 -2
  38. package/src/read.ts +108 -36
  39. package/src/replace-match.ts +8 -6
  40. package/src/replace-undo.ts +3 -3
  41. package/src/replace.ts +7 -6
  42. package/src/utils.ts +19 -3
  43. package/tool-prompts/read-guidelines.md +3 -0
  44. package/{prompts → tool-prompts}/read.md +2 -0
  45. package/{prompts → tool-prompts}/replace-match.md +1 -1
  46. package/{prompts → tool-prompts}/replace.md +1 -1
  47. package/prompts/read-guidelines.md +0 -1
  48. package/src/auto-read-all-state.ts +0 -27
  49. /package/{prompts → tool-prompts}/copy-guidelines.md +0 -0
  50. /package/{prompts → tool-prompts}/copy-snippet.md +0 -0
  51. /package/{prompts → tool-prompts}/copy.md +0 -0
  52. /package/{prompts → tool-prompts}/grep-guidelines.md +0 -0
  53. /package/{prompts → tool-prompts}/grep-snippet.md +0 -0
  54. /package/{prompts → tool-prompts}/grep.md +0 -0
  55. /package/{prompts → tool-prompts}/insert-guidelines.md +0 -0
  56. /package/{prompts → tool-prompts}/insert-snippet.md +0 -0
  57. /package/{prompts → tool-prompts}/insert.md +0 -0
  58. /package/{prompts → tool-prompts}/move-guidelines.md +0 -0
  59. /package/{prompts → tool-prompts}/move-snippet.md +0 -0
  60. /package/{prompts → tool-prompts}/move.md +0 -0
  61. /package/{prompts → tool-prompts}/read-snippet.md +0 -0
  62. /package/{prompts → tool-prompts}/replace-guidelines.md +0 -0
  63. /package/{prompts → tool-prompts}/replace-match-guidelines.md +0 -0
  64. /package/{prompts → tool-prompts}/replace-match-snippet.md +0 -0
  65. /package/{prompts → tool-prompts}/replace-snippet.md +0 -0
  66. /package/{prompts → tool-prompts}/transfer-guidelines.md +0 -0
  67. /package/{prompts → tool-prompts}/undo-last-change-guidelines.md +0 -0
  68. /package/{prompts → tool-prompts}/undo-last-change-snippet.md +0 -0
  69. /package/{prompts → tool-prompts}/undo-last-change.md +0 -0
package/README.md CHANGED
@@ -159,13 +159,13 @@ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert
159
159
 
160
160
  ### read
161
161
 
162
- `read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address.
162
+ `read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address. `offset` accepts a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return after it. Anchor offsets are stable across edits and return the same editable rows.
163
163
 
164
164
  | Parameter | Description |
165
165
  | --- | --- |
166
166
  | `path` | Path to the file (relative or absolute). |
167
- | `offset` | Line number to start reading from (1-indexed). |
168
- | `limit` | Maximum number of lines to return. |
167
+ | `offset` | Line number (1-indexed) or a served anchor to start reading from. |
168
+ | `limit` | Maximum number of lines to return from `offset`. |
169
169
 
170
170
  Output is capped at 2000 lines and 50KB. Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
171
171
 
@@ -190,6 +190,8 @@ Edge cases:
190
190
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
191
191
  | `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
192
192
 
193
+ `replace_from` and `replace_to` are accepted as aliases for `remove_from` and `remove_to`.
194
+
193
195
  Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
194
196
 
195
197
  ```json
@@ -219,6 +221,8 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
219
221
 
220
222
  `replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
221
223
 
224
+ `remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
225
+
222
226
  `old_string` is matched against the range's text (LF line breaks, no final terminator) and every non-overlapping occurrence is replaced, left to right. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are matched against the file as it currently stands on disk.
223
227
 
224
228
  In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `old_string` aborts the whole batch unwritten. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
@@ -332,21 +336,21 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
332
336
 
333
337
  ## Auto-read all
334
338
 
335
- Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), and `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
339
+ Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing; `outline` attaches an anchor-stamped structural outline per file; and `full` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk) and attaches full contents. `Git repos only` is on by default and keeps the whole injection inside a git repository; turn it off to attach files in any directory. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
336
340
 
337
- Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
341
+ Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive; `resources/views/vendor` is kept so Laravel view overrides stay attached), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
338
342
 
339
- Each attached file is shown as `=== path ===` followed by its `anchor│content` rows. Edit directly from the attachment with replace and insert, so no `read` is needed. Files attach whole.
343
+ Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget.
340
344
 
341
- A coverage line after the header reports the complete count. A `[files complete: [...] omitted: [...]]` line right after it lists every attached complete file and omitted file in one place. Check that line instead of scanning sections.
345
+ A coverage line after the header reports how many attached files were served in full; a section truncated by the per-file budget is not counted as complete. Each attached file begins with a `=== path ===` header, and files that were not attached are named in the footer.
342
346
 
343
- The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `anchor_grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
347
+ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"outline"`, or `"full"`; older configs with `"on"` or `true` are read as `"full"`, `"git"` as `"full"`, `"outline(git)"` as `"outline"` — both with Git repos only on — and `false` as `"off"`), and as `autoReadAllRequireGit`, the Git repos only switch (on by default). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `anchor_grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
344
348
 
345
349
  ## Configuration
346
350
 
347
351
  | Command | Description |
348
352
  | --- | --- |
349
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, disable on models, disable read on models, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
353
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, git repos only, ignore folders/files, disable on models, read on disabled models, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
350
354
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
351
355
 
352
356
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -355,6 +359,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
355
359
  {
356
360
  "autoRead": true,
357
361
  "autoReadAll": "off",
362
+ "autoReadAllRequireGit": true,
358
363
  "autoReadAllIgnore": [],
359
364
  "anchorGrepEnabled": true,
360
365
  "copyMoveEnabled": true,
@@ -363,14 +368,15 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
363
368
  "strictInput": false,
364
369
  "diffContextLines": 1,
365
370
  "disableOnModels": [],
366
- "disableReadOnModels": true
371
+ "readOnDisabledModels": "vanilla"
367
372
  }
368
373
  ```
369
374
 
370
375
  | Key | `/hashline-config` label | Default | Effect |
371
376
  | --- | --- | --- | --- |
372
377
  | `autoRead` | Auto-read | `true` | Append the auto-read block after `write` and show post-edit diffs. |
373
- | `autoReadAll` | Auto-read all | `"off"` | Attachment mode: `"off"`, `"on"`, or `"git"`. |
378
+ | `autoReadAll` | Auto-read all | `"off"` | Attachment mode: `"off"`, `"outline"` (anchor-stamped per-file outlines), or `"full"` (full contents). Legacy `"git"`/`"outline(git)"` read as `"full"`/`"outline"` with Git repos only on; `"on"`/`true` read as `"full"`. |
379
+ | `autoReadAllRequireGit` | Git repos only | `true` | Run the injection only when the working directory is inside a git repository; when off, auto-read all also attaches files discovered by `ripgrep` or the directory walk. |
374
380
  | `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
375
381
  | `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
376
382
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
@@ -378,10 +384,10 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
378
384
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
379
385
  | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
380
386
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
381
- | `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no anchored edit tools, no auto-read, and no write-echo refusal; `read` is only removed while Disable read on models is on. |
382
- | `disableReadOnModels` | Disable read on models | `true` | Also remove `read` from the active tools for a model matched by `disableOnModels`. Turn it off to keep `read` active on those models. |
387
+ | `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no anchored edit tools, no auto-read, and no write-echo refusal; `read` stays available as pi's ordinary read unless Read on disabled models is `remove`. |
388
+ | `readOnDisabledModels` | Read on disabled models | `"vanilla"` | `read` for a model matched by `disableOnModels`: `"vanilla"` keeps pi's ordinary `read` active while the anchored edit tools stay off; `"remove"` disables `read` with the rest of the anchored tools. |
383
389
 
384
- `disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change` from the active tools, and also `read` while Disable read on models is on (the default), restoring the built-in `grep` when it was active, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. Because the extension's `read` replaces the built-in one, a matched model has no `read` at all unless another extension provides one, so turn Disable read on models off to keep the anchored `read` active on those models. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
390
+ `disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change` from the active tools, and also `read` when Read on disabled models is `remove`, restoring the built-in `grep` when it was active, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. Because the extension's `read` replaces the built-in one, a matched model has no `read` at all under `remove` unless another extension provides one; the default `vanilla` gives those models pi's ordinary `read` while the anchored edit tools stay off. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
385
391
 
386
392
  When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows the directory always uses `~/.config`, where `~` is `%USERPROFILE%`. To move the directory explicitly, see [Isolated state](#isolated-state).
387
393
 
@@ -430,7 +436,7 @@ All eight tools also declare an `outputSchema` and return `structuredContent`, s
430
436
 
431
437
  ## Error, warning, and hint codes
432
438
 
433
- Codes starting with `E_` are errors: nothing was written, with one exception. `File was written; anchor finalization failed` means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice or an anchor-reclaim notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed. Codes starting with `H_` are hints: the call succeeded and the file holds exactly what was requested, so the notice is informational, never blocks an edit (not even in strict-input mode), and is reported in `details.hints` instead of `details.warnings`. `[E_AUTO_READ_ALL]` is informational rather than a failure: the `read` was refused because the file is unchanged since the start-of-session auto-read, so the attached content is still exact.
439
+ Codes starting with `E_` are errors: nothing was written, with one exception. `File was written; anchor finalization failed` means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice or an anchor-reclaim notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed. Codes starting with `H_` are hints: the call succeeded and the file holds exactly what was requested, so the notice is informational, never blocks an edit (not even in strict-input mode), and is reported in `details.hints` instead of `details.warnings`.
434
440
 
435
441
  Most common, with the fix:
436
442
 
@@ -479,7 +485,6 @@ Full reference:
479
485
  | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
480
486
  | `[E_GREP_FAILED]` | `anchor_grep` could not start ripgrep or ripgrep exited with an error (for example a pattern valid in JavaScript but unsupported by ripgrep's regex engine); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
481
487
  | `[E_GREP_TIMEOUT]` | `anchor_grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
482
- | `[E_AUTO_READ_ALL]` | File already attached and unchanged since this session's start; the attached content is still exact. |
483
488
 
484
489
  ## Troubleshooting
485
490
 
@@ -565,6 +570,7 @@ Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
565
570
 
566
571
  - [RimuruW](https://github.com/RimuruW), original `pi-hashline-edit` and the strict-semantics policy
567
572
  - [can1357](https://github.com/can1357), original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept
573
+ - [HanzCEO](https://github.com/HanzCEO), [pi-codebase-reader](https://github.com/HanzCEO/pi-codebase-reader): vendored tree-sitter parsers and outline extraction (Apache-2.0), exposed through the auto-read-all `outline` and `outline(git)` modes
568
574
 
569
575
  ## License
570
576
 
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
2
+ import { createReadToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
3
3
  import { initHasher } from "./src/hashline";
4
4
  import { regReplaceMatch } from "./src/replace-match";
5
5
  import { regReplace } from "./src/replace";
@@ -9,23 +9,23 @@ import { regGrep } from "./src/grep";
9
9
  import { regUndo, clearUndo } from "./src/replace-undo";
10
10
  import { regRead, fmtReadPreview } from "./src/read";
11
11
  import { ANCHOR_TOOL_NAMES, modelDisabled, type ModelLike } from "./src/model-gate";
12
- import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-all";
13
- import { clearAutoReadAllComplete } from "./src/auto-read-all-state";
12
+ import { buildAutoReadAllInjection, autoReadAllBudget, isOutlineAutoReadAll } from "./src/auto-read-all";
14
13
  import type { RMetrics } from "./src/replace-response";
15
14
  import type { ReplaceDetails } from "./src/replace";
16
15
  import { extractHints, extractWarnings } from "./src/replace-render";
17
16
  import { MAX_HASH_LINES } from "./src/hashline";
18
17
  import { withStructuredText } from "./src/structured";
19
- import type { AutoReadAllMode } from "./src/config";
18
+ import type { AutoReadAllMode, ReadOnDisabledModels } from "./src/config";
20
19
  import {
21
20
  readConfig,
22
21
  readConfigWithStatus,
23
22
  toggleAutoRead,
24
23
  cycleAutoReadAllMode,
24
+ toggleAutoReadAllRequireGit,
25
25
  toggleAnchorGrep,
26
26
  toggleCopyMove,
27
27
  toggleReplaceMatch,
28
- toggleDisableReadOnModels,
28
+ cycleReadOnDisabledModels,
29
29
  toggleRequirePath,
30
30
  toggleStrictInput,
31
31
  adjustDiffContextLines,
@@ -61,8 +61,11 @@ export default function (pi: ExtensionAPI): void {
61
61
  let autoRead = true;
62
62
  let autoReadAll: AutoReadAllMode = "off";
63
63
  let autoReadAllIgnore: string[] = [];
64
+ let autoReadAllRequireGit = true;
64
65
  let disableOnModels: string[] = [];
65
- let disableReadOnModels = true;
66
+ let readOnDisabledModels: ReadOnDisabledModels = "vanilla";
67
+ let readFlavor: "anchored" | "vanilla" = "anchored";
68
+ let readFlavorCwd: string | undefined;
66
69
  let autoReadAllInjected = false;
67
70
  let grepWasActive = false;
68
71
  const baseAnchorTools = new Set<string>();
@@ -71,7 +74,7 @@ export default function (pi: ExtensionAPI): void {
71
74
  async function refreshEditTools(): Promise<void> {
72
75
  try {
73
76
  const flags = await currentEditFlags(pi.getActiveTools().includes("codemode"));
74
- regRead(pi, flags);
77
+ if (readFlavor !== "vanilla") regRead(pi, flags);
75
78
  regReplace(pi, flags);
76
79
  regReplaceMatch(pi, flags);
77
80
  regInsert(pi, flags);
@@ -84,27 +87,45 @@ export default function (pi: ExtensionAPI): void {
84
87
  }
85
88
  }
86
89
 
87
- async function syncModelGate(model: ModelLike | undefined): Promise<void> {
88
- if (disableOnModels.length > 0 && modelDisabled(model, disableOnModels)) {
89
- const active = pi.getActiveTools();
90
- const gated = disableReadOnModels ? ANCHOR_TOOL_NAMES : ANCHOR_TOOL_NAMES.filter((tool) => tool !== "read");
91
- const next = active.filter((tool) => !gated.includes(tool));
90
+ async function syncModelGate(model: ModelLike | undefined, cwd: string): Promise<void> {
91
+ const gated = disableOnModels.length > 0 && modelDisabled(model, disableOnModels);
92
+ if (gated && readOnDisabledModels === "vanilla") {
93
+ if (readFlavor !== "vanilla" || readFlavorCwd !== cwd) {
94
+ pi.registerTool(createReadToolDefinition(cwd));
95
+ readFlavor = "vanilla";
96
+ readFlavorCwd = cwd;
97
+ }
98
+ } else if (readFlavor === "vanilla") {
99
+ readFlavor = "anchored";
100
+ readFlavorCwd = undefined;
101
+ await refreshEditTools();
102
+ }
103
+ if (gated) {
104
+ const active = pi.getActiveTools().filter((tool) => tool !== "edit");
105
+ const gatedTools = readOnDisabledModels === "vanilla" ? ANCHOR_TOOL_NAMES.filter((tool) => tool !== "read") : ANCHOR_TOOL_NAMES;
106
+ const next = active.filter((tool) => !gatedTools.includes(tool));
92
107
  if (grepWasActive && !next.includes("grep")) next.push("grep");
93
108
  pi.setActiveTools(next);
94
109
  gateApplied = true;
95
110
  return;
96
111
  }
97
- if (!gateApplied) return;
112
+ if (!gateApplied) {
113
+ const current = pi.getActiveTools();
114
+ const withoutEdit = current.filter((tool) => tool !== "edit");
115
+ if (withoutEdit.length !== current.length) pi.setActiveTools(withoutEdit);
116
+ return;
117
+ }
98
118
  gateApplied = false;
99
119
  const config = await readConfig();
100
120
  const enabled = ANCHOR_TOOL_NAMES.filter((tool) => {
121
+ if (tool === "read") return true;
101
122
  if (!baseAnchorTools.has(tool)) return false;
102
123
  if (tool === "replace_match") return config.replaceMatchEnabled !== false;
103
124
  if (tool === "copy" || tool === "move") return config.copyMoveEnabled !== false;
104
125
  if (tool === "anchor_grep") return config.anchorGrepEnabled === true;
105
126
  return true;
106
127
  });
107
- let next = [...new Set([...pi.getActiveTools(), ...enabled])];
128
+ let next = [...new Set([...pi.getActiveTools().filter((tool) => tool !== "edit"), ...enabled])];
108
129
  if (enabled.includes("anchor_grep") && grepWasActive) next = next.filter((tool) => tool !== "grep");
109
130
  pi.setActiveTools(next);
110
131
  }
@@ -134,9 +155,10 @@ export default function (pi: ExtensionAPI): void {
134
155
  if (corrupted && (ctx as { hasUI?: boolean }).hasUI) ctx.ui.notify("Hashline config was corrupt and was reset to defaults", "warning");
135
156
  autoRead = config.autoRead;
136
157
  autoReadAll = config.autoReadAll ?? "off";
158
+ autoReadAllRequireGit = config.autoReadAllRequireGit ?? true;
137
159
  autoReadAllIgnore = config.autoReadAllIgnore ?? [];
138
160
  disableOnModels = config.disableOnModels ?? [];
139
- disableReadOnModels = config.disableReadOnModels !== false;
161
+ readOnDisabledModels = config.readOnDisabledModels ?? "vanilla";
140
162
  const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
141
163
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
142
164
  await refreshEditTools();
@@ -148,7 +170,7 @@ export default function (pi: ExtensionAPI): void {
148
170
  return true;
149
171
  }),
150
172
  );
151
- await syncModelGate(ctx.model);
173
+ await syncModelGate(ctx.model, ctx.cwd);
152
174
  const debugValue = process.env.PI_HASHLINE_DEBUG;
153
175
  if (debugValue === "1" || debugValue === "true") {
154
176
  ctx.ui.notify(`Hashline Edit mode active`, "info");
@@ -158,25 +180,24 @@ export default function (pi: ExtensionAPI): void {
158
180
  pi.on("session_shutdown", async (_event, ctx) => {
159
181
  try {
160
182
  const key = sessionKeyFor(ctx);
161
- clearAutoReadAllComplete(key);
162
183
  if (key !== undefined) releaseRegistrySession(key);
163
184
  } catch (error) {
164
185
  console.error("Failed to release anchor registry session:", error);
165
186
  }
166
187
  });
167
188
  pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
168
- await syncModelGate(event.model);
189
+ await syncModelGate(event.model, ctx.cwd);
169
190
  }));
170
191
 
171
192
  pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
172
- await syncModelGate(ctx.model);
193
+ await syncModelGate(ctx.model, ctx.cwd);
173
194
  if (autoReadAll === "off" || autoReadAllInjected) return;
174
195
  if (modelDisabled(ctx.model, disableOnModels)) return;
175
196
  autoReadAllInjected = true;
176
197
  try {
177
- const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, sessionKeyFor(ctx));
198
+ const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, autoReadAllRequireGit);
178
199
  if (!injection) return;
179
- if (ctx.hasUI) ctx.ui.notify(`Auto-read all: attached ${injection.files} file(s) with anchors`, "info");
200
+ if (ctx.hasUI) ctx.ui.notify(`Auto-read all: ${isOutlineAutoReadAll(autoReadAll) ? "outlined" : "attached"} ${injection.files} file(s)`, "info");
180
201
  return { message: { customType: AUTO_READ_ALL_CUSTOM_TYPE, content: injection.text, display: false } };
181
202
  } catch (error) {
182
203
  console.error("Auto-read all failed:", error);
@@ -185,7 +206,7 @@ export default function (pi: ExtensionAPI): void {
185
206
  }));
186
207
 
187
208
  pi.registerCommand("hashline-config", {
188
- description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, disable on models, diff context, grep, copy/move, replace_match, path, strict input)",
209
+ description: "Open the hashline settings window (auto-read, diff context, auto-read all, git repos only, ignore folders/files, grep, copy/move, replace_match, path, strict input, disable on models, read on disabled models)",
189
210
  handler: async (_args, ctx) => {
190
211
  if (!ctx.hasUI) {
191
212
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -194,14 +215,16 @@ export default function (pi: ExtensionAPI): void {
194
215
  await ctx.ui.custom<void>(async (tui, theme, _keybindings, done) => {
195
216
  const overlay = new HashlineConfigOverlay({
196
217
  tui,
218
+ maxHeight: () => tui.terminal ? Math.max(6, Math.floor(tui.terminal.rows * 0.9)) : undefined,
197
219
  theme,
198
220
  done,
199
221
  onToggle: async (key, delta, value) => {
200
222
  if (key === "autoRead") autoRead = await toggleAutoRead();
201
- else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
223
+ else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(delta); autoReadAllInjected = false; }
224
+ else if (key === "autoReadAllRequireGit") autoReadAllRequireGit = await toggleAutoReadAllRequireGit();
202
225
  else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
203
226
  else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
204
- else if (key === "disableReadOnModels") disableReadOnModels = await toggleDisableReadOnModels();
227
+ else if (key === "readOnDisabledModels") readOnDisabledModels = await cycleReadOnDisabledModels(delta);
205
228
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
206
229
  else if (key === "anchorGrepEnabled") {
207
230
  const enabled = await toggleAnchorGrep();
@@ -229,7 +252,7 @@ export default function (pi: ExtensionAPI): void {
229
252
  else if (key === "requirePath") await toggleRequirePath();
230
253
  else if (key === "strictInput") await toggleStrictInput();
231
254
  await refreshEditTools();
232
- await syncModelGate(ctx.model);
255
+ await syncModelGate(ctx.model, ctx.cwd);
233
256
  },
234
257
  });
235
258
  await overlay.load();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.1.1",
3
+ "version": "6.2.0",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -29,7 +29,7 @@
29
29
  "files": [
30
30
  "index.ts",
31
31
  "src",
32
- "prompts",
32
+ "tool-prompts",
33
33
  "README.md",
34
34
  "LICENSE"
35
35
  ],
@@ -41,6 +41,7 @@
41
41
  "dependencies": {
42
42
  "diff": "^9.0.0",
43
43
  "file-type": "^22.0.2",
44
+ "web-tree-sitter": "^0.27.0",
44
45
  "xxhash-wasm": "^1.1.0"
45
46
  },
46
47
  "peerDependencies": {
@@ -12,13 +12,13 @@ import {
12
12
  import { normalizeAutoReadAllIgnoreEntry, type AutoReadAllMode } from "./config";
13
13
  import { serveRows } from "./served";
14
14
  import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
15
- import { readNormFile, safeSnapId } from "./file-reader";
15
+ import { readNormFile } from "./file-reader";
16
16
  import { resolveRgPath } from "./grep";
17
17
  import { globToRegex } from "./glob";
18
18
  import { MAX_HASH_LINES } from "./hashline";
19
19
  import { fmtReadPreview } from "./read";
20
+ import { buildFileOutline } from "./outline";
20
21
  import { splitLines } from "./utils";
21
- import { recordAutoReadAllComplete } from "./auto-read-all-state";
22
22
 
23
23
  const EXEC_TIMEOUT_MS = 20_000;
24
24
  const EXEC_MAX_BYTES = 64 * 1024 * 1024;
@@ -27,7 +27,10 @@ const SCAN_LIMIT_MULTIPLIER = 4;
27
27
  const MAX_REPORTED_OMISSIONS = 50;
28
28
 
29
29
  const HEADER =
30
- "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for files in [files complete: ...].\nFiles listed as omitted or not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
30
+ "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
31
+
32
+ const OUTLINE_HEADER =
33
+ "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│symbol` rows with live anchors.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor and the row's `limit`; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
31
34
 
32
35
  const IMAGE_EXTENSIONS = new Set([
33
36
  ".avif",
@@ -78,12 +81,18 @@ export const AUTO_READ_ALL_EXCLUDED_NAMES = [
78
81
  "go.sum",
79
82
  "flake.lock",
80
83
  ".eslintcache",
84
+ "_ide_helper.php",
85
+ "_ide_helper_models.php",
86
+ ".phpstorm.meta.php",
81
87
  ];
82
88
  const EXCLUDED_NAME_SET = new Set(AUTO_READ_ALL_EXCLUDED_NAMES);
83
89
  const EXCLUDED_SEGMENT_SET = new Set(AUTO_READ_ALL_EXCLUDED_SEGMENTS);
84
90
  function isExcludedBySegment(path: string): boolean {
85
- for (const segment of path.split("/")) {
86
- if (EXCLUDED_SEGMENT_SET.has(segment.toLowerCase())) return true;
91
+ const lower = path.toLowerCase();
92
+ const vendorViews = lower.startsWith("resources/views/vendor/");
93
+ for (const segment of lower.split("/")) {
94
+ if (vendorViews && segment === "vendor") continue;
95
+ if (EXCLUDED_SEGMENT_SET.has(segment)) return true;
87
96
  }
88
97
  return false;
89
98
  }
@@ -190,6 +199,7 @@ export interface AutoReadAllSection {
190
199
  text: string;
191
200
  totalLines: number;
192
201
  absolutePath: string;
202
+ complete: boolean;
193
203
  }
194
204
  export interface AutoReadAllInjection {
195
205
  text: string;
@@ -295,12 +305,17 @@ async function forEachLimit<T>(items: T[], limit: number, work: (item: T) => Pro
295
305
  await Promise.all(workers);
296
306
  }
297
307
 
298
- export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = []): Promise<AutoReadAllDiscovery> {
308
+ export function isOutlineAutoReadAll(mode: AutoReadAllMode): boolean {
309
+ return mode === "outline";
310
+ }
311
+
312
+ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMode = "off", ignoreDirs: readonly string[] = [], requireGit = true): Promise<AutoReadAllDiscovery> {
313
+ if (mode === "off") return { files: [], source: "git", discovered: 0, skippedBinary: 0, skippedLarge: 0, skippedOther: 0, skippedByName: 0 };
299
314
  const customIgnore = normalizeAutoReadAllIgnoreList(ignoreDirs);
300
315
  let source: AutoReadAllSource = "git";
301
316
  let candidates = await listFromGit(cwd);
302
317
  if (candidates === undefined) {
303
- if (mode === "git") {
318
+ if (requireGit) {
304
319
  return { files: [], source: "git", discovered: 0, skippedBinary: 0, skippedLarge: 0, skippedOther: 0, skippedByName: 0 };
305
320
  }
306
321
  candidates = await listFromRg(cwd);
@@ -372,13 +387,18 @@ async function candidateFileBytes(cwd: string, file: string): Promise<number | u
372
387
  }
373
388
  }
374
389
 
375
- async function renderFile(file: string, cwd: string): Promise<AutoReadAllSection | undefined> {
390
+ async function renderFile(file: string, cwd: string, outline: boolean): Promise<AutoReadAllSection | undefined> {
376
391
  try {
377
392
  const { normalized, fileHashes, absolutePath } = await readNormFile(file, cwd, { maxLines: MAX_HASH_LINES });
393
+ const fileLines = splitLines(normalized);
394
+ if (outline) {
395
+ const section = await buildFileOutline({ displayPath: file, content: normalized, hashes: fileHashes });
396
+ serveRows(absolutePath, fileHashes, fileLines, section.servedHashes);
397
+ return { file, text: section.text, totalLines: fileHashes.length, absolutePath, complete: !section.truncated };
398
+ }
378
399
  const preview = await fmtReadPreview(normalized, {}, fileHashes, absolutePath, AUTO_READ_ALL_MAX_BUDGET_BYTES, MAX_HASH_LINES);
379
- serveRows(absolutePath, fileHashes, splitLines(normalized), preview.servedHashes);
380
- const totalLines = fileHashes.length;
381
- return { file, text: `=== ${file} ===\n${preview.text}`, totalLines, absolutePath };
400
+ serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
401
+ return { file, text: `=== ${file} ===\n${preview.text}`, totalLines: fileHashes.length, absolutePath, complete: preview.truncation === undefined && !preview.blockedByLongLine };
382
402
  } catch (error) {
383
403
  console.error(`Auto-read all: skipped ${file}:`, error);
384
404
  return undefined;
@@ -403,22 +423,24 @@ function buildFooter(attached: number, discovery: AutoReadAllDiscovery, omitted:
403
423
  return `[hashline auto-read-all: ${attached} file(s) attached from ${discovery.source}; ${summary}${omissionNote}]`;
404
424
  }
405
425
 
406
- export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = [], sessionKey?: string): Promise<AutoReadAllInjection | undefined> {
407
- const discovery = await discoverAutoReadAllFiles(cwd, mode, ignoreDirs);
426
+ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "off", ignoreDirs: readonly string[] = [], requireGit = true): Promise<AutoReadAllInjection | undefined> {
427
+ if (mode === "off") return undefined;
428
+ const discovery = await discoverAutoReadAllFiles(cwd, mode, ignoreDirs, requireGit);
408
429
  if (discovery.files.length === 0) return undefined;
430
+ const outline = isOutlineAutoReadAll(mode);
409
431
  const sections: AutoReadAllSection[] = [];
410
432
  const omitted: string[] = [];
411
433
  let bytes = 0;
412
434
  let completeFiles = 0;
413
435
  for (const file of discovery.files) {
414
- if (sections.length > 0) {
436
+ if (!outline && sections.length > 0) {
415
437
  const size = await candidateFileBytes(cwd, file);
416
438
  if (size !== undefined && bytes + size > budgetBytes) {
417
439
  omitted.push(file);
418
440
  continue;
419
441
  }
420
442
  }
421
- const section = await renderFile(file, cwd);
443
+ const section = await renderFile(file, cwd, outline);
422
444
  if (section === undefined) {
423
445
  omitted.push(file);
424
446
  continue;
@@ -429,18 +451,14 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
429
451
  continue;
430
452
  }
431
453
  sections.push(section);
432
- const snapshotId = await safeSnapId(section.absolutePath, "auto-read-all");
433
- if (snapshotId !== undefined) recordAutoReadAllComplete(sessionKey, section.absolutePath, snapshotId);
434
454
  bytes += sectionBytes;
435
- completeFiles += 1;
455
+ if (section.complete) completeFiles += 1;
436
456
  }
437
457
  if (sections.length === 0) return undefined;
438
458
  const sectionTexts = sections.map((section) => section.text);
439
459
  const coverage = `[coverage: ${completeFiles} complete]`;
440
- const completeNames = sections.map((section) => section.file);
441
- const machineList = `[files complete: ${JSON.stringify(completeNames)} omitted: ${JSON.stringify(omitted)}]`;
442
460
  const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
443
- const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}${reclaimNotice !== undefined ? `\n${reclaimNotice}` : ""}`;
461
+ const text = `${outline ? OUTLINE_HEADER : HEADER}\n\n${coverage}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}${reclaimNotice !== undefined ? `\n${reclaimNotice}` : ""}`;
444
462
  return { text, files: sections.length, bytes, omitted, completeFiles };
445
463
  }
446
464
 
package/src/batch.ts CHANGED
@@ -233,7 +233,7 @@ export function resetBatchStateForTests(): void {
233
233
 
234
234
  function normalizeEditArgs(name: string, args: unknown): NormalizedEditArgs | undefined {
235
235
  if (!isRec(args)) return undefined;
236
- const normalized = normReq(args);
236
+ const normalized = normReq(args, name === "replace_match" ? "replace" : name === "replace" ? "remove" : undefined);
237
237
  if (!isRec(normalized)) return undefined;
238
238
  const path = typeof normalized.path === "string" ? normalized.path : undefined;
239
239
  if (name === "copy" || name === "move") {
@@ -787,7 +787,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
787
787
  if (!planned) continue;
788
788
  if (executedOrders.has(planned.order)) continue;
789
789
  try {
790
- const normalized = normReq(planned.args);
790
+ const normalized = normReq(planned.args, planned.kind === "replace_match" ? "replace" : planned.kind === "replace" ? "remove" : undefined);
791
791
  if (planned.kind === "insert") assertInsertReq(normalized);
792
792
  else if (planned.kind === "replace") assertReq(normalized);
793
793
  else if (planned.kind === "replace_match") assertReplaceMatchReq(normalized);