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:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user