pi-graphnav 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sorin Albu-Irimies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,96 @@
1
- # Temporary Holding Version
1
+ # pi-graphnav
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/pi-graphnav.svg)](https://www.npmjs.com/package/pi-graphnav)
4
+ [![CI](https://github.com/sorinirimies/pi-graphnav/actions/workflows/ci.yml/badge.svg)](https://github.com/sorinirimies/pi-graphnav/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![Audit](https://github.com/sorinirimies/pi-graphnav/actions/workflows/security.yml/badge.svg)](https://github.com/sorinirimies/pi-graphnav/actions/workflows/security.yml)
7
+ [![Fuzz](https://github.com/sorinirimies/pi-graphnav/actions/workflows/fuzz.yml/badge.svg)](https://github.com/sorinirimies/pi-graphnav/actions/workflows/fuzz.yml)
8
+
9
+ CodeGraph-first code navigation for pi: the agent understands code through
10
+ [CodeGraph](https://github.com/colbymchenry/codegraph)'s symbol graph instead of grepping and
11
+ reading file after file, without being told to.
12
+
13
+ A [pi](https://github.com/earendil-works/pi-coding-agent) extension.
14
+
15
+ ## What it does
16
+
17
+ * **Keeps the index current.** At session start, in the background: `codegraph init` for a git repo
18
+ without an index, a full `codegraph index` when the index is stale or was built by another
19
+ CodeGraph version, `codegraph sync` for pending changes. While pi runs, the server's file
20
+ watcher follows your edits.
21
+ * **The `codegraph_*` tools** (`explore`, `search`, `node`, `callers`, `callees`, `impact`,
22
+ `files`, `status`) on one long-lived `codegraph serve --mcp` per project: no process start per
23
+ call, and the watcher stays alive.
24
+ * **Steers symbol searches to CodeGraph.** `rg parseConfig`, `grep -rn UserService src`,
25
+ `git grep`, `ag`, `ack` and pi's `grep` tool with a symbol-like pattern are blocked once with
26
+ the CodeGraph call that answers it (`enforce`), or run with that hint appended (`nudge`).
27
+ Running the same command again is allowed: CodeGraph cannot answer everything. Searches for
28
+ phrases, string literals or in docs and config (`*.md`, `--type yaml`, …) are never touched,
29
+ nor symbols already looked up with CodeGraph.
30
+ * **Hints after blind reading.** Reading several source files before any CodeGraph call adds
31
+ one hint to use `codegraph_explore`.
32
+ * **Prompt guidance** that stays the same every turn, so the prompt cache survives.
33
+
34
+ Needs the CodeGraph CLI: `npm i -g @colbymchenry/codegraph`. The project is the nearest
35
+ directory with `.codegraph/`, else the git work tree; never your home directory.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pi install npm:pi-graphnav
41
+ ```
42
+
43
+ ## Use
44
+
45
+ | Command | |
46
+ |---|---|
47
+ | `/codegraph` or `/codegraph status` | project, index state, mode, blocked/nudged counts |
48
+ | `/codegraph [on\|off]` | switch it all (remembered across sessions) |
49
+ | `/codegraph mode <mode>` | symbol searches: `enforce` (default), `nudge` or `off` |
50
+ | `/codegraph sync` | sync the index with changed files |
51
+ | `/codegraph reindex` | rebuild the index from scratch |
52
+ | `/codegraph init` | create the index (for a directory that is not a git repo) |
53
+
54
+ Settings live in `<pi agent dir>/codegraph.json` (`PI_CODING_AGENT_DIR`, else `~/.pi/agent`):
55
+
56
+ | Key | Default | |
57
+ |---|---|---|
58
+ | `enabled` | `true` | master switch |
59
+ | `showStatus` | `true` | status line entry |
60
+ | `mode` | `"enforce"` | `enforce`, `nudge` or `off` |
61
+ | `autoInit` | `true` | init a git repo that has no index |
62
+ | `autoSync` | `true` | sync or rebuild a stale index at session start |
63
+ | `watch` | `true` | keep the server's file watcher running |
64
+ | `readHintAfter` | `3` | source files read before the CodeGraph hint (`0`: never) |
65
+ | `timeoutMs` | `30000` | per-call timeout of the CodeGraph server |
66
+
67
+ Replaces `@vndv/pi-codegraph` (same tool names): uninstall that one first.
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ bun install
73
+ just check # typecheck + tests (95% coverage gate) + pack check + nu script tests
74
+ just --list # everything else: fuzz, audit, release, remotes
75
+ ```
76
+
77
+ Layout:
78
+
79
+ | Path | What |
80
+ |---|---|
81
+ | `extensions/graphnav.ts` | wiring: hooks, tools, the command, the status line |
82
+ | `extensions/core/gate.ts` | spotting symbol searches in shell commands; the gate decision |
83
+ | `extensions/core/project.ts` | project root, `codegraph status -j`, what keeps the index current |
84
+ | `extensions/core/mcp.ts` | the long-lived `codegraph serve --mcp` client |
85
+ | `extensions/core/tools.ts` | tool definitions and prompt guidelines |
86
+ | `extensions/core/config.ts`, `extensions/command/parse.ts` | settings, `/codegraph` parsing |
87
+ | `tests/` | `bun test`: one file per module, `extension` (hooks), `fuzz` (randomised properties) |
88
+ | `scripts/` | Nushell only, each with a test in `scripts/tests/` |
89
+ | `.github/`, `.gitea/` | CI, release, nightlies on both hosts |
90
+
91
+ Releases: `just release patch` bumps `package.json`, regenerates `CHANGELOG.md` with git-cliff, tags and
92
+ pushes; the Release workflow publishes to npm (secret `NPM_TOKEN`).
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,75 @@
1
+ /** `/codegraph ...`: parsing and argument completion. Pure: the handler in graphnav.ts acts on the result. */
2
+ import { MODES, type Mode } from "../core/config.ts";
3
+
4
+ export type Command =
5
+ | { kind: "status" }
6
+ | { kind: "on" }
7
+ | { kind: "off" }
8
+ | { kind: "mode"; mode: Mode }
9
+ | { kind: "sync" }
10
+ | { kind: "reindex" }
11
+ | { kind: "init" }
12
+ | { kind: "invalid"; message: string };
13
+
14
+ export const USAGE = "Usage: /codegraph [status] · on · off · mode <enforce|nudge|off> · sync · reindex · init";
15
+
16
+ export function parseCommand(args: string | undefined): Command {
17
+ const [cmd, ...rest] = (args ?? "").trim().split(/\s+/).filter(Boolean);
18
+ switch (cmd?.toLowerCase()) {
19
+ case undefined:
20
+ case "status":
21
+ return { kind: "status" };
22
+ case "on":
23
+ case "enable":
24
+ return { kind: "on" };
25
+ case "off":
26
+ case "disable":
27
+ return { kind: "off" };
28
+ case "mode": {
29
+ const m = rest[0]?.toLowerCase();
30
+ return MODES.includes(m as Mode) && rest.length === 1
31
+ ? { kind: "mode", mode: m as Mode }
32
+ : { kind: "invalid", message: "Usage: /codegraph mode <enforce|nudge|off>" };
33
+ }
34
+ case "sync":
35
+ return { kind: "sync" };
36
+ case "reindex":
37
+ case "index":
38
+ return { kind: "reindex" };
39
+ case "init":
40
+ return { kind: "init" };
41
+ default:
42
+ return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
43
+ }
44
+ }
45
+
46
+ export interface CompletionItem {
47
+ value: string;
48
+ /** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
49
+ label: string;
50
+ description?: string;
51
+ }
52
+
53
+ const TOP_LEVEL: CompletionItem[] = [
54
+ { value: "status", label: "status", description: "Index state, mode and gate counts" },
55
+ { value: "on", label: "on", description: "Turn CodeGraph-first on" },
56
+ { value: "off", label: "off", description: "Turn CodeGraph-first off" },
57
+ { value: "mode ", label: "mode", description: "Symbol searches: enforce, nudge or off" },
58
+ { value: "sync", label: "sync", description: "Sync the index with changed files" },
59
+ { value: "reindex", label: "reindex", description: "Rebuild the index from scratch" },
60
+ { value: "init", label: "init", description: "Create the index for this project" },
61
+ ];
62
+
63
+ const MODE_ITEMS: CompletionItem[] = [
64
+ { value: "mode enforce", label: "enforce", description: "Block a symbol search once, pointing to CodeGraph" },
65
+ { value: "mode nudge", label: "nudge", description: "Let it run, add a CodeGraph hint to the result" },
66
+ { value: "mode off", label: "off", description: "Leave text searches alone" },
67
+ ];
68
+
69
+ /** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
70
+ export function completions(prefix: string): CompletionItem[] | null {
71
+ const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
72
+ const pool = text.startsWith("mode ") ? MODE_ITEMS : TOP_LEVEL;
73
+ const out = pool.filter((i) => i.value.startsWith(text));
74
+ return out.length > 0 ? out : null;
75
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The persisted settings: <agent dir>/codegraph.json.
3
+ *
4
+ * A hand-edited file can hold anything, so `sanitizeConfig` keeps valid values and defaults the
5
+ * rest. A missing file is not a problem (defaults); a broken one is reported, never thrown.
6
+ */
7
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ export type Mode = "enforce" | "nudge" | "off";
12
+ export const MODES: readonly Mode[] = ["enforce", "nudge", "off"];
13
+
14
+ export interface Config {
15
+ /** Master switch: when false the extension adds no guidance, gates nothing and indexes nothing. */
16
+ enabled: boolean;
17
+ /** Show the status line entry. */
18
+ showStatus: boolean;
19
+ /** Symbol searches with rg/grep: block once (`enforce`), only add a hint (`nudge`) or leave alone (`off`). */
20
+ mode: Mode;
21
+ /** Create the index (`codegraph init`) for a git repo that has none. */
22
+ autoInit: boolean;
23
+ /** Sync or rebuild a stale index when the session starts. */
24
+ autoSync: boolean;
25
+ /** Keep the CodeGraph server's file watcher running, so the index follows edits. */
26
+ watch: boolean;
27
+ /** Source files read before any CodeGraph call that earn a hint (0 = never). */
28
+ readHintAfter: number;
29
+ /** Per-call timeout for the CodeGraph server, in ms. */
30
+ timeoutMs: number;
31
+ }
32
+
33
+ export const DEFAULT_CONFIG: Config = {
34
+ enabled: true,
35
+ showStatus: true,
36
+ mode: "enforce",
37
+ autoInit: true,
38
+ autoSync: true,
39
+ watch: true,
40
+ readHintAfter: 3,
41
+ timeoutMs: 30_000,
42
+ };
43
+
44
+ export const clampInt = (v: unknown, min: number, max: number, fallback: number): number =>
45
+ typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : fallback;
46
+
47
+ export function sanitizeConfig(raw: unknown): Config {
48
+ const r = (typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>;
49
+ const d = DEFAULT_CONFIG;
50
+ return {
51
+ enabled: typeof r.enabled === "boolean" ? r.enabled : d.enabled,
52
+ showStatus: typeof r.showStatus === "boolean" ? r.showStatus : d.showStatus,
53
+ mode: MODES.includes(r.mode as Mode) ? (r.mode as Mode) : d.mode,
54
+ autoInit: typeof r.autoInit === "boolean" ? r.autoInit : d.autoInit,
55
+ autoSync: typeof r.autoSync === "boolean" ? r.autoSync : d.autoSync,
56
+ watch: typeof r.watch === "boolean" ? r.watch : d.watch,
57
+ readHintAfter: clampInt(r.readHintAfter, 0, 100, d.readHintAfter),
58
+ timeoutMs: clampInt(r.timeoutMs, 1000, 600_000, d.timeoutMs),
59
+ };
60
+ }
61
+
62
+ /** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
63
+ export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
64
+ if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
65
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
66
+ return join(home, ".pi", "agent");
67
+ }
68
+
69
+ export const configPath = (env: Record<string, string | undefined> = process.env, home = homedir()): string =>
70
+ join(resolveAgentDir(env, home), "codegraph.json");
71
+
72
+ /** The config, plus a `problem` when the file exists but cannot be used. */
73
+ export async function readConfig(path = configPath()): Promise<{ config: Config; problem?: string }> {
74
+ let raw: string;
75
+ try {
76
+ raw = await readFile(path, "utf8");
77
+ } catch (e) {
78
+ const code = (e as { code?: string } | null)?.code;
79
+ return code === "ENOENT" ? { config: sanitizeConfig({}) } : { config: sanitizeConfig({}), problem: `cannot read ${path} (${code ?? "error"})` };
80
+ }
81
+ try {
82
+ return { config: sanitizeConfig(JSON.parse(raw)) };
83
+ } catch {
84
+ return { config: sanitizeConfig({}), problem: `${path} is not valid JSON` };
85
+ }
86
+ }
87
+
88
+ export async function loadConfig(path = configPath()): Promise<Config> {
89
+ return (await readConfig(path)).config;
90
+ }
91
+
92
+ /** Atomic: written to a temp file and renamed, so a crash or a second pi session never leaves half a file. */
93
+ export async function saveConfig(config: Config, path = configPath()): Promise<void> {
94
+ await mkdir(join(path, ".."), { recursive: true });
95
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
96
+ try {
97
+ await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
98
+ await rename(tmp, path);
99
+ } catch (e) {
100
+ await Promise.allSettled([rm(tmp, { force: true })]); // the original error matters more
101
+ throw e;
102
+ }
103
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * The CodeGraph-first gate: spots text searches (`rg`, `grep`, `git grep`, `ag`, `ack`, pi's
3
+ * `grep` tool) whose pattern is really a symbol lookup, and decides whether to let them run.
4
+ *
5
+ * Pure: the extension feeds it commands and remembers what CodeGraph was already asked.
6
+ */
7
+
8
+ /** One text search found in a command. `pattern` is undefined for a listing (`rg --files`). */
9
+ export interface Search {
10
+ tool: string;
11
+ pattern?: string;
12
+ /** File and directory operands. */
13
+ paths: string[];
14
+ /** `--type`/`--glob`/`--include` filters, as written. */
15
+ filters: string[];
16
+ }
17
+
18
+ const OPERATORS = new Set(["|", "||", "&", "&&", ";", "(", ")", "\n", "|&"]);
19
+
20
+ /**
21
+ * Split a shell command into simple commands (arrays of words). Handles quotes, backslash
22
+ * escapes and the usual operators; anything stranger (subshell syntax, heredocs) degrades to
23
+ * extra words, which only makes the gate more permissive.
24
+ */
25
+ export function splitCommands(command: string): string[][] {
26
+ return splitPipeline(command).map((c) => c.words);
27
+ }
28
+
29
+ /** Like `splitCommands`, also telling which commands read a pipe (`a | b`: b is piped). */
30
+ export function splitPipeline(command: string): { words: string[]; piped: boolean }[] {
31
+ const out: { words: string[]; piped: boolean }[] = [];
32
+ let cur: string[] = [];
33
+ let word = "";
34
+ let inWord = false;
35
+ let piped = false;
36
+ const endWord = () => {
37
+ if (inWord) cur.push(word);
38
+ word = "";
39
+ inWord = false;
40
+ };
41
+ const endCommand = (nextPiped: boolean) => {
42
+ endWord();
43
+ if (cur.length > 0) out.push({ words: cur, piped });
44
+ cur = [];
45
+ piped = nextPiped;
46
+ };
47
+ for (let i = 0; i < command.length; i++) {
48
+ const c = command[i]!;
49
+ if (c === "'") {
50
+ const end = command.indexOf("'", i + 1);
51
+ const stop = end === -1 ? command.length : end;
52
+ word += command.slice(i + 1, stop);
53
+ inWord = true;
54
+ i = stop;
55
+ } else if (c === '"') {
56
+ inWord = true;
57
+ for (i++; i < command.length && command[i] !== '"'; i++) {
58
+ if (command[i] === "\\" && i + 1 < command.length && '"\\$`'.includes(command[i + 1]!)) i++;
59
+ word += command[i];
60
+ }
61
+ } else if (c === "\\" && i + 1 < command.length) {
62
+ if (command[i + 1] !== "\n") word += command[i + 1];
63
+ inWord = true;
64
+ i++;
65
+ } else if (c === " " || c === "\t") {
66
+ endWord();
67
+ } else if (OPERATORS.has(c)) {
68
+ const double = command[i + 1] === c;
69
+ endCommand(c === "|" && !double);
70
+ if (double || (c === "|" && command[i + 1] === "&")) i++;
71
+ } else {
72
+ word += c;
73
+ inWord = true;
74
+ }
75
+ }
76
+ endCommand(false);
77
+ return out;
78
+ }
79
+
80
+ /** Flags that take a value, per tool. Long flags may also be written `--flag=value`. */
81
+ const VALUE_FLAGS: Record<string, Set<string>> = {
82
+ rg: new Set(
83
+ "-A -B -C -m -M -j -d -E -g -t -T -r -f -e --after-context --before-context --context --max-count --max-columns --threads --max-depth --encoding --glob --iglob --type --type-not --type-add --type-clear --replace --file --regexp --color --colors --sort --sortr --pre --pre-glob --ignore-file --max-filesize --path-separator --field-match-separator --field-context-separator --context-separator --engine --dfa-size-limit --regex-size-limit --hostname-bin --hyperlink-format".split(" "),
84
+ ),
85
+ grep: new Set("-A -B -C -m -e -f -d -D --after-context --before-context --context --max-count --regexp --file --include --exclude --exclude-dir --exclude-from --directories --devices --color --colour --label --binary-files".split(" ")),
86
+ ag: new Set("-A -B -C -m -G -g --after --before --context --max-count --file-search-regex --ignore --ignore-dir --depth --pager".split(" ")),
87
+ ack: new Set("-A -B -C -m -g --after-context --before-context --context --max-count --type --ignore-dir --ignore-file --output --match".split(" ")),
88
+ };
89
+ VALUE_FLAGS.egrep = VALUE_FLAGS.grep!;
90
+ VALUE_FLAGS.fgrep = VALUE_FLAGS.grep!;
91
+ VALUE_FLAGS["git grep"] = new Set("-A -B -C -m -e -f --after-context --before-context --context --max-count --max-depth --threads".split(" "));
92
+
93
+ const PATTERN_FLAGS = new Set(["-e", "--regexp"]);
94
+ const FILTER_FLAGS = new Set(["-g", "-t", "-T", "--glob", "--iglob", "--type", "--type-not", "--include", "-G", "--file-search-regex"]);
95
+ const WRAPPERS = new Set(["sudo", "command", "exec", "time", "nice", "nohup", "env", "xargs", "timeout"]);
96
+
97
+ /** The program of a simple command, skipping `VAR=x` assignments and wrappers like `xargs -0`. */
98
+ function programIndex(words: string[]): number {
99
+ let i = 0;
100
+ for (;;) {
101
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) i++;
102
+ const w = words[i];
103
+ if (w === undefined || !WRAPPERS.has(baseName(w))) return i;
104
+ i++;
105
+ while (i < words.length && words[i]!.startsWith("-")) i++;
106
+ if (baseName(words[i - 1] ?? "") === "timeout" && /^\d/.test(words[i] ?? "")) i++;
107
+ }
108
+ }
109
+
110
+ const baseName = (w: string) => w.slice(w.lastIndexOf("/") + 1);
111
+
112
+ /** Every text search over files in a shell command (a filter reading a pipe searches no code). */
113
+ export function findSearches(command: string): Search[] {
114
+ const out: Search[] = [];
115
+ for (const { words, piped } of splitPipeline(command)) {
116
+ const at = programIndex(words);
117
+ const prog = baseName(words[at] ?? "");
118
+ let tool: string;
119
+ let args: string[];
120
+ if (prog === "git") {
121
+ let j = at + 1;
122
+ while (j < words.length && words[j]!.startsWith("-")) j += words[j] === "-C" || words[j] === "-c" ? 2 : 1;
123
+ if (words[j] !== "grep") continue;
124
+ tool = "git grep";
125
+ args = words.slice(j + 1);
126
+ } else if (prog in VALUE_FLAGS && prog !== "git grep") {
127
+ tool = prog;
128
+ args = words.slice(at + 1);
129
+ } else continue;
130
+ const search = parseArgs(tool, args);
131
+ // `… | rg x` filters the pipe; `… | xargs rg x` searches the files the pipe names.
132
+ if (piped && search.paths.length === 0 && !words.slice(0, at).some((w) => baseName(w) === "xargs")) continue;
133
+ out.push(search);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function parseArgs(tool: string, args: string[]): Search {
139
+ const valueFlags = VALUE_FLAGS[tool]!;
140
+ const positional: string[] = [];
141
+ const filters: string[] = [];
142
+ let pattern: string | undefined;
143
+ let listing = false;
144
+ let endOfFlags = false;
145
+ for (let i = 0; i < args.length; i++) {
146
+ const a = args[i]!;
147
+ if (endOfFlags || !a.startsWith("-") || a === "-") {
148
+ positional.push(a);
149
+ continue;
150
+ }
151
+ if (a === "--") {
152
+ endOfFlags = true;
153
+ continue;
154
+ }
155
+ const eq = a.startsWith("--") ? a.indexOf("=") : -1;
156
+ const flag = eq === -1 ? a : a.slice(0, eq);
157
+ // Short flag cluster with an attached value (`-tmd`, `-e foo` as `-efoo`), or a cluster of booleans (`-rn`).
158
+ const short = !a.startsWith("--") && a.length > 2 ? `-${a[1]}` : undefined;
159
+ let value: string | undefined;
160
+ let name = flag;
161
+ if (eq !== -1) value = a.slice(eq + 1);
162
+ else if (valueFlags.has(flag)) value = args[++i];
163
+ else if (short && valueFlags.has(short)) {
164
+ name = short;
165
+ value = a.slice(2);
166
+ }
167
+ if (tool === "rg" && (name === "--files" || name === "--type-list")) listing = true;
168
+ if (tool === "ag" && name === "-g") listing = true;
169
+ if (value === undefined) continue;
170
+ if (PATTERN_FLAGS.has(name)) pattern = pattern === undefined ? value : `${pattern}|${value}`;
171
+ else if (FILTER_FLAGS.has(name) || (tool === "ack" && name === "--type")) filters.push(value);
172
+ else if (name === "-f" || name === "--file") listing = true; // patterns come from a file: unknowable
173
+ }
174
+ if (listing && pattern === undefined) return { tool, paths: positional, filters };
175
+ if (pattern === undefined) pattern = positional.shift();
176
+ return { tool, pattern, paths: positional, filters };
177
+ }
178
+
179
+ /** Extensions CodeGraph indexes as code. Searches limited to anything else are left alone. */
180
+ export const CODE_EXTENSIONS = new Set(
181
+ "ts tsx js jsx mjs cjs mts cts kt kts java scala groovy rs go py pyi rb php cs fs swift m mm c cc cpp cxx h hh hpp hxx dart lua ex exs erl hs ml vue svelte zig nim sol".split(" "),
182
+ );
183
+ const CODE_TYPES = new Set(
184
+ "ts typescript js javascript kotlin kt java scala rust rs go py python ruby rb php cs csharp swift objc objcpp c cpp dart lua elixir erlang haskell ocaml vue svelte zig".split(" "),
185
+ );
186
+
187
+ const extOf = (p: string): string | undefined => {
188
+ const base = baseName(p);
189
+ const dot = base.lastIndexOf(".");
190
+ return dot > 0 ? base.slice(dot + 1).toLowerCase() : undefined;
191
+ };
192
+
193
+ /** Is the search restricted to non-code files (docs, config, logs)? */
194
+ export function targetsOnlyNonCode(s: Search): boolean {
195
+ if (s.filters.length > 0) {
196
+ const nonCode = s.filters.every((f) => {
197
+ const neg = f.startsWith("!");
198
+ if (neg) return false;
199
+ const ext = /\.([A-Za-z0-9]+)\}?$/.exec(f)?.[1]?.toLowerCase() ?? (f.includes("*") || f.includes(".") ? undefined : f.toLowerCase());
200
+ return ext !== undefined && !CODE_EXTENSIONS.has(ext) && !CODE_TYPES.has(ext);
201
+ });
202
+ if (nonCode) return true;
203
+ }
204
+ const files = s.paths.filter((p) => extOf(p) !== undefined);
205
+ return s.paths.length > 0 && files.length === s.paths.length && files.every((p) => !CODE_EXTENSIONS.has(extOf(p)!));
206
+ }
207
+
208
+ const DECL_KEYWORDS =
209
+ "fn|pub fn|pub\\(crate\\) fn|async fn|def|class|struct|enum|trait|impl|interface|type|typealias|func|function|export function|export const|export class|export interface|export type|const|let|var|val|fun|suspend fun|object|data class|sealed class|protocol|extension|module|mod|use|import|from|new";
210
+ const DECL = new RegExp(`^(?:${DECL_KEYWORDS})(?:\\s+|\\\\s[+*])`);
211
+ const KEYWORDS = new Set(DECL_KEYWORDS.split("|").filter((k) => !k.includes(" ") && !k.includes("\\")));
212
+ const QUALIFIED = /^[A-Za-z_$][\w$]*(?:(?:::|\.|#|->)[A-Za-z_$][\w$]*)*$/;
213
+ const NOT_SYMBOLS = new Set(["todo", "fixme", "hack", "xxx", "note", "bug", "deprecated", "error", "warn", "warning", "info", "debug"]);
214
+
215
+ /** Strip regex syntax that does not change what is being looked for. */
216
+ function normalizeAlternative(raw: string): string {
217
+ let s = raw.trim();
218
+ s = s.replace(/\\[bB<>]/g, "").replace(/\[\[:<:\]\]|\[\[:>:\]\]/g, "");
219
+ s = s.replace(/^\^/, "").replace(/\$$/, "");
220
+ s = s.replace(/^\(\?:?/, "").replace(/\)$/, "");
221
+ s = s.replace(DECL, "");
222
+ s = s.replace(/(?:\\?\(|\\?<|\s*\\s\*\\?\(|\s*\\s\*)$/, "");
223
+ s = s.replace(/^(?:\.\*|\\s\*|\\s\+)+|(?:\.\*|\\s\*|\\s\+)+$/g, "");
224
+ return s.trim();
225
+ }
226
+
227
+ /**
228
+ * The identifiers a pattern looks for, when it is a symbol lookup (`fooBar`, `\bFoo\b`,
229
+ * `fn parse_args`, `Foo::new`, `foo(`, `a|b`). Undefined for a text search (a sentence, a
230
+ * string literal, a path, an error message, `TODO`).
231
+ */
232
+ export function symbolsIn(pattern: string | undefined): string[] | undefined {
233
+ if (pattern === undefined || pattern.length > 200) return undefined;
234
+ const alternatives = pattern.split(/\\?\|/);
235
+ const out: string[] = [];
236
+ for (const alt of alternatives) {
237
+ const s = normalizeAlternative(alt.replace(/^\(|\)$/g, ""));
238
+ if (s.length < 3 || !QUALIFIED.test(s) || KEYWORDS.has(s)) return undefined;
239
+ const parts = s.split(/::|\.|#|->/);
240
+ if (parts.some((p) => NOT_SYMBOLS.has(p.toLowerCase()))) return undefined;
241
+ if (/^[A-Z0-9_]+$/.test(s) && s.length <= 4) return undefined;
242
+ out.push(s);
243
+ }
244
+ return out.length > 0 ? out : undefined;
245
+ }
246
+
247
+ /** Words a CodeGraph call looked up, for remembering what is already answered. */
248
+ export function lookupWords(params: Record<string, unknown>): string[] {
249
+ const out: string[] = [];
250
+ for (const key of ["query", "symbol"]) {
251
+ const v = params[key];
252
+ if (typeof v === "string") for (const w of v.split(/[^\w$:.#>-]+/)) if (w.length >= 3) out.push(...w.split(/::|\.|#|->/).filter((x) => x.length >= 3), w);
253
+ }
254
+ return out.map((w) => w.toLowerCase());
255
+ }
256
+
257
+ export type Mode = "enforce" | "nudge" | "off";
258
+
259
+ export type Decision =
260
+ | { action: "allow" }
261
+ | { action: "block"; reason: string }
262
+ | { action: "nudge"; hint: string };
263
+
264
+ export interface GateMemory {
265
+ /** Lower-cased words already looked up with CodeGraph this session. */
266
+ lookedUp: Set<string>;
267
+ /** Commands blocked once: running the same command again is the agent's explicit override. */
268
+ blocked: Set<string>;
269
+ }
270
+
271
+ export const newMemory = (): GateMemory => ({ lookedUp: new Set(), blocked: new Set() });
272
+
273
+ const known = (symbol: string, mem: GateMemory) => {
274
+ const s = symbol.toLowerCase();
275
+ if (mem.lookedUp.has(s)) return true;
276
+ const last = s.split(/::|\.|#|->/).pop()!;
277
+ return mem.lookedUp.has(last);
278
+ };
279
+
280
+ export function suggestion(symbols: string[]): string {
281
+ const q = symbols.join(" ");
282
+ const one = symbols[0]!;
283
+ return [
284
+ `codegraph_search({ query: "${one}" }) for the definition`,
285
+ `codegraph_callers({ symbol: "${one}" }) for usages`,
286
+ `codegraph_explore({ query: "${q}" }) for the code around it`,
287
+ ].join(", ");
288
+ }
289
+
290
+ /**
291
+ * What to do with a call to a text-search tool. `key` identifies the exact call (for the
292
+ * re-run override); `searches` are what it searches for.
293
+ */
294
+ export function decide(mode: Mode, searches: Search[], key: string, mem: GateMemory): Decision {
295
+ if (mode === "off") return { action: "allow" };
296
+ const symbols = new Set<string>();
297
+ for (const s of searches) {
298
+ if (targetsOnlyNonCode(s)) continue;
299
+ for (const sym of symbolsIn(s.pattern) ?? []) if (!known(sym, mem)) symbols.add(sym);
300
+ }
301
+ if (symbols.size === 0) return { action: "allow" };
302
+ const list = [...symbols];
303
+ const hint = `"${list.join('", "')}" ${list.length === 1 ? "looks like a symbol" : "look like symbols"}: CodeGraph answers this structurally. Use ${suggestion(list)}.`;
304
+ if (mode === "nudge") return { action: "nudge", hint: `[codegraph] ${hint}` };
305
+ if (mem.blocked.has(key)) return { action: "allow" };
306
+ mem.blocked.add(key);
307
+ return {
308
+ action: "block",
309
+ reason: `CodeGraph first: ${hint} If CodeGraph cannot answer it (string literals, comments, generated or non-indexed files), run the exact same command again and it will be allowed.`,
310
+ };
311
+ }
312
+
313
+ /** Source files: reading many of them before any CodeGraph call earns a hint. */
314
+ export const isSourceFile = (path: string): boolean => {
315
+ const ext = extOf(path);
316
+ return ext !== undefined && CODE_EXTENSIONS.has(ext);
317
+ };