pi-hashline-edit-pro 6.1.2 → 6.3.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 (73) hide show
  1. package/README.md +41 -23
  2. package/index.ts +20 -12
  3. package/package.json +3 -2
  4. package/src/anchor-registry.ts +22 -5
  5. package/src/auto-read-all.ts +39 -21
  6. package/src/batch.ts +127 -41
  7. package/src/commit.ts +1 -1
  8. package/src/config-ui.ts +135 -55
  9. package/src/config.ts +27 -9
  10. package/src/copy-move.ts +61 -72
  11. package/src/edit-common.ts +6 -4
  12. package/src/grep.ts +53 -121
  13. package/src/hash-store.ts +13 -12
  14. package/src/hashline/resolve.ts +13 -2
  15. package/src/insert.ts +40 -34
  16. package/src/line-endings.ts +10 -1
  17. package/src/outline/index.ts +46 -0
  18. package/src/outline/parsers.ts +42 -0
  19. package/src/outline/render.ts +170 -0
  20. package/src/outline/vendor/LICENSE +201 -0
  21. package/src/outline/vendor/README.md +17 -0
  22. package/src/outline/vendor/parsers/blade.ts +138 -0
  23. package/src/outline/vendor/parsers/index.ts +75 -0
  24. package/src/outline/vendor/parsers/java.ts +501 -0
  25. package/src/outline/vendor/parsers/manager.ts +1721 -0
  26. package/src/outline/vendor/parsers/php.ts +332 -0
  27. package/src/outline/vendor/parsers/sass.ts +416 -0
  28. package/src/outline/vendor/parsers/scss.ts +566 -0
  29. package/src/outline/vendor/types.ts +82 -0
  30. package/src/outline/vendor/wasm/LICENSES.md +37 -0
  31. package/src/outline/vendor/wasm/tree-sitter-blade.wasm +0 -0
  32. package/src/outline/vendor/wasm/tree-sitter-go.wasm +0 -0
  33. package/src/outline/vendor/wasm/tree-sitter-java.wasm +0 -0
  34. package/src/outline/vendor/wasm/tree-sitter-javascript.wasm +0 -0
  35. package/src/outline/vendor/wasm/tree-sitter-php.wasm +0 -0
  36. package/src/outline/vendor/wasm/tree-sitter-python.wasm +0 -0
  37. package/src/outline/vendor/wasm/tree-sitter-rust.wasm +0 -0
  38. package/src/outline/vendor/wasm/tree-sitter-sass.wasm +0 -0
  39. package/src/outline/vendor/wasm/tree-sitter-scss.wasm +0 -0
  40. package/src/outline/vendor/wasm/tree-sitter-solidity.wasm +0 -0
  41. package/src/outline/vendor/wasm/tree-sitter-tsx.wasm +0 -0
  42. package/src/outline/vendor/wasm/tree-sitter-typescript.wasm +0 -0
  43. package/src/read.ts +118 -41
  44. package/src/replace-match.ts +42 -41
  45. package/src/replace-undo.ts +4 -4
  46. package/src/replace.ts +30 -19
  47. package/tool-prompts/read-guidelines.md +3 -0
  48. package/{prompts → tool-prompts}/read.md +2 -0
  49. package/prompts/read-guidelines.md +0 -1
  50. package/src/auto-read-all-state.ts +0 -27
  51. /package/{prompts → tool-prompts}/copy-guidelines.md +0 -0
  52. /package/{prompts → tool-prompts}/copy-snippet.md +0 -0
  53. /package/{prompts → tool-prompts}/copy.md +0 -0
  54. /package/{prompts → tool-prompts}/grep-guidelines.md +0 -0
  55. /package/{prompts → tool-prompts}/grep-snippet.md +0 -0
  56. /package/{prompts → tool-prompts}/grep.md +0 -0
  57. /package/{prompts → tool-prompts}/insert-guidelines.md +0 -0
  58. /package/{prompts → tool-prompts}/insert-snippet.md +0 -0
  59. /package/{prompts → tool-prompts}/insert.md +0 -0
  60. /package/{prompts → tool-prompts}/move-guidelines.md +0 -0
  61. /package/{prompts → tool-prompts}/move-snippet.md +0 -0
  62. /package/{prompts → tool-prompts}/move.md +0 -0
  63. /package/{prompts → tool-prompts}/read-snippet.md +0 -0
  64. /package/{prompts → tool-prompts}/replace-guidelines.md +0 -0
  65. /package/{prompts → tool-prompts}/replace-match-guidelines.md +0 -0
  66. /package/{prompts → tool-prompts}/replace-match-snippet.md +0 -0
  67. /package/{prompts → tool-prompts}/replace-match.md +0 -0
  68. /package/{prompts → tool-prompts}/replace-snippet.md +0 -0
  69. /package/{prompts → tool-prompts}/replace.md +0 -0
  70. /package/{prompts → tool-prompts}/transfer-guidelines.md +0 -0
  71. /package/{prompts → tool-prompts}/undo-last-change-guidelines.md +0 -0
  72. /package/{prompts → tool-prompts}/undo-last-change-snippet.md +0 -0
  73. /package/{prompts → tool-prompts}/undo-last-change.md +0 -0
package/README.md CHANGED
@@ -159,15 +159,15 @@ 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
- 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.]`.
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=Emno to continue.]`.
171
171
 
172
172
  A line whose `anchor│content` row exceeds 50KB is replaced by a marker that keeps the line's anchor: `anchor│[Line N is 2.2MB, exceeds 50.0KB; content not shown. Use bash: sed -n 'Np' <path> | head -c 51200]`. The marker is served like a normal row, so the whole line can still be replaced through it.
173
173
 
@@ -225,7 +225,7 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
225
225
 
226
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.
227
227
 
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`.
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` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
229
229
 
230
230
  ### insert
231
231
 
@@ -289,7 +289,7 @@ Every matching line, and each requested context line, is returned as `lineNumber
289
289
 
290
290
  Directory searches respect `.gitignore` (including parent directories); `.git` is always skipped, and hidden files are searched. `node_modules`, `.tmp`, and `coverage` are skipped only when a `.gitignore` lists them. Binary, image, and oversized files are skipped silently.
291
291
 
292
- Regexes with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned. Use `literal: true` when regex behavior is unnecessary.
292
+ Patterns are evaluated by ripgrep's linear-time regex engine, so shapes that stall a backtracking engine (nested quantifiers, several `.*` terms, huge repetitions) are safe. Pattern syntax is ripgrep's Rust regex syntax; backreferences and look-around are rejected with `[E_GREP_FAILED]`. Use `literal: true` when regex behavior is unnecessary.
293
293
 
294
294
  Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whichever comes first, with a note naming the caps that cut the results (the exact one is in `details.truncation`). A matched line whose `anchor│content` row exceeds 500 bytes is shown as a fragment around the match, with `...` marking the truncated sides; a context row over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable, and `replace` always replaces the whole line.
295
295
 
@@ -314,13 +314,13 @@ Multiple `replace`, `replace_match`, `insert`, `copy`, and `move` calls on the s
314
314
  - A call outside a batch commits before its result returns.
315
315
  - A cross-file `move` whose source file also has batched edits in the same message is not grouped: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
316
316
  - A batched cross-file `move` displays only the destination diff: the arrival is covered by the batch's undo, and the source removal is committed with the same batch but is not shown. Read the source file for fresh anchors; it keeps its own undo entry, and `details.patch` still contains the source patch.
317
- - A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N (queued)`, and the batch's last call shows the combined diff, with one undo reverting the whole batch. A `copy` always duplicates the content its source anchors were served from: when the source file is also edited in the message, the copy reads that file's pre-batch state, even if the source batch commits before the copy runs. A batched cross-file `move` defers the source-file removal to the batch commit, so if the batch aborts, the source file is untouched.
318
- - If a batch aborts, nothing is written: the failing call's error ends with `Aborts batch N.` and reports that the whole batch was discarded, and an earlier member's row renders the abort message instead of the queued placeholder. Nothing commits until the last call succeeds.
317
+ - A batch validates every call against the pre-batch state and commits once, during the batch's last applied call: earlier applied calls reply `In batch N (queued)`, and the batch's last applied call shows the combined diff, with one undo reverting the whole batch. A `copy` always duplicates the content its source anchors were served from: when the source file is also edited in the message, the copy reads that file's pre-batch state, even if the source batch commits before the copy runs. A batched cross-file `move` defers the source-file removal to the batch commit, so if the batch aborts, the source file is untouched.
318
+ - Before committing, the batch validates every call against the pre-batch state. A call whose own request is invalid (`[E_BAD_SHAPE]`, `[E_BAD_REF]`, `[E_STALE_ANCHOR]`, `[E_RANGE_STALE]`, `[E_SUBSTRING_NOT_FOUND]`, or a strict-input rejection) is left out: it fails on its own call without an `Aborts batch N.` suffix, and the remaining calls still commit as one batch with one undo. A failure of the file, the commit, or a cross-file member aborts the whole batch instead: nothing is written, the failing call's error ends with `Aborts batch N.`, and an earlier member's row renders the abort message instead of the queued placeholder.
319
319
  - A batch member accepts the same request shapes and auto-fixes as a standalone call.
320
320
 
321
- Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. A `copy`'s `insert_after` line is the copy's destination range, so replacing or moving that same line in the batch is an overlap. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`.
321
+ Batched calls must target disjoint ranges; overlapping ranges abort the whole batch unwritten. A `copy`'s `insert_after` line is the copy's destination range, so replacing or moving that same line in the batch is an overlap. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails a batch-level step aborts its batch-mates with `[E_OP_ABORTED]`; a call left out of the batch does not.
322
322
 
323
- A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch and abort it instead of applying partially. A `copy` or `move` whose source anchors no longer resolve cannot be grouped, because the source file cannot be identified; it runs on its own and fails with its own error. A cross-file `move` whose source file has batched edits in the message is also left out of the batch and commits on its own. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
323
+ A call whose anchors resolve nowhere never joins a batch: it runs on its own and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, are grouped into their file's batch; when their own request then fails validation against the pre-batch state, they are left out of the batch and fail on their own instead of applying partially. A `copy` or `move` whose source anchors no longer resolve cannot be grouped, because the source file cannot be identified; it runs on its own and fails with its own error. A cross-file `move` whose source file has batched edits in the message is also left out of the batch and commits on its own. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
324
324
 
325
325
  The hashline tools are sequential in pi, so a message that contains one runs all of its tool calls one at a time in the order given; a `read` or shell `cat` issued before the edit commits can still observe the pre-commit state, so verify in the next message with the post-edit diff or a fresh `read`.
326
326
 
@@ -328,7 +328,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
328
328
 
329
329
  Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call. A model matched by `disableOnModels` skips the block, and the post-edit diff substitution below is skipped with it.
330
330
 
331
- After `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
331
+ After `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last applied call shows the combined diff, headed by a `batch N:` line; earlier applied calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
332
332
 
333
333
  An edit that changes only line endings has no content diff; the result still reports `applied`, and one `undo_last_change` reverts it.
334
334
 
@@ -336,21 +336,21 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
336
336
 
337
337
  ## Auto-read all
338
338
 
339
- 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.
340
340
 
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), 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.
342
342
 
343
- 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. In outline mode, multi-line symbols appear as labels with `[limit N]` and single-line symbols appear as their content.
344
344
 
345
- 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.
346
346
 
347
- 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.
348
348
 
349
349
  ## Configuration
350
350
 
351
351
  | Command | Description |
352
352
  | --- | --- |
353
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, 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. |
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. |
354
354
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
355
355
 
356
356
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -359,6 +359,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
359
359
  {
360
360
  "autoRead": true,
361
361
  "autoReadAll": "off",
362
+ "autoReadAllRequireGit": true,
362
363
  "autoReadAllIgnore": [],
363
364
  "anchorGrepEnabled": true,
364
365
  "copyMoveEnabled": true,
@@ -374,7 +375,8 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
374
375
  | Key | `/hashline-config` label | Default | Effect |
375
376
  | --- | --- | --- | --- |
376
377
  | `autoRead` | Auto-read | `true` | Append the auto-read block after `write` and show post-edit diffs. |
377
- | `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. |
378
380
  | `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
379
381
  | `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
380
382
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
@@ -434,7 +436,7 @@ All eight tools also declare an `outputSchema` and return `structuredContent`, s
434
436
 
435
437
  ## Error, warning, and hint codes
436
438
 
437
- 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`.
438
440
 
439
441
  Most common, with the fix:
440
442
 
@@ -479,11 +481,9 @@ Full reference:
479
481
  | `[E_WRITE_HASH_ECHO]` | A `write` `content` line reproduces a served row for this file (a bare `anchor│` read row, a `+anchor│`, ` anchor│`, or `-anchor│` diff row, or a `lineNumber │ anchor│content` grep row). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
480
482
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
481
483
  | `[E_BATCH_OVERLAP]` | Batched edit calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
482
- | `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
483
- | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
484
- | `[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. |
484
+ | `[E_OP_ABORTED]` | A batch-level failure aborted the edit before writing (the base read failed, a cross-file member could not be prepared, the file changed or was deleted during the batch, or the composed result exceeded a limit). Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
485
+ | `[E_GREP_FAILED]` | `anchor_grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences or look-around, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
485
486
  | `[E_GREP_TIMEOUT]` | `anchor_grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
486
- | `[E_AUTO_READ_ALL]` | File already attached and unchanged since this session's start; the attached content is still exact. |
487
487
 
488
488
  ## Troubleshooting
489
489
 
@@ -517,8 +517,25 @@ Config, SQLite (including WAL/SHM and undo history), registry sidecars, and the
517
517
 
518
518
  Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` without deleting records or logging each inaccessible path. Unexpected errors remain visible; tool file-access failures and SQLite errors are not silenced.
519
519
 
520
+ ### Environment variables
521
+
522
+ | Variable | Effect |
523
+ | --- | --- |
524
+ | `PI_HASHLINE_DIR` | Absolute path overriding this extension's state directory on all platforms; an unset or empty value keeps the XDG/home defaults and a relative value is rejected with `[E_CONFIG]`. |
525
+ | `PI_HASHLINE_DEBUG` | Set to `1` or `true` to show the "Hashline Edit mode active" notification at session start. |
526
+
520
527
  ## How anchors work
521
528
 
529
+ ### Request flow
530
+
531
+ ```text
532
+ read / anchor_grep / auto-read-all ──► anchor registry (ownership + served records)
533
+ │
534
+ edit call ──► resolve anchors ──► apply ──► commit ──► post-edit diff with fresh anchors
535
+ │
536
+ hash store (SQLite snapshots + undo)
537
+ ```
538
+
522
539
  ### Allocation
523
540
 
524
541
  Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `replace_match`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
@@ -569,6 +586,7 @@ Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
569
586
 
570
587
  - [RimuruW](https://github.com/RimuruW), original `pi-hashline-edit` and the strict-semantics policy
571
588
  - [can1357](https://github.com/can1357), original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept
589
+ - [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
572
590
 
573
591
  ## License
574
592
 
package/index.ts CHANGED
@@ -9,8 +9,7 @@ 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";
@@ -22,6 +21,7 @@ import {
22
21
  readConfigWithStatus,
23
22
  toggleAutoRead,
24
23
  cycleAutoReadAllMode,
24
+ toggleAutoReadAllRequireGit,
25
25
  toggleAnchorGrep,
26
26
  toggleCopyMove,
27
27
  toggleReplaceMatch,
@@ -61,6 +61,7 @@ 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
66
  let readOnDisabledModels: ReadOnDisabledModels = "vanilla";
66
67
  let readFlavor: "anchored" | "vanilla" = "anchored";
@@ -100,26 +101,31 @@ export default function (pi: ExtensionAPI): void {
100
101
  await refreshEditTools();
101
102
  }
102
103
  if (gated) {
103
- const active = pi.getActiveTools();
104
+ const active = pi.getActiveTools().filter((tool) => tool !== "edit");
104
105
  const gatedTools = readOnDisabledModels === "vanilla" ? ANCHOR_TOOL_NAMES.filter((tool) => tool !== "read") : ANCHOR_TOOL_NAMES;
105
106
  const next = active.filter((tool) => !gatedTools.includes(tool));
106
- if (readOnDisabledModels === "vanilla" && baseAnchorTools.has("read") && !next.includes("read")) next.push("read");
107
107
  if (grepWasActive && !next.includes("grep")) next.push("grep");
108
108
  pi.setActiveTools(next);
109
109
  gateApplied = true;
110
110
  return;
111
111
  }
112
- 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
+ }
113
118
  gateApplied = false;
114
119
  const config = await readConfig();
115
120
  const enabled = ANCHOR_TOOL_NAMES.filter((tool) => {
121
+ if (tool === "read") return true;
116
122
  if (!baseAnchorTools.has(tool)) return false;
117
123
  if (tool === "replace_match") return config.replaceMatchEnabled !== false;
118
124
  if (tool === "copy" || tool === "move") return config.copyMoveEnabled !== false;
119
125
  if (tool === "anchor_grep") return config.anchorGrepEnabled === true;
120
126
  return true;
121
127
  });
122
- let next = [...new Set([...pi.getActiveTools(), ...enabled])];
128
+ let next = [...new Set([...pi.getActiveTools().filter((tool) => tool !== "edit"), ...enabled])];
123
129
  if (enabled.includes("anchor_grep") && grepWasActive) next = next.filter((tool) => tool !== "grep");
124
130
  pi.setActiveTools(next);
125
131
  }
@@ -149,6 +155,7 @@ export default function (pi: ExtensionAPI): void {
149
155
  if (corrupted && (ctx as { hasUI?: boolean }).hasUI) ctx.ui.notify("Hashline config was corrupt and was reset to defaults", "warning");
150
156
  autoRead = config.autoRead;
151
157
  autoReadAll = config.autoReadAll ?? "off";
158
+ autoReadAllRequireGit = config.autoReadAllRequireGit ?? true;
152
159
  autoReadAllIgnore = config.autoReadAllIgnore ?? [];
153
160
  disableOnModels = config.disableOnModels ?? [];
154
161
  readOnDisabledModels = config.readOnDisabledModels ?? "vanilla";
@@ -173,7 +180,6 @@ export default function (pi: ExtensionAPI): void {
173
180
  pi.on("session_shutdown", async (_event, ctx) => {
174
181
  try {
175
182
  const key = sessionKeyFor(ctx);
176
- clearAutoReadAllComplete(key);
177
183
  if (key !== undefined) releaseRegistrySession(key);
178
184
  } catch (error) {
179
185
  console.error("Failed to release anchor registry session:", error);
@@ -189,9 +195,9 @@ export default function (pi: ExtensionAPI): void {
189
195
  if (modelDisabled(ctx.model, disableOnModels)) return;
190
196
  autoReadAllInjected = true;
191
197
  try {
192
- 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);
193
199
  if (!injection) return;
194
- 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");
195
201
  return { message: { customType: AUTO_READ_ALL_CUSTOM_TYPE, content: injection.text, display: false } };
196
202
  } catch (error) {
197
203
  console.error("Auto-read all failed:", error);
@@ -200,7 +206,7 @@ export default function (pi: ExtensionAPI): void {
200
206
  }));
201
207
 
202
208
  pi.registerCommand("hashline-config", {
203
- 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)",
204
210
  handler: async (_args, ctx) => {
205
211
  if (!ctx.hasUI) {
206
212
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -209,14 +215,16 @@ export default function (pi: ExtensionAPI): void {
209
215
  await ctx.ui.custom<void>(async (tui, theme, _keybindings, done) => {
210
216
  const overlay = new HashlineConfigOverlay({
211
217
  tui,
218
+ maxHeight: () => tui.terminal ? Math.max(6, Math.floor(tui.terminal.rows * 0.9)) : undefined,
212
219
  theme,
213
220
  done,
214
221
  onToggle: async (key, delta, value) => {
215
222
  if (key === "autoRead") autoRead = await toggleAutoRead();
216
- 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();
217
225
  else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
218
226
  else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
219
- else if (key === "readOnDisabledModels") readOnDisabledModels = await cycleReadOnDisabledModels();
227
+ else if (key === "readOnDisabledModels") readOnDisabledModels = await cycleReadOnDisabledModels(delta);
220
228
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
221
229
  else if (key === "anchorGrepEnabled") {
222
230
  const enabled = await toggleAnchorGrep();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.1.2",
3
+ "version": "6.3.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": {
@@ -7,7 +7,7 @@ import { sessionClaimsDir } from "./paths";
7
7
  import { contentChecksum } from "./hashline/hasher";
8
8
  import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
9
9
  import { HASH_PROBE_STRIDE } from "./hashline/hash";
10
- import { errCode, splitLines } from "./utils";
10
+ import { errCode, isRec, splitLines } from "./utils";
11
11
  import * as Diff from "diff";
12
12
  import { lineChecksum } from "./hashline";
13
13
  import { getAllocatedState, persistSnapshot, type HashStore } from "./hash-store";
@@ -197,6 +197,7 @@ function seedServedFromOwned(state: SessionState): void {
197
197
  export function foldRegistryEvents(events: RegistryEvent[], seed?: string): SessionState {
198
198
  const state = newSessionState(seed);
199
199
  for (const event of events) {
200
+ if (!isRegistryEvent(event)) continue;
200
201
  if (event.kind === "clear") {
201
202
  state.owned.clear();
202
203
  state.lastUsedAt.clear();
@@ -232,15 +233,31 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
232
233
  return state;
233
234
  }
234
235
 
236
+ function isStringArray(value: unknown): value is string[] {
237
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
238
+ }
239
+
240
+ function isRegistryEvent(value: unknown): value is RegistryEvent {
241
+ if (!isRec(value) || typeof value.kind !== "string") return false;
242
+ if (value.kind === "session") return typeof value.sessionFile === "string";
243
+ if (value.kind === "clear") return true;
244
+ if (value.kind === "allocate") {
245
+ return typeof value.path === "string"
246
+ && Array.isArray(value.rows)
247
+ && value.rows.every((row) => Array.isArray(row) && row.length === 2 && typeof row[0] === "string" && typeof row[1] === "string");
248
+ }
249
+ if (value.kind === "free") return typeof value.path === "string" && (value.anchors === undefined || isStringArray(value.anchors));
250
+ if (value.kind === "minted") return isStringArray(value.anchors);
251
+ return false;
252
+ }
253
+
235
254
  export function parseRegistryLog(raw: string): RegistryEvent[] {
236
255
  const events: RegistryEvent[] = [];
237
256
  for (const line of raw.split("\n")) {
238
257
  if (!line.trim()) continue;
239
258
  try {
240
- const parsed = JSON.parse(line) as RegistryEvent;
241
- if (parsed && (parsed.kind === "allocate" || parsed.kind === "free" || parsed.kind === "clear" || parsed.kind === "session" || parsed.kind === "minted")) {
242
- events.push(parsed);
243
- }
259
+ const parsed: unknown = JSON.parse(line);
260
+ if (isRegistryEvent(parsed)) events.push(parsed);
244
261
  } catch {
245
262
  continue;
246
263
  }
@@ -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│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\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