repl: expand_mentions tiered @<r1>..<r2> diff retry (Phase 6 commit #4)

Per A6 (tiered resolution): @<token> tries file lookup first; if the
file doesn't exist AND the token contains "..", retry as a git
ref-range and substitute with a fenced `diff` block. Preserves the
existing peel-on-trailing-punct logic (e.g., `@HEAD~1..HEAD,` peels
the comma, resolves the ref, restores the comma after the closing
fence).

Resolution order for @<token>:
  1. io.open(token, "rb")    -- file lookup, with trailing-punct peel
  2. if (1) fails and token contains "..":
        git --no-pager -c color.ui=never diff <r1>..<r2>
     on exit 0 + non-empty body: substitute as ```diff fenced block
  3. else: leave literal `@token` + emit "[aish] @X: not found" status

Examples:
  @README.md            -> file (path branch)
  @../sibling.txt       -> file (path branch; `..` only triggers retry
                                 when path lookup FAILS, so existing
                                 paths with `..` segments are unaffected)
  @HEAD~1..HEAD         -> diff (path fails, ref succeeds)
  @origin/main..feature -> diff (path fails — no such literal file;
                                 ref succeeds; `/` in ref is fine because
                                 we don't use the path's `/`-absence as
                                 a discriminator)
  @nonsense..gibberish  -> literal preserved (both fail)

Required restructuring:
  - _shq and _git_clean_cmd lifted from M.run closure scope to module
    scope (above expand_mentions). Single source of truth for the
    B1 prefix shared with commit #3's :diff. The in-M.run duplicates
    are removed.
  - expand_mentions now references `executor` (already required at
    module scope on line 7) for the diff retry.

Status messages updated:
  - File expansion: "@<path> expanded (N bytes, truncated)"  (existing)
  - Diff expansion: "@<path> expanded (N bytes, diff)"        (new)

Tested with the 7 existing #7 cases + 7 new diff-retry cases (14/14):
  ref-range expansion shape, body contains `diff --git`, trailing
  prose preserved, @../path stays as file (not diff), neither-path-
  nor-ref preserves literal, trailing-comma peel composes with ref
  retry.

Regression: test_safety 87/87, test_router_model 31/31, repl loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 22:20:25 +00:00
parent 4d5f93aaa5
commit 0d63f01601
+40 -11
View File
@@ -45,6 +45,16 @@ local function _read_truncated(path)
.. ("\n... [%d bytes elided] ...\n"):format(#content - MENTION_HEAD - MENTION_TAIL) .. ("\n... [%d bytes elided] ...\n"):format(#content - MENTION_HEAD - MENTION_TAIL)
.. tail, true .. tail, true
end end
-- ---------------------------------------------------------------- shared shell helpers
-- Lifted from M.run closure scope so expand_mentions (module-scope) can also
-- use them for the @<r1>..<r2> diff-retry path. Same single source of truth
-- for the B1 git invocation prefix; commits #3 and #4 both call _git_clean_cmd.
local function _shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end
local function _git_clean_cmd(subcmd_and_args)
return "git --no-pager -c color.ui=never " .. subcmd_and_args
end
local function expand_mentions(line, on_status) local function expand_mentions(line, on_status)
-- Walk the line; for each "@<path>" preceded by SOL or whitespace, -- Walk the line; for each "@<path>" preceded by SOL or whitespace,
-- attempt to read and substitute. Missing files leave the literal -- attempt to read and substitute. Missing files leave the literal
@@ -72,13 +82,35 @@ local function expand_mentions(line, on_status)
end end
if path ~= "" then if path ~= "" then
local content, truncated = _read_truncated(path) local content, truncated = _read_truncated(path)
local lang_override = nil
-- Phase 6 / A6: tiered resolution — if path lookup
-- failed AND token contains "..", try as a git diff
-- ref-range. `@HEAD~1..HEAD` and `@origin/main..feature`
-- both fall through to this branch when no such file
-- exists. `@../sibling.txt` resolves as path first
-- and never reaches this retry.
if not content and path:find("..", 1, true) then
local r1, r2 = path:match("^(.-)%.%.(.+)$")
if r1 and r2 and r1 ~= "" and r2 ~= "" then
local out_diff, code = executor.exec(
_git_clean_cmd(("diff %s..%s 2>/dev/null")
:format(_shq(r1), _shq(r2))))
if code == 0 and out_diff and out_diff:match("%S") then
content = out_diff
lang_override = "diff"
end
end
end
if content then if content then
local lang = lang_override or _lang_of(path)
if on_status then if on_status then
on_status(("@%s expanded (%d bytes%s)"):format( on_status(("@%s expanded (%d bytes%s)"):format(
path, #content, truncated and ", truncated" or "")) path, #content,
truncated and ", truncated"
or (lang_override == "diff" and ", diff" or "")))
end end
out[#out + 1] = ("```%s path=%s\n%s\n```%s"):format( out[#out + 1] = ("```%s path=%s\n%s\n```%s"):format(
_lang_of(path), path, content, trail) lang, path, content, trail)
i = path_end i = path_end
else else
if on_status then if on_status then
@@ -669,7 +701,8 @@ function M.run(config)
-- receives the command on stdin and AISH_CMD/AISH_TURN/AISH_CWD as -- receives the command on stdin and AISH_CMD/AISH_TURN/AISH_CWD as
-- env vars. Non-zero exit on pre_cmd aborts. post_cmd exit is -- env vars. Non-zero exit on pre_cmd aborts. post_cmd exit is
-- ignored; its stdout is logged via renderer.status. -- ignored; its stdout is logged via renderer.status.
local function _shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end -- _shq lifted to module scope (above expand_mentions) so the
-- @-mention diff retry can share the same quoter.
local function _run_hook(script, cmd, want_output) local function _run_hook(script, cmd, want_output)
local cwd = (require("ffi.libc").getcwd()) or os.getenv("PWD") or "?" local cwd = (require("ffi.libc").getcwd()) or os.getenv("PWD") or "?"
local pipeline = string.format( local pipeline = string.format(
@@ -686,14 +719,10 @@ function M.run(config)
end end
end end
-- Phase 6 (B1): every git invocation that flows back into context -- _git_clean_cmd lifted to module scope (above expand_mentions);
-- MUST suppress git's interactive pager + color output. Forkpty -- shared with the @<r1>..<r2> @-mention diff retry. Same B1
-- makes git think stdout is a TTY, which enables both. Helper -- invariant: every git invocation that flows back into context
-- prepends `git --no-pager -c color.ui=never <subcmd_and_args>`; -- runs with `--no-pager -c color.ui=never`.
-- used by :diff and the @<r1>..<r2> @-mention path in commit #4.
local function _git_clean_cmd(subcmd_and_args)
return "git --no-pager -c color.ui=never " .. subcmd_and_args
end
-- Phase 6 (§6 + N4): project file-tree scanner. Prefers -- Phase 6 (§6 + N4): project file-tree scanner. Prefers
-- `git -C <dir> ls-files --cached --others --exclude-standard` -- `git -C <dir> ls-files --cached --others --exclude-standard`