repl: _scan_project_tree + :tree meta + auto_tree (Phase 6 commit #2)

First user-visible Phase 6 verb. Builds on commit #1's compose_project
plumbing — sets ctx.project from either the :tree meta or the
cfg.project.auto_tree startup hook.

Changes:
  - _scan_project_tree(dir, opts) helper near _run_hook:
      git -C <dir> ls-files --cached --others --exclude-standard
      when <dir> is inside a git repo (N4: no subshell);
      find <dir> -mindepth 1 -maxdepth <depth+1> -type f
      -not -path '*/.*' otherwise.
    Returns (body, info={file_count, truncated, in_git}). Sorted
    paths, truncated to max_chars (default 4096 per cfg).

  - :tree [<depth>|refresh|off] meta:
      no arg     -> scan with config defaults; resets _project_opts
      <N>        -> scan with depth=N; caches as _project_opts
      refresh    -> re-scan with cached _project_opts (else defaults)
      off        -> clear ctx.project AND ctx._project_opts (R5)
    Status line reports file count + truncation flag + which backend
    fired (git/find).

  - cfg.project.auto_tree startup hook before the main loop:
      if true, scan libc.getcwd() once and set ctx.project.
      Failures status-logged once; REPL continues. Default off
      (existing configs unchanged).

  - HELP updated with three :tree lines.

Plan §12 deliberately defers the config.lua example block to commit #6
along with the status header bump (R9 single-owner).

Smoke (aish repo cwd):
  - :tree no-arg            -> "33 files (git ls-files)"
  - :tree refresh           -> same
  - :tree off               -> "project tree cleared"
  - :tree 1                 -> rescans
  - cfg.project.auto_tree=true at startup -> auto-injected status visible

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:14:36 +00:00
parent c4fc7fde01
commit d1dce832da
+114
View File
@@ -148,6 +148,9 @@ Meta commands:
:bg-list list background jobs (issued via CMD&: or :bg-spawn) :bg-list list background jobs (issued via CMD&: or :bg-spawn)
:bg-output <id> dump the log of a background job :bg-output <id> dump the log of a background job
:bg-kill <id> SIGTERM a background job :bg-kill <id> SIGTERM a background job
:tree [<depth>] scan cwd file-tree, inject as [project] block in system prompt
:tree refresh re-scan with last opts (or config defaults)
:tree off clear the [project] block
:delegate <p> <prompt> one-shot sub-broker call to preset <p>; prints reply :delegate <p> <prompt> one-shot sub-broker call to preset <p>; prints reply
:help this message :help this message
]] ]]
@@ -680,6 +683,58 @@ function M.run(config)
return code, out return code, out
end end
end end
-- Phase 6 (§6 + N4): project file-tree scanner. Prefers
-- `git -C <dir> ls-files --cached --others --exclude-standard`
-- when <dir> is inside a git repo (free .gitignore honor);
-- falls back to `find ... -not -path '*/.<wildcard>'` for non-repo
-- cwds. opts: { depth = N, max_chars = N }; defaults via cfg.project.
-- Returns (body, info) where info = { file_count, truncated }.
local function _scan_project_tree(dir, opts)
opts = opts or {}
local p_cfg = config.project or {}
local depth = opts.depth or p_cfg.tree_depth or 3
local max_chars = opts.max_chars or p_cfg.tree_max_chars or 4096
-- N4: `git -C <dir>` skips the subshell vs `cd && git ...`.
local in_git = os.execute(
("git -C %s rev-parse --git-dir >/dev/null 2>&1"):format(_shq(dir))
) == 0
local listcmd
if in_git then
listcmd = ("git -C %s ls-files --cached --others --exclude-standard")
:format(_shq(dir))
else
-- find honors -maxdepth from the start path; we count the
-- depth in terms of nested subdirectories beneath <dir>.
listcmd = ("find %s -mindepth 1 -maxdepth %d -type f -not -path '*/.*' 2>/dev/null")
:format(_shq(dir), depth + 1)
end
local pipe = io.popen(listcmd)
if not pipe then return nil, "scan failed (popen)" end
local files = {}
for line in pipe:lines() do
-- Depth filter is a no-op for the git case (ls-files emits
-- full repo-relative paths); for find we already capped via
-- -maxdepth. Keep the slash count here as a defensive bound.
local _, slashes = line:gsub("/", "")
if slashes <= depth then files[#files + 1] = line end
end
pipe:close()
table.sort(files)
local body = table.concat(files, "\n")
local truncated = false
if #body > max_chars then
body = body:sub(1, max_chars) .. "\n... (truncated)"
truncated = true
end
return body, { file_count = #files, truncated = truncated, in_git = in_git }
end
local function run_shell(cmd) local function run_shell(cmd)
local chd, err = executor.maybe_chdir(cmd) local chd, err = executor.maybe_chdir(cmd)
if chd ~= nil then if chd ~= nil then
@@ -1702,6 +1757,46 @@ function M.run(config)
_every_fire(j) _every_fire(j)
end end
end end
-- Phase 6: :tree meta — scan + inject project file-tree as the
-- [project] block in the system prompt. Variants per §6:
-- :tree scan with config defaults; resets _project_opts
-- :tree <N> scan with depth=N; cached as _project_opts
-- :tree refresh re-scan with cached opts; else config defaults
-- :tree off clear ctx.project AND ctx._project_opts
meta.tree = function(args)
local sub = (args or ""):match("^%s*(%S*)") or ""
if sub == "off" then
ctx.project = nil
ctx._project_opts = nil
renderer.status("project tree cleared")
return
end
local opts
if sub == "refresh" then
opts = ctx._project_opts or {}
elseif sub == "" then
opts = {}
ctx._project_opts = nil
else
local n = tonumber(sub)
if not n or n < 1 then
renderer.status("usage: :tree [<depth>|refresh|off]"); return
end
opts = { depth = n }
ctx._project_opts = opts
end
local dir = libc.getcwd() or "."
local body, info = _scan_project_tree(dir, opts)
if not body then
renderer.status("tree scan failed: " .. tostring(info))
return
end
ctx.project = body
renderer.status(("project tree: %d files%s (%s)"):format(
info.file_count,
info.truncated and " (truncated)" or "",
info.in_git and "git ls-files" or "find fallback"))
end
meta.every = function(args) meta.every = function(args)
local sub = args:match("^%s*(%S*)") or "" local sub = args:match("^%s*(%S*)") or ""
if sub == "list" or sub == "" and args:match("^%s*$") then if sub == "list" or sub == "" and args:match("^%s*$") then
@@ -1919,6 +2014,25 @@ function M.run(config)
renderer.status("no such bg job: #" .. id) renderer.status("no such bg job: #" .. id)
end end
-- Phase 6: cfg.project.auto_tree startup hook. Runs once before the
-- main loop opens; opts.dir = cwd at startup. Failures are status-
-- logged once and skipped — the rest of the REPL works fine.
-- :tree refresh later picks up cwd changes (cd intercept doesn't
-- auto-refresh per A8 — v2 polish).
if config.project and config.project.auto_tree then
local dir = libc.getcwd() or "."
local body, info = _scan_project_tree(dir, {})
if body then
ctx.project = body
renderer.status(("project tree auto-injected: %d files%s (%s)")
:format(info.file_count,
info.truncated and " (truncated)" or "",
info.in_git and "git ls-files" or "find fallback"))
else
renderer.status("project tree auto-inject failed: " .. tostring(info))
end
end
-- Main loop. -- Main loop.
while true do while true do
check_every_due() check_every_due()