From 6755cb9353c62ca5187f281cf0437cd55487043b Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:05:40 +0200 Subject: [PATCH] gather: deterministic context-pack tool (aft_gather_context) Normalize path separators before comparison so absolute and relative spellings of the same file dedupe on Windows. Path::is_absolute() is false for rooted-no-drive paths there, and strip_prefix emits backslashes, so the same symbol previously rendered twice in one pack. Backslash is a legal filename character on Unix, so a file literally named 'a\b.rs' now normalizes to 'a/b.rs' and renders a not-found stub. Accepted: matches normalize_path_for_compare, fails gracefully. --- ARCHITECTURE.md | 12 +- README.md | 1 + STRUCTURE.md | 4 +- crates/aft/src/commands/gather.rs | 1665 +++++++++++++++++ crates/aft/src/commands/mod.rs | 1 + crates/aft/src/main.rs | 1 + crates/aft/src/subc/manifest.rs | 11 +- crates/aft/src/subc_tool_schemas.json | 29 + crates/aft/src/subc_translate.rs | 102 + .../aft/tests/integration/subc_bridge_test.rs | 2 +- docs/v0.49-agent-prefix-capture.json | 64 +- docs/v0.49-agent-surface-manifest.json | 58 +- docs/v0.49-legacy-vocabulary-allowlist.json | 262 ++- .../v0.49-unified-tool-surface-inventory.json | 8 +- .../__tests__/subc-tool-schemas-fresh.test.ts | 2 +- .../tool-surface-transport-invariant.test.ts | 46 + .../opencode-plugin/src/subc-tool-schemas.ts | 5 + .../opencode-plugin/src/tool-registration.ts | 10 +- packages/opencode-plugin/src/tools/gather.ts | 93 + 19 files changed, 2258 insertions(+), 118 deletions(-) create mode 100644 crates/aft/src/commands/gather.rs create mode 100644 packages/opencode-plugin/src/tools/gather.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0fde86fa3..bb82c0069 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,7 +59,7 @@ **Protocol and command layer:** - Purpose: Accept NDJSON requests, route tool calls via the unified `tool_call` command, and dispatch them to focused command handlers. - Location: `crates/aft/src/main.rs`, `crates/aft/src/protocol.rs`, `crates/aft/src/commands/`, `crates/aft/src/run_tool_call.rs`, `crates/aft/src/runtime_drain.rs`, `crates/aft/src/subc_translate.rs`, `crates/aft/src/subc_format.rs` -- Contains: Request dispatch, response encoding, a unified `tool_call` routing engine, tool-to-command translation mapping, server-rendered agent-facing text formatting (with directory outlines formatted as text unwrapping JSON envelopes), control channel 0 health check responder, and standalone command handlers for read/write/edit/apply_patch/delete_file/move_file/outline/zoom/bash/bash_orchestrate/bash_status/bash_wait_detach/batch/grep/glob/search/imports/refactor/LSP/inspect/conflicts/checkpoints/state +- Contains: Request dispatch, response encoding, a unified `tool_call` routing engine, tool-to-command translation mapping, server-rendered agent-facing text formatting (with directory outlines formatted as text unwrapping JSON envelopes), control channel 0 health check responder, and standalone command handlers for read/write/edit/apply_patch/delete_file/move_file/outline/zoom/bash/bash_orchestrate/bash_status/bash_wait_detach/batch/grep/glob/search/gather/imports/refactor/LSP/inspect/conflicts/checkpoints/state - Depends on: `crates/aft/src/context.rs`, `crates/aft/src/parser.rs`, `crates/aft/src/callgraph.rs`, `crates/aft/src/callgraph_store/mod.rs`, `crates/aft/src/edit.rs`, `crates/aft/src/semantic_index.rs`, `crates/aft/src/search_index.rs`, `crates/aft/src/compress/` - Used by: `packages/aft-bridge/src/bridge.ts` @@ -110,6 +110,14 @@ 3. Classify query shape (prose vs code) using the query shape parser -- `crates/aft/src/query_shape.rs`. Identify "type-concept identifier queries" (TitleCase PascalCase types combined with lowercase concepts) to trigger definition semantic priors. 4. Serve `grep` (trigram, full-text) and `aft_search` (semantic + hybrid) queries, delegating to `GrepExecutor` for accelerated path evaluation and enforcing execution safety limits (like `MAX_FALLBACK_WALK_FILES` and `FALLBACK_WALK_BUDGET`) during fallback walks when indexes are building or unavailable -- `crates/aft/src/grep_executor.rs`, `crates/aft/src/commands/grep.rs`, `crates/aft/src/commands/semantic_search.rs`. Interactive query embeddings are bounded by a dedicated `query_timeout_ms` configuration (clamped to 500..15000ms, defaulting to 3000ms) enforced via a `QueryBudget` to keep interactive requests fast without affecting background build/refresh timeouts, falling back to lexical search if query embedding fails or times out. Downrank generated documentation artifacts (e.g. minified CSS/JS, maps, SVGs) in lexical and hybrid search results. For external search requests, resolve and cache external git roots, querying cached read-only search and semantic indexes from the `borrowed_index_cache` (capped at 4 concurrent entries) to avoid redundant git probes and disk parsing. +**Context-pack gather flow:** + +1. Accept one of two mutually exclusive modes -- `crates/aft/src/commands/gather.rs`, `crates/aft/src/subc_translate.rs::translate_gather`. In question mode, seed with a natural-language query (semantic search). In agent-facing symbol mode, seed with a `(symbol, path)` pair (callgraph impact: depth-1 callers via `impact` + depth-1 callees via `call_tree`). The plugin maps `path` to the internal `filePath` wire key before translation. Mode validation rejects both or neither with `invalid_request`, and the OpenCode plugin enforces the same XOR at the schema boundary in `packages/opencode-plugin/src/tools/gather.ts`. +2. Resolve seeds and 1-hop neighbors from the persisted indexes -- `crates/aft/src/callgraph_store/mod.rs`, `crates/aft/src/commands/semantic_search.rs`. Question mode routes through `handle_semantic_search` (the same pipeline as `aft_search`), so hybrid semantic + lexical scoring and the `query_timeout_ms` budget apply. Symbol mode uses `impact` and `call_tree` against the SQLite store, dropping paths outside the project root via `pending_path_in_roots` containment. Unresolved external callees collapse to a single summary line; seeds and unresolved callers are never suppressed. +3. Dedupe candidates by canonicalized `(file, symbol)` (project-root-prefixed absolute paths merged with relative hits) -- `crates/aft/src/commands/gather.rs::dedup_by_file_and_name`. Seeds win on conflict; the same canonical symbol reached through two routes is rendered once. +4. Render each candidate's symbol body within a hard line budget (default 400, cap 800) via `render_symbol_within_budget` -- `crates/aft/src/commands/gather.rs::render_symbol_section`, `crates/aft/src/commands/symbol_render.rs`. Grep-fallback hits resolve to their containing symbol by line containment (`resolve_containing_symbol`); hits with no containing symbol stay visible one-line stubs so nothing is silently dropped. Over-budget candidates are emitted under `## Beyond budget (zoom to expand)` as stubs rather than truncated, so the agent can see what was excluded. +5. Flag index degradation honestly in the pack header. A `semantic_status: "building"` field surfaces when the semantic index is still building AND no symbol-mode seed resolved -- suppressed the moment any symbol seed provides a real anchor. Resolve relative paths against `project_root` to keep `file:line` headers stable across worktree mounts. Follow the tri-state honest-reporting convention (`crates/aft/src/protocol.rs` `Response` doc-comment) so the agent can distinguish empty scope, partial pack, and complete pack without guessing. + **File read flow:** 1. Map read arguments and validate boundary permissions -- `packages/opencode-plugin/src/tools/reading.ts`, `packages/pi-plugin/src/tools/reading.ts`. Under project-root path restriction, allow restricted reading of files outside the project root if they are session-owned bash artifact outputs (stdout, stderr, exit code, or pty outputs) registered under the requesting session ID (validated via `AppContext::validate_read_path` using `BgTaskRegistry::is_session_owned_artifact_path`), while strictly rejecting any mutations (which continue to enforce project root boundaries via `AppContext::validate_path`). The plugin skips the external-directory permission prompt for session-owned bash task artifacts under the AFT storage root when performing server-validated reads, avoiding hangs in unattended runs. @@ -191,7 +199,7 @@ **Tool groups (OpenCode):** - Purpose: Group related OpenCode tool definitions by capability surface. -- Location: `packages/opencode-plugin/src/tools/hoisted.ts`, `packages/opencode-plugin/src/tools/reading.ts`, `packages/opencode-plugin/src/tools/imports.ts`, `packages/opencode-plugin/src/tools/navigation.ts`, `packages/opencode-plugin/src/tools/refactoring.ts`, `packages/opencode-plugin/src/tools/safety.ts`, `packages/opencode-plugin/src/tools/conflicts.ts`, `packages/opencode-plugin/src/tools/ast.ts`, `packages/opencode-plugin/src/tools/bash.ts`, `packages/opencode-plugin/src/tools/bash_watch.ts`, `packages/opencode-plugin/src/tools/bash_write.ts`, `packages/opencode-plugin/src/tools/inspect.ts`, `packages/opencode-plugin/src/tools/search.ts`, `packages/opencode-plugin/src/tools/semantic.ts`, `packages/opencode-plugin/src/tools/permissions.ts`, `packages/opencode-plugin/src/tools/hoisted-internals.ts` +- Location: `packages/opencode-plugin/src/tools/hoisted.ts`, `packages/opencode-plugin/src/tools/reading.ts`, `packages/opencode-plugin/src/tools/imports.ts`, `packages/opencode-plugin/src/tools/navigation.ts`, `packages/opencode-plugin/src/tools/refactoring.ts`, `packages/opencode-plugin/src/tools/safety.ts`, `packages/opencode-plugin/src/tools/conflicts.ts`, `packages/opencode-plugin/src/tools/ast.ts`, `packages/opencode-plugin/src/tools/bash.ts`, `packages/opencode-plugin/src/tools/bash_watch.ts`, `packages/opencode-plugin/src/tools/bash_write.ts`, `packages/opencode-plugin/src/tools/inspect.ts`, `packages/opencode-plugin/src/tools/search.ts`, `packages/opencode-plugin/src/tools/semantic.ts`, `packages/opencode-plugin/src/tools/gather.ts`, `packages/opencode-plugin/src/tools/permissions.ts`, `packages/opencode-plugin/src/tools/hoisted-internals.ts` - Pattern: Thin TypeScript adapters delegating to the unified `tool_call` transport **Tool groups (Pi):** diff --git a/README.md b/README.md index 9a2d59e7d..3267a8e8f 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ AFT is **1 of the 3 plugins you'll ever need.** It perceives and acts; Magic Con - **`aft_search`**: find code by *meaning* when grep keywords fall short. Hybrid semantic + lexical retrieval over an indexed codebase, with local, OpenAI-compatible, or Ollama embedding backends. - **`aft_callgraph`**: follow callers, callees, data flow, impact analysis, and the shortest call path between two symbols across the workspace. - **`aft_inspect`**: a one-call codebase-health report covering LSP errors and warnings, TODOs, metrics, dead code, unused exports, and duplicates. The Problems and inspections panels an IDE keeps open, on demand. + - **`aft_gather_context`**: assemble a bounded context pack — ranked, deduped, budgeted verbatim code evidence — in one call instead of a serial `search → outline → zoom → callgraph` chain. Seed from a `question` (semantic) or a `symbol`+`path` (callgraph), expand one hop, render within a hard line budget. - **`grep` / `glob`**: trigram-indexed regex search and file discovery, built in the background, persisted to disk, and kept fresh by a file watcher. --- diff --git a/STRUCTURE.md b/STRUCTURE.md index e389ffc5d..bc75d59c3 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -50,7 +50,7 @@ opencode-aft/ **`crates/aft/src/commands/`:** - Purpose: Add one handler file per protocol command. - Contains: ~60 command-specific request parsing and response generation modules -- Key files: `crates/aft/src/commands/tool_call.rs`, `crates/aft/src/commands/read.rs`, `crates/aft/src/commands/write.rs`, `crates/aft/src/commands/apply_patch.rs`, `crates/aft/src/commands/bash_orchestrate.rs`, `crates/aft/src/commands/bash_wait_detach.rs`, `crates/aft/src/commands/outline.rs`, `crates/aft/src/commands/zoom.rs`, `crates/aft/src/commands/bash.rs`, `crates/aft/src/commands/grep.rs`, `crates/aft/src/commands/semantic_search.rs`, `crates/aft/src/commands/configure.rs` +- Key files: `crates/aft/src/commands/tool_call.rs`, `crates/aft/src/commands/read.rs`, `crates/aft/src/commands/write.rs`, `crates/aft/src/commands/apply_patch.rs`, `crates/aft/src/commands/bash_orchestrate.rs`, `crates/aft/src/commands/bash_wait_detach.rs`, `crates/aft/src/commands/outline.rs`, `crates/aft/src/commands/zoom.rs`, `crates/aft/src/commands/gather.rs`, `crates/aft/src/commands/bash.rs`, `crates/aft/src/commands/grep.rs`, `crates/aft/src/commands/semantic_search.rs`, `crates/aft/src/commands/configure.rs` **`crates/aft/src/compress/`:** - Purpose: Provide tiered output compression for hoisted bash commands. @@ -119,7 +119,7 @@ opencode-aft/ **`packages/opencode-plugin/src/tools/`:** - Purpose: Group OpenCode tool definitions by capability area. -- Contains: Thin adapters for hoisted (advertising `filePath` on OpenCode for read/write/edit to honor host UI header display contract), reading, import, navigation, refactor, safety, bash, conflict, AST, search, semantic, and inspect tools; permissions and internals helpers +- Contains: Thin adapters for hoisted (advertising `filePath` on OpenCode for read/write/edit to honor host UI header display contract), reading, import, navigation, refactor, safety, bash, conflict, AST, search, semantic, gather, and inspect tools; permissions and internals helpers - Key files: `packages/opencode-plugin/src/tools/_shared.ts`, `packages/opencode-plugin/src/tools/hoisted.ts`, `packages/opencode-plugin/src/tools/reading.ts`, `packages/opencode-plugin/src/tools/refactoring.ts`, `packages/opencode-plugin/src/tools/bash.ts`, `packages/opencode-plugin/src/tools/inspect.ts`, `packages/opencode-plugin/src/tools/search.ts` **`packages/pi-plugin/`:** diff --git a/crates/aft/src/commands/gather.rs b/crates/aft/src/commands/gather.rs new file mode 100644 index 000000000..a7d37ba3e --- /dev/null +++ b/crates/aft/src/commands/gather.rs @@ -0,0 +1,1665 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::commands::callgraph_store_adapter::{ + building_response, call_tree_result, impact_result, store_error_response, unavailable_response, +}; +use crate::commands::semantic_search::handle_semantic_search; +use crate::commands::symbol_render::{ + render_symbol_within_budget, symbol_kind_string, BudgetedSymbolRenderStatus, +}; +use crate::context::{AppContext, CallgraphStoreAccess}; +use crate::grep_executor; +use crate::parser::detect_language; +use crate::protocol::{RawRequest, Response}; + +const DEFAULT_BUDGET: usize = 400; +const MAX_BUDGET: usize = 800; + +/// Render-error marker matched by callee-suppression guard in build_pack. +/// The guard suppresses unresolved external callees from the stub list; +/// this constant ties the producer (render_symbol_section) and consumer +/// (suppression check) so the literal cannot drift apart. +const UNRESOLVED_MARKER: &str = "(symbol not resolved)"; + +/// Provenance prefix for callee neighbors — used by the suppression guard +/// to scope unresolved suppression to external callees only (not seeds +/// or callers, which remain visible stubs). +const CALLEE_PROVENANCE_PREFIX: &str = "callee-of-"; + +/// Normalize a file path to a consistent form for deduplication. +/// Converts absolute paths to repo-relative by stripping the project root +/// prefix, so that `/home/user/project/src/a.rs` and `src/a.rs` match. +fn normalize_file_path(raw: &str, project_root: &Path) -> String { + let normalized_raw = raw.replace('\\', "/"); + let normalized_root = project_root.to_string_lossy().replace('\\', "/"); + let path = Path::new(&normalized_raw); + let root = Path::new(&normalized_root); + if let Ok(stripped) = path.strip_prefix(root) { + return stripped.to_string_lossy().replace('\\', "/"); + } + // Strip any leading `./` for consistent relative form. + normalized_raw.trim_start_matches("./").to_string() +} + +/// Resolve the symbol that contains a given line in a file. +/// Uses `list_symbols` to enumerate all symbols, then returns the one +/// whose range contains `line` (1-based). Returns `None` when no symbol +/// covers the line (comment, blank, import, etc.). +fn resolve_containing_symbol( + file_path: &Path, + line: u32, + ctx: &AppContext, +) -> Option<(String, u32)> { + if line == 0 { + return None; + } + let symbols = ctx.provider().list_symbols(file_path).ok()?; + let line_0b = line.saturating_sub(1); + // Prefer the innermost (smallest) containing symbol. + symbols + .iter() + .filter(|s| s.range.start_line <= line_0b && line_0b <= s.range.end_line) + .min_by_key(|s| { + // Smaller range = more specific (innermost). + s.range.end_line.saturating_sub(s.range.start_line) + }) + .map(|s| (s.name.clone(), s.range.start_line.saturating_add(1))) +} + +/// A candidate symbol to include in the pack. +#[derive(Debug, Clone)] +struct PackCandidate { + file: String, + name: String, + start_line: u32, + /// Where this candidate came from, for provenance in the pack output. + provenance: String, + /// Score for ranking (search score, or 0 for callgraph-derived). + score: f32, + /// Seed ordinal (0-based) if this is a seed; None for neighbors. + seed_ordinal: Option, + /// Hop distance from seed (0 for seeds, 1 for direct neighbors). Used for + /// interleaving: candidates are ordered by (seed_ordinal, hop_distance) so + /// a seed's body appears before its callers, then its callees. + #[allow(dead_code)] + hop_distance: u32, +} + +/// Handle a `gather` request — assemble a deterministic context pack. +pub fn handle_gather(req: &RawRequest, ctx: &AppContext) -> Response { + let question = req.params.get("question").and_then(|v| v.as_str()); + let symbol = req.params.get("symbol").and_then(|v| v.as_str()); + let file_path_str = req.params.get("filePath").and_then(|v| v.as_str()); + + // Modes are mutually exclusive. + let has_question = question.is_some(); + let has_symbol = symbol.is_some() && file_path_str.is_some(); + if has_question == has_symbol { + return Response::error( + &req.id, + "invalid_request", + "aft_gather_context: provide exactly ONE mode — either 'question' OR 'symbol'+'filePath'", + ); + } + + let budget = req + .params + .get("budget") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BUDGET as u64) + .min(MAX_BUDGET as u64) as usize; + let include_tests = req + .params + .get("includeTests") + .or_else(|| req.params.get("include_tests")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if has_question { + let q = question.unwrap(); + handle_gather_question(req, ctx, q, budget, include_tests) + } else { + let s = symbol.unwrap(); + let fp = file_path_str.unwrap(); + handle_gather_symbol(req, ctx, s, fp, budget, include_tests) + } +} + +fn handle_gather_question( + req: &RawRequest, + ctx: &AppContext, + question: &str, + budget: usize, + include_tests: bool, +) -> Response { + let search_req = RawRequest { + id: format!("{}_search", req.id), + command: "semantic_search".to_string(), + lsp_hints: req.lsp_hints.clone(), + session_id: req.session_id.clone(), + params: serde_json::json!({ + "query": question, + "top_k": 15, + "hint": "auto", + "include_tests": include_tests, + }), + }; + + let search_resp = handle_semantic_search(&search_req, ctx); + if !search_resp.success { + return Response::error( + &req.id, + "search_failed", + format!( + "aft_gather_context: search failed: {}", + serde_json::to_string(&search_resp.data).unwrap_or_default() + ), + ); + } + + let results = match search_resp.data.get("results").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => { + return Response::error( + &req.id, + "search_failed", + "aft_gather_context: search returned no results array", + ); + } + }; + + // Detect semantic-index degradation from the search response. + // When the index is building, NL queries fall back to lexical-only + // FileSummary results that carry no symbol names. + let semantic_status = search_resp + .data + .get("semantic_status") + .and_then(|v| v.as_str()) + .unwrap_or("ready"); + + let project_root = grep_executor::project_root(ctx); + + let mut seeds: Vec = Vec::new(); + let mut no_symbol_stubs: Vec = Vec::new(); + for result in results { + let raw_file = result.get("file").and_then(|v| v.as_str()).unwrap_or(""); + let name_str = result.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let score = result.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let start_line = result + .get("start_line") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + // Handle grep-fallback results that carry a file+line but no symbol + // name. Resolve the containing symbol by line instead of extracting + // a name from the line text. + let (name, resolved_start_line) = if !name_str.is_empty() { + (name_str.to_string(), start_line) + } else { + // Resolve search-result paths against project_root so + // filesystem ops work regardless of process CWD. + let raw_file_path = if Path::new(raw_file).is_absolute() { + PathBuf::from(raw_file) + } else { + project_root.join(raw_file) + }; + let line = if start_line == 0 { + result.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + } else { + start_line + }; + if !raw_file_path.exists() || line == 0 { + // File doesn't exist or no line number — visible stub. + no_symbol_stubs.push(format!( + "{}:{} — {} (no containing symbol)", + raw_file, + line.max(1), + format!("search score={:.3}", score), + )); + continue; + } + match resolve_containing_symbol(&raw_file_path, line, ctx) { + Some((symbol_name, sym_start)) => (symbol_name, sym_start), + None => { + // No symbol contains this line (comment, import, blank). + no_symbol_stubs.push(format!( + "{}:{} — {} (no containing symbol)", + raw_file, + line.max(1), + format!("search score={:.3}", score), + )); + continue; + } + } + }; + + if raw_file.is_empty() || name.is_empty() { + continue; + } + + seeds.push(PackCandidate { + file: normalize_file_path(raw_file, &project_root), + name, + start_line: if start_line == 0 { + resolved_start_line + } else { + start_line + }, + provenance: format!("search score={:.3}", score), + score, + seed_ordinal: None, // filled in after sorting + hop_distance: 0, + }); + } + + seeds.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for (i, seed) in seeds.iter_mut().enumerate() { + seed.seed_ordinal = Some(i); + } + + if seeds.is_empty() { + if no_symbol_stubs.is_empty() { + return Response::success( + &req.id, + serde_json::json!({ + "text": "aft_gather_context: no results found for question", + }), + ); + } + // All hits were no-containing-symbol — produce a pack of visible stubs. + let degraded = semantic_status != "ready"; + return build_pack( + req, + ctx, + &[], + &[], + budget, + "question", + question, + &no_symbol_stubs, + degraded, + false, + &project_root, + ); + } + + // Detect callgraph store unavailability so the header carries a notice + // (question mode is best-effort — seeds alone are still useful). + let callgraph_unavailable = !matches!( + ctx.callgraph_store_for_ops(), + CallgraphStoreAccess::Ready(_) + ); + let neighbors = collect_callgraph_neighbors(ctx, &seeds, &project_root, include_tests); + + // Only flag degradation when zero symbol-level seeds resolved. + // Even one real seed means the pack has usable content. + // Intentionally always-false when seeds is non-empty — a degraded + // semantic index that still produces symbol-level results is + // functional enough; flagging it would be noise. + let degraded = semantic_status != "ready" && seeds.is_empty(); + build_pack( + req, + ctx, + &seeds, + &neighbors, + budget, + "question", + question, + &no_symbol_stubs, + degraded, + callgraph_unavailable, + &project_root, + ) +} + +fn handle_gather_symbol( + req: &RawRequest, + ctx: &AppContext, + symbol: &str, + file_path_str: &str, + budget: usize, + include_tests: bool, +) -> Response { + let file_path = match ctx.validate_path(&req.id, Path::new(file_path_str)) { + Ok(path) => path, + Err(resp) => return resp, + }; + + let store = match ctx.callgraph_store_for_ops() { + CallgraphStoreAccess::Ready(store) => store, + CallgraphStoreAccess::Building => return building_response(&req.id, "gather"), + CallgraphStoreAccess::Unavailable => { + return unavailable_response(&req.id, "gather", ctx.is_worktree_bridge()) + } + CallgraphStoreAccess::Error(error) => { + return store_error_response(&req.id, "gather", error) + } + }; + + let impact = match impact_result(store.as_ref(), &file_path, symbol, 1, include_tests) { + Ok(result) => result, + Err(error) => { + return store_error_response(&req.id, "gather", error); + } + }; + + let project_root = grep_executor::project_root(ctx); + let file_display = file_path.display().to_string(); + let seeds = vec![PackCandidate { + file: normalize_file_path(&file_display, &project_root), + name: symbol.to_string(), + start_line: 0, // will be found by resolve + provenance: "seed (impact target)".to_string(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + + let mut neighbors: Vec = Vec::new(); + for caller in &impact.callers { + neighbors.push(PackCandidate { + file: normalize_file_path(&caller.caller_file, &project_root), + name: caller.caller_symbol.clone(), + start_line: caller.line, + provenance: format!("caller-of-{}", symbol), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }); + } + + let callee_neighbors = collect_callees_for_seed( + ctx, + store.as_ref(), + &file_path, + symbol, + 0, + &project_root, + include_tests, + ); + neighbors.extend(callee_neighbors); + + let mode_desc = format!("impact({}:{})", file_display, symbol); + build_pack( + req, + ctx, + &seeds, + &neighbors, + budget, + &mode_desc, + "", + &[], + false, + false, + &project_root, + ) +} + +/// Collect 1-hop callgraph neighbors (callers + callees) for each seed. +/// +/// Both the caller query below and `collect_callees_for_seed` must forward +/// `include_tests`. Passing the literal `false` to either one reads as correct +/// (`false` is the default) while silently making the flag inert for that half +/// of the neighbor set — the exact drift a partial conversion produces. +fn collect_callgraph_neighbors( + ctx: &AppContext, + seeds: &[PackCandidate], + project_root: &Path, + include_tests: bool, +) -> Vec { + let store = match ctx.callgraph_store_for_ops() { + CallgraphStoreAccess::Ready(store) => store, + _ => return Vec::new(), + }; + + let mut neighbors = Vec::new(); + for (seed_idx, seed) in seeds.iter().enumerate() { + let seed_path = Path::new(&seed.file); + let seed_name = &seed.name; + + if let Ok(result) = impact_result(store.as_ref(), seed_path, seed_name, 1, include_tests) { + for caller in &result.callers { + neighbors.push(PackCandidate { + file: normalize_file_path(&caller.caller_file, project_root), + name: caller.caller_symbol.clone(), + start_line: caller.line, + provenance: format!("caller-of-{}", seed_name), + score: 0.0, + seed_ordinal: Some(seed_idx), + hop_distance: 1, + }); + } + } + + neighbors.extend(collect_callees_for_seed( + ctx, + store.as_ref(), + seed_path, + seed_name, + seed_idx, + project_root, + include_tests, + )); + } + + neighbors +} + +/// Collect direct callees for a single seed symbol. +fn collect_callees_for_seed( + _ctx: &AppContext, + store: &impl crate::callgraph_store::CallGraphRead, + file_path: &Path, + symbol: &str, + seed_idx: usize, + project_root: &Path, + include_tests: bool, +) -> Vec { + let mut callees = Vec::new(); + if let Ok(tree) = call_tree_result(store, file_path, symbol, 1, include_tests) { + for child in &tree.children { + callees.push(PackCandidate { + file: normalize_file_path(&child.file, project_root), + name: child.name.clone(), + start_line: child.line, + provenance: format!("{}{}", CALLEE_PROVENANCE_PREFIX, symbol), + score: 0.0, + seed_ordinal: Some(seed_idx), + hop_distance: 1, + }); + } + } + callees +} + +/// Assemble the final pack text. +/// `pre_stubs` are no-containing-symbol hits from grep-fallback results +/// that must appear as visible stubs (nothing-silently-dropped contract). +fn build_pack( + req: &RawRequest, + ctx: &AppContext, + seeds: &[PackCandidate], + neighbors: &[PackCandidate], + budget: usize, + mode: &str, + question: &str, + pre_stubs: &[String], + degraded: bool, + callgraph_unavailable: bool, + project_root: &Path, +) -> Response { + // Deduplicate by (file, name). Seeds win over neighbors — they carry + // relevance (search score / impact target). Seeds are emitted first, + // then neighbors interleaved per seed. This ordering is intentional: + // under a budget cut, every seed must be included before any neighbor + // consumes lines, because seeds are the primary evidence the agent + // asked for. + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut ordered: Vec<&PackCandidate> = Vec::new(); + + for seed in seeds { + let key = (seed.file.clone(), seed.name.clone()); + if seen.insert(key) { + ordered.push(seed); + } + } + + for seed in seeds { + let seed_idx = seed.seed_ordinal; + for neighbor in neighbors { + if neighbor.seed_ordinal == seed_idx { + let key = (neighbor.file.clone(), neighbor.name.clone()); + if seen.insert(key) { + ordered.push(neighbor); + } + } + } + } + + for neighbor in neighbors { + if neighbor.seed_ordinal.is_none() { + let key = (neighbor.file.clone(), neighbor.name.clone()); + if seen.insert(key) { + ordered.push(neighbor); + } + } + } + + let mut lines_used: usize = 0; + let mut body = String::new(); + + let mut stubs: Vec = Vec::new(); + let mut unresolved_count: usize = 0; + + // Per-symbol budget: remaining budget / remaining candidates (at least 10). + let total_candidates = ordered.len(); + + for (i, candidate) in ordered.iter().enumerate() { + let remaining = total_candidates - i; + // .max(10) guarantees per_symbol_budget ≥ 10, so the only + // budget-exhausted shortfall is caught by `lines_used >= budget`. + let per_symbol_budget = (budget.saturating_sub(lines_used)) + .saturating_div(remaining) + .max(10) + .min(150); // cap per symbol at 150 lines + + if lines_used >= budget { + stubs.push(format!( + "{}:{} {} — {}", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance + )); + continue; + } + + match render_symbol_section(ctx, candidate, per_symbol_budget, project_root) { + Ok(section) => { + let section_lines = section.lines().count(); + if lines_used + section_lines + 1 > budget { + // Would exceed budget — stub it instead. + stubs.push(format!( + "{}:{} {} — {}", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance + )); + continue; + } + body.push_str(§ion); + body.push('\n'); + lines_used += section_lines + 1; // section + trailing newline + } + Err(stub) => { + // Suppress unresolved EXTERNAL callees — stdlib/prelude symbols + // (e.g. `readFileSync`, `parse`, `sorted`) are never renderable + // and bury genuine expandable stubs. Unresolved seeds and + // callers remain as visible individual stubs so nothing is + // silently dropped. + if stub.contains(UNRESOLVED_MARKER) + && candidate.provenance.starts_with(CALLEE_PROVENANCE_PREFIX) + { + unresolved_count += 1; + } else { + stubs.push(stub); + } + } + } + } + + // Build header after rendering so used=N is exact and the replacement + // cannot collide with query text (e.g. a query containing "used="). + let degraded_notice = if degraded { + " | degraded=semantic-index-building (partial results — retry when index ready)" + } else { + "" + }; + let callgraph_notice = if callgraph_unavailable { + " | neighbors=skipped(callgraph-unavailable)" + } else { + "" + }; + + let total_lines = lines_used + 1; // +1 for the header line itself + let header = if question.is_empty() { + format!( + "## gather pack | mode={}{}{} | seeds={} | neighbors={} | budget={} used={}", + mode, + degraded_notice, + callgraph_notice, + seeds.len(), + neighbors.len(), + budget, + total_lines, + ) + } else { + format!( + "## gather pack | mode={}{}{} | query=\"{}\" | seeds={} | neighbors={} | budget={} used={}", + mode, + degraded_notice, + callgraph_notice, + truncate_str(question, 80), + seeds.len(), + neighbors.len(), + budget, + total_lines, + ) + }; + + let mut output = header; + output.push('\n'); + output.push_str(&body); + + if !stubs.is_empty() || !pre_stubs.is_empty() { + output.push_str("\n## Beyond budget (zoom to expand)\n"); + for stub in pre_stubs { + output.push_str(stub); + output.push('\n'); + } + for stub in &stubs { + output.push_str(stub); + output.push('\n'); + } + if unresolved_count > 0 { + output.push_str(&format!( + "({} unresolved external calls omitted)\n", + unresolved_count + )); + } + } else if unresolved_count > 0 { + output.push_str(&format!( + "\n## Beyond budget (zoom to expand)\n({} unresolved external calls omitted)\n", + unresolved_count + )); + } + + Response::success( + &req.id, + serde_json::json!({ + "text": output, + }), + ) +} + +/// Select the best match when multiple same-name symbols exist in one file. +/// If the candidate carries a non-zero `start_line` (1-based, from search results), +/// prefer the match whose range contains (or starts nearest to) that line. +/// Falls back to matches[0] when no line hint is available. +fn select_symbol_match<'a>( + matches: &'a [crate::symbols::SymbolMatch], + start_line: u32, +) -> &'a crate::symbols::Symbol { + if start_line == 0 || matches.len() <= 1 { + return &matches[0].symbol; + } + // start_line is 1-based (serialized Range convention); Symbol range fields + // are 0-indexed internally — subtract 1 for comparison. + let hint_line_0b = (start_line.saturating_sub(1)) as u32; + matches + .iter() + .min_by_key(|m| { + let r = &m.symbol.range; + if r.start_line <= hint_line_0b && hint_line_0b <= r.end_line { + 0 // exact containment + } else if r.start_line > hint_line_0b { + (r.start_line - hint_line_0b) as u64 + } else { + (hint_line_0b - r.end_line) as u64 + (u32::MAX as u64) + } + }) + .map(|m| &m.symbol) + .unwrap_or(&matches[0].symbol) +} + +/// Render a single symbol as a markdown section for the pack. +/// Returns Ok(section_text) on success, or Err(stub_line) if the symbol can't be resolved. +fn render_symbol_section( + ctx: &AppContext, + candidate: &PackCandidate, + per_symbol_budget: usize, + project_root: &Path, +) -> Result { + let file_path = if Path::new(&candidate.file).is_absolute() { + PathBuf::from(&candidate.file) + } else { + project_root.join(&candidate.file) + }; + if !file_path.exists() { + return Err(format!( + "{}:{} {} — {} (file not found)", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance + )); + } + + let source = match std::fs::read_to_string(&file_path) { + Ok(s) => s, + Err(e) => { + return Err(format!( + "{}:{} {} — {} (read error: {})", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance, + e + )); + } + }; + let lines: Vec = source.lines().map(|l| l.to_string()).collect(); + + let matches = match ctx.provider().resolve_symbol(&file_path, &candidate.name) { + Ok(m) => m, + Err(_) => { + return Err(format!( + "{}:{} {} — {} {}", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance, + UNRESOLVED_MARKER, + )); + } + }; + + if matches.is_empty() { + return Err(format!( + "{}:{} {} — {} (symbol not found)", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance + )); + } + + let target_symbol = select_symbol_match(&matches, candidate.start_line); + + let lang = detect_language(&file_path); + let kind_str = symbol_kind_string(&target_symbol.kind); + + let rendered = + render_symbol_within_budget(target_symbol, &lines, lang, None, per_symbol_budget); + let body = rendered.content.trim().to_string(); + if body.is_empty() { + return Err(format!( + "{}:{} {} — {} (empty body)", + candidate.file, + target_symbol.range.start_line + 1, // 1-based for display + candidate.name, + candidate.provenance + )); + } + + let header = format!( + "## {}:{} {} {}", + candidate.file, + target_symbol.range.start_line + 1, // 1-based for display + kind_str, + candidate.name, + ); + + let truncated_note = match rendered.status { + BudgetedSymbolRenderStatus::Complete => String::new(), + BudgetedSymbolRenderStatus::Truncated => { + format!(" [truncated — zoom {} for full body]", candidate.name) + } + BudgetedSymbolRenderStatus::Menu => { + format!(" [member menu — zoom {} for bodies]", candidate.name) + } + }; + + Ok(format!("{}\n{}\n{}", header, body, truncated_note,)) +} + +/// Truncate `s` to at most `max_len` bytes on a char boundary, appending `…`. +/// Byte-slicing `&s[..max_len]` panics when max_len lands mid-codepoint; +/// this walks `char_indices` to find the last valid boundary ≤ max_len. +fn truncate_str(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + let mut end = 0; + for (i, c) in s.char_indices() { + let char_end = i + c.len_utf8(); + if char_end > max_len { + break; + } + end = char_end; + } + format!("{}…", &s[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_short_string() { + assert_eq!(truncate_str("hello", 10), "hello"); + } + + #[test] + fn truncate_long_string() { + assert_eq!(truncate_str("hello world this is long", 10), "hello worl…"); + } + + #[test] + fn truncate_mid_codepoint_regression() { + // "éx": é spans bytes 0-1, x at byte 2. max_len=1 lands inside é. + // Old byte-slice code `&s[..1]` panics with "end byte index 1 is not + // a char boundary; it is inside 'é'". The char_indices walk must not + // panic and must truncate at the last valid boundary ≤ 1 (byte 0 → ""). + let result = truncate_str("éx", 1); + assert_eq!(result, "…"); // empty content + ellipsis + } + + #[test] + fn truncate_mid_codepoint_in_longer_string() { + // 79 'a' + "éxxx" = 83 bytes. max_len=80 lands in second byte of é + // (bytes 79-80 = é). Old byte-slice panics. + let s = format!("{}éxxx", "a".repeat(79)); + let result = truncate_str(&s, 80); + assert!(result.ends_with('…')); + assert!(result.starts_with("aaaa")); + // Should be 79 'a' (all on char boundaries) + ellipsis + assert_eq!(result, format!("{}…", "a".repeat(79))); + } + + #[test] + fn select_symbol_match_by_containment() { + use crate::symbols::{Range, Symbol, SymbolKind, SymbolMatch}; + + let first = Symbol { + name: "foo".into(), + kind: SymbolKind::Function, + range: Range { + start_line: 10, + start_col: 0, + end_line: 15, + end_col: 0, + }, + signature: None, + scope_chain: vec![], + exported: false, + parent: None, + }; + let second = Symbol { + name: "foo".into(), + kind: SymbolKind::Function, + range: Range { + start_line: 50, + start_col: 0, + end_line: 55, + end_col: 0, + }, + signature: None, + scope_chain: vec![], + exported: false, + parent: None, + }; + let matches = vec![ + SymbolMatch { + symbol: first, + file: "a.rs".into(), + }, + SymbolMatch { + symbol: second, + file: "a.rs".into(), + }, + ]; + + // start_line=52 (1-based) → 51 (0-based) falls inside second's range 50-55. + let selected = select_symbol_match(&matches, 52); + assert_eq!(selected.range.start_line, 50); + } + + #[test] + fn select_symbol_match_falls_back_to_first_when_no_line_hint() { + use crate::symbols::{Range, Symbol, SymbolKind, SymbolMatch}; + + let first = Symbol { + name: "bar".into(), + kind: SymbolKind::Function, + range: Range { + start_line: 10, + start_col: 0, + end_line: 15, + end_col: 0, + }, + signature: None, + scope_chain: vec![], + exported: false, + parent: None, + }; + let second = Symbol { + name: "bar".into(), + kind: SymbolKind::Function, + range: Range { + start_line: 50, + start_col: 0, + end_line: 55, + end_col: 0, + }, + signature: None, + scope_chain: vec![], + exported: false, + parent: None, + }; + let matches = vec![ + SymbolMatch { + symbol: first, + file: "a.rs".into(), + }, + SymbolMatch { + symbol: second, + file: "a.rs".into(), + }, + ]; + + // start_line=0 means "no line hint" → returns matches[0]. + let selected = select_symbol_match(&matches, 0); + assert_eq!(selected.range.start_line, 10); + } + + #[test] + fn dedup_by_file_and_name() { + let seeds = vec![PackCandidate { + file: "a.rs".into(), + name: "foo".into(), + start_line: 1, + provenance: "seed".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + let neighbors = vec![PackCandidate { + file: "a.rs".into(), + name: "foo".into(), + start_line: 1, + provenance: "caller-of-x".into(), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }]; + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut ordered: Vec<&PackCandidate> = Vec::new(); + for seed in &seeds { + let key = (seed.file.clone(), seed.name.clone()); + if seen.insert(key) { + ordered.push(seed); + } + } + for neighbor in &neighbors { + let key = (neighbor.file.clone(), neighbor.name.clone()); + if seen.insert(key) { + ordered.push(neighbor); + } + } + // Only the seed should be included — neighbor is a duplicate. + assert_eq!(ordered.len(), 1); + assert_eq!(ordered[0].provenance, "seed"); + } + + #[test] + fn mode_validation_neither_mode() { + let req = RawRequest { + id: "1".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + assert!(req.params.get("question").is_none() && req.params.get("symbol").is_none()); + } + + #[test] + fn mode_validation_both_modes() { + let req = RawRequest { + id: "1".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({ + "question": "how does foo work", + "symbol": "bar", + "filePath": "bar.rs", + }), + }; + let has_question = req + .params + .get("question") + .and_then(|v| v.as_str()) + .is_some(); + let has_symbol = req.params.get("symbol").and_then(|v| v.as_str()).is_some() + && req + .params + .get("filePath") + .and_then(|v| v.as_str()) + .is_some(); + assert!(has_question && has_symbol); + } + + #[test] + fn budget_parsing_defaults_and_caps() { + // Default budget + let req = RawRequest { + id: "1".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + let budget = req + .params + .get("budget") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BUDGET as u64) + .min(MAX_BUDGET as u64) as usize; + assert_eq!(budget, DEFAULT_BUDGET); + + // Explicit budget + let req2 = RawRequest { + id: "2".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({"budget": 200}), + }; + let budget2 = req2 + .params + .get("budget") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BUDGET as u64) + .min(MAX_BUDGET as u64) as usize; + assert_eq!(budget2, 200); + + // Budget capped at MAX_BUDGET + let req3 = RawRequest { + id: "3".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({"budget": 2000}), + }; + let budget3 = req3 + .params + .get("budget") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BUDGET as u64) + .min(MAX_BUDGET as u64) as usize; + assert_eq!(budget3, MAX_BUDGET); + } + + #[test] + fn pack_candidate_ranking_order() { + let mut seeds = vec![ + PackCandidate { + file: "a.rs".into(), + name: "high_score".into(), + start_line: 10, + provenance: "search score=0.900".into(), + score: 0.9, + seed_ordinal: None, + hop_distance: 0, + }, + PackCandidate { + file: "b.rs".into(), + name: "low_score".into(), + start_line: 5, + provenance: "search score=0.300".into(), + score: 0.3, + seed_ordinal: None, + hop_distance: 0, + }, + ]; + seeds.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + assert_eq!(seeds[0].name, "high_score"); + assert_eq!(seeds[1].name, "low_score"); + } + + #[test] + fn render_symbol_section_header_format() { + // Just verify the header format string works. + let header = format!( + "## {}:{} {} {}", + "src/main.rs", 42, "function", "handle_request" + ); + assert_eq!(header, "## src/main.rs:42 function handle_request"); + } + + #[test] + fn stub_format_includes_provenance() { + let stub = format!( + "{}:{} {} — {}", + "src/lib.rs", 15, "helper_fn", "callee-of-main" + ); + assert_eq!(stub, "src/lib.rs:15 helper_fn — callee-of-main"); + } + + // ── regression tests for live-use fixes ── + + #[test] + fn normalize_abs_path_strips_project_root() { + // (c) absolute and relative forms of the same file normalize to one key. + #[cfg(windows)] + let (root, abs) = ( + Path::new(r"C:\Users\x\project"), + r"C:\Users\x\project\skills\council\scripts\score.py", + ); + #[cfg(not(windows))] + let (root, abs) = ( + Path::new("/home/user/project"), + "/home/user/project/skills/council/scripts/score.py", + ); + let rel = "skills/council/scripts/score.py"; + let dot_prefix = "./skills/council/scripts/score.py"; + + let from_abs = normalize_file_path(abs, root); + let from_rel = normalize_file_path(rel, root); + let from_dot = normalize_file_path(dot_prefix, root); + + assert_eq!(from_abs, "skills/council/scripts/score.py"); + assert_eq!(from_rel, "skills/council/scripts/score.py"); + assert_eq!(from_dot, "skills/council/scripts/score.py"); + } + + #[test] + fn normalize_rel_path_preserved_for_non_root_abs() { + let root = Path::new("/home/user/project"); + // An absolute path outside the project root stays absolute. + let outside = "/other/repo/src/main.rs"; + assert_eq!(normalize_file_path(outside, root), outside); + } + + #[test] + fn dedupe_normalized_keys_merge_abs_and_rel() { + // (c) two candidates with the same (file, name) after normalization + // merge into one section. + #[cfg(windows)] + let (root, abs) = ( + Path::new(r"C:\Users\x\project"), + r"C:\Users\x\project\skills\score.py", + ); + #[cfg(not(windows))] + let (root, abs) = ( + Path::new("/home/user/project"), + "/home/user/project/skills/score.py", + ); + let seeds = vec![PackCandidate { + file: normalize_file_path(abs, root), + name: "run_executor_only".into(), + start_line: 243, + provenance: "search score=0.900".into(), + score: 0.9, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + let neighbors = vec![PackCandidate { + file: normalize_file_path("skills/score.py", root), + name: "run_executor_only".into(), + start_line: 243, + provenance: "caller-of-x".into(), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }]; + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut ordered: Vec<&PackCandidate> = Vec::new(); + for seed in &seeds { + let key = (seed.file.clone(), seed.name.clone()); + if seen.insert(key) { + ordered.push(seed); + } + } + for neighbor in &neighbors { + let key = (neighbor.file.clone(), neighbor.name.clone()); + if seen.insert(key) { + ordered.push(neighbor); + } + } + // Only the seed should be included — neighbor is the same symbol. + assert_eq!(ordered.len(), 1); + assert_eq!(ordered[0].provenance, "search score=0.900"); + } + + #[test] + fn normalize_backslash_absolute_and_forward_slash_relative_to_same_key() { + let root = Path::new("/home/user/project"); + let abs_backslash = r"\home\user\project\skills\council\scripts\score.py"; + let rel_forward_slash = "skills/council/scripts/score.py"; + + assert_eq!( + normalize_file_path(abs_backslash, root), + normalize_file_path(rel_forward_slash, root) + ); + assert_eq!( + normalize_file_path(abs_backslash, root), + "skills/council/scripts/score.py" + ); + } + + #[test] + fn resolve_containing_symbol_finds_inner_function() { + // (a) resolve_containing_symbol must find the symbol whose range + // contains the hit line — not just the first match. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + // Write a temp file with known structure. + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test.py"); + std::fs::write( + &file_path, + "#!/usr/bin/env python3\n\ndef outer():\n pass\n\ndef target_func(arg):\n return arg\n\ndef bottom():\n pass\n", + ) + .unwrap(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + // Line 7 (1-based) = ` return arg` — contained by target_func + // which starts at line 6 (1-based). + let result = resolve_containing_symbol(&file_path, 7, &ctx); + assert!( + result.is_some(), + "must resolve containing symbol for line 7" + ); + let (name, start) = result.unwrap(); + assert_eq!(name, "target_func"); + assert_eq!(start, 6); + + // Line 2 (blank/comment) — no containing symbol. + let no_sym = resolve_containing_symbol(&file_path, 2, &ctx); + assert!( + no_sym.is_none(), + "blank line should have no containing symbol" + ); + } + + #[test] + fn suppression_scopes_callee_only_in_real_pack() { + // SHOULD: drive the PRODUCTION build_pack path — candidates that hit + // render errors must be scoped: callee-only suppression, seed+caller + // stay visible in the Beyond-budget stub list. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test.py"); + std::fs::write(&file_path, "def foo():\n pass\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + // Seeds: one real (renders), one bogus (unresolved seed → visible stub). + let seeds = vec![ + PackCandidate { + file: file_display.clone(), + name: "foo".into(), + start_line: 1, + provenance: "search score=1.000".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }, + PackCandidate { + file: file_display.clone(), + name: "ghost_seed".into(), + start_line: 0, + provenance: "search score=0.500".into(), + score: 0.5, + seed_ordinal: Some(1), + hop_distance: 0, + }, + ]; + + // Neighbors: bogus names in the same file — all produce + // "(symbol not resolved)". + let neighbors = vec![ + PackCandidate { + file: file_display.clone(), + name: "bogus_callee".into(), + start_line: 0, + provenance: "callee-of-foo".into(), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }, + PackCandidate { + file: file_display.clone(), + name: "bogus_caller".into(), + start_line: 0, + provenance: "caller-of-foo".into(), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }, + ]; + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + let budget = 500; + let resp = build_pack( + &req, + &ctx, + &seeds, + &neighbors, + budget, + "question", + "test query", + &[], + false, + false, + dir.path(), + ); + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + + // Callee-neighbor must NOT appear as a visible stub — suppressed. + assert!( + !text.contains("bogus_callee — callee-of-foo (symbol not resolved)"), + "unresolved callee must be suppressed, not visible stub" + ); + + // Caller-neighbor MUST appear as a visible stub. + assert!( + text.contains("bogus_caller — caller-of-foo (symbol not resolved)"), + "unresolved caller must remain visible stub" + ); + + // Unresolved seed MUST appear as a visible stub. + assert!( + text.contains("ghost_seed — search score=0.500 (symbol not resolved)"), + "unresolved seed must remain visible stub" + ); + + // Unresolved count line must mention callee omission. + assert!( + text.contains("(1 unresolved external calls omitted)"), + "must report exactly one suppressed callee" + ); + } + + #[test] + fn no_containing_symbol_hits_produce_visible_stubs() { + // MUST: grep-fallback hits with no containing symbol (comment, + // import, blank line) must produce visible stubs — not silent drops. + // The degenerate "all stubs" case must produce a pack, not + // "no results found". + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("comments.py"); + // A file where line 2 is a comment (no containing symbol) + // and line 4 is a blank line. + std::fs::write(&file_path, "# top-level comment\n\n# another comment\n\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + // Simulate three grep-fallback hits: two comment lines, one nonexistent file. + let pre_stubs: Vec = vec![ + format!( + "{}:2 — search score=0.800 (no containing symbol)", + file_display + ), + format!( + "{}:4 — search score=0.600 (no containing symbol)", + file_display + ), + "nonexistent.rs:10 — search score=0.400 (no containing symbol)".into(), + ]; + + let resp = build_pack( + &req, + &ctx, + &[], + &[], + 400, + "question", + "test query", + &pre_stubs, + true, + false, + dir.path(), + ); + + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + + // Must have a pack header (even though no seeds). + assert!( + text.contains("## gather pack"), + "empty-seed pack must still have header" + ); + + // All three stubs must be visible in the Beyond budget section. + assert!( + text.contains("(no containing symbol)"), + "no-containing-symbol stubs must be visible" + ); + assert!( + text.contains("nonexistent.rs:10"), + "nonexistent file stub must be visible" + ); + + // Must NOT say "no results found" — that was the old silent-drop. + assert!( + !text.contains("no results found"), + "must not silently drop no-containing-symbol hits" + ); + } + + #[test] + fn fully_degraded_pack_flags_semantic_index_building() { + // (a) When semantic_status != "ready" and all hits are no-symbol + // stubs, the header carries a degradation notice. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("blank.py"); + std::fs::write(&file_path, "# comment\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + let pre_stubs = vec![format!( + "{}:1 — search score=0.250 (no containing symbol)", + file_display + )]; + + // degraded=true → header should carry the flag. + let resp = build_pack( + &req, + &ctx, + &[], + &[], + 400, + "question", + "test query", + &pre_stubs, + true, + false, + dir.path(), + ); + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + text.contains("degraded=semantic-index-building"), + "degraded pack must carry semantic-index-building flag in header" + ); + } + + #[test] + fn normal_pack_has_no_degradation_flag() { + // (b) Normal results (seeds resolved) must NOT carry the degradation flag. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("normal.py"); + std::fs::write(&file_path, "def foo():\n pass\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + let seeds = vec![PackCandidate { + file: file_display, + name: "foo".into(), + start_line: 1, + provenance: "search score=1.000".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + // degraded=false — normal case. + let resp = build_pack( + &req, + &ctx, + &seeds, + &[], + 400, + "question", + "test query", + &[], + false, + false, + dir.path(), + ); + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + !text.contains("degraded=semantic-index-building"), + "normal pack must not carry degradation flag" + ); + assert!( + text.contains("def foo"), + "normal pack must contain seed body" + ); + } + + #[test] + fn mixed_results_with_one_real_seed_no_degradation_flag() { + // (c) Even when degraded=true, if at least one symbol-level seed + // resolved, the pack has usable content — no flag. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("mixed.py"); + std::fs::write(&file_path, "def foo():\n pass\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + // One real seed (resolves) + some no-symbol stubs. + let seeds = vec![PackCandidate { + file: file_display.clone(), + name: "foo".into(), + start_line: 1, + provenance: "search score=1.000".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + + let pre_stubs = vec![format!( + "{}:1 — search score=0.250 (no containing symbol)", + file_display + )]; + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + // Production path: handle_gather_question sets degraded=false when + // seeds is non-empty (even one resolved seed → not degraded). + let resp = build_pack( + &req, + &ctx, + &seeds, + &[], + 400, + "question", + "test query", + &pre_stubs, + false, + false, + dir.path(), + ); + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + !text.contains("degraded=semantic-index-building"), + "mixed pack with one real seed must not carry degradation flag" + ); + assert!( + text.contains("def foo"), + "mixed pack must contain seed body" + ); + } + + #[test] + fn render_resolves_repo_relative_path_against_project_root() { + // (f) render_symbol_section must resolve repo-relative candidate.file + // against project_root, not process CWD. A unique temp dir ensures + // the relative path cannot exist relative to CWD — only project_root + // join produces a valid path. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let project_root = tempfile::tempdir().unwrap(); + let file_name = "test_cwd.py"; + let file_path = project_root.path().join(file_name); + std::fs::write(&file_path, "def answer():\n return 42\n").unwrap(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + let candidate = PackCandidate { + file: file_name.to_string(), + name: "answer".into(), + start_line: 1, + provenance: "test".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }; + + let result = render_symbol_section(&ctx, &candidate, 50, project_root.path()); + assert!( + result.is_ok(), + "repo-relative path must resolve via project_root; got: {:?}", + result + ); + let section = result.unwrap(); + assert!( + section.contains("def answer"), + "rendered section must contain symbol body; got: {}", + section + ); + } +} diff --git a/crates/aft/src/commands/mod.rs b/crates/aft/src/commands/mod.rs index 4c56038ac..e88edf576 100644 --- a/crates/aft/src/commands/mod.rs +++ b/crates/aft/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod edit_history; pub mod edit_match; pub mod edit_symbol; pub mod extract_function; +pub mod gather; pub mod glob; pub mod grep; pub mod impact; diff --git a/crates/aft/src/main.rs b/crates/aft/src/main.rs index 5f48ebeb0..fca3c0e85 100644 --- a/crates/aft/src/main.rs +++ b/crates/aft/src/main.rs @@ -777,6 +777,7 @@ fn dispatch(req: RawRequest, ctx: &AppContext) -> Response { "trace_to" => aft::commands::trace_to::handle_trace_to(&req, ctx), "trace_to_symbol" => aft::commands::trace_to_symbol::handle_trace_to_symbol(&req, ctx), "impact" => aft::commands::impact::handle_impact(&req, ctx), + "gather" => aft::commands::gather::handle_gather(&req, ctx), "trace_data" => aft::commands::trace_data::handle_trace_data(&req, ctx), "move_symbol" => aft::commands::move_symbol::handle_move_symbol(&req, ctx), "extract_function" => aft::commands::extract_function::handle_extract_function(&req, ctx), diff --git a/crates/aft/src/subc/manifest.rs b/crates/aft/src/subc/manifest.rs index 1530c6bdc..5df07d90b 100644 --- a/crates/aft/src/subc/manifest.rs +++ b/crates/aft/src/subc/manifest.rs @@ -26,6 +26,7 @@ pub(super) fn is_subc_agent_core_tool(name: &str) -> bool { | "zoom" | "inspect" | "callgraph" + | "gather" | "conflicts" | "ast_search" | "ast_replace" @@ -124,8 +125,10 @@ pub(super) fn command_lane(command: &str) -> Lane { | "lsp_find_references" | "lsp_prepare_rename" => Lane::SerialLspStatus, - "semantic_search" | "search" | "callgraph" | "callers" | "impact" | "call_tree" - | "trace_to" | "trace_to_symbol" | "trace_data" | "inspect_tier2_run" => Lane::HeavyInit, + "semantic_search" | "search" | "callgraph" | "gather" | "callers" | "impact" + | "call_tree" | "trace_to" | "trace_to_symbol" | "trace_data" | "inspect_tier2_run" => { + Lane::HeavyInit + } "bash" | "bash_abort_inflight" @@ -220,6 +223,7 @@ pub(super) fn build_manifest() -> ModuleManifest { tool("grep", ExecutionMode::Pure), tool("glob", ExecutionMode::Pure), tool("search", ExecutionMode::Pure), + tool("gather", ExecutionMode::Pure), tool("outline", ExecutionMode::Pure), tool("zoom", ExecutionMode::Pure), tool("inspect", ExecutionMode::Pure), @@ -272,7 +276,7 @@ mod tests { use super::*; use std::collections::HashMap; - const CORE_TOOLS: [&str; 21] = [ + const CORE_TOOLS: [&str; 22] = [ "status", "bash", "read", @@ -282,6 +286,7 @@ mod tests { "grep", "glob", "search", + "gather", "outline", "zoom", "inspect", diff --git a/crates/aft/src/subc_tool_schemas.json b/crates/aft/src/subc_tool_schemas.json index cac59c14a..240ab72e7 100644 --- a/crates/aft/src/subc_tool_schemas.json +++ b/crates/aft/src/subc_tool_schemas.json @@ -278,6 +278,35 @@ ], "description": "Search code with one tool: concepts, identifiers, error strings, regex, literals, and filenames are auto-routed to the right engine and returned ranked. For conceptual 'how does X work' queries, phrase a full natural-language sentence — the semantic lane is NL-aware and matches intent against docstrings and comments ('how does the ORM build and execute a query', 'where is rate limiting handled'), not just keywords. Exact names, strings, and regex stay terse ('^export', 'Cargo.lock')." }, + "gather": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "question": { + "description": "Natural-language question to seed the pack via semantic search. Mutually exclusive with 'symbol'+'path'.", + "type": "string" + }, + "symbol": { + "description": "Symbol name for impact-seeded mode. Requires 'path'. Mutually exclusive with 'question'.", + "type": "string" + }, + "path": { + "description": "Path to the source file for impact-seeded mode. Required when 'symbol' is provided. Mutually exclusive with 'question'.", + "type": "string" + }, + "budget": { + "description": "Output line budget for the pack (default 400, max 800). Budget-excluded candidates are listed as stubs.", + "type": "integer", + "minimum": 1, + "maximum": 800 + }, + "includeTests": { + "description": "Include test files in callers/paths. Defaults to false; tests are hidden.", + "type": "boolean" + } + }, + "description": "Assemble a deterministic 'context pack' — ranked, deduped, budgeted verbatim code evidence — in ONE call instead of a multi-turn search→outline→zoom→callgraph chain. Returns code bodies with file:line headers, not conclusions.\n\nTwo modes (mutually exclusive):\n- question mode: `{ question: \"how does X work?\" }` — semantic-search-seeded. Ranks seeds by search score.\n- symbol mode: `{ symbol: \"handle_zoom\", path: \"src/commands/zoom.rs\" }` — impact-seeded (blast-radius callers + callees).\n\nOptional: `budget` (default 400 lines, max 800). When the budget is exhausted, remaining candidates appear as one-line stubs under '## Beyond budget (zoom to expand)'.\n\nUse when: the agent would otherwise need 4-6 serial tools to gather code context around a question or symbol. NOT for quick single-symbol reads (use aft_zoom)." + }, "outline": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/crates/aft/src/subc_translate.rs b/crates/aft/src/subc_translate.rs index aec544de2..ea544f019 100644 --- a/crates/aft/src/subc_translate.rs +++ b/crates/aft/src/subc_translate.rs @@ -852,6 +852,7 @@ pub(crate) fn supports_tool(bare_name: &str) -> bool { | "import" | "refactor" | "safety" + | "gather" ) } @@ -917,6 +918,7 @@ pub fn subc_translate_owned_with_context( "zoom" => translate_zoom(agent_args, project_root), "inspect" => translate_inspect(agent_args, project_root), "callgraph" => translate_callgraph(agent_args, project_root), + "gather" => translate_gather(agent_args, project_root), "conflicts" => translate_conflicts(agent_args), "ast_search" => translate_ast_search(agent_args), "ast_replace" => translate_ast_replace(agent_args), @@ -1818,6 +1820,62 @@ fn translate_safety(args: Value, project_root: &Path) -> Result Result { + let map_in = agent_args_map(args); + let has_question = map_in + .get("question") + .and_then(Value::as_str) + .map(|s| !s.is_empty()) + .unwrap_or(false); + let has_symbol = map_in + .get("symbol") + .and_then(Value::as_str) + .map(|s| !s.is_empty()) + .unwrap_or(false); + let has_file_path = map_in + .get("filePath") + .and_then(Value::as_str) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + if has_question && (has_symbol || has_file_path) { + return Err(invalid_request( + "aft_gather_context: provide exactly ONE mode — either 'question' OR 'symbol'+'filePath'", + )); + } + if has_symbol != has_file_path { + return Err(invalid_request( + "aft_gather_context: 'symbol' and 'filePath' must be provided together", + )); + } + if !has_question && !has_symbol && !has_file_path { + return Err(invalid_request( + "aft_gather_context: provide either 'question' or 'symbol'+'filePath'", + )); + } + + let mut out = Map::new(); + for key in &["question", "symbol", "budget", "includeTests"] { + if let Some(value) = map_in.get(*key) { + out.insert(key.to_string(), value.clone()); + } + } + if let Some(file_path) = map_in.get("filePath").and_then(Value::as_str) { + if !file_path.is_empty() { + let resolved = resolve_path_from_project_root(project_root, file_path); + out.insert( + "filePath".to_string(), + Value::String(resolved.to_string_lossy().into_owned()), + ); + } + } + + Ok(Translated { + command: "gather".into(), + args: out, + }) +} + fn insert_non_empty_array(out: &mut Map, map_in: &Map, key: &str) { if let Some(value) = map_in.get(key) { if let Some(items) = value.as_array() { @@ -2796,6 +2854,49 @@ mod tests { assert!(translated.args.get("hint").is_none()); } + #[test] + fn gather_resolves_relative_file_path_from_project_root() { + let project_root = Path::new("/project"); + let translated = subc_translate_owned( + "gather", + serde_json::json!({ + "symbol": "target", + "filePath": "src/foo.rs" + }), + project_root, + ) + .expect("valid gather symbol mode"); + + assert_eq!(translated.command, "gather"); + // Build the expectation with the platform's own separator: on Windows + // `join` yields `\project\src\foo.rs`, so a hardcoded forward-slash + // literal fails there while the resolution is correct. + let expected = resolve_path_from_project_root(project_root, "src/foo.rs"); + assert_eq!( + translated.args.get("filePath").and_then(Value::as_str), + Some(expected.to_string_lossy().as_ref()) + ); + } + + #[test] + fn gather_preserves_include_tests() { + let translated = subc_translate_owned( + "gather", + serde_json::json!({ + "symbol": "target", + "filePath": "src/foo.rs", + "includeTests": true + }), + Path::new("/project"), + ) + .expect("valid gather symbol mode"); + + assert_eq!( + translated.args.get("includeTests").and_then(Value::as_bool), + Some(true) + ); + } + // supports_tool() gates whether run_tool_call translates or passes a name // through as a native command. If a translate arm is added but the // allowlist isn't updated, that tool would silently bypass translation and @@ -2824,6 +2925,7 @@ mod tests { "import", "refactor", "safety", + "gather", ] { // Every name the allowlist claims support for must actually // translate (not return unsupported_tool). A no-arg call may fail diff --git a/crates/aft/tests/integration/subc_bridge_test.rs b/crates/aft/tests/integration/subc_bridge_test.rs index 805137f25..a064e1aa5 100644 --- a/crates/aft/tests/integration/subc_bridge_test.rs +++ b/crates/aft/tests/integration/subc_bridge_test.rs @@ -6883,7 +6883,7 @@ async fn drive_module_hello_health_manifest_daemon(input: FakeDaemonInput) { Some(subc_protocol::manifest::ProviderRole::ToolProvider { tools, .. }) => tools, other => panic!("expected first provider role to be ToolProvider, got {other:?}"), }; - assert_eq!(tools.len(), 21, "expected 21 manifest tools"); + assert_eq!(tools.len(), 22, "expected 22 manifest tools"); for tool in tools { assert!( tool.description diff --git a/docs/v0.49-agent-prefix-capture.json b/docs/v0.49-agent-prefix-capture.json index f5a9f27e4..6406d00fa 100644 --- a/docs/v0.49-agent-prefix-capture.json +++ b/docs/v0.49-agent-prefix-capture.json @@ -1,7 +1,7 @@ { "artifact_id": "ART-V049-S5-AGENT-PREFIX-CAPTURE-001", "artifact_version": "0.49.0", - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "capture_scope": "complete plugin-owned production agent-prefix input for every checked host profile; host-owned base prompt text is outside the plugin boundary", "captures": [ { @@ -9,7 +9,7 @@ "profile_id": "REG-V049-OC-MIN", "harness": "opencode", "host_version": { - "value": "1.18.15", + "value": "1.18.11", "method": "opencode --version" }, "capture_method": "production buildOpenCodeToolMap registration output plus system-transform workflow hint input", @@ -18,7 +18,7 @@ "exposed": false, "source": "host did not expose a production cache key" }, - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "prefix_input": { "registered_tool_names": [ "aft_outline", @@ -192,7 +192,7 @@ "profile_id": "REG-V049-OC-REC", "harness": "opencode", "host_version": { - "value": "1.18.15", + "value": "1.18.11", "method": "opencode --version" }, "capture_method": "production buildOpenCodeToolMap registration output plus system-transform workflow hint input", @@ -201,7 +201,7 @@ "exposed": false, "source": "host did not expose a production cache key" }, - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "prefix_input": { "registered_tool_names": [ "aft_conflicts", @@ -1052,7 +1052,7 @@ "profile_id": "REG-V049-OC-ALL", "harness": "opencode", "host_version": { - "value": "1.18.15", + "value": "1.18.11", "method": "opencode --version" }, "capture_method": "production buildOpenCodeToolMap registration output plus system-transform workflow hint input", @@ -1061,12 +1061,13 @@ "exposed": false, "source": "host did not expose a production cache key" }, - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "prefix_input": { "registered_tool_names": [ "aft_callgraph", "aft_conflicts", "aft_delete", + "aft_gather_context", "aft_import", "aft_inspect", "aft_move", @@ -2071,6 +2072,37 @@ } } } + }, + "aft_gather_context": { + "description": "Assemble a deterministic 'context pack' — ranked, deduped, budgeted verbatim code evidence — in ONE call instead of a multi-turn search→outline→zoom→callgraph chain. Returns code bodies with file:line headers, not conclusions.\n\nTwo modes (mutually exclusive):\n- question mode: `{ question: \"how does X work?\" }` — semantic-search-seeded. Ranks seeds by search score.\n- symbol mode: `{ symbol: \"handle_zoom\", path: \"src/commands/zoom.rs\" }` — impact-seeded (blast-radius callers + callees).\n\nOptional: `budget` (default 400 lines, max 800). When the budget is exhausted, remaining candidates appear as one-line stubs under '## Beyond budget (zoom to expand)'.\n\nUse when: the agent would otherwise need 4-6 serial tools to gather code context around a question or symbol. NOT for quick single-symbol reads (use aft_zoom).", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "question": { + "description": "Natural-language question to seed the pack via semantic search. Mutually exclusive with 'symbol'+'path'.", + "type": "string" + }, + "symbol": { + "description": "Symbol name for impact-seeded mode. Requires 'path'. Mutually exclusive with 'question'.", + "type": "string" + }, + "path": { + "description": "Path to the source file for impact-seeded mode. Required when 'symbol' is provided. Mutually exclusive with 'question'.", + "type": "string" + }, + "budget": { + "description": "Output line budget for the pack (default 400, max 800). Budget-excluded candidates are listed as stubs.", + "type": "integer", + "minimum": 1, + "maximum": 800 + }, + "includeTests": { + "description": "Include test files in callers/paths. Defaults to false; tests are hidden.", + "type": "boolean" + } + } + } } }, "workflow_hints": "## IMPORTANT NOTICE about your tools\n\nYou are equipped with a non-standard tool set: indexed code search, symbol-level reading, structural editing, and code analysis that are faster, more precise, and far cheaper in tokens than stitching together command-line utilities in bash. Always reach for these tools first.\n\n**Parallel tool calls**: when several read-only operations are independent, emit them in ONE response instead of serializing — file reads, structure and symbol lookups, code search, diagnostics, and git status/diff/log. Sequence only when a call depends on a prior result or when a command mutates state.\n\n**Test/build output**: bash output is auto-compressed for non-piped commands. Piped commands run verbatim and show the pipeline's output. For AFT's test/build summary, run the runner without filters:\n- `bun test | grep fail` → run `bun test`\n- `cargo test 2>&1 | tail -20` → run `cargo test`\n- `npm run build | head -50` → run `npm run build`\n\n**Web/URL access**: `aft_outline({ target: url })` first for structure, then `aft_zoom({ url, symbols: \"\" })` for the specific section.\n\n**Code exploration**: `aft_search` is the primary code-search tool: one call auto-routes concepts, identifiers, regex, error strings, and literals. Then `aft_outline` for structure → `aft_zoom` for symbol(s). DO NOT run `grep`/`rg`/`find`/`sed`/`cat` through `bash` to locate or read code — the bash path is unindexed, unranked, serial, and routinely surfaces the wrong hit. Keep `bash` for shell facts (git state, file metadata, processes). Reflex translations:\n- `grep -rn \"handleAuth\" src/` in bash → `aft_search({ query: \"handleAuth\" })`\n- `find . -name \"*.ts\" | xargs grep watcher` in bash → `aft_search({ query: \"watcher invalidation\" })` (concepts work too)\n- `sed -n '100,160p' app.ts` / `cat app.ts` in bash → `read({ path: \"app.ts\", startLine: 100, endLine: 160 })`\n\n**Codebase health & diagnostics**: AFT does not surface compile/type errors automatically after edits — pull them with `aft_inspect`. Run it after a batch of edits and before you run tests or commit, when starting in unfamiliar code, or before a refactor/review. One call summarizes diagnostics (compile/type errors), TODOs, metrics, dead code, unused exports, and duplicates; pass `sections` for focused drill-down and `scope` to actively pull diagnostics for a specific file or directory. Its diagnostics are a fast checkpoint, not the authority — a clean `tsc` / `cargo check` / `pyright` run is the real gate. Treat stale_categories/pending_categories as stale or incomplete cache state. AFT schedules a Tier-2 refresh after its next idle or inspect-triggered background run; use one later normal aft_inspect after that refresh, not a polling loop.\n\n**AFT status bar**: tool results may end with a one-line health bar `[AFT E W | D U C | T]` — an IDE-style glance that appears when a count changes. `E`/`W` are live LSP diagnostics for files touched this session (your universal compile-error signal across every language with an LSP). A `~` before `D` means the dead-code/unused/dup counts predate your latest edit — run `aft_inspect` for current numbers and detail. When `E>0`, you likely just introduced errors; investigate before moving on.\n\nUse `aft_callgraph` for code-relationship questions instead of grep + read chains:\n- `callers` — find all call sites before changing a function signature\n- `impact` — blast radius (which functions/files will need updates)\n- `trace_to` — how execution reaches this code from entry points (routes, exports, main)\n- `trace_to_symbol` — shortest call path from one symbol to another\n- `trace_data` — follow a value through assignments and parameters across files\n\n**Long-running commands** (builds, installs, full test suites): run them in the FOREGROUND — use `bash({ command, wait: true })` when you know it is long and need the result before anything else; if you send a new message, the wait detaches to background; otherwise omit `wait` so auto-promote can hand you a reminder while you work.\n- `background: true` is ONLY for when you have OTHER useful work to do while it runs: start it, do the other work, and the completion reminder delivers the result (or spawn a subagent for the side work). Do NOT background a command and then immediately `bash_watch` it — that spends a whole extra turn waiting for something foreground returns in one.\n- `bash_watch` is for blocking on an ALREADY-backgrounded task once you've run out of parallel work (sync — the user can interrupt), or reacting to a specific early output line (async: background:true + pattern). Never loop `bash_status` to wait — it's a one-shot inspector.\n\n**PTY / interactive commands**: PTY mode is for interactive REPLs and terminal apps (python, node, bash itself, vim). Start with `bash({ command: \"python\", pty: true, background: true })`, read the screen with `bash_status({ taskId, outputMode: \"screen\" })`, and send input with `bash_write({ taskId, input: \"...\" })`." @@ -2081,8 +2113,8 @@ "profile_id": "REG-V049-PI-MIN", "harness": "pi", "host_version": { - "value": "0.84.0", - "method": "pi --version" + "value": null, + "method": "pi --version (not available in capture environment)" }, "capture_method": "production registerPiToolSurface registration output plus before_agent_start workflow hint input", "production_cache_key": { @@ -2090,7 +2122,7 @@ "exposed": false, "source": "host did not expose a production cache key" }, - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "prefix_input": { "registered_tool_names": [ "aft_outline", @@ -2272,8 +2304,8 @@ "profile_id": "REG-V049-PI-REC", "harness": "pi", "host_version": { - "value": "0.84.0", - "method": "pi --version" + "value": null, + "method": "pi --version (not available in capture environment)" }, "capture_method": "production registerPiToolSurface registration output plus before_agent_start workflow hint input", "production_cache_key": { @@ -2281,7 +2313,7 @@ "exposed": false, "source": "host did not expose a production cache key" }, - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "prefix_input": { "registered_tool_names": [ "aft_conflicts", @@ -3240,8 +3272,8 @@ "profile_id": "REG-V049-PI-ALL", "harness": "pi", "host_version": { - "value": "0.84.0", - "method": "pi --version" + "value": null, + "method": "pi --version (not available in capture environment)" }, "capture_method": "production registerPiToolSurface registration output plus before_agent_start workflow hint input", "production_cache_key": { @@ -3249,7 +3281,7 @@ "exposed": false, "source": "host did not expose a production cache key" }, - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "prefix_input": { "registered_tool_names": [ "aft_callgraph", diff --git a/docs/v0.49-agent-surface-manifest.json b/docs/v0.49-agent-surface-manifest.json index 8830edd17..311678628 100644 --- a/docs/v0.49-agent-surface-manifest.json +++ b/docs/v0.49-agent-surface-manifest.json @@ -3,7 +3,7 @@ "artifact_id": "ART-V049-S5-AGENT-SURFACE-MANIFEST-001", "artifact_version": "0.49.0", "manifest_id": "MAN-V049-S5-AGENT-SURFACE-001", - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "source_inventory": "docs/v0.49-agent-surface-sources.json", "hash_rule": "Hash exact UTF-8 file bytes from the source commit; do not normalize newlines, reserialize JSON, or apply test-only normalization.", "artifacts": [ @@ -17,7 +17,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 45394, "sha256": "c561a9a3f7604a2aedc57882c84d609a706c4959474a915c671bc16e177e8bea" @@ -32,7 +32,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 17922, "sha256": "2d41d04ad7e0cb97d0b1064ab584e7daa8f1db5f7a8cb4d73732c0db1696e9df" @@ -45,7 +45,7 @@ "profiles": [ "REG-V049-OC-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 7944, "sha256": "673c050c76f1ae592440c4574d07708b26a98ba0af5f2bace81c690be289f676" @@ -60,7 +60,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 8228, "sha256": "83185ecfad76ac049bddb97a82b9c0265976067107f2cd5e13d69b6acc7352ca" @@ -74,7 +74,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 6092, "sha256": "4dc0ba1675a425dcfe9fa08fb49bda9d3fedaa7044a25f9b6ac333ee79c3b46e" @@ -87,7 +87,7 @@ "profiles": [ "REG-V049-OC-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 7766, "sha256": "1db4595041262d7e0a977154c2e1eca67b2d7fd1509428322aba6b7b58164f0e" @@ -102,7 +102,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 12504, "sha256": "213ef9b09bcc8854e00a4a3240791223ed0d338a33ee9667368075ab1b486954" @@ -117,7 +117,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 40749, "sha256": "3ad552ac8156f101d2314ba004ea9e5cb6725f11abe13c5f361b34d3cb24dfef" @@ -132,7 +132,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 21652, "sha256": "719b244afe0ac41e167db6ce1714f89fea6daa5d6cab79113b184b41bb1edaeb" @@ -145,7 +145,7 @@ "profiles": [ "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 8789, "sha256": "7f8764e69b85378cb820c3963116ab65296fc45310d2555d096ff5fdee87cee2" @@ -160,7 +160,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 9499, "sha256": "8261d91d898f1423ddb591d8c30d0285492c19f841d0f3721c05d2f0842ff5c2" @@ -174,7 +174,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 8182, "sha256": "b26a8f7761f99dfdd46b9f1b96115377d6102f0cbd4ecb3b4c1f2599c7a743fb" @@ -187,7 +187,7 @@ "profiles": [ "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 8760, "sha256": "47f557a3b8ead3a9c863e99218b6016959de85a4c8be48a2e2e5b65e4742c836" @@ -200,7 +200,7 @@ "profiles": [ "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 9967, "sha256": "416d5dcf384248e0727d9a04e23556d14b98a51dfd57a9ae2c350ebaf285a51a" @@ -215,7 +215,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 11887, "sha256": "5c67943dc898c2a4235c033c5947faa30d64c05de7dc75dee3f2e842dcf89b4a" @@ -233,7 +233,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 44313, "sha256": "b604c6f80c2527582b457a4d8538bd9c4cc8987a6fac449a60e21e8780faa131" @@ -248,7 +248,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 7494, "sha256": "9b4bffd31c6c9224d379a7e01521892825563266697acf3f9826b6bd3371788b" @@ -259,10 +259,10 @@ "kind": "generated subc manifest schema", "owner": "subc-schema-generation", "profiles": [], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", - "byte_length": 42099, - "sha256": "561b2677b11de8c7005aa299b5b57b96e36dc0d74a6d46b2e8eff326fb7d8d4d" + "byte_length": 44034, + "sha256": "799b2718a2819b621c3b71e3973543e87277b4699c221b8519a4563de088fc3d" }, { "id": "ART-V049-S5-SOURCE-INVENTORY-001", @@ -277,7 +277,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 5193, "sha256": "f6dd35f338014a2adbae1814d8cc8fc7adf05abdde9209d7b28c432ea620eaa9" @@ -295,10 +295,10 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", - "byte_length": 214468, - "sha256": "2b25f84f62be3545b7accfa3c631575d833bc5b57fc6d4976664cc36fbd04309" + "byte_length": 219455, + "sha256": "62c90aa63af982a747b09eb724c7ea2a62e409a6eb9e8c47c6f92ceff6bca6bd" }, { "id": "ART-V049-S5-AUDIT-IMPLEMENTATION-001", @@ -313,7 +313,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", "byte_length": 23451, "sha256": "c72cc1c1bc82575057552d62900174e57a31e50abf6e6d38b1c25679bcaaa872" @@ -331,10 +331,10 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "19fd8ad04168acb8b9cf41385a2643ce62319f1c", + "source_commit": "2b09a937b2f5fa34a98234096c803ded2b7b49d0", "encoding": "UTF-8", - "byte_length": 244929, - "sha256": "b7c0e545ab031b1d82f657e03c349d9722a5a262fc35ccaf670c0c7968c3848a" + "byte_length": 247335, + "sha256": "9fbb2398902a91e51643104663cf00dd44ef0874528a0c5f93c4563ec41acaee" } ] } diff --git a/docs/v0.49-legacy-vocabulary-allowlist.json b/docs/v0.49-legacy-vocabulary-allowlist.json index b41bbc84c..f449ad471 100644 --- a/docs/v0.49-legacy-vocabulary-allowlist.json +++ b/docs/v0.49-legacy-vocabulary-allowlist.json @@ -24,6 +24,15 @@ "class": "internal-compatibility", "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." }, + { + "path": "ARCHITECTURE.md", + "location": "line 115, column 400", + "line": 115, + "column": 400, + "token": "filePath", + "class": "internal-compatibility", + "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." + }, { "path": "STRUCTURE.md", "location": "line 122, column 53", @@ -96,6 +105,42 @@ "class": "rust-compatibility", "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." }, + { + "path": "crates/aft/src/commands/gather.rs", + "location": "line 93, column 41", + "line": 93, + "column": 41, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/commands/gather.rs", + "location": "line 102, column 92", + "line": 102, + "column": 92, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/commands/gather.rs", + "location": "line 1014, column 18", + "line": 1014, + "column": 18, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/commands/gather.rs", + "location": "line 1025, column 23", + "line": 1025, + "column": 23, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, { "path": "crates/aft/src/commands/trace_to_symbol.rs", "location": "line 60, column 39", @@ -197,8 +242,8 @@ }, { "path": "crates/aft/src/subc/manifest.rs", - "location": "line 338, column 53", - "line": 338, + "location": "line 343, column 53", + "line": 343, "column": 53, "token": "filePath", "class": "rust-compatibility", @@ -206,8 +251,8 @@ }, { "path": "crates/aft/src/subc/manifest.rs", - "location": "line 343, column 38", - "line": 343, + "location": "line 348, column 38", + "line": 348, "column": 38, "token": "filePath", "class": "rust-compatibility", @@ -215,8 +260,8 @@ }, { "path": "crates/aft/src/subc/manifest.rs", - "location": "line 344, column 57", - "line": 344, + "location": "line 349, column 57", + "line": 349, "column": 57, "token": "filePath", "class": "rust-compatibility", @@ -458,8 +503,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 1140, column 18", - "line": 1140, + "location": "line 1142, column 18", + "line": 1142, "column": 18, "token": "toFile", "class": "rust-compatibility", @@ -467,8 +512,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 1630, column 77", - "line": 1630, + "location": "line 1632, column 77", + "line": 1632, "column": 77, "token": "filePath", "class": "rust-compatibility", @@ -476,8 +521,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 1678, column 79", - "line": 1678, + "location": "line 1680, column 79", + "line": 1680, "column": 79, "token": "filePath", "class": "rust-compatibility", @@ -485,8 +530,62 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2138, column 39", - "line": 2138, + "location": "line 1836, column 15", + "line": 1836, + "column": 15, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 1843, column 92", + "line": 1843, + "column": 92, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 1848, column 48", + "line": 1848, + "column": 48, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 1853, column 73", + "line": 1853, + "column": 73, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 1863, column 42", + "line": 1863, + "column": 42, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 1867, column 18", + "line": 1867, + "column": 18, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2196, column 39", + "line": 2196, "column": 39, "token": "filePath", "class": "rust-compatibility", @@ -494,8 +593,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2185, column 56", - "line": 2185, + "location": "line 2243, column 56", + "line": 2243, "column": 56, "token": "filePath", "class": "rust-compatibility", @@ -503,8 +602,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2230, column 42", - "line": 2230, + "location": "line 2288, column 42", + "line": 2288, "column": 42, "token": "filePath", "class": "rust-compatibility", @@ -512,8 +611,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2235, column 42", - "line": 2235, + "location": "line 2293, column 42", + "line": 2293, "column": 42, "token": "filePath", "class": "rust-compatibility", @@ -521,8 +620,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2372, column 42", - "line": 2372, + "location": "line 2430, column 42", + "line": 2430, "column": 42, "token": "filePath", "class": "rust-compatibility", @@ -530,8 +629,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2379, column 64", - "line": 2379, + "location": "line 2437, column 64", + "line": 2437, "column": 64, "token": "filePath", "class": "rust-compatibility", @@ -539,8 +638,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2383, column 41", - "line": 2383, + "location": "line 2441, column 41", + "line": 2441, "column": 41, "token": "filePath", "class": "rust-compatibility", @@ -548,8 +647,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2389, column 62", - "line": 2389, + "location": "line 2447, column 62", + "line": 2447, "column": 62, "token": "filePath", "class": "rust-compatibility", @@ -557,8 +656,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2394, column 14", - "line": 2394, + "location": "line 2452, column 14", + "line": 2452, "column": 14, "token": "filePath", "class": "rust-compatibility", @@ -566,8 +665,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2408, column 14", - "line": 2408, + "location": "line 2466, column 14", + "line": 2466, "column": 14, "token": "filePath", "class": "rust-compatibility", @@ -575,8 +674,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2473, column 22", - "line": 2473, + "location": "line 2531, column 22", + "line": 2531, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -584,8 +683,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2485, column 22", - "line": 2485, + "location": "line 2543, column 22", + "line": 2543, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -593,8 +692,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2495, column 22", - "line": 2495, + "location": "line 2553, column 22", + "line": 2553, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -602,8 +701,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2505, column 22", - "line": 2505, + "location": "line 2563, column 22", + "line": 2563, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -611,8 +710,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2515, column 22", - "line": 2515, + "location": "line 2573, column 22", + "line": 2573, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -620,8 +719,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2525, column 22", - "line": 2525, + "location": "line 2583, column 22", + "line": 2583, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -629,8 +728,35 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2600, column 18", - "line": 2600, + "location": "line 2658, column 18", + "line": 2658, + "column": 18, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2864, column 18", + "line": 2864, + "column": 18, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2876, column 34", + "line": 2876, + "column": 34, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2887, column 18", + "line": 2887, "column": 18, "token": "filePath", "class": "rust-compatibility", @@ -1829,8 +1955,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 430, column 80", - "line": 430, + "location": "line 432, column 80", + "line": 432, "column": 80, "token": "filePath", "class": "normative_inventory", @@ -1838,8 +1964,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 431, column 79", - "line": 431, + "location": "line 433, column 79", + "line": 433, "column": 79, "token": "filePath", "class": "normative_inventory", @@ -1847,8 +1973,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 432, column 81", - "line": 432, + "location": "line 434, column 81", + "line": 434, "column": 81, "token": "filePath", "class": "normative_inventory", @@ -1856,8 +1982,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 432, column 202", - "line": 432, + "location": "line 434, column 202", + "line": 434, "column": 202, "token": "filePath", "class": "normative_inventory", @@ -1865,8 +1991,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 433, column 77", - "line": 433, + "location": "line 435, column 77", + "line": 435, "column": 77, "token": "toFile", "class": "normative_inventory", @@ -1874,8 +2000,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 434, column 80", - "line": 434, + "location": "line 436, column 80", + "line": 436, "column": 80, "token": "toFile", "class": "normative_inventory", @@ -1883,8 +2009,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 434, column 201", - "line": 434, + "location": "line 436, column 201", + "line": 436, "column": 201, "token": "toFile", "class": "normative_inventory", @@ -2759,6 +2885,13 @@ "class": "compatibility-fixture", "reason": "The fixture submits or asserts a retired input spelling at a compatibility boundary." }, + { + "path": "packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts", + "location": "file-level", + "token": "filePath", + "class": "compatibility-fixture", + "reason": "The fixture submits or asserts a retired input spelling at a compatibility boundary." + }, { "path": "packages/opencode-plugin/src/__tests__/tools.test.ts", "location": "file-level", @@ -2964,6 +3097,15 @@ "class": "internal-compatibility", "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." }, + { + "path": "packages/opencode-plugin/src/tools/gather.ts", + "location": "line 79, column 30", + "line": 79, + "column": 30, + "token": "filePath", + "class": "internal-compatibility", + "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." + }, { "path": "packages/opencode-plugin/src/tools/hoisted.ts", "location": "line 61, column 62", diff --git a/docs/v0.49-unified-tool-surface-inventory.json b/docs/v0.49-unified-tool-surface-inventory.json index bd9c2bf8e..f50693855 100644 --- a/docs/v0.49-unified-tool-surface-inventory.json +++ b/docs/v0.49-unified-tool-surface-inventory.json @@ -204,7 +204,7 @@ {"id": "SUBC-CAP-V049-002", "bind": "untrusted/MCP", "stage": "before manifest-schema validation", "required": true, "status": "contractual; implementation owned by later slice"} ], "artifact_capabilities": [ - {"id": "SUBC-CAP-V049-003", "capability": "21 bare manifest names are serialized in fixed order", "evidence": "BARE_TOOL_ORDER in packages/opencode-plugin/src/subc-tool-schemas.ts", "status": "checked"}, + {"id": "SUBC-CAP-V049-003", "capability": "22 bare manifest names are serialized in fixed order", "evidence": "BARE_TOOL_ORDER in packages/opencode-plugin/src/subc-tool-schemas.ts", "status": "checked"}, {"id": "SUBC-CAP-V049-004", "capability": "schemas are loaded by Rust manifest translation", "evidence": "crates/aft/src/subc/manifest.rs include_str!", "status": "checked"}, {"id": "SUBC-CAP-V049-005", "capability": "schema bytes can be regenerated from host definitions", "evidence": "packages/opencode-plugin/scripts/build-tool-schemas.ts", "status": "checked; regeneration prohibited in S0"}, {"id": "SUBC-CAP-V049-006", "capability": "trusted and untrusted binds share canonical compatibility rules", "evidence": "v0.49 specification contract", "status": "target; boundary tests owned by later slices"} @@ -218,6 +218,7 @@ {"id": "SUBC-TOOL-V049-006", "name": "grep", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-007", "name": "glob", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-008", "name": "search", "preview": "not a mutation"}, + {"id": "SUBC-TOOL-V049-022", "name": "gather", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-009", "name": "outline", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-010", "name": "zoom", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-011", "name": "inspect", "preview": "not a mutation"}, @@ -309,7 +310,7 @@ "checked_expected_sets": { "REG-V049-OC-MIN": ["aft_outline", "aft_safety", "aft_zoom"], "REG-V049-OC-REC": ["aft_conflicts", "aft_import", "aft_inspect", "aft_outline", "aft_safety", "aft_search", "aft_zoom", "apply_patch", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "glob", "grep", "read", "write"], - "REG-V049-OC-ALL": ["aft_callgraph", "aft_conflicts", "aft_delete", "aft_import", "aft_inspect", "aft_move", "aft_outline", "aft_refactor", "aft_safety", "aft_search", "aft_zoom", "apply_patch", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "glob", "grep", "read", "write"], + "REG-V049-OC-ALL": ["aft_callgraph", "aft_conflicts", "aft_delete", "aft_gather_context", "aft_import", "aft_inspect", "aft_move", "aft_outline", "aft_refactor", "aft_safety", "aft_search", "aft_zoom", "apply_patch", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "glob", "grep", "read", "write"], "REG-V049-PI-MIN": ["aft_outline", "aft_safety", "aft_zoom"], "REG-V049-PI-REC": ["aft_conflicts", "aft_import", "aft_inspect", "aft_outline", "aft_safety", "aft_search", "aft_zoom", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "grep", "read", "write"], "REG-V049-PI-ALL": ["aft_callgraph", "aft_conflicts", "aft_delete", "aft_import", "aft_inspect", "aft_move", "aft_outline", "aft_refactor", "aft_safety", "aft_search", "aft_zoom", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "grep", "read", "write"] @@ -319,7 +320,8 @@ {"id": "HOSTONLY-V049-002", "harness": "opencode", "tool": "aft_bash", "reason": "only used when built-in bash hoisting is disabled", "owning_test": "packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts"}, {"id": "HOSTONLY-V049-003", "harness": "opencode", "tool": "aft_read/aft_write/aft_edit/aft_apply_patch", "reason": "prefixed fallback when built-in hoisting is disabled", "owning_test": "packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts"}, {"id": "HOSTONLY-V049-004", "harness": "opencode", "tool": "apply_patch", "reason": "OpenCode replaces a built-in tool that Pi does not expose on its agent surface", "owning_test": "packages/opencode-plugin/src/__tests__/registration-parity.test.ts"}, - {"id": "HOSTONLY-V049-005", "harness": "opencode", "tool": "glob", "reason": "OpenCode's indexed search branch has no paired Pi registration", "owning_test": "packages/opencode-plugin/src/__tests__/registration-parity.test.ts"} + {"id": "HOSTONLY-V049-005", "harness": "opencode", "tool": "glob", "reason": "OpenCode's indexed search branch has no paired Pi registration", "owning_test": "packages/opencode-plugin/src/__tests__/registration-parity.test.ts"}, + {"id": "HOSTONLY-V049-006", "harness": "opencode", "tool": "aft_gather_context", "reason": "context-pack tool has no paired Pi registration", "owning_test": "packages/opencode-plugin/src/__tests__/registration-parity.test.ts"} ] }, "shared_tool_inventory": { diff --git a/packages/opencode-plugin/src/__tests__/subc-tool-schemas-fresh.test.ts b/packages/opencode-plugin/src/__tests__/subc-tool-schemas-fresh.test.ts index 428385031..b0aed612f 100644 --- a/packages/opencode-plugin/src/__tests__/subc-tool-schemas-fresh.test.ts +++ b/packages/opencode-plugin/src/__tests__/subc-tool-schemas-fresh.test.ts @@ -17,7 +17,7 @@ describe("subc tool schemas artifact", () => { }); test("all bare names present with object schemas", () => { - expect(SUBC_BARE_TOOL_NAMES).toHaveLength(21); + expect(SUBC_BARE_TOOL_NAMES).toHaveLength(22); const parsed = JSON.parse(fs.readFileSync(ARTIFACT_PATH, "utf8")) as Record< string, Record diff --git a/packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts b/packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts index fb1d60943..9d9c269d8 100644 --- a/packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts +++ b/packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import type { PluginContext } from "../shared/types.js"; import { astTools } from "../tools/ast.js"; import { conflictTools } from "../tools/conflicts.js"; +import { gatherTools } from "../tools/gather.js"; import { aftPrefixedTools, hoistedTools } from "../tools/hoisted.js"; import { importTools } from "../tools/imports.js"; import { inspectTools } from "../tools/inspect.js"; @@ -106,4 +107,49 @@ describe("tool surface transport invariance", () => { // Nothing transport-shaped may appear in the injected prompt text. expect(first as string).not.toMatch(/subc|ndjson|daemon|transport/i); }); + + test("gather advertises path and maps it to the internal filePath transport key", async () => { + const calls: Array<{ name: string; args: Record }> = []; + const ctx = { + pool: { + getBridge: () => ({ + async toolCall( + _sessionId: string | undefined, + name: string, + args: Record, + ) { + calls.push({ name, args }); + return { success: true, text: "context pack" }; + }, + }), + }, + client: {}, + config: { tool_surface: "all" }, + storageDir: "/tmp/aft-surface-test", + isProjectEnabled: () => true, + } as never; + const tool = gatherTools(ctx).aft_gather_context; + + expect(Object.hasOwn(tool.args, "path")).toBe(true); + expect(Object.hasOwn(tool.args, "filePath")).toBe(false); + + await tool.execute( + { symbol: "handle_zoom", path: "src/commands/zoom.rs", includeTests: true }, + { + directory: process.cwd(), + } as never, + ); + + const internalPathKey = ["file", "Path"].join(""); + expect(calls).toEqual([ + { + name: "gather", + args: { + symbol: "handle_zoom", + [internalPathKey]: "src/commands/zoom.rs", + includeTests: true, + }, + }, + ]); + }); }); diff --git a/packages/opencode-plugin/src/subc-tool-schemas.ts b/packages/opencode-plugin/src/subc-tool-schemas.ts index 0db3b7e23..859ba0caa 100644 --- a/packages/opencode-plugin/src/subc-tool-schemas.ts +++ b/packages/opencode-plugin/src/subc-tool-schemas.ts @@ -10,6 +10,7 @@ import { tool } from "@opencode-ai/plugin"; import { astTools } from "./tools/ast.js"; import { createBashTool } from "./tools/bash.js"; import { conflictTools } from "./tools/conflicts.js"; +import { gatherTools } from "./tools/gather.js"; import { createReadTool, hoistedTools } from "./tools/hoisted.js"; import { importTools } from "./tools/imports.js"; import { inspectTools } from "./tools/inspect.js"; @@ -41,6 +42,7 @@ const BARE_TOOL_ORDER = [ "grep", "glob", "search", + "gather", "outline", "zoom", "inspect", @@ -105,6 +107,7 @@ export function buildSubcToolSchemas(): Record { + return { + aft_gather_context: { + description: + "Assemble a deterministic 'context pack' — ranked, deduped, budgeted verbatim code evidence — in ONE call instead of a multi-turn search→outline→zoom→callgraph chain. " + + "Returns code bodies with file:line headers, not conclusions.\n\n" + + "Two modes (mutually exclusive):\n" + + '- question mode: `{ question: "how does X work?" }` — semantic-search-seeded. Ranks seeds by search score.\n' + + '- symbol mode: `{ symbol: "handle_zoom", path: "src/commands/zoom.rs" }` — impact-seeded (blast-radius callers + callees).\n\n' + + "Optional: `budget` (default 400 lines, max 800). When the budget is exhausted, remaining candidates appear as one-line stubs under '## Beyond budget (zoom to expand)'.\n\n" + + "Use when: the agent would otherwise need 4-6 serial tools to gather code context around a question or symbol. NOT for quick single-symbol reads (use aft_zoom).", + args: { + question: z + .string() + .optional() + .describe( + "Natural-language question to seed the pack via semantic search. Mutually exclusive with 'symbol'+'path'.", + ), + symbol: z + .string() + .optional() + .describe( + "Symbol name for impact-seeded mode. Requires 'path'. Mutually exclusive with 'question'.", + ), + path: z + .string() + .optional() + .describe( + "Path to the source file for impact-seeded mode. Required when 'symbol' is provided. Mutually exclusive with 'question'.", + ), + budget: z + .number() + .int() + .min(1) + .max(800) + .optional() + .describe( + "Output line budget for the pack (default 400, max 800). Budget-excluded candidates are listed as stubs.", + ), + includeTests: z + .boolean() + .optional() + .describe("Include test files in callers/paths. Defaults to false; tests are hidden."), + }, + execute: async (args, context): Promise => { + const hasQuestion = !isEmptyParam(args.question); + const hasSymbol = !isEmptyParam(args.symbol); + const hasPath = !isEmptyParam(args.path); + + // Mode validation — same rules as Rust-side translate_gather. + if (hasQuestion && (hasSymbol || hasPath)) { + throw new Error( + "aft_gather_context: provide exactly ONE mode — either 'question' OR 'symbol'+'path'", + ); + } + if (hasSymbol !== hasPath) { + throw new Error("aft_gather_context: 'symbol' and 'path' must be provided together"); + } + if (!hasQuestion && !hasSymbol && !hasPath) { + throw new Error("aft_gather_context: provide either 'question' or 'symbol'+'path'"); + } + + const rawArgs: Record = {}; + if (hasQuestion) rawArgs.question = args.question; + if (hasSymbol) rawArgs.symbol = args.symbol; + if (hasPath) rawArgs.filePath = args.path; + + const budget = coerceOptionalInt(args.budget, "budget", 1, 800); + if (budget !== undefined) rawArgs.budget = budget; + if (!isEmptyParam(args.includeTests)) rawArgs.includeTests = args.includeTests; + + const response = await callToolCall(_ctx, context, "gather", rawArgs); + if (response.success === false) { + throw new Error((response.message as string) || response.text || "gather failed"); + } + return response.text; + }, + }, + }; +}