@selesai/code 0.13.21 → 0.13.23

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/extensions/capability-gateway/index.ts +28 -29
  3. package/dist/extensions/capability-gateway/integration.test.ts +43 -6
  4. package/dist/extensions/capability-gateway/package.json +1 -0
  5. package/dist/extensions/package.json +2 -0
  6. package/dist/extensions/pi-graft/cli.test.ts +408 -0
  7. package/dist/extensions/pi-graft/cli.ts +547 -0
  8. package/dist/extensions/pi-graft/commands.test.ts +478 -0
  9. package/dist/extensions/pi-graft/commands.ts +392 -0
  10. package/dist/extensions/pi-graft/graft.integration.test.ts +153 -0
  11. package/dist/extensions/pi-graft/index.test.ts +167 -0
  12. package/dist/extensions/pi-graft/index.ts +521 -0
  13. package/dist/extensions/pi-graft/lifecycle.test.ts +609 -0
  14. package/dist/extensions/pi-graft/prompt.test.ts +213 -0
  15. package/dist/extensions/pi-graft/prompt.ts +216 -0
  16. package/dist/extensions/pi-graft/state.test.ts +258 -0
  17. package/dist/extensions/pi-graft/state.ts +335 -0
  18. package/dist/extensions/pi-graft/test-support.ts +199 -0
  19. package/dist/extensions/pi-graft/tools.test.ts +330 -0
  20. package/dist/extensions/pi-graft/tools.ts +333 -0
  21. package/dist/extensions/pi-graft/tsconfig.json +16 -0
  22. package/dist/extensions/pi-subagents/src/agents/builtin-agent-augmentations.ts +71 -0
  23. package/dist/extensions/pi-subagents/src/extension/index.ts +9 -3
  24. package/dist/extensions/pi-subagents/test/unit/builtin-agent-augmentations.test.ts +44 -0
  25. package/dist/extensions/tps.test.ts +9 -1
  26. package/dist/extensions/tps.ts +9 -3
  27. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.23] - 2026-09-13
6
+
7
+ ### Added
8
+ - **Bundled Graft code context.** Selesai now ships native integration for `@nanonets/graft`: it installs a compatible CLI when missing, builds a consent-gated local graph, injects bounded source-backed context for eligible coding turns, and exposes six read-only semantic retrieval tools plus `/graft` lifecycle commands. Structural builds stay local and offline; deep provider-backed builds require separate confirmation.
9
+ - **Graft-aware builtin subagents.** The `scout`, `reviewer`, `worker`, `delegate`, and `oracle` builtins automatically receive Graft's read-only code-context tools and child-only extension when Graft is loaded. Explicit user tool or extension overrides retain precedence.
10
+
11
+ ### Changed
12
+ - **Capability gateway Graft exemption.** Graft's code-context tools remain active rather than becoming dormant, preserving its pre-turn retrieval and precise follow-ups.
13
+
14
+ ## [0.13.22] - 2026-09-12
15
+
16
+ ### Fixed
17
+ - **Live TPS gate.** The live tokens-per-second status no longer reports implausible rates: a live reading now needs at least a 200 ms sample window and is dropped when the rate exceeds the plausible maximum, matching the gates the final measurement already applied.
18
+ - **Capability gateway hint noise.** The gateway no longer injects fuzzy skill recommendations or ambiguous-match catalog hints into the model context. A uniquely matched tool is still auto-activated, and optional capabilities remain discoverable through `capability_catalog`.
19
+
20
+ ### Changed
21
+ - **Capability catalog tool rows.** `capability_catalog` calls now render the search query and optional kind in the TUI instead of the bare tool name.
22
+
5
23
  ## [0.13.21] - 2026-09-11
6
24
 
7
25
  ### Fixed
@@ -6,17 +6,16 @@
6
6
  *
7
7
  * Behavior:
8
8
  * - On by default; disable via SELESAI_CAPABILITY_GATEWAY=0.
9
- * - At session start, extension tools (except the gateway's own tools) become
10
- * dormant: they stay registered but are removed from the active tool set.
11
- * Built-in tools are never touched.
9
+ * - At session start, extension tools become dormant except the gateway's own
10
+ * tools and Graft's code-context tools: they stay registered but are removed
11
+ * from the active tool set. Built-in tools are never touched.
12
12
  * - A compact catalog tool lists eligible tools/skills with one-line summaries.
13
13
  * - capability_discover validates one catalogued tool and activates its native
14
14
  * definition for the current agent run; capability_skill_show loads exactly
15
15
  * the selected skill instructions.
16
- * - A deterministic router inspects each user prompt and, for a unique
17
- * high-confidence tool match, activates it before the run; for a unique
18
- * high-confidence skill match it adds a concise recommendation; ambiguous
19
- * matches add a catalog hint; unrelated prompts are untouched.
16
+ * - A deterministic router activates a uniquely matched tool before the run.
17
+ * Skills and ambiguous matches remain discoverable through the catalog
18
+ * without injecting fuzzy hints into the model context.
20
19
  * - Temporary activations reset at agent_settled, restoring the baseline
21
20
  * active-tool set.
22
21
  * - The system-prompt skill index is replaced by a compact capability
@@ -28,6 +27,7 @@ import { readFileSync } from "node:fs";
28
27
  import { dirname } from "node:path";
29
28
  import { stripFrontmatter, type ExtensionAPI, type ToolInfo } from "@selesai/code";
30
29
  import { StringEnum } from "@earendil-works/pi-ai";
30
+ import { Text } from "@earendil-works/pi-tui";
31
31
  import { Type } from "typebox";
32
32
  import {
33
33
  buildSkillCatalog,
@@ -40,6 +40,17 @@ import {
40
40
  export const GATEWAY_ENV = "SELESAI_CAPABILITY_GATEWAY";
41
41
  export const GATEWAY_TOOLS = new Set(["capability_catalog", "capability_discover", "capability_skill_show"]);
42
42
 
43
+ // Graft supplies pre-turn hybrid context and must remain callable for precise
44
+ // follow-ups; making it dormant defeats both paths.
45
+ const ALWAYS_ACTIVE_EXTENSION_TOOLS = new Set([
46
+ "graft_check_freshness",
47
+ "graft_file_api",
48
+ "graft_find_all",
49
+ "graft_find_code",
50
+ "graft_repo_map",
51
+ "graft_trace_calls",
52
+ ]);
53
+
43
54
  export const CAPABILITY_INSTRUCTION = `Optional capabilities (extension tools and skills) are not listed here by default. To use one:
44
55
  - Search the compact catalog with capability_catalog (kind: "tool" or "skill", natural-language query) when no active tool fits or a specialized integration/workflow is requested.
45
56
  - Activate a catalogued tool with capability_discover, then call it normally on the next turn.
@@ -53,7 +64,7 @@ function isEnabled(): boolean {
53
64
  function eligibleTools(pi: ExtensionAPI): ToolInfo[] {
54
65
  return pi
55
66
  .getAllTools()
56
- .filter((tool) => !GATEWAY_TOOLS.has(tool.name) && !BUILTIN_TOOL_NAMES.has(tool.name));
67
+ .filter((tool) => !GATEWAY_TOOLS.has(tool.name) && !ALWAYS_ACTIVE_EXTENSION_TOOLS.has(tool.name) && !BUILTIN_TOOL_NAMES.has(tool.name));
57
68
  }
58
69
 
59
70
  function catalogEntries(pi: ExtensionAPI): CatalogEntry[] {
@@ -177,6 +188,13 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
177
188
  details: { count: shown.length, total: filtered.length },
178
189
  };
179
190
  },
191
+ renderCall(args, theme) {
192
+ const query = typeof args.query === "string" && args.query.length > 0 ? args.query : "(all)";
193
+ let text = theme.fg("toolTitle", theme.bold("capability_catalog "));
194
+ text += theme.fg("accent", `"${query}"`);
195
+ if (args.kind) text += " " + theme.fg("muted", args.kind);
196
+ return new Text(text, 0, 0);
197
+ },
180
198
  });
181
199
 
182
200
  pi.registerTool({
@@ -276,27 +294,8 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
276
294
  emitTelemetry(pi, "route_activate", { tool: result.entry.name });
277
295
  return undefined;
278
296
  }
279
- if (result.action === "recommend" && result.entry) {
280
- emitTelemetry(pi, "route_recommend", { skill: result.entry.name });
281
- return {
282
- message: {
283
- customType: "capability-gateway-hint",
284
- content: `The request matches the optional skill "${result.entry.name}". Load it with capability_skill_show before applying it.`,
285
- display: false,
286
- },
287
- };
288
- }
289
- if (result.action === "hint" && result.candidates) {
290
- const names = result.candidates.map((c) => c.name).join(", ");
291
- emitTelemetry(pi, "route_hint", { candidates: result.candidates.map((c) => c.name) });
292
- return {
293
- message: {
294
- customType: "capability-gateway-hint",
295
- content: `The request may match optional capabilities: ${names}. Search capability_catalog to confirm before selecting.`,
296
- display: false,
297
- },
298
- };
299
- }
297
+ // Do not inject fuzzy recommendations or catalog hints into the model
298
+ // context. It can discover capabilities when it actually needs one.
300
299
  return undefined;
301
300
  });
302
301
 
@@ -16,6 +16,7 @@ import {
16
16
  const EXTENSIONS_DIR = fileURLToPath(new URL("../../", import.meta.url));
17
17
  const GATEWAY_DIR = fileURLToPath(new URL(".", import.meta.url));
18
18
  const GREP_APP_DIR = fileURLToPath(new URL("../grep-app", import.meta.url));
19
+ const GRAFT_DIR = fileURLToPath(new URL("../pi-graft", import.meta.url));
19
20
  const INLINE_SKILLS_FILE = fileURLToPath(new URL("../inline-skills.ts", import.meta.url));
20
21
 
21
22
  interface Harness {
@@ -134,6 +135,20 @@ describe("capability gateway integration", () => {
134
135
  expect(active).toContain("capability_catalog");
135
136
  });
136
137
 
138
+ it("keeps all Graft tools active for hybrid context and precise follow-ups", async () => {
139
+ const h = await createGatewaySession({ enabled: true, extensions: [GATEWAY_DIR, GRAFT_DIR] });
140
+ harnesses.push(h);
141
+ const active = h.session.getActiveToolNames();
142
+ for (const name of [
143
+ "graft_check_freshness",
144
+ "graft_file_api",
145
+ "graft_find_all",
146
+ "graft_find_code",
147
+ "graft_repo_map",
148
+ "graft_trace_calls",
149
+ ]) expect(active).toContain(name);
150
+ });
151
+
137
152
  it("keeps all-visible behavior when disabled (compatibility mode)", async () => {
138
153
  const h = await createGatewaySession({ enabled: false });
139
154
  harnesses.push(h);
@@ -175,6 +190,19 @@ describe("capability gateway integration", () => {
175
190
  expect(result.details).toMatchObject({ count: 1 });
176
191
  });
177
192
 
193
+ it("renders the query and kind in the tool call line", async () => {
194
+ const h = await createGatewaySession({ enabled: true });
195
+ harnesses.push(h);
196
+ const catalog = h.session.getToolDefinition("capability_catalog");
197
+ const theme = { fg: (_name: string, text: string) => text, bold: (text: string) => text } as never;
198
+ const draw = (args: unknown) =>
199
+ (catalog!.renderCall!(args as never, theme, {} as never) as { render(width: number): string[] }).render(120).join("\n");
200
+
201
+ expect(draw({ query: "ponytail-debt", kind: "skill" })).toContain('"ponytail-debt"');
202
+ expect(draw({ query: "ponytail-debt", kind: "skill" })).toContain("skill");
203
+ expect(draw({})).toContain("(all)");
204
+ });
205
+
178
206
  it("activates a discovered tool for the run and resets after agent_settled", async () => {
179
207
  const h = await createGatewaySession({ enabled: true });
180
208
  harnesses.push(h);
@@ -212,26 +240,35 @@ describe("capability gateway integration", () => {
212
240
  expect(h.session.getActiveToolNames()).toContain("grep_app_search");
213
241
  });
214
242
 
215
- it("does not auto-load skills from fuzzy matching", async () => {
243
+ it("does not inject fuzzy skill recommendations", async () => {
216
244
  const h = await createGatewaySession({
217
245
  enabled: true,
218
246
  withSkill: true,
219
247
  extensions: [GATEWAY_DIR, GREP_APP_DIR],
220
248
  });
221
249
  harnesses.push(h);
222
- const runner = h.session.extensionRunner;
223
- const result = await runner.emitBeforeAgentStart(
250
+ const result = await h.session.extensionRunner.emitBeforeAgentStart(
224
251
  "do research on this topic",
225
252
  undefined,
226
253
  h.session.systemPrompt,
227
254
  { cwd: process.cwd() } as never,
228
255
  );
229
- // A recommendation message is injected, but the skill body is not loaded
230
- // into the prompt and the skill is not auto-activated.
231
- expect(result?.messages?.[0]?.content).toContain("research");
256
+ expect(result).toBeUndefined();
232
257
  expect(h.session.systemPrompt).not.toContain("Research instructions body.");
233
258
  });
234
259
 
260
+ it("does not inject catalog hints for ambiguous tool matches", async () => {
261
+ const h = await createGatewaySession({ enabled: true, extensions: [GATEWAY_DIR, GREP_APP_DIR] });
262
+ harnesses.push(h);
263
+ const result = await h.session.extensionRunner.emitBeforeAgentStart(
264
+ "grep app",
265
+ undefined,
266
+ h.session.systemPrompt,
267
+ { cwd: process.cwd() } as never,
268
+ );
269
+ expect(result).toBeUndefined();
270
+ });
271
+
235
272
  it("does not recommend capability_skill_show for an inline-loaded $skill", async () => {
236
273
  const h = await createGatewaySession({
237
274
  enabled: true,
@@ -10,6 +10,7 @@
10
10
  "peerDependencies": {
11
11
  "@selesai/code": "*",
12
12
  "@earendil-works/pi-ai": "*",
13
+ "@earendil-works/pi-tui": "*",
13
14
  "typebox": "*"
14
15
  }
15
16
  }
@@ -2,6 +2,7 @@
2
2
  "name": "@selesai/bundled-extensions",
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
+ "type": "module",
5
6
  "description": "Built-in extensions shipped with @selesai/code, loaded via additionalExtensionPaths at boot.",
6
7
  "pi": {
7
8
  "extensions": [
@@ -19,6 +20,7 @@
19
20
  "./inline-skills.ts",
20
21
  "./rtk.ts",
21
22
  "./tokenin-onboarding.ts",
23
+ "./pi-graft",
22
24
  "./undo.ts",
23
25
  "./pi-subagents",
24
26
  "./pi-tool-display",
@@ -0,0 +1,408 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ applyTelemetryDefault,
4
+ askReferences,
5
+ boundText,
6
+ compareVersions,
7
+ DEFAULT_FIND_LIMIT,
8
+ GRAFT_INSTALL_SPEC,
9
+ graftArgs,
10
+ graftCommand,
11
+ INSTALL_COMMAND,
12
+ INSTALL_TIMEOUT_MS,
13
+ installGraft,
14
+ graftFailureMessage,
15
+ isCompatibleVersion,
16
+ MAX_GRAFT_VERSION_EXCLUSIVE,
17
+ MIN_GRAFT_VERSION,
18
+ parseAskJson,
19
+ parsePointer,
20
+ npmCommand,
21
+ parseVersion,
22
+ probeGraft,
23
+ renderAsk,
24
+ runGraft,
25
+ TELEMETRY_ENV,
26
+ type GraftOp,
27
+ type GraftRun,
28
+ } from "./cli.ts";
29
+ import { FAILED, makePi, OK, type StubExecResult } from "./test-support.ts";
30
+
31
+ function execReturning(result: StubExecResult) {
32
+ return { ...result };
33
+ }
34
+
35
+ describe("graftCommand", () => {
36
+ it("invokes the executable directly off Windows", () => {
37
+ const original = process.platform;
38
+ if (original === "win32") return;
39
+ expect(graftCommand(["ask", "hi"])).toEqual({ command: "graft", args: ["ask", "hi"] });
40
+ });
41
+
42
+ it("routes through cmd.exe on win32 so the .cmd shim can be spawned without a shell", () => {
43
+ const original = process.platform;
44
+ Object.defineProperty(process, "platform", { value: "win32" });
45
+ try {
46
+ expect(graftCommand(["build"])).toEqual({ command: "cmd", args: ["/c", "graft", "build"] });
47
+ } finally {
48
+ Object.defineProperty(process, "platform", { value: original });
49
+ }
50
+ });
51
+ });
52
+
53
+ describe("installGraft", () => {
54
+ it("runs the compatible global npm install without a shell off Windows", async () => {
55
+ const { pi } = makePi(async () => execReturning(OK));
56
+ const run = await installGraft(pi.exec as never, "/repo");
57
+ expect(GRAFT_INSTALL_SPEC).toBe("@nanonets/graft@^0.18");
58
+ expect(INSTALL_COMMAND).toBe("npm install -g @nanonets/graft@^0.18");
59
+ const [command, args, options] = pi.exec.mock.calls[0] as [string, string[], { cwd: string; timeout: number }];
60
+ expect(command).toBe(process.platform === "win32" ? "cmd" : "npm");
61
+ expect(args).toContain("install");
62
+ expect(args).toContain(GRAFT_INSTALL_SPEC);
63
+ expect(options).toMatchObject({ cwd: "/repo", timeout: INSTALL_TIMEOUT_MS });
64
+ expect(run.code).toBe(0);
65
+ });
66
+
67
+ it("routes npm through cmd.exe on win32", () => {
68
+ const original = process.platform;
69
+ Object.defineProperty(process, "platform", { value: "win32" });
70
+ try {
71
+ expect(npmCommand(["install", "-g", GRAFT_INSTALL_SPEC])).toEqual({
72
+ command: "cmd",
73
+ args: ["/c", "npm", "install", "-g", GRAFT_INSTALL_SPEC],
74
+ });
75
+ } finally {
76
+ Object.defineProperty(process, "platform", { value: original });
77
+ }
78
+ });
79
+
80
+ it("normalizes install spawn failures", async () => {
81
+ const { pi } = makePi(async () => {
82
+ throw new Error("spawn npm ENOENT");
83
+ });
84
+ const run = await installGraft(pi.exec as never, "/repo");
85
+ expect(run.code).toBe(1);
86
+ expect(run.stderr).toContain("ENOENT");
87
+ });
88
+ });
89
+
90
+ describe("version handling", () => {
91
+ it("reads a semantic version out of arbitrary CLI output", () => {
92
+ expect(parseVersion("0.18.0")).toBe("0.18.0");
93
+ expect(parseVersion("graft v0.18.3\nlatest on npm: 0.19.0")).toBe("0.18.3");
94
+ expect(parseVersion("0.18.0-beta.2")).toBe("0.18.0-beta.2");
95
+ expect(parseVersion("no version here")).toBeUndefined();
96
+ });
97
+
98
+ it("compares numeric segments, ignoring suffixes", () => {
99
+ expect(compareVersions("0.18.0", "0.18.0")).toBe(0);
100
+ expect(compareVersions("0.17.9", "0.18.0")).toBeLessThan(0);
101
+ expect(compareVersions("0.18.1-beta.1", "0.18.0")).toBeGreaterThan(0);
102
+ expect(compareVersions("1.0.0", "0.99.99")).toBeGreaterThan(0);
103
+ });
104
+
105
+ it("accepts the verified range and rejects the boundaries", () => {
106
+ expect(isCompatibleVersion(MIN_GRAFT_VERSION)).toBe(true);
107
+ expect(isCompatibleVersion("0.18.4")).toBe(true);
108
+ expect(isCompatibleVersion("0.99.0")).toBe(true);
109
+ expect(isCompatibleVersion("0.17.9")).toBe(false);
110
+ expect(isCompatibleVersion(MAX_GRAFT_VERSION_EXCLUSIVE)).toBe(false);
111
+ expect(isCompatibleVersion("1.2.0")).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe("probeGraft", () => {
116
+ it("accepts a supported version and reports it", async () => {
117
+ const { pi } = makePi(async () => execReturning({ ...OK, stdout: "0.18.0\n" }));
118
+ const probe = await probeGraft(pi.exec as never, "/repo");
119
+ expect(probe).toMatchObject({ kind: "ok", version: "0.18.0" });
120
+ if (probe.kind === "ok") expect(probe.argv.at(-1)).toBe("--version");
121
+ });
122
+
123
+ it("reports a missing executable when the spawn throws", async () => {
124
+ const { pi } = makePi(async () => {
125
+ throw new Error("spawn graft ENOENT");
126
+ });
127
+ const probe = await probeGraft(pi.exec as never, "/repo");
128
+ expect(probe.kind).toBe("missing");
129
+ if (probe.kind === "missing") expect(probe.detail).toContain("ENOENT");
130
+ });
131
+
132
+ it("reports a missing executable when the probe is killed", async () => {
133
+ const { pi } = makePi(async () => execReturning({ ...OK, killed: true }));
134
+ const probe = await probeGraft(pi.exec as never, "/repo");
135
+ expect(probe.kind).toBe("missing");
136
+ });
137
+
138
+ it("refuses to accept an out-of-range version", async () => {
139
+ const { pi } = makePi(async () => execReturning({ ...OK, stdout: "0.4.0\n" }));
140
+ const probe = await probeGraft(pi.exec as never, "/repo");
141
+ expect(probe.kind).toBe("incompatible");
142
+ if (probe.kind === "incompatible") {
143
+ expect(probe.version).toBe("0.4.0");
144
+ expect(probe.detail).toContain(MIN_GRAFT_VERSION);
145
+ }
146
+ });
147
+
148
+ it("treats an unreadable version as incompatible rather than assuming compatibility", async () => {
149
+ const { pi } = makePi(async () => execReturning({ ...OK, stdout: "graft (unknown build)\n" }));
150
+ const probe = await probeGraft(pi.exec as never, "/repo");
151
+ expect(probe.kind).toBe("incompatible");
152
+ if (probe.kind === "incompatible") expect(probe.version).toBeUndefined();
153
+ });
154
+ });
155
+
156
+ describe("graftArgs", () => {
157
+ it("maps every read-only operation to its documented CLI invocation", () => {
158
+ const cases: [GraftOp, string[]][] = [
159
+ [{ kind: "find-code", question: "how does auth work" }, ["ask", "how does auth work", "--source", "--json"]],
160
+ [
161
+ { kind: "find-code", question: "auth", limit: 3, in: "packages/api" },
162
+ ["ask", "auth", "--source", "--json", "-n", "3", "--in", "packages/api"],
163
+ ],
164
+ [{ kind: "file-api", path: "src/app.ts" }, ["skeleton", "src/app.ts"]],
165
+ [{ kind: "trace-calls", symbol: "build", direction: "in" }, ["callers", "build"]],
166
+ [
167
+ { kind: "trace-calls", symbol: "build", direction: "out", depth: 3 },
168
+ ["callers", "build", "--direction", "out", "-d", "3"],
169
+ ],
170
+ [
171
+ { kind: "find-all", pattern: "TODO", ignoreCase: true, fixed: true, in: "src" },
172
+ ["grep", "TODO", "-i", "--fixed", "--in", "src"],
173
+ ],
174
+ [{ kind: "repo-map", maxDirs: 12 }, ["map", "--max-dirs", "12"]],
175
+ [{ kind: "check-freshness" }, ["check", "--json"]],
176
+ [{ kind: "build", deep: false }, ["build"]],
177
+ [{ kind: "build", deep: true }, ["build", "--deep"]],
178
+ ];
179
+ for (const [op, expected] of cases) expect(graftArgs(op)).toEqual(expected);
180
+ });
181
+
182
+ it("keeps the default depth implicit so the CLI's own default applies", () => {
183
+ expect(graftArgs({ kind: "trace-calls", symbol: "x", direction: "in", depth: 1 })).toEqual(["callers", "x"]);
184
+ });
185
+
186
+ it("passes hostile input as one verbatim argv entry, never as shell text", () => {
187
+ const question = 'rm -rf / && echo "pwned"; `whoami`';
188
+ // The whole string survives as exactly one argv element: there is no shell to
189
+ // reinterpret the metacharacters, and nothing splices it into another argument.
190
+ expect(graftArgs({ kind: "find-code", question })).toEqual(["ask", question, "--source", "--json"]);
191
+
192
+ const hostilePattern = '"; rm -rf $HOME; "';
193
+ expect(graftArgs({ kind: "find-all", pattern: hostilePattern })).toEqual(["grep", hostilePattern]);
194
+
195
+ expect(graftArgs({ kind: "file-api", path: "src/with space/and;rm.ts" })).toEqual([
196
+ "skeleton",
197
+ "src/with space/and;rm.ts",
198
+ ]);
199
+ });
200
+ });
201
+
202
+ describe("runGraft", () => {
203
+ it("runs in the resolved repository root and reports the full argv", async () => {
204
+ const { pi } = makePi(async () => execReturning({ ...OK, stdout: "ok" }));
205
+ const run = await runGraft(pi.exec as never, "/repo/root", { kind: "repo-map" });
206
+ expect(pi.exec).toHaveBeenCalledTimes(1);
207
+ const [command, args, options] = pi.exec.mock.calls[0] as [string, string[], { cwd: string; timeout: number }];
208
+ expect(command).toBe(process.platform === "win32" ? "cmd" : "graft");
209
+ expect(args).toContain("map");
210
+ expect(options.cwd).toBe("/repo/root");
211
+ expect(options.timeout).toBe(60_000);
212
+ expect(run.cwd).toBe("/repo/root");
213
+ expect(run.argv.at(-1)).toBe("map");
214
+ });
215
+
216
+ it("uses the long build timeout for builds", async () => {
217
+ const { pi } = makePi(async () => execReturning(OK));
218
+ await runGraft(pi.exec as never, "/repo", { kind: "build", deep: true });
219
+ const [, , options] = pi.exec.mock.calls[0] as [string, string[], { timeout: number }];
220
+ expect(options.timeout).toBe(1_800_000);
221
+ });
222
+
223
+ it("distinguishes a caller abort from a timeout", async () => {
224
+ const { pi } = makePi(async () => execReturning({ ...OK, killed: true }));
225
+ const controller = new AbortController();
226
+ controller.abort();
227
+ const cancelled = await runGraft(pi.exec as never, "/repo", { kind: "repo-map" }, { signal: controller.signal });
228
+ expect(cancelled.cancelled).toBe(true);
229
+ expect(cancelled.timedOut).toBe(false);
230
+
231
+ const noSignal = await runGraft(pi.exec as never, "/repo", { kind: "repo-map" });
232
+ expect(noSignal.timedOut).toBe(true);
233
+ expect(noSignal.cancelled).toBe(false);
234
+ });
235
+
236
+ it("normalizes a spawn failure into a failed run instead of throwing", async () => {
237
+ const { pi } = makePi(async () => {
238
+ throw new Error("spawn graft ENOENT");
239
+ });
240
+ const run = await runGraft(pi.exec as never, "/repo", { kind: "repo-map" });
241
+ expect(run.code).toBe(1);
242
+ expect(run.stderr).toContain("ENOENT");
243
+ });
244
+ });
245
+
246
+ describe("graftFailureMessage", () => {
247
+ const base: GraftRun = {
248
+ op: { kind: "repo-map" },
249
+ argv: ["graft", "map"],
250
+ cwd: "/repo",
251
+ code: 1,
252
+ stdout: "",
253
+ stderr: "",
254
+ killed: false,
255
+ cancelled: false,
256
+ timedOut: false,
257
+ };
258
+
259
+ it("names the command and its exit status, taking the first stderr line", () => {
260
+ expect(graftFailureMessage({ ...base, stderr: "boom\ndetails" })).toBe("graft map failed (exit 1): boom");
261
+ });
262
+
263
+ it("falls back to stdout when stderr is empty", () => {
264
+ expect(graftFailureMessage({ ...base, stdout: "✗ no graft/ here" })).toContain("no graft/ here");
265
+ });
266
+
267
+ it("reports cancellation and timeouts distinctly", () => {
268
+ expect(graftFailureMessage({ ...base, cancelled: true })).toContain("cancelled");
269
+ expect(graftFailureMessage({ ...base, timedOut: true })).toContain("timed out");
270
+ });
271
+ });
272
+
273
+ describe("boundText", () => {
274
+ it("passes short output through untouched", () => {
275
+ const bounded = boundText("one\ntwo");
276
+ expect(bounded).toEqual({ text: "one\ntwo", truncated: false, truncatedBy: null, totalBytes: 7 });
277
+ });
278
+
279
+ it("truncates by lines and says how to see the rest", () => {
280
+ const bounded = boundText(Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n"), { maxLines: 10 });
281
+ expect(bounded.truncated).toBe(true);
282
+ expect(bounded.truncatedBy).toBe("lines");
283
+ expect(bounded.text).toContain("line 0");
284
+ expect(bounded.text).not.toContain("line 20");
285
+ expect(bounded.text).toContain("[graft] output truncated (10 of 50 lines)");
286
+ });
287
+
288
+ it("truncates by bytes and reports the byte ceiling", () => {
289
+ const bounded = boundText("x".repeat(500), { maxBytes: 100, maxLines: 100 });
290
+ expect(bounded.truncated).toBe(true);
291
+ expect(bounded.truncatedBy).toBe("bytes");
292
+ expect(bounded.text).toContain("of 500 bytes");
293
+ });
294
+ });
295
+
296
+ describe("parseAskJson", () => {
297
+ const valid = JSON.stringify({
298
+ query: "auth",
299
+ mode: "lexical",
300
+ hits: [
301
+ { kind: "symbol", title: "verify · function", pointer: "src/auth.ts:L10-L20", snippet: "checks", score: 3, code: "x" },
302
+ { kind: "concept", title: "Auth", pointer: "src/auth.ts", related: ["a"], score: 1 },
303
+ ],
304
+ coverage: 0.6,
305
+ coverageStrong: 0.5,
306
+ saved: { files: 2, baselineChars: 4000 },
307
+ rules: [{ text: "always verify" }],
308
+ });
309
+
310
+ it("reads the documented hit shape", () => {
311
+ const payload = parseAskJson(valid);
312
+ expect(payload?.mode).toBe("lexical");
313
+ expect(payload?.coverage).toBe(0.6);
314
+ expect(payload?.saved).toEqual({ files: 2, baselineChars: 4000 });
315
+ expect(payload?.hits).toHaveLength(2);
316
+ expect(payload?.hits[0]).toMatchObject({ pointer: "src/auth.ts:L10-L20", code: "x" });
317
+ expect(payload?.rules).toEqual([{ id: undefined, text: "always verify" }]);
318
+ });
319
+
320
+ it("returns undefined for anything it cannot trust", () => {
321
+ expect(parseAskJson("not json")).toBeUndefined();
322
+ expect(parseAskJson("[]")).toBeUndefined();
323
+ expect(parseAskJson(JSON.stringify({ query: "x" }))).toBeUndefined();
324
+ });
325
+
326
+ it("skips malformed hits instead of failing the whole parse", () => {
327
+ const payload = parseAskJson(JSON.stringify({ hits: [{ title: "no pointer" }, { pointer: "src/a.ts" }, 7] }));
328
+ expect(payload?.hits).toHaveLength(1);
329
+ expect(payload?.hits[0]?.title).toBe("src/a.ts");
330
+ });
331
+
332
+ it("drops a malformed saved block rather than surfacing wrong numbers", () => {
333
+ expect(parseAskJson(JSON.stringify({ hits: [], saved: { files: "two" } }))?.saved).toBeUndefined();
334
+ });
335
+ });
336
+
337
+ describe("parsePointer", () => {
338
+ it("splits a file:span pointer", () => {
339
+ expect(parsePointer("src/a.ts:L1-L9")).toEqual({ path: "src/a.ts", span: "L1-L9" });
340
+ expect(parsePointer("src/a.ts:12")).toEqual({ path: "src/a.ts:12" });
341
+ expect(parsePointer("src/a.ts")).toEqual({ path: "src/a.ts" });
342
+ });
343
+ });
344
+
345
+ describe("renderAsk", () => {
346
+ it("renders a compact ranked pack with pointers, snippets, source and receipts", () => {
347
+ const payload = parseAskJson(
348
+ JSON.stringify({
349
+ mode: "lexical",
350
+ note: "ranked by relevance",
351
+ hits: [
352
+ { kind: "symbol", title: "verify · function", pointer: "src/auth.ts:L10-L20", snippet: "checks the token", code: "return true;" },
353
+ { kind: "caller", title: "login", pointer: "src/login.ts:L1-L4", snippet: "", relation: "calls" },
354
+ ],
355
+ saved: { files: 2, baselineChars: 4000 },
356
+ rules: [{ text: "always verify" }],
357
+ }),
358
+ )!;
359
+ const text = renderAsk(payload);
360
+ expect(text).toContain("graft ask (lexical) — 2 hit(s)");
361
+ expect(text).toContain("1. verify · function — src/auth.ts:L10-L20");
362
+ expect(text).toContain("return true;");
363
+ expect(text).toContain("2. login (calls) — src/login.ts:L1-L4");
364
+ expect(text).toContain("governing rules:");
365
+ expect(text).toContain("reading them instead would cost ~1000 tokens");
366
+ expect(askReferences(payload)).toEqual([
367
+ { path: "src/auth.ts", span: "L10-L20" },
368
+ { path: "src/login.ts", span: "L1-L4" },
369
+ ]);
370
+ });
371
+
372
+ it("labels a scoped hit with its sub-project", () => {
373
+ const payload = parseAskJson(JSON.stringify({ hits: [{ pointer: "src/a.ts", scope: "packages/api" }] }))!;
374
+ expect(renderAsk(payload)).toContain("packages/api/src/a.ts");
375
+ });
376
+ });
377
+
378
+ describe("applyTelemetryDefault", () => {
379
+ it("sets DO_NOT_TRACK and restores the prior absence on session shutdown", () => {
380
+ const env: NodeJS.ProcessEnv = {};
381
+ const handle = applyTelemetryDefault(env);
382
+ expect(env[TELEMETRY_ENV]).toBe("1");
383
+ handle.restore();
384
+ expect(env[TELEMETRY_ENV]).toBeUndefined();
385
+ });
386
+
387
+ it("leaves an explicit user preference alone", () => {
388
+ const env: NodeJS.ProcessEnv = { [TELEMETRY_ENV]: "0" };
389
+ const handle = applyTelemetryDefault(env);
390
+ expect(env[TELEMETRY_ENV]).toBe("0");
391
+ handle.restore();
392
+ expect(env[TELEMETRY_ENV]).toBe("0");
393
+ });
394
+ });
395
+
396
+ describe("modest defaults", () => {
397
+ it("keeps the ask limit aligned with the CLI default", () => {
398
+ expect(DEFAULT_FIND_LIMIT).toBe(8);
399
+ });
400
+
401
+ it("never calls the executable through a shell", async () => {
402
+ const { pi } = makePi(async () => execReturning(FAILED));
403
+ await runGraft(pi.exec as never, "/repo", { kind: "find-all", pattern: "a|b" });
404
+ const [command] = pi.exec.mock.calls[0] as [string];
405
+ expect(["graft", "cmd"]).toContain(command);
406
+ expect(vi.isMockFunction(pi.exec)).toBe(true);
407
+ });
408
+ });