From b7f040cd7d9f0596fdad7382069af2d1e4891434 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 18:12:28 +0000 Subject: [PATCH 1/4] feat(codedb): list_dir, status, and a cwd jail for the index tool codedb is the lean-catalog navigator; pairing it with the harness means folder listing and index health cannot depend on the CLI being installed, and path-bearing queries cannot bypass read_file's jail. list_dir is in-process (gitignore, 10k cap, PathConfine). status reports codedb.snapshot without a spawn. read/outline/deps/file/glob stay inside the cwd. The codedb guard now redirects ls/find/tree even when the binary is missing (ADR 0013). --- CHANGELOG.md | 8 + README.md | 11 +- docs/adr/0013-list-dir-lives-in-codedb.md | 30 ++ docs/adr/README.md | 1 + src/codedb_exec.zig | 183 +++++++++ src/codedb_health.zig | 92 +++++ src/commands_session.zig | 2 +- src/exec.zig | 91 +--- src/gitignore.zig | 288 +++++++++++++ src/list_dir.zig | 479 ++++++++++++++++++++++ src/prompt_snapshot_tests.zig | 2 +- src/prompt_text.zig | 2 +- src/prompts.zig | 4 +- src/schema.zig | 2 +- src/shapes.zig | 6 +- src/startup.zig | 1 + src/tool_handle.zig | 2 +- src/tools.zig | 33 +- 18 files changed, 1137 insertions(+), 100 deletions(-) create mode 100644 docs/adr/0013-list-dir-lives-in-codedb.md create mode 100644 src/codedb_exec.zig create mode 100644 src/codedb_health.zig create mode 100644 src/gitignore.zig create mode 100644 src/list_dir.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 5933bc44..b2bb7d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ The release workflow uses a tag's section here as its release notes (a hand-written `docs/releases/.md` wins if present), so keeping this file current is part of cutting a release. +## Unreleased + +- codedb pairing: `list_dir ` is in-process (gitignore, PathConfine, + works without the binary), `status` reports `codedb.snapshot`, and + path-bearing subcommands (`read`/`outline`/`deps`/`file`) share the + file-tool jail. The codedb guard now redirects `ls`/`find`/`tree` even + when the CLI is missing (ADR 0013). + ## v0.0.267 (2026-08-19) - Background jobs wait like grok-build: `bash_output(wait_ms>0)` and diff --git a/README.md b/README.md index 9b71a586..79d5fd1b 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,8 @@ whole-file reads. Instead of paying for a 2,000-line file to find one function, the model asks for exactly the shape it needs: ``` +codedb list_dir src # live tree (gitignore, 10k cap; no index needed) +codedb status # is codedb.snapshot present? codedb outline src/main.zig # just the symbol map, functions/types, no bodies codedb symbol switchProvider --body # one function, by name codedb callers recordUsage # who calls it (call sites, not files) @@ -233,8 +235,11 @@ Why this keeps token cost low: Pure-Zig client to a pure-Zig server, zero dependencies on either side. Allowed subcommands: `search · symbol · callers · find · outline · read · tree · -context · word · deps · glob · ls · file · hot`. Not installed? The tool says so -and points at the one-line install; everything else keeps working without it. +list_dir · status · context · word · deps · glob · ls · file · hot`. +`list_dir` and `status` are in-process (PathConfine; they work without the +binary). Path-bearing queries stay inside the cwd, same jail as `read_file`. +Not installed? Structural search says so and points at the one-line install; +folder listing still works. --- @@ -840,7 +845,7 @@ get web-request economics, and no boot-and-provision tax on time to first token. | `read_file` | built-in | `Io.Dir.cwd().readFileAlloc` (256 KB cap) | | `edit_file` | built-in | exact string replace; unique match required unless `replace_all` | | `write_file` | built-in | `Io.Dir.cwd().writeFile` | -| `codedb` | built-in | shells out to [codedb](https://github.com/justrach/codedb): read-only code-intel (search/symbol/callers/outline/…) | +| `codedb` | built-in | [codedb](https://github.com/justrach/codedb) index plus in-process `list_dir`/`status` (search/symbol/callers/outline/…) | | `subagent` | built-in | this same agent loop, recursively (root agent only) | | `workflow` | built-in | phases of parallel subagents; `{{prev}}` carries results forward (root only) | | `todo_write`/`_read` | meta | mutate/read the agent's own task list | diff --git a/docs/adr/0013-list-dir-lives-in-codedb.md b/docs/adr/0013-list-dir-lives-in-codedb.md new file mode 100644 index 00000000..9a406fea --- /dev/null +++ b/docs/adr/0013-list-dir-lives-in-codedb.md @@ -0,0 +1,30 @@ +# 0013. Directory listing is a codedb subcommand, not a catalog tool + +Status: accepted 2026-08-20 + +## Context + +grok-build's `list_dir` is a first-class tool. graff already has `codedb` +for structural nav and tells the model to prefer it over bash `ls`. A new +always-on catalog entry would tax every turn — that is how the first #574 +A/B lost. `codedb ls` / `tree` are the **index**; they do not list an +unindexed tree. The native `codedb` spawn also had no PathConfine, so +`codedb read /etc/passwd` bypassed the file-tool jail. + +## Decision + +`codedb list_dir ` is in-process (PathConfine, gitignore, 10k cap) +and works without the codedb binary. `codedb status` reports +`codedb.snapshot` without spawning. Path-bearing subcommands +(`read`/`outline`/`deps`/`file`) and escaping `glob` patterns use the same +cwd jail as `read_file`. `ls` / `tree` stay index queries. + +Do not add a sibling graff `list_dir` catalog tool unless an A/B shows the +extra schema bytes beat this subcommand. + +## Consequences + +- Folder listing no longer needs `bash ls` / `find` for confined trees. +- The codedb guard redirects those shell commands even when the CLI is missing. +- A missing index is a status line, not a dead tool — `list_dir` still works. +- The model has to read the codedb description to find `list_dir`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5619f8a2..8e754183 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,6 +22,7 @@ record only when you need the evidence or the edge cases. | [0009](0009-gpt-5-6-explicit-prompt-cache-boundary.md) | GPT-5.6 OpenAI Platform marks the stable prefix explicitly; Codex and xAI stay on their supported keyed automatic-cache paths. | | [0010](0010-background-jobs-wait-for-exit.md) | `bash_output`/`agent_output` `wait_ms>0` blocks until exit (10h cap); do not poll every 30s. | | [0011](0011-prompt-cache-max-is-visible.md) | Prompt-cache max is `/cache` posture, not a new default; `/btw` rides the parent prefix. | +| [0013](0013-list-dir-lives-in-codedb.md) | Directory listing is `codedb list_dir`, not a catalog tool; path-bearing codedb commands share the file-tool jail. | ## When to write one diff --git a/src/codedb_exec.zig b/src/codedb_exec.zig new file mode 100644 index 00000000..d3b840e1 --- /dev/null +++ b/src/codedb_exec.zig @@ -0,0 +1,183 @@ +//! Native `codedb` tool dispatch. Split out of exec.zig (600-line cap). +//! +//! In-process: `list_dir` (PathConfine + gitignore) and `status` (snapshot +//! health). Everything else is a read-only spawn of the codedb binary. +//! Path-bearing subcommands (read/outline/deps/file) get the same jail as +//! read_file — the binary is not a second way out of the cwd. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const tools = @import("tools.zig"); +const ToolCtx = tools.ToolCtx; +const ToolOutput = tools.ToolOutput; +const harness_policy = @import("harness_policy.zig"); +const list_dir = @import("list_dir.zig"); +const codedb_health = @import("codedb_health.zig"); +const jobs = @import("jobs.zig"); +const main_mod = @import("main.zig"); +const hooks = @import("hooks.zig"); + +pub const deadline_ms: u64 = 60 * 1000; + +const ok_subs = [_][]const u8{ + "search", "symbol", "callers", "find", "outline", "read", "tree", + "list_dir", "status", "context", "word", "deps", "glob", "ls", + "file", "hot", +}; + +const path_subs = [_][]const u8{ "outline", "read", "deps", "file" }; + +fn allowed(sub: []const u8) bool { + for (ok_subs) |s| if (std.mem.eql(u8, s, sub)) return true; + return false; +} + +fn isPathSub(sub: []const u8) bool { + for (path_subs) |s| if (std.mem.eql(u8, s, sub)) return true; + return false; +} + +fn firstPathArg(rest: []const u8) ?[]const u8 { + var it = std.mem.tokenizeAny(u8, rest, " \t"); + var skip_next = false; + while (it.next()) |tok| { + if (skip_next) { + skip_next = false; + continue; + } + if (tok.len == 0) continue; + if (tok[0] == '-') { + if (std.mem.eql(u8, tok, "-L") or std.mem.eql(u8, tok, "--lines")) skip_next = true; + continue; + } + return tok; + } + return null; +} + +fn globEscapes(pattern: []const u8) bool { + if (pattern.len == 0) return false; + if (pattern[0] == '/') return true; + var it = std.mem.tokenizeAny(u8, pattern, "/\\"); + while (it.next()) |comp| { + if (std.mem.eql(u8, comp, "..")) return true; + } + return false; +} + +pub fn exec(ctx: ToolCtx, input: std.json.Value) !ToolOutput { + const gpa = ctx.gpa; + const io = ctx.io; + const cmd = tools.strField(input, "command") orelse return tools.missingArg(gpa, "command"); + var it = std.mem.tokenizeAny(u8, cmd, " \t"); + const sub = it.next() orelse return .{ + .text = try gpa.dupe(u8, "usage: codedb [args] — e.g. search , symbol , callers , outline , list_dir , status"), + .is_error = true, + }; + if (std.mem.eql(u8, sub, "list_dir")) return list_dir.exec(ctx, it.rest()); + if (std.mem.eql(u8, sub, "status")) return codedb_health.exec(ctx); + if (!allowed(sub)) return .{ + .text = try std.fmt.allocPrint(gpa, "codedb subcommand '{s}' is not allowed here — use one of: search, symbol, callers, find, outline, read, tree, list_dir, status, context, word, deps, glob, ls, file, hot", .{sub}), + .is_error = true, + }; + + if (isPathSub(sub)) { + if (firstPathArg(it.rest())) |path| { + if (!harness_policy.confinedPath(path) or !harness_policy.noSymlinkEscape(io, path, ctx.agent_cwd)) + return tools.outsideCwd(gpa, path); + } + } + if (std.mem.eql(u8, sub, "glob")) { + if (firstPathArg(it.rest())) |pat| { + if (globEscapes(pat)) return tools.outsideCwd(gpa, pat); + } + } + + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(gpa); + try argv.append(gpa, "codedb"); + try argv.append(gpa, sub); + var rest = std.mem.tokenizeAny(u8, it.rest(), " \t"); + while (rest.next()) |tok| try argv.append(gpa, tok); + + const run = jobs.runCappedWithOptions(gpa, io, argv.items, 512 * 1024, 4096, deadline_ms, jobs.toolRunOptions(ctx.agent_cwd)) catch |e| switch (e) { + error.FileNotFound => return .{ + .text = try gpa.dupe(u8, "codedb isn't installed — it's open source at github.com/justrach/codedb; install it, then run `codedb` once in the repo to index it. Folder listing still works: codedb list_dir ."), + .is_error = true, + }, + else => return tools.failure(gpa, e), + }; + gpa.free(run.stderr); + const text = run.stdout; + if (run.timed_out) { + defer gpa.free(text); + return .{ + .text = try std.fmt.allocPrint(gpa, "codedb {s} timed out after {d}s and was killed — narrow the query, or run it through bash if it really needs that long", .{ sub, deadline_ms / 1000 }), + .is_error = true, + }; + } + if (text.len == 0) { + defer gpa.free(text); + return .{ .text = try gpa.dupe(u8, "(codedb returned nothing — try `codedb status` or `codedb tree` to confirm the repo is indexed, or refine the query)") }; + } + return .{ .text = text }; +} + +/// Opt-in exploratory read via `codedb read [-L a-b] --compact`. Lossy +/// view for reasoning only; returns null on any codedb failure so the caller +/// falls back to the native byte-exact read (#66). +pub fn compactRead(gpa: Allocator, io: Io, path: []const u8, start: ?i64, end: ?i64) !?ToolOutput { + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(gpa); + var lbuf: [48]u8 = undefined; + try argv.append(gpa, "codedb"); + try argv.append(gpa, "read"); + try argv.append(gpa, path); + if (start != null and end != null and start.? >= 1 and end.? >= start.?) { + try argv.append(gpa, "-L"); + try argv.append(gpa, std.fmt.bufPrint(&lbuf, "{d}-{d}", .{ start.?, end.? }) catch return null); + } + try argv.append(gpa, "--compact"); + const run = jobs.runCapped(gpa, io, argv.items, tools.codedb_result_cap, 4096, 0) catch return null; + defer gpa.free(run.stdout); + defer gpa.free(run.stderr); + const ok = switch (run.term) { + .exited => |c| c == 0, + else => false, + }; + if (!ok or run.stdout.len == 0) return null; + return ToolOutput{ .text = try std.fmt.allocPrint(gpa, "{s}\n[compact view — comments/blank lines stripped, line numbers shown; re-read WITHOUT compact before building an edit_file old_string]", .{run.stdout}) }; +} + +pub fn maybeCompactRead(ctx: ToolCtx, path: []const u8, start: ?i64, end: ?i64) !?ToolOutput { + if (ctx.agent_cwd != null) return null; + if (main_mod.g_codedb_present == null) main_mod.g_codedb_present = @import("skills.zig").binOnPath(ctx.io, "codedb"); + if (main_mod.g_codedb_present != true) return null; + if (!hooks.codedbFileIndexed(ctx.io, ctx.gpa, path)) return null; + return compactRead(ctx.gpa, ctx.io, path, start, end); +} + +test "firstPathArg skips flags" { + try std.testing.expectEqualStrings("src/main.zig", firstPathArg("-L 1-10 src/main.zig").?); + try std.testing.expectEqualStrings("src/a.zig", firstPathArg("src/a.zig --compact").?); + try std.testing.expect(firstPathArg("--compact") == null); +} + +test "globEscapes rejects parent and absolute patterns" { + try std.testing.expect(globEscapes("/etc/*")); + try std.testing.expect(globEscapes("../**")); + try std.testing.expect(globEscapes("foo/../bar")); + try std.testing.expect(!globEscapes("**/*.zig")); + try std.testing.expect(!globEscapes("src/*")); +} + +test "allowed subcommands include list_dir and status, not update" { + try std.testing.expect(allowed("list_dir")); + try std.testing.expect(allowed("status")); + try std.testing.expect(allowed("search")); + try std.testing.expect(!allowed("update")); + try std.testing.expect(!allowed("nuke")); + try std.testing.expect(!allowed("mcp")); +} diff --git a/src/codedb_health.zig b/src/codedb_health.zig new file mode 100644 index 00000000..874f11e8 --- /dev/null +++ b/src/codedb_health.zig @@ -0,0 +1,92 @@ +//! Snapshot health for the native codedb tool. +//! +//! The index lives in `codedb.snapshot` (gitignored, built by the codedb +//! binary). Graff never writes it. `status` reports whether it is present +//! so a model is not stuck between empty `search` and guessing `tree`. +//! `list_dir` does not need the snapshot — it walks the live tree. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const tools = @import("tools.zig"); +const no_local_tools = @import("no_local_tools.zig"); + +pub const snapshot_name = "codedb.snapshot"; + +pub const Snapshot = struct { + present: bool, + bytes: u64 = 0, +}; + +pub fn probe(io: Io, cwd: []const u8) Snapshot { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = if (cwd.len == 0 or std.mem.eql(u8, cwd, ".")) + snapshot_name + else + std.fmt.bufPrint(&buf, "{s}/{s}", .{ cwd, snapshot_name }) catch return .{ .present = false }; + const st = Io.Dir.cwd().statFile(io, path, .{}) catch return .{ .present = false }; + if (st.kind != .file) return .{ .present = false }; + return .{ .present = true, .bytes = st.size }; +} + +pub fn render(gpa: Allocator, snap: Snapshot) ![]u8 { + if (snap.present) { + return std.fmt.allocPrint(gpa, "codedb.snapshot present ({d} bytes). Structural queries (search/symbol/callers/outline) read this index. Folder listing does not: codedb list_dir .", .{snap.bytes}); + } + return gpa.dupe(u8, "codedb index missing — no codedb.snapshot in this cwd. Run `codedb` once in the repo to build it. Folder listing still works without an index: codedb list_dir ."); +} + +pub fn exec(ctx: tools.ToolCtx) !tools.ToolOutput { + const cwd = ctx.agent_cwd orelse "."; + const text = try render(ctx.gpa, probe(ctx.io, cwd)); + return .{ .text = text }; +} + +/// One-line prompt segment when local tools are on and the snapshot is gone. +/// Null when the index is present or `--no-local-tools` removed codedb — +/// repo_map already orients the tree, and a present index needs no nag. +pub fn segment(io: Io, _: Allocator) ?[]const u8 { + if (no_local_tools.enabled) return null; + if (probe(io, ".").present) return null; + return "\n\nCodedb index: no codedb.snapshot here. `codedb search`/`symbol` need `codedb` run once to index. Folder listing works anyway: codedb list_dir ."; +} + +test "probe: missing snapshot" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPath(io, &buf); + const snap = probe(io, buf[0..n]); + try std.testing.expect(!snap.present); +} + +test "probe: present snapshot reports size" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + tmp.dir.writeFile(io, .{ .sub_path = snapshot_name, .data = "abcd" }) catch unreachable; + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPath(io, &buf); + const snap = probe(io, buf[0..n]); + try std.testing.expect(snap.present); + try std.testing.expectEqual(@as(u64, 4), snap.bytes); +} + +test "render names list_dir in both states" { + const missing = try render(std.testing.allocator, .{ .present = false }); + defer std.testing.allocator.free(missing); + try std.testing.expect(std.mem.indexOf(u8, missing, "list_dir") != null); + const present = try render(std.testing.allocator, .{ .present = true, .bytes = 12 }); + defer std.testing.allocator.free(present); + try std.testing.expect(std.mem.indexOf(u8, present, "12 bytes") != null); + try std.testing.expect(std.mem.indexOf(u8, present, "list_dir") != null); +} + +test "segment is silent when no-local-tools is on" { + const saved = no_local_tools.enabled; + defer no_local_tools.enabled = saved; + no_local_tools.enabled = true; + try std.testing.expect(segment(std.testing.io, std.testing.allocator) == null); +} diff --git a/src/commands_session.zig b/src/commands_session.zig index d2ee58cc..0840f90d 100644 --- a/src/commands_session.zig +++ b/src/commands_session.zig @@ -374,7 +374,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, return true; } if (std.mem.eql(u8, line, "/hooks")) { - try out.print("{s}codedb guard{s} (built-in, issue #626): {s} — blocks bash grep/sed/cat/wc on indexed source files and redirects to the codedb tool; GRAFF_NO_CODEDB_GUARD=1 disables.\n", .{ style.bold, style.reset, if (main_mod.g_codedb_guard) "on" else "off" }); + try out.print("{s}codedb guard{s} (built-in, issue #626): {s} — blocks bash grep/sed/cat/wc on indexed source files and bash ls/find/tree (use codedb list_dir); GRAFF_NO_CODEDB_GUARD=1 disables.\n", .{ style.bold, style.reset, if (main_mod.g_codedb_guard) "on" else "off" }); if (main_mod.g_hooks.total() == 0) { try out.print("no lifecycle hooks. Add them to {s}:\n {s}{{\"hooks\": {{\"pre_tool\": [{{\"match\": \"bash\", \"command\": \"./guard.sh\"}}]}}}}{s}\n events: pre_tool (exit 2 blocks, stderr → model) · post_tool · turn_end; loaded at startup\n", .{ Approvals.settings_path, style.dim, style.reset }); try out.flush(); diff --git a/src/exec.zig b/src/exec.zig index ac4b980f..768e7580 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -38,7 +38,6 @@ const rawFetch = tools.rawFetch; const bash_stdout_cap = tools.bash_stdout_cap; const bash_stderr_cap = tools.bash_stderr_cap; const webfetch_cap = tools.webfetch_cap; -const codedb_result_cap = tools.codedb_result_cap; const subagent = @import("subagent.zig"); const execSubagent = subagent.execSubagent; @@ -69,7 +68,6 @@ const edit_verify = @import("edit_verify.zig"); const edit_batch = @import("edit_batch.zig"); // batched edit_file spans (#476) const fsErrorText = edit_verify.fsErrorText; const preserveMode = edit_verify.preserveMode; -const hooks = @import("hooks.zig"); const telemetry = @import("telemetry.zig"); const learning_privacy = @import("learning_privacy.zig"); const no_local_tools = @import("no_local_tools.zig"); // #330: the hard --no-local-tools gate @@ -77,6 +75,7 @@ const native_fold = @import("native_fold.zig"); // folded native power tools: la const vision = @import("vision.zig"); // read_file stages images like MCP image results (#249) const input_util = @import("input_util.zig"); const imagegen = @import("imagegen.zig"); // #352: the codex-gated image tool (advertising lives in schema.zig/tool_gates.zig) +const codedb_exec = @import("codedb_exec.zig"); // native codedb dispatch (list_dir / status / path jail) /// Wall-clock ceiling for one *subagent* bash command. Subagents run on pool /// threads with no TTY, so there is no Esc to kill a runaway command — without @@ -101,12 +100,6 @@ test "isSshCommand: bare and pathed ssh, not scp or substrings" { try std.testing.expect(!isSshCommand("")); } -/// Wall-clock ceiling for one `codedb` query (#198). Every allowed subcommand -/// is a read that normally answers in seconds; a query that has not returned -/// in a minute is stuck, and before this it stayed stuck forever — the tool -/// blocked on EOF with no deadline at all. -const codedb_deadline_ms: u64 = 60 * 1000; - fn learningArgv(argv: *[10][]const u8, exe_path: []const u8, contribute: bool) usize { var argc: usize = 0; for ([_][]const u8{ exe_path, "--learning-privacy", if (contribute) "aggregate" else "local", "learn", "run" }) |arg| { @@ -414,11 +407,8 @@ fn execToolInner(ctx: ToolCtx, call: ToolCall) !ToolOutput { // #276: skipped entirely for a worktree-isolated agent — codedb's index is // built over the main checkout, not the scratch worktree, so a compact // read there could show stale or altogether wrong content. - if (want_compact and ctx.agent_cwd == null) { - if (main_mod.g_codedb_present == null) main_mod.g_codedb_present = skills.binOnPath(io, "codedb"); - if (main_mod.g_codedb_present == true and hooks.codedbFileIndexed(io, gpa, path)) { - if (try codedbCompactRead(gpa, io, path, start_line, end_line)) |out| return out; - } + if (want_compact) { + if (try codedb_exec.maybeCompactRead(ctx, path, start_line, end_line)) |out| return out; } // #276 P0-1: resolve under the agent's isolated worktree when set — // path itself stays relative (that's the agent's own view, used in @@ -462,50 +452,7 @@ fn execToolInner(ctx: ToolCtx, call: ToolCall) !ToolOutput { }, }; } - if (std.mem.eql(u8, call.name, "codedb")) { - const cmd = strField(input, "command") orelse return missingArg(gpa, "command"); - var it = std.mem.tokenizeAny(u8, cmd, " \t"); - const sub = it.next() orelse return .{ .text = try gpa.dupe(u8, "usage: codedb [args] — e.g. search , symbol , callers , outline "), .is_error = true }; - // Allowlist read-only subcommands: never run the long-lived daemons - // (serve/mcp) — they'd block this tool forever — or the destructive - // ones (update/nuke). - const ok_subs = [_][]const u8{ "search", "symbol", "callers", "find", "outline", "read", "tree", "context", "word", "deps", "glob", "ls", "file", "hot" }; - var allowed = false; - for (ok_subs) |s| if (std.mem.eql(u8, s, sub)) { - allowed = true; - }; - if (!allowed) return .{ .text = try std.fmt.allocPrint(gpa, "codedb subcommand '{s}' is not allowed here — use one of: search, symbol, callers, find, outline, read, tree, context, word, deps, glob, ls, file, hot", .{sub}), .is_error = true }; - var argv: std.ArrayList([]const u8) = .empty; - defer argv.deinit(gpa); - argv.append(gpa, "codedb") catch {}; - argv.append(gpa, sub) catch {}; - while (it.next()) |tok| argv.append(gpa, tok) catch {}; - // #198: this used to spawn by hand and block until EOF — no deadline, no - // process group, no Esc check — which is how abandoned sessions ended up - // owning dozens of `codedb search` children still asleep days later. The - // capped runner owns the group and tears it down on Esc or the deadline. - const run = runCappedWithOptions(gpa, io, argv.items, 512 * 1024, 4096, codedb_deadline_ms, toolRunOptions(null)) catch |e| switch (e) { - error.FileNotFound => return .{ .text = try gpa.dupe(u8, "codedb isn't installed — it's open source at github.com/justrach/codedb; install it, then run `codedb` once in the repo to index it"), .is_error = true }, - else => return failure(gpa, e), - }; - gpa.free(run.stderr); - const text = run.stdout; - if (run.timed_out) { - defer gpa.free(text); - return .{ .text = try std.fmt.allocPrint(gpa, "codedb {s} timed out after {d}s and was killed — narrow the query, or run it through bash if it really needs that long", .{ sub, codedb_deadline_ms / 1000 }), .is_error = true }; - } - if (text.len == 0) { - defer gpa.free(text); - return .{ .text = try gpa.dupe(u8, "(codedb returned nothing — try `codedb tree` to confirm the repo is indexed, or refine the query)") }; - } - // #440: this used to truncate anything past 64 KB, because an unbounded - // `read ` once dumped 500KB into a subagent's context and - // ballooned it to 160k tokens. The guard was right and its method was - // destructive: the same 500KB now becomes a handle at tool time, so the - // context is bounded harder than it ever was here AND the bytes survive - // for a targeted read of the part that mattered. - return .{ .text = text }; - } + if (std.mem.eql(u8, call.name, "codedb")) return codedb_exec.exec(ctx, input); // #337: the read/splice/write/VERIFY path lives in edit_verify.zig, where // the post-edit check sits ON the success path — a write that did not land // can no longer be reported as `replaced N occurrence(s)`. @@ -559,32 +506,6 @@ fn execToolInner(ctx: ToolCtx, call: ToolCall) !ToolOutput { return .{ .text = try std.fmt.allocPrint(gpa, "unknown tool: {s}", .{call.name}), .is_error = true }; } -/// Opt-in exploratory read via `codedb read [-L a-b] --compact`. Lossy view -/// for reasoning only; returns null on any codedb failure so the caller falls back -/// to the native byte-exact read (#66). -fn codedbCompactRead(gpa: Allocator, io: Io, path: []const u8, start: ?i64, end: ?i64) !?ToolOutput { - var argv: std.ArrayList([]const u8) = .empty; - defer argv.deinit(gpa); - var lbuf: [48]u8 = undefined; - try argv.append(gpa, "codedb"); - try argv.append(gpa, "read"); - try argv.append(gpa, path); - if (start != null and end != null and start.? >= 1 and end.? >= start.?) { - try argv.append(gpa, "-L"); - try argv.append(gpa, std.fmt.bufPrint(&lbuf, "{d}-{d}", .{ start.?, end.? }) catch return null); - } - try argv.append(gpa, "--compact"); - const run = runCapped(gpa, io, argv.items, codedb_result_cap, 4096, 0) catch return null; - defer gpa.free(run.stdout); - defer gpa.free(run.stderr); - const ok = switch (run.term) { - .exited => |c| c == 0, - else => false, - }; - if (!ok or run.stdout.len == 0) return null; - return ToolOutput{ .text = try std.fmt.allocPrint(gpa, "{s}\n[compact view — comments/blank lines stripped, line numbers shown; re-read WITHOUT compact before building an edit_file old_string]", .{run.stdout}) }; -} - test "internal learning respects the parent privacy ceiling" { var argv: [10][]const u8 = undefined; var len = learningArgv(&argv, "graff", false); @@ -596,4 +517,8 @@ test "internal learning respects the parent privacy ceiling" { test { // main.zig is at the 600-line cap; exec.zig is these modules' importer, so the compiled-in references live here (the reach check diffs the test binary, not which file holds the line) _ = @import("codedbpro_report.zig"); _ = @import("tool_balance.zig"); + _ = @import("codedb_exec.zig"); + _ = @import("list_dir.zig"); + _ = @import("codedb_health.zig"); + _ = @import("gitignore.zig"); } diff --git a/src/gitignore.zig b/src/gitignore.zig new file mode 100644 index 00000000..6c73feae --- /dev/null +++ b/src/gitignore.zig @@ -0,0 +1,288 @@ +//! gitignore matching for `codedb list_dir`. +//! +//! Subset of git's rules: comments, `!` negation, trailing-`/` directories, +//! leading-`/` (or mid-slash) anchored to the ignore file's directory, `*` / `?` +//! in a path segment, and `**` across segments. A parent directory that wins +//! as ignored hides its children (a `!` on a child does not pierce it). +//! `.git` itself is the walker's job, not this file. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +pub const Rule = struct { + negated: bool, + dir_only: bool, + /// Leading `/` or a `/` in the middle — match from this ignore file's dir. + anchored: bool, + pattern: []const u8, + /// Absolute directory that contained the ignore file (no trailing slash). + base: []const u8, +}; + +pub const Verdict = enum { none, ignore, include }; + +pub fn parse(arena: Allocator, text: []const u8, base: []const u8) ![]Rule { + var list: std.ArrayList(Rule) = .empty; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |raw| { + const line = trim(raw); + if (line.len == 0 or line[0] == '#') continue; + try list.append(arena, parseLine(line, base)); + } + return list.items; +} + +fn trim(s: []const u8) []const u8 { + return std.mem.trim(u8, s, " \t\r"); +} + +fn parseLine(line: []const u8, base: []const u8) Rule { + var s = line; + var negated = false; + if (s[0] == '!') { + negated = true; + s = s[1..]; + } + var dir_only = false; + if (s.len > 0 and s[s.len - 1] == '/') { + dir_only = true; + s = s[0 .. s.len - 1]; + } + var anchored = false; + if (s.len > 0 and s[0] == '/') { + anchored = true; + s = s[1..]; + } + if (std.mem.indexOfScalar(u8, s, '/') != null) anchored = true; + return .{ + .negated = negated, + .dir_only = dir_only, + .anchored = anchored, + .pattern = s, + .base = base, + }; +} + +fn under(path: []const u8, root: []const u8) bool { + if (!std.mem.startsWith(u8, path, root)) return false; + if (path.len == root.len) return true; + return path[root.len] == '/'; +} + +/// Path of `abs` relative to `base`, or null when `abs` is not under `base`. +/// The base directory itself yields empty string. +pub fn relTo(abs: []const u8, base: []const u8) ?[]const u8 { + if (!under(abs, base)) return null; + if (abs.len == base.len) return ""; + return abs[base.len + 1 ..]; +} + +fn globSeg(pat: []const u8, s: []const u8) bool { + if (pat.len == 0) return s.len == 0; + if (pat[0] == '*') { + var i: usize = 0; + while (i <= s.len) : (i += 1) { + if (globSeg(pat[1..], s[i..])) return true; + } + return false; + } + if (s.len == 0) return false; + if (pat[0] == '?' or pat[0] == s[0]) return globSeg(pat[1..], s[1..]); + return false; +} + +fn matchSegs(pat: []const []const u8, path: []const []const u8) bool { + if (pat.len == 0) return path.len == 0; + if (std.mem.eql(u8, pat[0], "**")) { + if (pat.len == 1) return true; + var i: usize = 0; + while (i <= path.len) : (i += 1) { + if (matchSegs(pat[1..], path[i..])) return true; + } + return false; + } + if (path.len == 0) return false; + if (!globSeg(pat[0], path[0])) return false; + return matchSegs(pat[1..], path[1..]); +} + +fn split(arena: Allocator, s: []const u8) ![]const []const u8 { + var list: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitScalar(u8, s, '/'); + while (it.next()) |p| { + if (p.len == 0) continue; + try list.append(arena, p); + } + return list.items; +} + +fn matchPattern(arena: Allocator, pattern: []const u8, local: []const u8, anchored: bool) !bool { + if (pattern.len == 0) return false; + const p = try split(arena, pattern); + const segs = try split(arena, local); + if (anchored) return matchSegs(p, segs); + var i: usize = 0; + while (i <= segs.len) : (i += 1) { + if (matchSegs(p, segs[i..])) return true; + } + return false; +} + +fn ruleHits(arena: Allocator, rule: Rule, abs: []const u8, is_dir: bool) !bool { + if (rule.dir_only and !is_dir) return false; + const local = relTo(abs, rule.base) orelse return false; + if (local.len == 0) return false; // the ignore file's own directory + return matchPattern(arena, rule.pattern, local, rule.anchored); +} + +/// Last matching rule wins. +pub fn verdict(arena: Allocator, rules: []const Rule, abs: []const u8, is_dir: bool) !Verdict { + var v: Verdict = .none; + for (rules) |r| { + if (try ruleHits(arena, r, abs, is_dir)) { + v = if (r.negated) .include else .ignore; + } + } + return v; +} + +/// True when `abs` or a parent under the walk is ignored. `walk_root` stops +/// the parent walk so we do not apply a rule to a path above the listing. +pub fn ignored(arena: Allocator, rules: []const Rule, walk_root: []const u8, abs: []const u8, is_dir: bool) !bool { + if (!under(abs, walk_root)) return false; + var start: usize = if (abs.len > walk_root.len) walk_root.len + 1 else abs.len; + while (start <= abs.len) { + const slash = if (start >= abs.len) abs.len else (std.mem.indexOfScalarPos(u8, abs, start, '/') orelse abs.len); + const prefix = abs[0..slash]; + const prefix_dir = slash < abs.len or is_dir; + const last = slash == abs.len; + switch (try verdict(arena, rules, prefix, prefix_dir)) { + .ignore => return true, + .include => if (last) return false, + .none => {}, + } + if (slash == abs.len) break; + start = slash + 1; + } + return false; +} + +fn hasGit(io: Io, dir: []const u8) bool { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const p = std.fmt.bufPrint(&buf, "{s}/.git", .{dir}) catch return false; + _ = Io.Dir.cwd().statFile(io, p, .{}) catch return false; + return true; +} + +fn parentOf(path: []const u8) ?[]const u8 { + if (path.len <= 1) return null; + const slash = std.mem.lastIndexOfScalar(u8, path, '/') orelse return null; + if (slash == 0) return path[0..1]; + return path[0..slash]; +} + +fn loadFile(io: Io, arena: Allocator, path: []const u8, base: []const u8, out: *std.ArrayList(Rule)) void { + const text = Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024)) catch return; + const extra = parse(arena, text, base) catch return; + out.appendSlice(arena, extra) catch {}; +} + +/// Climb from `abs_root` to the git root (or 32 parents) and load each +/// `.gitignore`, then `.git/info/exclude`. Parent files first so a nested +/// file's later rules win. +pub fn loadClimb(io: Io, arena: Allocator, abs_root: []const u8) ![]Rule { + var dirs: std.ArrayList([]const u8) = .empty; + var cur = abs_root; + var git_root: ?[]const u8 = null; + var n: usize = 0; + while (n < 32) : (n += 1) { + try dirs.append(arena, cur); + if (hasGit(io, cur)) { + git_root = cur; + break; + } + cur = parentOf(cur) orelse break; + } + // With a git root: parent files first (nested wins). Without one: only + // the walk root — climbing into /tmp/.gitignore would make listings + // depend on the host. + var out: std.ArrayList(Rule) = .empty; + if (git_root != null) { + var i: usize = dirs.items.len; + while (i > 0) { + i -= 1; + const d = dirs.items[i]; + var buf: [std.fs.max_path_bytes]u8 = undefined; + const gi = std.fmt.bufPrint(&buf, "{s}/.gitignore", .{d}) catch continue; + loadFile(io, arena, gi, d, &out); + } + if (git_root) |g| { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const ex = std.fmt.bufPrint(&buf, "{s}/.git/info/exclude", .{g}) catch return out.items; + loadFile(io, arena, ex, g, &out); + } + } else { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const gi = std.fmt.bufPrint(&buf, "{s}/.gitignore", .{abs_root}) catch return out.items; + loadFile(io, arena, gi, abs_root, &out); + } + return out.items; +} + +test "star and dir-only and negation" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const rules = try parse(a, "*.log\nbuild/\n!keep.log\n", "/work"); + try std.testing.expect(try ignored(a, rules, "/work", "/work/a.log", false)); + try std.testing.expect(!try ignored(a, rules, "/work", "/work/a.zig", false)); + try std.testing.expect(try ignored(a, rules, "/work", "/work/build", true)); + try std.testing.expect(try ignored(a, rules, "/work", "/work/build/x.o", false)); + try std.testing.expect(!try ignored(a, rules, "/work", "/work/keep.log", false)); +} + +test "rooted pattern does not match nested names" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const rules = try parse(a, "/secret\n", "/work"); + try std.testing.expect(try ignored(a, rules, "/work", "/work/secret", false)); + try std.testing.expect(!try ignored(a, rules, "/work", "/work/sub/secret", false)); +} + +test "nested ignore file only covers its subtree" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const rules = try parse(a, "*.tmp\n", "/work/src"); + try std.testing.expect(try ignored(a, rules, "/work", "/work/src/x.tmp", false)); + try std.testing.expect(!try ignored(a, rules, "/work", "/work/x.tmp", false)); +} + +test "without a git root only the walk dir gitignore applies" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + tmp.dir.writeFile(io, .{ .sub_path = ".gitignore", .data = "local.skip\n" }) catch unreachable; + tmp.dir.writeFile(io, .{ .sub_path = "local.skip", .data = "x" }) catch unreachable; + tmp.dir.writeFile(io, .{ .sub_path = "keep.txt", .data = "x" }) catch unreachable; + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPath(io, &buf); + const rules = try loadClimb(io, a, buf[0..n]); + try std.testing.expect(try ignored(a, rules, buf[0..n], try std.fmt.allocPrint(a, "{s}/local.skip", .{buf[0..n]}), false)); + try std.testing.expect(!try ignored(a, rules, buf[0..n], try std.fmt.allocPrint(a, "{s}/keep.txt", .{buf[0..n]}), false)); +} + +test "double-star and comments" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const rules = try parse(a, "# hi\n\n**/skip.dat\n", "/work"); + try std.testing.expect(try ignored(a, rules, "/work", "/work/skip.dat", false)); + try std.testing.expect(try ignored(a, rules, "/work", "/work/a/b/skip.dat", false)); + try std.testing.expect(!try ignored(a, rules, "/work", "/work/keep.dat", false)); +} diff --git a/src/list_dir.zig b/src/list_dir.zig new file mode 100644 index 00000000..87c15059 --- /dev/null +++ b/src/list_dir.zig @@ -0,0 +1,479 @@ +//! grok-build-style directory listing, served as `codedb list_dir`. +//! +//! Not a new catalog tool (that tax is how #574 lost its first A/B). The +//! model already has `codedb` and is told to prefer it over bash ls. This +//! subcommand is in-process — PathConfine, extra `--add-dir` roots, works +//! without a codedb binary. The same command lives in the codedb repo +//! (`src/list_dir.zig` there, issue #696) for CLI/MCP; keep the output +//! aligned. `codedb ls` / `tree` remain index queries. +//! +//! BFS seed of depth-1 first, then a budgeted deep walk. `.gitignore` (and +//! `.git/info/exclude`) hide noise. Output is capped at 10k characters; +//! unexpanded dirs collapse to `[N files in subtree: K *.ext, …]`. +//! `.git` is never listed. Other dotfiles stay visible (`.github` is load- +//! bearing here; grok-build hides every dot). + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const tools = @import("tools.zig"); +const harness_policy = @import("harness_policy.zig"); +const gitignore = @import("gitignore.zig"); + +pub const max_output_chars: usize = 10_000; +pub const max_walk_items: usize = 20_000; +pub const top_k_exts: usize = 3; + +const root_truncation_notice = + \\ ... + \\ + \\Note: this directory is too large to list fully. Try codedb list_dir on a narrower path, or use codedb search / bash. +; + +const walk_truncation_notice = + \\ + \\Note: there are more than 20000 items in the directory, so not all files may be shown. +; + +const Node = struct { + name: []const u8, + is_dir: bool, + depth: usize, + parent: ?*Node, + kids: std.ArrayList(*Node), + file_count: usize, + ext: std.StringHashMap(usize), + expanded: bool, +}; + +fn newNode(arena: Allocator, name: []const u8, is_dir: bool, depth: usize, parent: ?*Node) !*Node { + const n = try arena.create(Node); + n.* = .{ + .name = name, + .is_dir = is_dir, + .depth = depth, + .parent = parent, + .kids = .empty, + .file_count = 0, + .ext = std.StringHashMap(usize).init(arena), + .expanded = false, + }; + return n; +} + +fn extKey(arena: Allocator, name: []const u8) ![]const u8 { + const dot = std.mem.lastIndexOfScalar(u8, name, '.') orelse return "no-ext"; + if (dot == 0 or dot + 1 == name.len) return "no-ext"; + const raw = name[dot + 1 ..]; + const out = try arena.alloc(u8, raw.len); + for (raw, out) |c, *d| d.* = std.ascii.toLower(c); + return out; +} + +fn addFile(arena: Allocator, node: *Node, name: []const u8) !void { + const key = try extKey(arena, name); + var p: ?*Node = node; + while (p) |n| { + n.file_count += 1; + const gop = try n.ext.getOrPut(key); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += 1; + p = n.parent; + } +} + +fn join(arena: Allocator, a: []const u8, b: []const u8) ![]const u8 { + if (a.len == 0) return b; + return std.fmt.allocPrint(arena, "{s}/{s}", .{ a, b }); +} + +fn isGitComponent(name: []const u8) bool { + return std.mem.eql(u8, name, ".git"); +} + +const Walk = struct { + io: Io, + arena: Allocator, + root_abs: []const u8, + rules: std.ArrayList(gitignore.Rule), + items: usize = 0, + truncated: bool = false, +}; + +fn skip(w: *Walk, abs: []const u8, is_dir: bool) !bool { + if (isGitComponent(std.fs.path.basename(abs))) return true; + return gitignore.ignored(w.arena, w.rules.items, w.root_abs, abs, is_dir); +} + +fn fill(w: *Walk, node: *Node, rel: []const u8) !void { + const abs = if (rel.len == 0) w.root_abs else try join(w.arena, w.root_abs, rel); + var dir = Io.Dir.cwd().openDir(w.io, abs, .{ .iterate = true, .follow_symlinks = false }) catch return; + defer dir.close(w.io); + + var names: std.ArrayList(struct { name: []const u8, is_dir: bool }) = .empty; + var it = dir.iterate(); + var saw_ignore = false; + while (it.next(w.io) catch null) |ent| { + if (ent.kind == .sym_link) continue; + const name = try w.arena.dupe(u8, ent.name); + if (std.mem.eql(u8, name, ".gitignore") and rel.len > 0) saw_ignore = true; + const is_dir = ent.kind == .directory; + try names.append(w.arena, .{ .name = name, .is_dir = is_dir }); + } + if (saw_ignore) { + const gi = try join(w.arena, abs, ".gitignore"); + const text = Io.Dir.cwd().readFileAlloc(w.io, gi, w.arena, .limited(64 * 1024)) catch null; + if (text) |t| { + const extra = gitignore.parse(w.arena, t, abs) catch &.{}; + w.rules.appendSlice(w.arena, extra) catch {}; + } + } + + for (names.items) |e| { + if (isGitComponent(e.name)) continue; + const child_rel = if (rel.len == 0) e.name else try join(w.arena, rel, e.name); + const child_abs = try join(w.arena, w.root_abs, child_rel); + if (try skip(w, child_abs, e.is_dir)) continue; + const child = try newNode(w.arena, e.name, e.is_dir, node.depth + 1, node); + try node.kids.append(w.arena, child); + if (!e.is_dir) try addFile(w.arena, node, e.name); + w.items += 1; + if (w.items >= max_walk_items) { + w.truncated = true; + return; + } + } +} + +fn nameLess(_: void, a: *Node, b: *Node) bool { + const an = a.name; + const bn = b.name; + const n = @min(an.len, bn.len); + var i: usize = 0; + while (i < n) : (i += 1) { + const ca = std.ascii.toLower(an[i]); + const cb = std.ascii.toLower(bn[i]); + if (ca < cb) return true; + if (ca > cb) return false; + } + return an.len < bn.len; +} + +fn sortTree(n: *Node) void { + std.mem.sort(*Node, n.kids.items, {}, nameLess); + for (n.kids.items) |k| sortTree(k); +} + +const ExtPair = struct { k: []const u8, n: usize }; + +fn extLess(_: void, a: ExtPair, b: ExtPair) bool { + if (a.n != b.n) return a.n > b.n; + return std.mem.lessThan(u8, a.k, b.k); +} + +fn summary(arena: Allocator, n: *Node) ![]const u8 { + if (n.file_count == 0) return ""; + var pairs: std.ArrayList(ExtPair) = .empty; + var it = n.ext.iterator(); + while (it.next()) |e| try pairs.append(arena, .{ .k = e.key_ptr.*, .n = e.value_ptr.* }); + std.mem.sort(ExtPair, pairs.items, {}, extLess); + const take = @min(pairs.items.len, top_k_exts); + var aw: Io.Writer.Allocating = .init(arena); + const word: []const u8 = if (n.file_count == 1) "file" else "files"; + try aw.writer.print("[{d} {s} in subtree: ", .{ n.file_count, word }); + var shown: usize = 0; + for (pairs.items[0..take], 0..) |p, i| { + if (i > 0) try aw.writer.writeAll(", "); + if (std.mem.eql(u8, p.k, "no-ext")) { + try aw.writer.print("{d} *no-ext", .{p.n}); + } else { + try aw.writer.print("{d} *.{s}", .{ p.n, p.k }); + } + shown += p.n; + } + if (shown < n.file_count) try aw.writer.writeAll(", ..."); + try aw.writer.writeByte(']'); + return aw.writer.buffered(); +} + +fn writeKidLine(w: *Io.Writer, n: *Node, child: *Node) !void { + var i: usize = 0; + while (i < n.depth + 1) : (i += 1) try w.writeAll(" "); + try w.writeAll("- "); + try w.writeAll(child.name); + if (child.is_dir) try w.writeByte('/'); + try w.writeByte('\n'); +} + +fn renderKids(arena: Allocator, n: *Node) ![]const u8 { + var aw: Io.Writer.Allocating = .init(arena); + for (n.kids.items) |child| { + try writeKidLine(&aw.writer, n, child); + if (!child.is_dir) continue; + if (child.expanded) { + try aw.writer.writeAll(try renderKids(arena, child)); + } else { + const sum = try summary(arena, child); + if (sum.len == 0) continue; + var i: usize = 0; + while (i < n.depth + 2) : (i += 1) try aw.writer.writeAll(" "); + try aw.writer.writeAll(sum); + try aw.writer.writeByte('\n'); + } + } + return aw.writer.buffered(); +} + +fn truncateRoot(arena: Allocator, root: *Node, budget: usize) ![]const u8 { + var aw: Io.Writer.Allocating = .init(arena); + var used: usize = 0; + for (root.kids.items) |child| { + var chunk: Io.Writer.Allocating = .init(arena); + try writeKidLine(&chunk.writer, root, child); + if (child.is_dir) { + const sum = try summary(arena, child); + if (sum.len > 0) { + try chunk.writer.writeAll(" "); + try chunk.writer.writeAll(sum); + try chunk.writer.writeByte('\n'); + } + } + const bytes = chunk.writer.buffered(); + if (used + bytes.len > budget) break; + try aw.writer.writeAll(bytes); + used += bytes.len; + } + try aw.writer.writeAll(root_truncation_notice); + return aw.writer.buffered(); +} + +fn budgetExpand(arena: Allocator, root: *Node, max_chars: usize, walk_cut: bool) ![]const u8 { + const cutoff: []const u8 = if (walk_cut) walk_truncation_notice else ""; + if (root.kids.items.len == 0) return cutoff; + root.expanded = true; + const first = try renderKids(arena, root); + if (first.len > max_chars) { + return std.fmt.allocPrint(arena, "{s}{s}", .{ try truncateRoot(arena, root, max_chars), cutoff }); + } + var remaining = max_chars - first.len; + var q: std.ArrayList(*Node) = .empty; + for (root.kids.items) |k| { + if (k.is_dir) try q.append(arena, k); + } + var qi: usize = 0; + while (qi < q.items.len) : (qi += 1) { + const node = q.items[qi]; + node.expanded = true; + const expanded = try renderKids(arena, node); + const sum = try summary(arena, node); + const sum_cost: usize = if (sum.len == 0) 0 else (node.depth + 1) * 2 + sum.len + 1; + if (expanded.len > remaining + sum_cost) { + node.expanded = false; + continue; + } + remaining += sum_cost; + remaining -= expanded.len; + for (node.kids.items) |k| { + if (k.is_dir) try q.append(arena, k); + } + } + return std.fmt.allocPrint(arena, "{s}{s}", .{ try renderKids(arena, root), cutoff }); +} + +fn walkTree(io: Io, arena: Allocator, abs: []const u8) !struct { root: *Node, truncated: bool } { + const climbed = try gitignore.loadClimb(io, arena, abs); + var w: Walk = .{ + .io = io, + .arena = arena, + .root_abs = abs, + .rules = .empty, + }; + try w.rules.appendSlice(arena, climbed); + const root = try newNode(arena, "", true, 0, null); + try fill(&w, root, ""); + var q: std.ArrayList(struct { n: *Node, rel: []const u8 }) = .empty; + for (root.kids.items) |k| { + if (k.is_dir) try q.append(arena, .{ .n = k, .rel = k.name }); + } + var qi: usize = 0; + while (qi < q.items.len and !w.truncated) : (qi += 1) { + const item = q.items[qi]; + try fill(&w, item.n, item.rel); + for (item.n.kids.items) |k| { + if (k.is_dir) try q.append(arena, .{ .n = k, .rel = try join(arena, item.rel, k.name) }); + } + } + sortTree(root); + return .{ .root = root, .truncated = w.truncated }; +} + +fn stripDot(path: []const u8) []const u8 { + const t = std.mem.trim(u8, path, " \t"); + if (t.len == 0) return "."; + if (std.mem.eql(u8, t, ".") or std.mem.eql(u8, t, "./")) return "."; + if (std.mem.startsWith(u8, t, "./")) return t[2..]; + return t; +} + +/// Render a listing for an already-resolved absolute directory. +pub fn listAbs(io: Io, arena: Allocator, abs: []const u8, display: []const u8) ![]const u8 { + const walked = try walkTree(io, arena, abs); + const body = try budgetExpand(arena, walked.root, max_output_chars, walked.truncated); + const trimmed = std.mem.trimEnd(u8, body, "\n"); + if (trimmed.len == 0) return std.fmt.allocPrint(arena, "- {s}/", .{display}); + return std.fmt.allocPrint(arena, "- {s}/\n{s}", .{ display, trimmed }); +} + +fn resolveAbs(gpa: Allocator, path: []const u8, agent_cwd: ?[]const u8) ![]const u8 { + if (std.fs.path.isAbsolute(path)) return path; + if (agent_cwd) |base| return std.fmt.allocPrint(gpa, "{s}/{s}", .{ base, path }); + return path; +} + +/// PathConfine + list. `rest` is everything after `list_dir` in the codedb command. +pub fn run(io: Io, gpa: Allocator, rest: []const u8, agent_cwd: ?[]const u8) !tools.ToolOutput { + const path = stripDot(rest); + if (!harness_policy.confinedPath(path) or !harness_policy.noSymlinkEscape(io, path, agent_cwd)) + return tools.outsideCwd(gpa, path); + + var scratch = std.heap.ArenaAllocator.init(gpa); + defer scratch.deinit(); + const arena = scratch.allocator(); + + const resolved = try resolveAbs(gpa, path, agent_cwd); + defer if (resolved.ptr != path.ptr) gpa.free(resolved); + + const st = Io.Dir.cwd().statFile(io, resolved, .{}) catch |err| switch (err) { + error.FileNotFound => return .{ + .text = try std.fmt.allocPrint(gpa, "Error: {s} was not found.", .{path}), + .is_error = true, + }, + error.AccessDenied => return .{ + .text = try std.fmt.allocPrint(gpa, "Permission denied: {s}", .{path}), + .is_error = true, + }, + else => return .{ + .text = try std.fmt.allocPrint(gpa, "Error: {s} is not a valid directory.", .{path}), + .is_error = true, + }, + }; + if (st.kind == .file) return .{ + .text = try std.fmt.allocPrint(gpa, "Error: {s} is a file, not a directory.", .{path}), + .is_error = true, + }; + if (st.kind != .directory) return .{ + .text = try std.fmt.allocPrint(gpa, "Error: {s} is not a valid directory.", .{path}), + .is_error = true, + }; + + var buf: [std.fs.max_path_bytes]u8 = undefined; + var opened = Io.Dir.cwd().openDir(io, resolved, .{}) catch return .{ + .text = try std.fmt.allocPrint(gpa, "Error: {s} is not a valid directory.", .{path}), + .is_error = true, + }; + defer opened.close(io); + const n = opened.realPath(io, &buf) catch return .{ + .text = try std.fmt.allocPrint(gpa, "Error: {s} is not a valid directory.", .{path}), + .is_error = true, + }; + const abs = buf[0..n]; + const text = try listAbs(io, arena, abs, path); + return .{ .text = try gpa.dupe(u8, text) }; +} + +pub fn exec(ctx: tools.ToolCtx, rest: []const u8) !tools.ToolOutput { + return run(ctx.io, ctx.gpa, rest, ctx.agent_cwd); +} + +fn tmpAbs(io: Io, tmp: *std.testing.TmpDir, buf: *[std.fs.max_path_bytes]u8) ![]const u8 { + const n = try tmp.dir.realPath(io, buf); + return buf[0..n]; +} + +test "lists files and dirs, hides gitignore and .git" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + tmp.dir.writeFile(io, .{ .sub_path = ".gitignore", .data = "skip.log\nbuild/\n" }) catch unreachable; + tmp.dir.writeFile(io, .{ .sub_path = "keep.zig", .data = "x" }) catch unreachable; + tmp.dir.writeFile(io, .{ .sub_path = "skip.log", .data = "x" }) catch unreachable; + tmp.dir.createDirPath(io, "build") catch unreachable; + tmp.dir.writeFile(io, .{ .sub_path = "build/a.o", .data = "x" }) catch unreachable; + tmp.dir.createDirPath(io, ".git/objects") catch unreachable; + tmp.dir.writeFile(io, .{ .sub_path = ".git/HEAD", .data = "ref\n" }) catch unreachable; + tmp.dir.createDirPath(io, ".github") catch unreachable; + tmp.dir.writeFile(io, .{ .sub_path = ".github/ci.yml", .data = "x" }) catch unreachable; + + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const abs = try tmpAbs(io, &tmp, &buf); + const out = try listAbs(io, arena_state.allocator(), abs, "."); + try std.testing.expect(std.mem.indexOf(u8, out, "keep.zig") != null); + try std.testing.expect(std.mem.indexOf(u8, out, ".github") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "skip.log") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "build") == null); + try std.testing.expect(std.mem.indexOf(u8, out, ".git/") == null); +} + +test "fat sibling collapses; later sibling still listed" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + tmp.dir.createDirPath(io, "aaa") catch unreachable; + var i: usize = 0; + while (i < 800) : (i += 1) { + var name: [48]u8 = undefined; + const n = std.fmt.bufPrint(&name, "aaa/file_with_a_longer_name_{d:0>3}.zig", .{i}) catch unreachable; + tmp.dir.writeFile(io, .{ .sub_path = n, .data = "x" }) catch unreachable; + } + tmp.dir.createDirPath(io, "zzz") catch unreachable; + tmp.dir.writeFile(io, .{ .sub_path = "zzz/tail.md", .data = "x" }) catch unreachable; + + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const abs = try tmpAbs(io, &tmp, &buf); + const out = try listAbs(io, arena_state.allocator(), abs, "root"); + try std.testing.expect(std.mem.indexOf(u8, out, "- aaa/") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "- zzz/") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "files in subtree") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "*.zig") != null); + try std.testing.expect(out.len < max_output_chars + root_truncation_notice.len + 64); +} + +test "run refuses a file and an escaped path" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + tmp.dir.writeFile(io, .{ .sub_path = "only.txt", .data = "x" }) catch unreachable; + var buf: [std.fs.max_path_bytes]u8 = undefined; + const abs = try tmpAbs(io, &tmp, &buf); + + const as_file = try run(io, std.testing.allocator, "only.txt", abs); + defer std.testing.allocator.free(as_file.text); + try std.testing.expect(as_file.is_error); + try std.testing.expect(std.mem.indexOf(u8, as_file.text, "is a file") != null); + + const escaped = try run(io, std.testing.allocator, "../outside", abs); + defer std.testing.allocator.free(escaped.text); + try std.testing.expect(escaped.is_error); + + const listing = try run(io, std.testing.allocator, ".", abs); + defer std.testing.allocator.free(listing.text); + try std.testing.expect(!listing.is_error); + try std.testing.expect(std.mem.indexOf(u8, listing.text, "only.txt") != null); +} + +test "empty directory is a header only" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const abs = try tmpAbs(io, &tmp, &buf); + const out = try listAbs(io, arena_state.allocator(), abs, "empty"); + try std.testing.expectEqualStrings("- empty/", out); +} diff --git a/src/prompt_snapshot_tests.zig b/src/prompt_snapshot_tests.zig index 6faa242d..6bf98c9c 100644 --- a/src/prompt_snapshot_tests.zig +++ b/src/prompt_snapshot_tests.zig @@ -41,7 +41,7 @@ const golden_full_prompt = \\call read_file once with contains set to the exact key and answer from its \\output; do not request the whole file first. To navigate code — finding symbols, \\definitions, or where logic lives — prefer the codedb tool (it's indexed - \\and structural) over bash grep/find/ls. Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one + \\and structural) over bash grep/find/ls. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one \\is declined, try another approach or ask. Native file tools deliberately \\stay inside the current working directory. If the user explicitly names \\a repository or path outside it, the root agent may inspect and modify diff --git a/src/prompt_text.zig b/src/prompt_text.zig index ec6fb1d8..c399da20 100644 --- a/src/prompt_text.zig +++ b/src/prompt_text.zig @@ -38,7 +38,7 @@ pub const local_tools_note = \\call read_file once with contains set to the exact key and answer from its \\output; do not request the whole file first. To navigate code — finding symbols, \\definitions, or where logic lives — prefer the codedb tool (it's indexed - \\and structural) over bash grep/find/ls. Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one + \\and structural) over bash grep/find/ls. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one \\is declined, try another approach or ask. Native file tools deliberately \\stay inside the current working directory. If the user explicitly names \\a repository or path outside it, the root agent may inspect and modify diff --git a/src/prompts.zig b/src/prompts.zig index ef3df401..82e87f96 100644 --- a/src/prompts.zig +++ b/src/prompts.zig @@ -275,8 +275,8 @@ pub const ultracode_system_note = \\ \\ULTRACODE MODE: use the workflow tool for coding tasks. Tell \\code-exploration subagents to go through the repo with the codedb tool - \\(search / symbol / callers / outline / context) before reaching for - \\bash grep — it is indexed and structural. + \\(search / symbol / callers / outline / list_dir / context) before reaching for + \\bash grep/ls — it is indexed and structural. \\ \\ ++ shapes.shape_catalog_note; diff --git a/src/schema.zig b/src/schema.zig index 56b7fac7..56d770e6 100644 --- a/src/schema.zig +++ b/src/schema.zig @@ -107,7 +107,7 @@ const base_specs = [_]ToolSpec{ .{ .name = skill_docs.tool_name, .desc = skill_docs.tool_desc, .schema = skill_docs.tool_schema }, .{ .name = "codedb", - .desc = "Query codedb (github.com/justrach/codedb) — the code-intelligence index for this repo (fast & structural; prefer over grep/bash for navigating code). `command` is a codedb subcommand line: search | symbol [--body] | callers | outline | find | deps | tree | context | read .", + .desc = "Query codedb (github.com/justrach/codedb) — indexed + structural nav; prefer over bash grep/find/ls. `command` is a subcommand line: search | symbol [--body] | callers | outline | find | deps | tree | list_dir | status | context | read . list_dir is in-process (gitignore, PathConfine, works without the binary). status reports codedb.snapshot. Paths stay inside the cwd.", .schema = \\{"type": "object", "properties": {"command": {"type": "string", "description": "codedb subcommand + args, e.g. \"search parseHeader\", \"symbol buildBody --body\", \"callers switchProvider\""}}, "required": ["command"]} , diff --git a/src/shapes.zig b/src/shapes.zig index 9782be0d..b0e5db7a 100644 --- a/src/shapes.zig +++ b/src/shapes.zig @@ -399,8 +399,8 @@ const ultracode_explicit_head = \\this turn into multi-agent orchestration. The workflow tool is \\available for it — available, not mandatory. \\Tell code-exploration subagents to go through the repo with the - \\codedb tool (search / symbol / callers / outline / context) before - \\reaching for bash grep — it is indexed and structural. + \\codedb tool (search / symbol / callers / outline / list_dir / context) before + \\reaching for bash grep/ls — it is indexed and structural. \\Escalate to the smallest rung that fits: work solo for a task scoped \\to 1-2 known files; spawn ONE scout when exploration would flood your \\own context; spawn a fleet only for 3+ genuinely independent @@ -426,7 +426,7 @@ const ultracode_persistent_note = \\rather than freeforming a structure. Below that bar, do the work \\yourself: 1-2 known files is solo work. Tell code-exploration \\subagents to go through the repo with the codedb tool (search / - \\symbol / callers / outline / context) before reaching for bash grep — + \\symbol / callers / outline / list_dir / context) before reaching for bash grep/ls — \\it is indexed and structural.] ; diff --git a/src/startup.zig b/src/startup.zig index 277bf3df..3852b37d 100644 --- a/src/startup.zig +++ b/src/startup.zig @@ -122,6 +122,7 @@ pub fn buildSystemPrompt( if (environ.get("GRAFF_NO_REPO_MAP") == null) { if (@import("repo_map.zig").segment(io, arena)) |map| sys_normal = try std.fmt.allocPrint(arena, "{s}{s}", .{ sys_normal, map }); } + if (@import("codedb_health.zig").segment(io, arena)) |note| sys_normal = try std.fmt.allocPrint(arena, "{s}{s}", .{ sys_normal, note }); if (unattended) sys_normal = try std.fmt.allocPrint(arena, "{s}{s}", .{ sys_normal, prompts.unattended_note }); // Codex-style skills: one capability line per installed optional // companion (skills_registry) — metadata in context, --help on demand. diff --git a/src/tool_handle.zig b/src/tool_handle.zig index 5aa0d19e..a031decf 100644 --- a/src/tool_handle.zig +++ b/src/tool_handle.zig @@ -164,7 +164,7 @@ pub fn withFirstNote(arena: Allocator, r: Result, shown: *bool) ![]const u8 { fn markerText(arena: Allocator, path: ?[]const u8, total: usize, threshold: usize, shape: []const u8) ![]const u8 { if (path) |p| return std.fmt.allocPrint( arena, - "[tool result handle: {d} bytes, {s} — the COMPLETE result is at {s}. Slice what you need out of that file (read_file with start_line/end_line, a grep-style bash command, codedb) instead of re-running the tool (#440).]", + "[tool result handle: {d} bytes, {s} — the COMPLETE result is at {s}. Slice what you need out of that file (read_file with start_line/end_line, codedb outline/symbol/list_dir, or a grep-style bash command) instead of re-running the tool (#440).]", .{ total, shape, p }, ); return std.fmt.allocPrint( diff --git a/src/tools.zig b/src/tools.zig index 20593d30..7671e11a 100644 --- a/src/tools.zig +++ b/src/tools.zig @@ -200,9 +200,7 @@ pub fn codedbGuard(ctx: ToolCtx, call: ToolCall) ?ToolOutput { // First word must be a code scan/read utility (basename, so /usr/bin/grep // counts; sudo/env prefixes are intentionally not unwrapped). - const trimmed = std.mem.trim(u8, cmd, " \t"); - const word_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; - const tool = std.fs.path.basename(trimmed[0..word_end]); + const tool = bashToolName(cmd); const scanners = [_][]const u8{ "grep", "egrep", "fgrep", "rg", "ripgrep", "ag", "ack", "sed", "awk", "cat", "head", "tail", "wc", "nl", @@ -212,6 +210,13 @@ pub fn codedbGuard(ctx: ToolCtx, call: ToolCall) ?ToolOutput { for (scanners) |s| if (std.mem.eql(u8, s, tool)) { is_scanner = true; }; + const is_lister = isListerName(tool); + // list_dir is in-process — no codedb binary required. Redirect ls/find/tree + // even on a machine that has not installed the CLI yet. + if (is_lister) { + const msg = std.fmt.allocPrint(ctx.gpa, "blocked: don't shell out to `{s}` to list the tree. Use the codedb tool: list_dir (in-process, gitignore, 10k cap) · glob · tree (index). If you genuinely need raw bash here, set GRAFF_NO_CODEDB_GUARD=1.", .{tool}) catch return .{ .text = &.{}, .is_error = true }; + return .{ .text = msg, .is_error = true }; + } if (!is_scanner) return null; // …aimed at a concrete source file (not a glob — codedb glob/tree cover that). @@ -227,10 +232,22 @@ pub fn codedbGuard(ctx: ToolCtx, call: ToolCall) ?ToolOutput { // codedb result. Let bash through for un-indexed files (issue #54). if (!hooks.codedbFileIndexed(ctx.io, ctx.gpa, src_path)) return null; - const msg = std.fmt.allocPrint(ctx.gpa, "blocked: this repo is codedb-indexed — don't shell out to `{s}` to read or search source. Use the codedb tool (indexed + structural): search · symbol [--body] · callers · deps · outline · read · context . If you genuinely need raw bash here, set GRAFF_NO_CODEDB_GUARD=1.", .{tool}) catch return .{ .text = &.{}, .is_error = true }; + const msg = std.fmt.allocPrint(ctx.gpa, "blocked: this repo is codedb-indexed — don't shell out to `{s}` to read or search source. Use the codedb tool (indexed + structural): search · symbol [--body] · callers · deps · outline · read · list_dir · context . If you genuinely need raw bash here, set GRAFF_NO_CODEDB_GUARD=1.", .{tool}) catch return .{ .text = &.{}, .is_error = true }; return .{ .text = msg, .is_error = true }; } +fn bashToolName(cmd: []const u8) []const u8 { + const trimmed = std.mem.trim(u8, cmd, " \t"); + const word_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; + return std.fs.path.basename(trimmed[0..word_end]); +} + +fn isListerName(tool: []const u8) bool { + const listers = [_][]const u8{ "ls", "find", "tree" }; + for (listers) |s| if (std.mem.eql(u8, s, tool)) return true; + return false; +} + /// Extract the first concrete source file path from a bash command (after /// the command word). Returns null for globs or non-source tokens. Used by /// the codedb guard to check whether the target file is actually indexed @@ -443,6 +460,14 @@ test "codedbGuard.referencesSourceFile: concrete code paths, not globs/logs/conf try std.testing.expect(!referencesSourceFile("./build.zig")); } +test "codedbGuard: ls/find/tree are listers, grep is not" { + try std.testing.expect(isListerName(bashToolName("ls -la src"))); + try std.testing.expect(isListerName(bashToolName("/bin/find . -name '*.zig'"))); + try std.testing.expect(isListerName(bashToolName("tree -L 2"))); + try std.testing.expect(!isListerName(bashToolName("grep -n foo src/main.zig"))); + try std.testing.expect(!isListerName(bashToolName("echo ls"))); +} + test "blankText: webfetch empty-output heuristic" { try std.testing.expect(blankText("")); try std.testing.expect(blankText(" \n\n\t\r\n \n")); // SPA: pages of blank lines From a020b203ba887c969440b9d02b857681e1e43416 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 18:33:27 +0000 Subject: [PATCH 2/4] feat(codedb): prefer context/around/callpath over hop chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graphify spends fewer agent calls because query/explain/path return a neighborhood. codedb already has those primitives (context, callpath); the harness was advertising search→outline→read instead, and blocking callpath. around folds symbol --body + callers into one tool result (ADR 0014). --- CHANGELOG.md | 5 +- README.md | 2 +- .../0014-codedb-one-shot-over-hop-chains.md | 33 ++++++ docs/adr/README.md | 1 + src/codedb_around.zig | 110 ++++++++++++++++++ src/codedb_exec.zig | 16 ++- src/codedb_health.zig | 2 +- src/prompt_snapshot_tests.zig | 2 +- src/prompt_text.zig | 2 +- src/prompts.zig | 2 +- src/schema.zig | 4 +- src/shapes.zig | 6 +- src/skills.zig | 4 +- src/tool_handle.zig | 2 +- src/tools.zig | 4 +- 15 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 docs/adr/0014-codedb-one-shot-over-hop-chains.md create mode 100644 src/codedb_around.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index b2bb7d42..9d151e62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,10 @@ current is part of cutting a release. works without the binary), `status` reports `codedb.snapshot`, and path-bearing subcommands (`read`/`outline`/`deps`/`file`) share the file-tool jail. The codedb guard now redirects `ls`/`find`/`tree` even - when the CLI is missing (ADR 0013). + when the CLI is missing (ADR 0013). One-shot verbs first (ADR 0014): + `context `, `around ` (def+callers), `callpath A B` + (codedb's shortest call chain; `path` is an alias) — not + search→outline→read. ## v0.0.267 (2026-08-19) diff --git a/README.md b/README.md index 79d5fd1b..f651fa45 100644 --- a/README.md +++ b/README.md @@ -845,7 +845,7 @@ get web-request economics, and no boot-and-provision tax on time to first token. | `read_file` | built-in | `Io.Dir.cwd().readFileAlloc` (256 KB cap) | | `edit_file` | built-in | exact string replace; unique match required unless `replace_all` | | `write_file` | built-in | `Io.Dir.cwd().writeFile` | -| `codedb` | built-in | [codedb](https://github.com/justrach/codedb) index plus in-process `list_dir`/`status` (search/symbol/callers/outline/…) | +| `codedb` | built-in | [codedb](https://github.com/justrach/codedb) index plus in-process `list_dir`/`status`/`around` (context/callpath/symbol/…) | | `subagent` | built-in | this same agent loop, recursively (root agent only) | | `workflow` | built-in | phases of parallel subagents; `{{prev}}` carries results forward (root only) | | `todo_write`/`_read` | meta | mutate/read the agent's own task list | diff --git a/docs/adr/0014-codedb-one-shot-over-hop-chains.md b/docs/adr/0014-codedb-one-shot-over-hop-chains.md new file mode 100644 index 00000000..9f534ce1 --- /dev/null +++ b/docs/adr/0014-codedb-one-shot-over-hop-chains.md @@ -0,0 +1,33 @@ +# 0014. codedb one-shots beat hop chains + +Status: accepted 2026-08-20 + +## Context + +[graphify](https://github.com/safishamsi/graphify) spends fewer agent +calls because the verbs are neighborhoods, not lookups: `query` (task +subgraph), `explain` (ego graph), `path A B` (shortest chain). codedb +already has the same primitives — `context ` (task-shaped +composer), `callpath A B` (resolved call graph BFS), PageRank god-nodes +inside the index — and it is deterministic AST, not an LLM extractor. +The harness advertised `search` / `symbol` / `callers` / `outline` as +siblings, so models paid 3–5 round-trips for one question. `callpath` +was implemented in codedb and blocked here. + +## Decision + +Steer the native `codedb` tool at one-shots first: `context `, +`around ` (def body + callers in one harness call), `callpath A B` +(`path` is an alias). Narrow lookups stay available. Do not add a +graphify-style LLM extract / wiki / GRAPH_REPORT inside graff — that is +a different product. Do not add a sibling catalog tool for "ask the +graph." + +## Consequences + +- First-touch orientation is one model hop when the model follows the + description. +- `around` is two codedb CLI spawns, one tool result. A codedb release + with a native `explain` can replace the composer later. +- graphify still wins on multimodal docs/images and a written wiki; + codedb still wins on local, labeled, sub-millisecond structural edges. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8e754183..6565eaa7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,6 +23,7 @@ record only when you need the evidence or the edge cases. | [0010](0010-background-jobs-wait-for-exit.md) | `bash_output`/`agent_output` `wait_ms>0` blocks until exit (10h cap); do not poll every 30s. | | [0011](0011-prompt-cache-max-is-visible.md) | Prompt-cache max is `/cache` posture, not a new default; `/btw` rides the parent prefix. | | [0013](0013-list-dir-lives-in-codedb.md) | Directory listing is `codedb list_dir`, not a catalog tool; path-bearing codedb commands share the file-tool jail. | +| [0014](0014-codedb-one-shot-over-hop-chains.md) | Prefer `context` / `around` / `callpath` over search→outline→read; do not copy graphify's LLM extractors. | ## When to write one diff --git a/src/codedb_around.zig b/src/codedb_around.zig new file mode 100644 index 00000000..f5feb00d --- /dev/null +++ b/src/codedb_around.zig @@ -0,0 +1,110 @@ +//! One-shot codedb composers. Graphify's hop win is `explain` / `path` / +//! `query` — one call, a neighborhood. codedb already has the graph +//! (`callpath`) and a task composer (`context`); this module collapses the +//! remaining "symbol then callers" dance the model otherwise pays two +//! round-trips for. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const tools = @import("tools.zig"); +const ToolCtx = tools.ToolCtx; +const ToolOutput = tools.ToolOutput; +const jobs = @import("jobs.zig"); + +const deadline_ms: u64 = 60 * 1000; +const section_cap = 24 * 1024; + +pub fn firstIdent(rest: []const u8) ?[]const u8 { + var it = std.mem.tokenizeAny(u8, rest, " \t"); + while (it.next()) |tok| { + if (tok.len == 0 or tok[0] == '-') continue; + return tok; + } + return null; +} + +pub fn pathEnds(rest: []const u8) ?struct { from: []const u8, to: []const u8 } { + var it = std.mem.tokenizeAny(u8, rest, " \t"); + const from = it.next() orelse return null; + const to = it.next() orelse return null; + if (from[0] == '-' or to[0] == '-') return null; + return .{ .from = from, .to = to }; +} + +fn spawn(ctx: ToolCtx, argv_tail: []const []const u8) !ToolOutput { + const gpa = ctx.gpa; + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(gpa); + try argv.append(gpa, "codedb"); + try argv.appendSlice(gpa, argv_tail); + const run = jobs.runCappedWithOptions(gpa, ctx.io, argv.items, 512 * 1024, 4096, deadline_ms, jobs.toolRunOptions(ctx.agent_cwd)) catch |e| switch (e) { + error.FileNotFound => return .{ + .text = try gpa.dupe(u8, "codedb isn't installed — it's open source at github.com/justrach/codedb; install it, then run `codedb` once in the repo to index it. Folder listing still works: codedb list_dir ."), + .is_error = true, + }, + else => return tools.failure(gpa, e), + }; + gpa.free(run.stderr); + const text = run.stdout; + if (run.timed_out) { + defer gpa.free(text); + return .{ + .text = try std.fmt.allocPrint(gpa, "codedb {s} timed out after {d}s and was killed — narrow the query", .{ argv_tail[0], deadline_ms / 1000 }), + .is_error = true, + }; + } + return .{ .text = text }; +} + +fn take(gpa: Allocator, out: ToolOutput, cap: usize) ![]u8 { + defer gpa.free(out.text); + const src = if (out.text.len > cap) out.text[0..cap] else out.text; + return gpa.dupe(u8, src); +} + +pub fn execAround(ctx: ToolCtx, rest: []const u8) !ToolOutput { + const gpa = ctx.gpa; + const name = firstIdent(rest) orelse return .{ + .text = try gpa.dupe(u8, "usage: codedb around — definition body + callers in one call (graphify explain). Alias: explain ."), + .is_error = true, + }; + + const def = try spawn(ctx, &.{ "symbol", name, "--body" }); + if (def.is_error) return def; + const def_text = try take(gpa, def, section_cap); + defer gpa.free(def_text); + + const callers = try spawn(ctx, &.{ "callers", name }); + if (callers.is_error) { + defer gpa.free(callers.text); + return .{ .text = try std.fmt.allocPrint(gpa, "## definition\n{s}\n\n## callers\n{s}", .{ def_text, callers.text }), .is_error = true }; + } + const callers_text = try take(gpa, callers, section_cap); + defer gpa.free(callers_text); + + return .{ .text = try std.fmt.allocPrint(gpa, "## definition\n{s}\n\n## callers\n{s}", .{ def_text, callers_text }) }; +} + +pub fn execPath(ctx: ToolCtx, rest: []const u8) !ToolOutput { + const ends = pathEnds(rest) orelse return .{ + .text = try ctx.gpa.dupe(u8, "usage: codedb callpath — shortest resolved call chain (graphify path). Alias: path ."), + .is_error = true, + }; + return spawn(ctx, &.{ "callpath", ends.from, ends.to }); +} + +test "firstIdent skips flags" { + try std.testing.expectEqualStrings("handleCallpath", firstIdent("handleCallpath").?); + try std.testing.expectEqualStrings("Store", firstIdent("--body Store").?); + try std.testing.expect(firstIdent("") == null); + try std.testing.expect(firstIdent(" ") == null); +} + +test "pathEnds needs two identifiers" { + const got = pathEnds("handleContext handleCallpath").?; + try std.testing.expectEqualStrings("handleContext", got.from); + try std.testing.expectEqualStrings("handleCallpath", got.to); + try std.testing.expect(pathEnds("onlyOne") == null); + try std.testing.expect(pathEnds("") == null); +} diff --git a/src/codedb_exec.zig b/src/codedb_exec.zig index d3b840e1..8b81a65a 100644 --- a/src/codedb_exec.zig +++ b/src/codedb_exec.zig @@ -15,6 +15,7 @@ const ToolOutput = tools.ToolOutput; const harness_policy = @import("harness_policy.zig"); const list_dir = @import("list_dir.zig"); const codedb_health = @import("codedb_health.zig"); +const codedb_around = @import("codedb_around.zig"); const jobs = @import("jobs.zig"); const main_mod = @import("main.zig"); const hooks = @import("hooks.zig"); @@ -22,9 +23,10 @@ const hooks = @import("hooks.zig"); pub const deadline_ms: u64 = 60 * 1000; const ok_subs = [_][]const u8{ - "search", "symbol", "callers", "find", "outline", "read", "tree", - "list_dir", "status", "context", "word", "deps", "glob", "ls", - "file", "hot", + "search", "symbol", "callers", "find", "outline", "read", + "tree", "list_dir", "status", "context", "word", "deps", + "glob", "ls", "file", "hot", "callpath", "path", + "around", "explain", }; const path_subs = [_][]const u8{ "outline", "read", "deps", "file" }; @@ -73,13 +75,15 @@ pub fn exec(ctx: ToolCtx, input: std.json.Value) !ToolOutput { const cmd = tools.strField(input, "command") orelse return tools.missingArg(gpa, "command"); var it = std.mem.tokenizeAny(u8, cmd, " \t"); const sub = it.next() orelse return .{ - .text = try gpa.dupe(u8, "usage: codedb [args] — e.g. search , symbol , callers , outline , list_dir , status"), + .text = try gpa.dupe(u8, "usage: codedb [args] — start with context , around , or callpath A B; also search/symbol/callers/outline/list_dir/status"), .is_error = true, }; if (std.mem.eql(u8, sub, "list_dir")) return list_dir.exec(ctx, it.rest()); if (std.mem.eql(u8, sub, "status")) return codedb_health.exec(ctx); + if (std.mem.eql(u8, sub, "around") or std.mem.eql(u8, sub, "explain")) return codedb_around.execAround(ctx, it.rest()); + if (std.mem.eql(u8, sub, "path")) return codedb_around.execPath(ctx, it.rest()); if (!allowed(sub)) return .{ - .text = try std.fmt.allocPrint(gpa, "codedb subcommand '{s}' is not allowed here — use one of: search, symbol, callers, find, outline, read, tree, list_dir, status, context, word, deps, glob, ls, file, hot", .{sub}), + .text = try std.fmt.allocPrint(gpa, "codedb subcommand '{s}' is not allowed here — use one of: context, around, callpath, search, symbol, callers, find, outline, read, tree, list_dir, status, word, deps, glob, ls, file, hot, path, explain", .{sub}), .is_error = true, }; @@ -177,6 +181,8 @@ test "allowed subcommands include list_dir and status, not update" { try std.testing.expect(allowed("list_dir")); try std.testing.expect(allowed("status")); try std.testing.expect(allowed("search")); + try std.testing.expect(allowed("callpath")); + try std.testing.expect(allowed("around")); try std.testing.expect(!allowed("update")); try std.testing.expect(!allowed("nuke")); try std.testing.expect(!allowed("mcp")); diff --git a/src/codedb_health.zig b/src/codedb_health.zig index 874f11e8..7f0a2904 100644 --- a/src/codedb_health.zig +++ b/src/codedb_health.zig @@ -32,7 +32,7 @@ pub fn probe(io: Io, cwd: []const u8) Snapshot { pub fn render(gpa: Allocator, snap: Snapshot) ![]u8 { if (snap.present) { - return std.fmt.allocPrint(gpa, "codedb.snapshot present ({d} bytes). Structural queries (search/symbol/callers/outline) read this index. Folder listing does not: codedb list_dir .", .{snap.bytes}); + return std.fmt.allocPrint(gpa, "codedb.snapshot present ({d} bytes). Prefer one-shot queries on this index: context , around , callpath A B. Folder listing does not need it: codedb list_dir .", .{snap.bytes}); } return gpa.dupe(u8, "codedb index missing — no codedb.snapshot in this cwd. Run `codedb` once in the repo to build it. Folder listing still works without an index: codedb list_dir ."); } diff --git a/src/prompt_snapshot_tests.zig b/src/prompt_snapshot_tests.zig index 6bf98c9c..4ba784e6 100644 --- a/src/prompt_snapshot_tests.zig +++ b/src/prompt_snapshot_tests.zig @@ -41,7 +41,7 @@ const golden_full_prompt = \\call read_file once with contains set to the exact key and answer from its \\output; do not request the whole file first. To navigate code — finding symbols, \\definitions, or where logic lives — prefer the codedb tool (it's indexed - \\and structural) over bash grep/find/ls. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one + \\and structural) over bash grep/find/ls. Start with one call: codedb context , or around / callpath A B for a known symbol — do not search then outline then read unless those missed. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one \\is declined, try another approach or ask. Native file tools deliberately \\stay inside the current working directory. If the user explicitly names \\a repository or path outside it, the root agent may inspect and modify diff --git a/src/prompt_text.zig b/src/prompt_text.zig index c399da20..4dcd13e4 100644 --- a/src/prompt_text.zig +++ b/src/prompt_text.zig @@ -38,7 +38,7 @@ pub const local_tools_note = \\call read_file once with contains set to the exact key and answer from its \\output; do not request the whole file first. To navigate code — finding symbols, \\definitions, or where logic lives — prefer the codedb tool (it's indexed - \\and structural) over bash grep/find/ls. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one + \\and structural) over bash grep/find/ls. Start with one call: codedb context , or around / callpath A B for a known symbol — do not search then outline then read unless those missed. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one \\is declined, try another approach or ask. Native file tools deliberately \\stay inside the current working directory. If the user explicitly names \\a repository or path outside it, the root agent may inspect and modify diff --git a/src/prompts.zig b/src/prompts.zig index 82e87f96..f1e63a7e 100644 --- a/src/prompts.zig +++ b/src/prompts.zig @@ -275,7 +275,7 @@ pub const ultracode_system_note = \\ \\ULTRACODE MODE: use the workflow tool for coding tasks. Tell \\code-exploration subagents to go through the repo with the codedb tool - \\(search / symbol / callers / outline / list_dir / context) before reaching for + \\(context / around / callpath / list_dir) before reaching for \\bash grep/ls — it is indexed and structural. \\ \\ diff --git a/src/schema.zig b/src/schema.zig index 56d770e6..f84f78e3 100644 --- a/src/schema.zig +++ b/src/schema.zig @@ -107,9 +107,9 @@ const base_specs = [_]ToolSpec{ .{ .name = skill_docs.tool_name, .desc = skill_docs.tool_desc, .schema = skill_docs.tool_schema }, .{ .name = "codedb", - .desc = "Query codedb (github.com/justrach/codedb) — indexed + structural nav; prefer over bash grep/find/ls. `command` is a subcommand line: search | symbol [--body] | callers | outline | find | deps | tree | list_dir | status | context | read . list_dir is in-process (gitignore, PathConfine, works without the binary). status reports codedb.snapshot. Paths stay inside the cwd.", + .desc = "Query codedb (github.com/justrach/codedb) — indexed + structural nav; prefer over bash grep/find/ls. Prefer ONE call: context (defs + neighbors + snippets) · around (def body + callers) · callpath A B (shortest call chain). Narrow follow-ups: search | symbol [--body] | callers | outline | find | deps | tree | list_dir | status | read . Do not search then outline then read unless those missed. list_dir is in-process (gitignore, PathConfine, works without the binary). status reports codedb.snapshot. Paths stay inside the cwd.", .schema = - \\{"type": "object", "properties": {"command": {"type": "string", "description": "codedb subcommand + args, e.g. \"search parseHeader\", \"symbol buildBody --body\", \"callers switchProvider\""}}, "required": ["command"]} + \\{"type": "object", "properties": {"command": {"type": "string", "description": "Start with a one-shot: \"context how does auth work\", \"around handleCallpath\", \"callpath handleContext handleCallpath\". Narrow: \"symbol buildBody --body\", \"callers switchProvider\"."}}, "required": ["command"]} , }, }; diff --git a/src/shapes.zig b/src/shapes.zig index b0e5db7a..8bc2222b 100644 --- a/src/shapes.zig +++ b/src/shapes.zig @@ -399,7 +399,7 @@ const ultracode_explicit_head = \\this turn into multi-agent orchestration. The workflow tool is \\available for it — available, not mandatory. \\Tell code-exploration subagents to go through the repo with the - \\codedb tool (search / symbol / callers / outline / list_dir / context) before + \\codedb tool (context / around / callpath / list_dir) before \\reaching for bash grep/ls — it is indexed and structural. \\Escalate to the smallest rung that fits: work solo for a task scoped \\to 1-2 known files; spawn ONE scout when exploration would flood your @@ -425,8 +425,8 @@ const ultracode_persistent_note = \\prompt already has the shape catalog; instantiate one of those shapes \\rather than freeforming a structure. Below that bar, do the work \\yourself: 1-2 known files is solo work. Tell code-exploration - \\subagents to go through the repo with the codedb tool (search / - \\symbol / callers / outline / list_dir / context) before reaching for bash grep/ls — + \\subagents to go through the repo with the codedb tool (context / + \\around / callpath / list_dir) before reaching for bash grep/ls — \\it is indexed and structural.] ; diff --git a/src/skills.zig b/src/skills.zig index 3b476d6b..c5dc37dd 100644 --- a/src/skills.zig +++ b/src/skills.zig @@ -73,11 +73,11 @@ const McpNote = struct { server: []const u8, note: []const u8 }; pub const mcp_notes = [_]McpNote{ .{ .server = "codedbpro", - .note = "The codedb-pro MCP server is connected (mcp__codedbpro__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first for code search (search/symbol/callers/outline/find); reach for mcp__codedbpro__faster_search or meta_search only when codedb can't answer (raw literal/regex content matches, fuzzy queries, non-indexed files) — codedb-pro is metered. Prefer mcp__codedbpro__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__codedbpro__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__codedbpro__ call fails, fall back to read_file/codedb/bash and continue.", + .note = "The codedb-pro MCP server is connected (mcp__codedbpro__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath, then search/symbol/callers); reach for mcp__codedbpro__faster_search or meta_search only when codedb can't answer (raw literal/regex content matches, fuzzy queries, non-indexed files) — codedb-pro is metered. Prefer mcp__codedbpro__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__codedbpro__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__codedbpro__ call fails, fall back to read_file/codedb/bash and continue.", }, .{ .server = "muonry", - .note = "The muonry MCP server is connected (mcp__muonry__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first for code search (search/symbol/callers/outline/find); use mcp__muonry__search or faster_search only when codedb can't answer (raw literal/regex content matches, non-code or non-indexed files) — muonry is metered. Prefer mcp__muonry__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__muonry__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__muonry__ call fails, fall back to read_file/codedb/bash and continue.", + .note = "The muonry MCP server is connected (mcp__muonry__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath, then search/symbol/callers); use mcp__muonry__search or faster_search only when codedb can't answer (raw literal/regex content matches, non-code or non-indexed files) — muonry is metered. Prefer mcp__muonry__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__muonry__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__muonry__ call fails, fall back to read_file/codedb/bash and continue.", }, .{ .server = "smolify", diff --git a/src/tool_handle.zig b/src/tool_handle.zig index a031decf..9e2f40e1 100644 --- a/src/tool_handle.zig +++ b/src/tool_handle.zig @@ -164,7 +164,7 @@ pub fn withFirstNote(arena: Allocator, r: Result, shown: *bool) ![]const u8 { fn markerText(arena: Allocator, path: ?[]const u8, total: usize, threshold: usize, shape: []const u8) ![]const u8 { if (path) |p| return std.fmt.allocPrint( arena, - "[tool result handle: {d} bytes, {s} — the COMPLETE result is at {s}. Slice what you need out of that file (read_file with start_line/end_line, codedb outline/symbol/list_dir, or a grep-style bash command) instead of re-running the tool (#440).]", + "[tool result handle: {d} bytes, {s} — the COMPLETE result is at {s}. Slice what you need out of that file (read_file with start_line/end_line, codedb around/outline/list_dir, or a grep-style bash command) instead of re-running the tool (#440).]", .{ total, shape, p }, ); return std.fmt.allocPrint( diff --git a/src/tools.zig b/src/tools.zig index 7671e11a..8accb713 100644 --- a/src/tools.zig +++ b/src/tools.zig @@ -232,7 +232,7 @@ pub fn codedbGuard(ctx: ToolCtx, call: ToolCall) ?ToolOutput { // codedb result. Let bash through for un-indexed files (issue #54). if (!hooks.codedbFileIndexed(ctx.io, ctx.gpa, src_path)) return null; - const msg = std.fmt.allocPrint(ctx.gpa, "blocked: this repo is codedb-indexed — don't shell out to `{s}` to read or search source. Use the codedb tool (indexed + structural): search · symbol [--body] · callers · deps · outline · read · list_dir · context . If you genuinely need raw bash here, set GRAFF_NO_CODEDB_GUARD=1.", .{tool}) catch return .{ .text = &.{}, .is_error = true }; + const msg = std.fmt.allocPrint(ctx.gpa, "blocked: this repo is codedb-indexed — don't shell out to `{s}` to read or search source. Use the codedb tool (indexed + structural): context · around · callpath A B · symbol [--body] · callers · deps · outline · read · list_dir . If you genuinely need raw bash here, set GRAFF_NO_CODEDB_GUARD=1.", .{tool}) catch return .{ .text = &.{}, .is_error = true }; return .{ .text = msg, .is_error = true }; } @@ -303,7 +303,7 @@ pub fn companionRoute(ctx: ToolCtx, call: ToolCall) ?ToolOutput { fn companionNativeFallback(bare: []const u8) []const u8 { if (std.mem.eql(u8, bare, "read")) return "read_file tool"; if (std.mem.eql(u8, bare, "search") or std.mem.eql(u8, bare, "faster_search") or std.mem.eql(u8, bare, "meta_search")) - return "codedb tool (search/symbol/callers/outline), or bash grep"; + return "codedb tool (context/around/callpath), or bash grep"; if (std.mem.eql(u8, bare, "edit") or std.mem.eql(u8, bare, "patch") or std.mem.eql(u8, bare, "replace")) return "edit_file tool"; if (std.mem.eql(u8, bare, "create")) return "write_file tool"; From 7c7afa29938126ee8ace5b61c3aae06f8652b0e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 18:52:24 +0000 Subject: [PATCH 3/4] fix(sdk): bun-types Process.off is memoryPressure-only CI tsc failed on remote.test.ts: process.off("unhandledRejection") is not in bun-types' Process overloads. Cast through EventEmitter so the test still asserts create-rejection is observed, not unhandled. --- sdk/ts/remote.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sdk/ts/remote.test.ts b/sdk/ts/remote.test.ts index 1f2a0485..4bf03790 100644 --- a/sdk/ts/remote.test.ts +++ b/sdk/ts/remote.test.ts @@ -171,12 +171,15 @@ describe("RemoteHarness transport", () => { test("constructor observes create rejection even when the caller never awaits it", async () => { let unhandled = 0; const listener = () => { unhandled += 1; }; - process.on("unhandledRejection", listener); + // bun-types narrows Process.on/off to memoryPressure; Node's + // unhandledRejection is the event this test actually needs. + const proc = process as unknown as NodeJS.EventEmitter; + proc.on("unhandledRejection", listener); globalThis.fetch = (async () => { throw new Error("create failed"); }) as unknown as typeof fetch; const h = new RemoteHarness({ url: "http://bridge.test" }); live.push(h); await Bun.sleep(30); - process.off("unhandledRejection", listener); + proc.off("unhandledRejection", listener); expect(unhandled).toBe(0); await expect(h.sessionId).rejects.toThrow("create failed"); }); From 5db0e0e32f584721c88f2bec3e0baf0f72320d4c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 00:58:23 +0000 Subject: [PATCH 4/4] fix(codedb): advertise five commands, not the hop menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog was still one codedb tool whose description listed search, outline, read, and the rest — so models kept chaining. Advertise only context / around / callpath / list_dir / status. Hop verbs stay callable; they are not on the menu (ADR 0014). --- CHANGELOG.md | 5 +-- README.md | 24 +++++++------- .../0014-codedb-one-shot-over-hop-chains.md | 12 ++++--- docs/adr/README.md | 2 +- src/codedb_exec.zig | 31 ++++++++++++++----- src/prompt_snapshot_tests.zig | 2 +- src/prompt_text.zig | 2 +- src/schema.zig | 4 +-- src/skills.zig | 4 +-- src/tools.zig | 2 +- 10 files changed, 53 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d151e62..1f4d18ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,9 @@ current is part of cutting a release. file-tool jail. The codedb guard now redirects `ls`/`find`/`tree` even when the CLI is missing (ADR 0013). One-shot verbs first (ADR 0014): `context `, `around ` (def+callers), `callpath A B` - (codedb's shortest call chain; `path` is an alias) — not - search→outline→read. + (codedb's shortest call chain; `path` is an alias). The catalog + advertises only those plus `list_dir`/`status`; hop verbs stay + callable, not on the menu. ## v0.0.267 (2026-08-19) diff --git a/README.md b/README.md index f651fa45..46640fa4 100644 --- a/README.md +++ b/README.md @@ -209,13 +209,11 @@ whole-file reads. Instead of paying for a 2,000-line file to find one function, the model asks for exactly the shape it needs: ``` +codedb context "add a new provider" # defs + neighbors + snippets (one call) +codedb around switchProvider # definition body + callers (one call) +codedb callpath exec codedbGuard # shortest resolved call chain codedb list_dir src # live tree (gitignore, 10k cap; no index needed) codedb status # is codedb.snapshot present? -codedb outline src/main.zig # just the symbol map, functions/types, no bodies -codedb symbol switchProvider --body # one function, by name -codedb callers recordUsage # who calls it (call sites, not files) -codedb search "parse SSE" # indexed search, ranked hits, not a grep dump -codedb context "add a new provider" # task-shaped orientation across the codebase ``` Why this keeps token cost low: @@ -233,13 +231,13 @@ Why this keeps token cost low: - **Same index powers the `@` file picker** (`codedb glob`), so attaching a file by name never shells out to a directory walk. -Pure-Zig client to a pure-Zig server, zero dependencies on either side. Allowed -subcommands: `search · symbol · callers · find · outline · read · tree · -list_dir · status · context · word · deps · glob · ls · file · hot`. -`list_dir` and `status` are in-process (PathConfine; they work without the -binary). Path-bearing queries stay inside the cwd, same jail as `read_file`. -Not installed? Structural search says so and points at the one-line install; -folder listing still works. +Pure-Zig client to a pure-Zig server, zero dependencies on either side. The +catalog advertises five commands: `context · around · callpath · list_dir · +status`. Hop verbs (`search`, `outline`, `read`, …) still run if the model +already knows them; they are not on the menu. `list_dir` and `status` are +in-process (PathConfine; they work without the binary). Path-bearing queries +stay inside the cwd, same jail as `read_file`. Not installed? Structural +search says so and points at the one-line install; folder listing still works. --- @@ -845,7 +843,7 @@ get web-request economics, and no boot-and-provision tax on time to first token. | `read_file` | built-in | `Io.Dir.cwd().readFileAlloc` (256 KB cap) | | `edit_file` | built-in | exact string replace; unique match required unless `replace_all` | | `write_file` | built-in | `Io.Dir.cwd().writeFile` | -| `codedb` | built-in | [codedb](https://github.com/justrach/codedb) index plus in-process `list_dir`/`status`/`around` (context/callpath/symbol/…) | +| `codedb` | built-in | [codedb](https://github.com/justrach/codedb) — advertised: `context` / `around` / `callpath` / `list_dir` / `status` | | `subagent` | built-in | this same agent loop, recursively (root agent only) | | `workflow` | built-in | phases of parallel subagents; `{{prev}}` carries results forward (root only) | | `todo_write`/`_read` | meta | mutate/read the agent's own task list | diff --git a/docs/adr/0014-codedb-one-shot-over-hop-chains.md b/docs/adr/0014-codedb-one-shot-over-hop-chains.md index 9f534ce1..cde9c2ed 100644 --- a/docs/adr/0014-codedb-one-shot-over-hop-chains.md +++ b/docs/adr/0014-codedb-one-shot-over-hop-chains.md @@ -16,12 +16,14 @@ was implemented in codedb and blocked here. ## Decision -Steer the native `codedb` tool at one-shots first: `context `, +The catalog advertises five commands only: `context `, `around ` (def body + callers in one harness call), `callpath A B` -(`path` is an alias). Narrow lookups stay available. Do not add a -graphify-style LLM extract / wiki / GRAPH_REPORT inside graff — that is -a different product. Do not add a sibling catalog tool for "ask the -graph." +(`path` is an alias), `list_dir `, `status`. Hop verbs +(`search` / `symbol` / `callers` / `outline` / `read` / …) stay +callable so a follow-up is not a dead end; they are not named in the +tool description or the system prompt. Do not add a graphify-style LLM +extract / wiki / GRAPH_REPORT inside graff — that is a different +product. Do not add a sibling catalog tool for "ask the graph." ## Consequences diff --git a/docs/adr/README.md b/docs/adr/README.md index 6565eaa7..fed2494a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,7 +23,7 @@ record only when you need the evidence or the edge cases. | [0010](0010-background-jobs-wait-for-exit.md) | `bash_output`/`agent_output` `wait_ms>0` blocks until exit (10h cap); do not poll every 30s. | | [0011](0011-prompt-cache-max-is-visible.md) | Prompt-cache max is `/cache` posture, not a new default; `/btw` rides the parent prefix. | | [0013](0013-list-dir-lives-in-codedb.md) | Directory listing is `codedb list_dir`, not a catalog tool; path-bearing codedb commands share the file-tool jail. | -| [0014](0014-codedb-one-shot-over-hop-chains.md) | Prefer `context` / `around` / `callpath` over search→outline→read; do not copy graphify's LLM extractors. | +| [0014](0014-codedb-one-shot-over-hop-chains.md) | Advertise only `context` / `around` / `callpath` / `list_dir` / `status`; hop verbs stay callable, not on the menu. | ## When to write one diff --git a/src/codedb_exec.zig b/src/codedb_exec.zig index 8b81a65a..93f703b1 100644 --- a/src/codedb_exec.zig +++ b/src/codedb_exec.zig @@ -22,11 +22,14 @@ const hooks = @import("hooks.zig"); pub const deadline_ms: u64 = 60 * 1000; -const ok_subs = [_][]const u8{ - "search", "symbol", "callers", "find", "outline", "read", - "tree", "list_dir", "status", "context", "word", "deps", - "glob", "ls", "file", "hot", "callpath", "path", - "around", "explain", +/// What the catalog and prompt name. Hop verbs stay callable so a +/// follow-up is not a dead end, but they are not advertised (ADR 0014). +const advertised_subs = [_][]const u8{ "context", "around", "callpath", "list_dir", "status" }; + +const ok_subs = advertised_subs ++ [_][]const u8{ + "search", "symbol", "callers", "find", "outline", "read", "tree", + "word", "deps", "glob", "ls", "file", "hot", "path", + "explain", }; const path_subs = [_][]const u8{ "outline", "read", "deps", "file" }; @@ -75,7 +78,7 @@ pub fn exec(ctx: ToolCtx, input: std.json.Value) !ToolOutput { const cmd = tools.strField(input, "command") orelse return tools.missingArg(gpa, "command"); var it = std.mem.tokenizeAny(u8, cmd, " \t"); const sub = it.next() orelse return .{ - .text = try gpa.dupe(u8, "usage: codedb [args] — start with context , around , or callpath A B; also search/symbol/callers/outline/list_dir/status"), + .text = try gpa.dupe(u8, "usage: codedb — context · around · callpath A B · list_dir · status"), .is_error = true, }; if (std.mem.eql(u8, sub, "list_dir")) return list_dir.exec(ctx, it.rest()); @@ -83,7 +86,7 @@ pub fn exec(ctx: ToolCtx, input: std.json.Value) !ToolOutput { if (std.mem.eql(u8, sub, "around") or std.mem.eql(u8, sub, "explain")) return codedb_around.execAround(ctx, it.rest()); if (std.mem.eql(u8, sub, "path")) return codedb_around.execPath(ctx, it.rest()); if (!allowed(sub)) return .{ - .text = try std.fmt.allocPrint(gpa, "codedb subcommand '{s}' is not allowed here — use one of: context, around, callpath, search, symbol, callers, find, outline, read, tree, list_dir, status, word, deps, glob, ls, file, hot, path, explain", .{sub}), + .text = try std.fmt.allocPrint(gpa, "codedb command '{s}' is not allowed here — advertised: context · around · callpath A B · list_dir · status", .{sub}), .is_error = true, }; @@ -187,3 +190,17 @@ test "allowed subcommands include list_dir and status, not update" { try std.testing.expect(!allowed("nuke")); try std.testing.expect(!allowed("mcp")); } + +test "advertised surface is the one-shots, hop verbs stay callable" { + const advertised = advertised_subs; + try std.testing.expectEqual(@as(usize, 5), advertised.len); + for (advertised) |s| try std.testing.expect(allowed(s)); + try std.testing.expect(allowed("search")); + try std.testing.expect(allowed("outline")); + try std.testing.expect(allowed("read")); + for (advertised) |s| { + try std.testing.expect(!std.mem.eql(u8, s, "search")); + try std.testing.expect(!std.mem.eql(u8, s, "outline")); + try std.testing.expect(!std.mem.eql(u8, s, "read")); + } +} diff --git a/src/prompt_snapshot_tests.zig b/src/prompt_snapshot_tests.zig index 4ba784e6..b439b270 100644 --- a/src/prompt_snapshot_tests.zig +++ b/src/prompt_snapshot_tests.zig @@ -41,7 +41,7 @@ const golden_full_prompt = \\call read_file once with contains set to the exact key and answer from its \\output; do not request the whole file first. To navigate code — finding symbols, \\definitions, or where logic lives — prefer the codedb tool (it's indexed - \\and structural) over bash grep/find/ls. Start with one call: codedb context , or around / callpath A B for a known symbol — do not search then outline then read unless those missed. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one + \\and structural) over bash grep/find/ls. The codedb commands are context , around , callpath A B, list_dir , and status — one call, not a chain. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one \\is declined, try another approach or ask. Native file tools deliberately \\stay inside the current working directory. If the user explicitly names \\a repository or path outside it, the root agent may inspect and modify diff --git a/src/prompt_text.zig b/src/prompt_text.zig index 4dcd13e4..92d363e7 100644 --- a/src/prompt_text.zig +++ b/src/prompt_text.zig @@ -38,7 +38,7 @@ pub const local_tools_note = \\call read_file once with contains set to the exact key and answer from its \\output; do not request the whole file first. To navigate code — finding symbols, \\definitions, or where logic lives — prefer the codedb tool (it's indexed - \\and structural) over bash grep/find/ls. Start with one call: codedb context , or around / callpath A B for a known symbol — do not search then outline then read unless those missed. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one + \\and structural) over bash grep/find/ls. The codedb commands are context , around , callpath A B, list_dir , and status — one call, not a chain. List a folder with codedb list_dir (in-process; no index required). Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one \\is declined, try another approach or ask. Native file tools deliberately \\stay inside the current working directory. If the user explicitly names \\a repository or path outside it, the root agent may inspect and modify diff --git a/src/schema.zig b/src/schema.zig index f84f78e3..16383dd8 100644 --- a/src/schema.zig +++ b/src/schema.zig @@ -107,9 +107,9 @@ const base_specs = [_]ToolSpec{ .{ .name = skill_docs.tool_name, .desc = skill_docs.tool_desc, .schema = skill_docs.tool_schema }, .{ .name = "codedb", - .desc = "Query codedb (github.com/justrach/codedb) — indexed + structural nav; prefer over bash grep/find/ls. Prefer ONE call: context (defs + neighbors + snippets) · around (def body + callers) · callpath A B (shortest call chain). Narrow follow-ups: search | symbol [--body] | callers | outline | find | deps | tree | list_dir | status | read . Do not search then outline then read unless those missed. list_dir is in-process (gitignore, PathConfine, works without the binary). status reports codedb.snapshot. Paths stay inside the cwd.", + .desc = "Indexed code nav (github.com/justrach/codedb). ONE command — not a hop chain: context · around · callpath A B · list_dir · status. Prefer over bash grep/find/ls. list_dir is in-process (gitignore, PathConfine; no binary). status reports codedb.snapshot. Paths stay in the cwd.", .schema = - \\{"type": "object", "properties": {"command": {"type": "string", "description": "Start with a one-shot: \"context how does auth work\", \"around handleCallpath\", \"callpath handleContext handleCallpath\". Narrow: \"symbol buildBody --body\", \"callers switchProvider\"."}}, "required": ["command"]} + \\{"type": "object", "properties": {"command": {"type": "string", "description": "One of: \"context how does auth work\", \"around codedbGuard\", \"callpath exec codedbGuard\", \"list_dir src\", \"status\"."}}, "required": ["command"]} , }, }; diff --git a/src/skills.zig b/src/skills.zig index c5dc37dd..ae0c1574 100644 --- a/src/skills.zig +++ b/src/skills.zig @@ -73,11 +73,11 @@ const McpNote = struct { server: []const u8, note: []const u8 }; pub const mcp_notes = [_]McpNote{ .{ .server = "codedbpro", - .note = "The codedb-pro MCP server is connected (mcp__codedbpro__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath, then search/symbol/callers); reach for mcp__codedbpro__faster_search or meta_search only when codedb can't answer (raw literal/regex content matches, fuzzy queries, non-indexed files) — codedb-pro is metered. Prefer mcp__codedbpro__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__codedbpro__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__codedbpro__ call fails, fall back to read_file/codedb/bash and continue.", + .note = "The codedb-pro MCP server is connected (mcp__codedbpro__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath/list_dir/status); reach for mcp__codedbpro__faster_search or meta_search only when codedb can't answer (raw literal/regex content matches, fuzzy queries, non-indexed files) — codedb-pro is metered. Prefer mcp__codedbpro__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__codedbpro__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__codedbpro__ call fails, fall back to read_file/codedb/bash and continue.", }, .{ .server = "muonry", - .note = "The muonry MCP server is connected (mcp__muonry__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath, then search/symbol/callers); use mcp__muonry__search or faster_search only when codedb can't answer (raw literal/regex content matches, non-code or non-indexed files) — muonry is metered. Prefer mcp__muonry__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__muonry__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__muonry__ call fails, fall back to read_file/codedb/bash and continue.", + .note = "The muonry MCP server is connected (mcp__muonry__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath/list_dir/status); use mcp__muonry__search or faster_search only when codedb can't answer (raw literal/regex content matches, non-code or non-indexed files) — muonry is metered. Prefer mcp__muonry__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__muonry__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__muonry__ call fails, fall back to read_file/codedb/bash and continue.", }, .{ .server = "smolify", diff --git a/src/tools.zig b/src/tools.zig index 8accb713..44169ded 100644 --- a/src/tools.zig +++ b/src/tools.zig @@ -232,7 +232,7 @@ pub fn codedbGuard(ctx: ToolCtx, call: ToolCall) ?ToolOutput { // codedb result. Let bash through for un-indexed files (issue #54). if (!hooks.codedbFileIndexed(ctx.io, ctx.gpa, src_path)) return null; - const msg = std.fmt.allocPrint(ctx.gpa, "blocked: this repo is codedb-indexed — don't shell out to `{s}` to read or search source. Use the codedb tool (indexed + structural): context · around · callpath A B · symbol [--body] · callers · deps · outline · read · list_dir . If you genuinely need raw bash here, set GRAFF_NO_CODEDB_GUARD=1.", .{tool}) catch return .{ .text = &.{}, .is_error = true }; + const msg = std.fmt.allocPrint(ctx.gpa, "blocked: this repo is codedb-indexed — don't shell out to `{s}` to read or search source. Use the codedb tool: context · around · callpath A B · list_dir · status. If you genuinely need raw bash here, set GRAFF_NO_CODEDB_GUARD=1.", .{tool}) catch return .{ .text = &.{}, .is_error = true }; return .{ .text = msg, .is_error = true }; }