@xynogen/pix-core 0.2.4 → 0.3.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.
Files changed (37) hide show
  1. package/package.json +11 -17
  2. package/skills/ask-user/SKILL.md +0 -48
  3. package/src/commands/agent-sop/agent-sop.ts +0 -58
  4. package/src/commands/clear/clear.ts +0 -32
  5. package/src/commands/diff/diff.ts +0 -32
  6. package/src/commands/models/models.test.ts +0 -95
  7. package/src/commands/models/models.ts +0 -367
  8. package/src/commands/models/patch-builtin.test.ts +0 -66
  9. package/src/commands/models/patch-builtin.ts +0 -120
  10. package/src/commands/tools.test.ts +0 -15
  11. package/src/commands/update/update.test.ts +0 -112
  12. package/src/commands/update/update.ts +0 -271
  13. package/src/index.ts +0 -45
  14. package/src/lib/data.ts +0 -33
  15. package/src/nudge/capability.test.ts +0 -258
  16. package/src/nudge/capability.ts +0 -189
  17. package/src/nudge/index.ts +0 -17
  18. package/src/nudge/tools.test.ts +0 -157
  19. package/src/nudge/tools.ts +0 -212
  20. package/src/tool/ask/ask.test.ts +0 -243
  21. package/src/tool/ask/components.ts +0 -55
  22. package/src/tool/ask/helpers.ts +0 -77
  23. package/src/tool/ask/index.ts +0 -130
  24. package/src/tool/ask/questionnaire.ts +0 -693
  25. package/src/tool/ask/rpc.ts +0 -84
  26. package/src/tool/ask/schema.ts +0 -69
  27. package/src/tool/ask/single-select-layout.test.ts +0 -124
  28. package/src/tool/ask/single-select-layout.ts +0 -237
  29. package/src/tool/ask/types.ts +0 -17
  30. package/src/tool/todo/todo.test.ts +0 -646
  31. package/src/tool/todo/todo.ts +0 -218
  32. package/src/tool/toolbox/toolbox.test.ts +0 -314
  33. package/src/tool/toolbox/toolbox.ts +0 -570
  34. package/src/ui/diagnostics.ts +0 -145
  35. package/src/ui/footer.ts +0 -513
  36. package/src/ui/welcome.test.ts +0 -124
  37. package/src/ui/welcome.ts +0 -369
@@ -1,258 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import registerCapabilityNudge, {
5
- buildOrientation,
6
- CAPABILITY_REMINDER,
7
- countInvocableSkills,
8
- graphifyHint,
9
- partitionTools,
10
- } from "./capability.ts";
11
-
12
- // Minimal Skill-shaped fixtures (only fields the builder reads).
13
- const skill = (
14
- name: string,
15
- description: string,
16
- disableModelInvocation = false,
17
- ) =>
18
- ({
19
- name,
20
- description,
21
- disableModelInvocation,
22
- filePath: "",
23
- baseDir: "",
24
- sourceInfo: {} as never,
25
- }) as never;
26
-
27
- // Minimal ToolInfo-shaped fixture.
28
- const tool = (name: string, source: string) =>
29
- ({
30
- name,
31
- description: `${name} desc.`,
32
- parameters: {},
33
- sourceInfo: { source, path: "", scope: "user", origin: "package" },
34
- }) as never;
35
-
36
- describe("CAPABILITY_REMINDER", () => {
37
- test("is terse — fires every turn, must stay cheap", () => {
38
- expect(CAPABILITY_REMINDER.split(/\s+/).length).toBeLessThanOrEqual(28);
39
- });
40
-
41
- test("names the core capability surfaces", () => {
42
- for (const cap of ["skill", "tool", "MCP", "web", "user"]) {
43
- expect(CAPABILITY_REMINDER).toContain(cap);
44
- }
45
- });
46
-
47
- test("steers away from improvising", () => {
48
- expect(CAPABILITY_REMINDER.toLowerCase()).toContain("improvis");
49
- });
50
-
51
- test("nudges model to call read_skills() when a skill matches", () => {
52
- expect(CAPABILITY_REMINDER).toContain("read_skills()");
53
- });
54
-
55
- test("does not mention user-only toolbox command", () => {
56
- expect(CAPABILITY_REMINDER).not.toContain("/toolbox");
57
- expect(CAPABILITY_REMINDER).not.toContain("toolbox(");
58
- });
59
- });
60
-
61
- describe("countInvocableSkills", () => {
62
- test("zero for undefined / empty", () => {
63
- expect(countInvocableSkills(undefined)).toBe(0);
64
- expect(countInvocableSkills([])).toBe(0);
65
- });
66
-
67
- test("excludes user-only skills", () => {
68
- const n = countInvocableSkills([
69
- skill("a", "."),
70
- skill("b", ".", true),
71
- skill("c", "."),
72
- ]);
73
- expect(n).toBe(2);
74
- });
75
- });
76
-
77
- describe("partitionTools", () => {
78
- test("splits MCP-sourced from other tools by source", () => {
79
- const { mcp, other } = partitionTools([
80
- tool("read", "builtin"),
81
- tool("context7-docs", "mcp:context7"),
82
- tool("notion-search", "MCP server notion"),
83
- tool("grep", "extension:pix-core"),
84
- ]);
85
- expect(mcp).toBe(2);
86
- expect(other).toBe(2);
87
- });
88
-
89
- test("handles undefined", () => {
90
- expect(partitionTools(undefined)).toEqual({
91
- mcp: 0,
92
- other: 0,
93
- active: 0,
94
- gated: 0,
95
- });
96
- });
97
-
98
- test("without an active set, every tool counts as active (gated 0)", () => {
99
- const { active, gated } = partitionTools([
100
- tool("read", "builtin"),
101
- tool("find", "builtin"),
102
- ]);
103
- expect(active).toBe(2);
104
- expect(gated).toBe(0);
105
- });
106
-
107
- test("with an active set, splits active vs gated", () => {
108
- const { active, gated } = partitionTools(
109
- [
110
- tool("read", "builtin"),
111
- tool("grep", "builtin"),
112
- tool("find", "builtin"),
113
- tool("ls", "builtin"),
114
- ],
115
- ["read", "grep"],
116
- );
117
- expect(active).toBe(2);
118
- expect(gated).toBe(2);
119
- });
120
- });
121
-
122
- describe("buildOrientation", () => {
123
- test("summarizes counts of tools, MCP tools, and skills", () => {
124
- const out = buildOrientation(
125
- [tool("read", "builtin"), tool("ctx", "mcp:context7")],
126
- [skill("commit", "Commit changes."), skill("plan", "Plan work.")],
127
- );
128
- expect(out).toContain("1 tool");
129
- expect(out).toContain("1 MCP tool");
130
- expect(out).toContain("2 skills");
131
- });
132
-
133
- test("explains how to use read_skills() without user-only toolbox", () => {
134
- const out = buildOrientation([tool("read", "builtin")], []);
135
- expect(out).toContain("read_skills()");
136
- expect(out).not.toContain("/toolbox");
137
- expect(out).not.toContain("toolbox(");
138
- });
139
-
140
- test("calls out gated tools and points at toolbox to enable them", () => {
141
- const out = buildOrientation(
142
- [
143
- tool("read", "builtin"),
144
- tool("grep", "builtin"),
145
- tool("find", "builtin"),
146
- tool("ls", "builtin"),
147
- ],
148
- [],
149
- ["read", "grep"], // active set: 2 gated out
150
- );
151
- expect(out).toContain("2 are gated");
152
- expect(out).toContain("function definitions");
153
- });
154
-
155
- test("singular phrasing when exactly one tool is gated", () => {
156
- const out = buildOrientation(
157
- [tool("read", "builtin"), tool("find", "builtin")],
158
- [],
159
- ["read"],
160
- );
161
- expect(out).toContain("1 is gated");
162
- expect(out).toContain("function definitions");
163
- });
164
-
165
- test("no gate line when nothing is gated", () => {
166
- const out = buildOrientation(
167
- [tool("read", "builtin"), tool("grep", "builtin")],
168
- [],
169
- ["read", "grep"],
170
- );
171
- expect(out).not.toContain("gated out of the prompt");
172
- });
173
-
174
- test("no gate line when active set is unknown", () => {
175
- const out = buildOrientation(
176
- [tool("read", "builtin"), tool("find", "builtin")],
177
- [],
178
- );
179
- expect(out).not.toContain("gated out of the prompt");
180
- });
181
-
182
- test("lists invocable skill names, sorted, excluding user-only", () => {
183
- const out = buildOrientation(
184
- [],
185
- [skill("zebra", "z."), skill("alpha", "a."), skill("hidden", "h.", true)],
186
- );
187
- expect(out).toContain("Skills: alpha, zebra.");
188
- expect(out).not.toContain("hidden");
189
- });
190
-
191
- test("omits the skills line when no invocable skills", () => {
192
- const out = buildOrientation(
193
- [tool("read", "builtin")],
194
- [skill("x", ".", true)],
195
- );
196
- expect(out).not.toContain("Skills:");
197
- });
198
-
199
- test("steers away from improvising", () => {
200
- const out = buildOrientation([tool("read", "builtin")], []);
201
- expect(out.toLowerCase()).toContain("improvis");
202
- });
203
-
204
- test("frames the block as non-actionable so the model acts on the prompt", () => {
205
- const out = buildOrientation([tool("read", "builtin")], []);
206
- const last = out.trim().split("\n").at(-1) ?? "";
207
- expect(last.toLowerCase()).toContain("not a task");
208
- expect(last.toLowerCase()).toContain("do not reply");
209
- });
210
- });
211
-
212
- describe("registerCapabilityNudge", () => {
213
- test("injects orientation into system prompt, not a custom message", async () => {
214
- let handler: ((event: { systemPrompt?: string }) => unknown) | undefined;
215
- const pi = {
216
- on(event: string, fn: typeof handler) {
217
- if (event === "before_agent_start") handler = fn;
218
- },
219
- getAllTools() {
220
- return [tool("read", "builtin")];
221
- },
222
- getActiveTools() {
223
- return ["read"];
224
- },
225
- } as never;
226
-
227
- registerCapabilityNudge(pi);
228
- const result = (await handler?.({ systemPrompt: "BASE" })) as {
229
- systemPrompt?: string;
230
- message?: unknown;
231
- };
232
-
233
- expect(result.message).toBeUndefined();
234
- expect(result.systemPrompt).toStartWith("BASE\n\nToolbelt:");
235
- expect(result.systemPrompt).toContain("Orientation only");
236
- });
237
- });
238
-
239
- describe("graphifyHint", () => {
240
- const tmpDir = join(import.meta.dir, ".graphify-hint-test-tmp");
241
-
242
- test("returns undefined when graphify-out/graph.json absent", () => {
243
- expect(graphifyHint(tmpDir)).toBeUndefined();
244
- });
245
-
246
- test("returns hint string when graphify-out/graph.json exists", () => {
247
- try {
248
- mkdirSync(join(tmpDir, "graphify-out"), { recursive: true });
249
- writeFileSync(join(tmpDir, "graphify-out", "graph.json"), "{}");
250
- const hint = graphifyHint(tmpDir);
251
- expect(hint).toBeTypeOf("string");
252
- expect(hint).toContain("graphify");
253
- expect(hint).toContain("graphify query");
254
- } finally {
255
- rmSync(tmpDir, { recursive: true, force: true });
256
- }
257
- });
258
- });
@@ -1,189 +0,0 @@
1
- /**
2
- * capability-nudge.ts — orient the model to its full toolbelt
3
- *
4
- * Models drift toward improvising mid-session, forgetting they can ask the
5
- * user, search the web, pull library docs (context7), use LSP, or invoke an
6
- * Agent Skill instead of guessing. Fires on EVERY user prompt via
7
- * `before_agent_start`, injected into the system prompt for the turn.
8
- *
9
- * Two modes:
10
- * 1. FIRST prompt of the session — an orientation block: a high-level
11
- * description of WHAT is available (counts of tools / MCP tools / skills)
12
- * and HOW to explore it. We deliberately do NOT dump the whole inventory
13
- * every turn — the model should call read_skills() for skills and use /toolbox
14
- * (slash command, user-facing) to discover/enable gated tools.
15
- * 2. EVERY subsequent prompt — the terse one-line CAPABILITY_REMINDER, a
16
- * cheap (~40 tok) reinforcement that steers toward read_skills() and /toolbox.
17
- *
18
- * NOTE: `toolbox` is a slash command only (/toolbox) — NOT a model-callable
19
- * function tool. The model cannot call toolbox() in function definitions.
20
- * The `read_skills` tool IS model-callable: read_skills() lists/loads bundled skills.
21
- */
22
-
23
- import { existsSync } from "node:fs";
24
- import { join } from "node:path";
25
- import type {
26
- BuildSystemPromptOptions,
27
- ExtensionAPI,
28
- ToolInfo,
29
- } from "@earendil-works/pi-coding-agent";
30
-
31
- type LoadedSkill = NonNullable<BuildSystemPromptOptions["skills"]>[number];
32
-
33
- /** The standing per-turn reminder. Kept terse — it ships on every turn. */
34
- /** How often (in turns after the first) to re-send the capability reminder. */
35
- const REMINDER_INTERVAL = 10;
36
-
37
- export const CAPABILITY_REMINDER =
38
- "Reminder — check knowledge resources " +
39
- "(skills/tools/MCP/web/user) before improvising. " +
40
- "Matching skill? Call read_skills() first.";
41
-
42
- /**
43
- * Build the optional graphify hint line.
44
- * Returns a string if graphify-out/graph.json exists in cwd, else undefined.
45
- */
46
- export function graphifyHint(cwd: string): string | undefined {
47
- if (existsSync(join(cwd, "graphify-out", "graph.json"))) {
48
- return (
49
- "graphify-out/graph.json exists — for codebase questions (how does X work, " +
50
- 'where is Y, trace Z) run `graphify query "<question>"` before reading files.'
51
- );
52
- }
53
- return undefined;
54
- }
55
-
56
- /** Count model-invocable skills (excludes user-only /skill:name entries). */
57
- export function countInvocableSkills(
58
- skills: LoadedSkill[] | undefined,
59
- ): number {
60
- return (skills ?? []).filter((s) => !s.disableModelInvocation).length;
61
- }
62
-
63
- /**
64
- * Split tools by source (MCP vs other) and, when an `active` set is supplied,
65
- * by gate status (active = schema in the prompt now; gated = registered but
66
- * gated out, reachable only via toolbox). Without `active`, everything counts
67
- * as active (gated = 0) so callers that don't track the gate are unaffected.
68
- */
69
- export function partitionTools(
70
- tools: ToolInfo[] | undefined,
71
- active?: Iterable<string>,
72
- ): {
73
- mcp: number;
74
- other: number;
75
- active: number;
76
- gated: number;
77
- } {
78
- const activeSet = active ? new Set(active) : undefined;
79
- let mcp = 0;
80
- let other = 0;
81
- let activeCount = 0;
82
- let gated = 0;
83
- for (const t of tools ?? []) {
84
- if (/mcp/i.test(t.sourceInfo?.source ?? "")) mcp++;
85
- else other++;
86
- // A tool is "gated" only when we have an active set and it's absent from it.
87
- if (activeSet && !activeSet.has(t.name)) gated++;
88
- else activeCount++;
89
- }
90
- return { mcp, other, active: activeCount, gated };
91
- }
92
-
93
- /**
94
- * Build the one-time orientation block shown on the FIRST prompt. Describes the
95
- * shape of the toolbelt (counts) and how to explore it via `toolbox`, plus the
96
- * sorted skill names so the model knows what exists by name without a dump of
97
- * descriptions (those live in the system prompt / are searchable via tools).
98
- */
99
- export function buildOrientation(
100
- tools: ToolInfo[] | undefined,
101
- skills: LoadedSkill[] | undefined,
102
- /** Currently-active tool names; absent ⇒ gate not tracked (all treated active). */
103
- activeToolNames?: Iterable<string>,
104
- ): string {
105
- const { mcp, other, gated } = partitionTools(tools, activeToolNames);
106
- const skillNames = (skills ?? [])
107
- .filter((s) => !s.disableModelInvocation)
108
- .map((s) => s.name)
109
- .sort();
110
-
111
- const inventory: string[] = [];
112
- if (other) inventory.push(`${other} tool${other === 1 ? "" : "s"}`);
113
- if (mcp) inventory.push(`${mcp} MCP tool${mcp === 1 ? "" : "s"}`);
114
- if (skillNames.length)
115
- inventory.push(
116
- `${skillNames.length} skill${skillNames.length === 1 ? "" : "s"}`,
117
- );
118
- const summary = inventory.length ? inventory.join(", ") : "your toolbelt";
119
-
120
- // Lead with the gate — tools not described in the prompt are still callable
121
- // via function definitions, but the model doesn't know about them.
122
- const gateLine = gated
123
- ? `${gated} ${gated === 1 ? "is" : "are"} gated (kept out of the prompt to save context). All tools are always callable via function definitions.`
124
- : undefined;
125
-
126
- const lines = [
127
- `Toolbelt: ${summary}, plus LSP, MCP (context7 docs), web search/fetch, and the user.`,
128
- ];
129
- if (gateLine) lines.push(gateLine);
130
- lines.push(
131
- "Don't improvise what a capability covers — ask the user, search the web, or pull docs first.",
132
- "`read_skills()` lists/loads bundled skills — call it when a skill matches your task.",
133
- );
134
- if (skillNames.length) {
135
- lines.push(`Skills: ${skillNames.join(", ")}.`);
136
- }
137
- // Graphify hint — only when a graph is already built for this project
138
- const gHint = graphifyHint(process.cwd());
139
- if (gHint) lines.push(gHint);
140
- // Framing — this block is orientation context, not a task. Without it the
141
- // model can mistake the first-turn orientation for the prompt and reply
142
- // "Ready, waiting for task" instead of acting on the user's request.
143
- lines.push(
144
- "(Orientation only — not a task. Act on the user's request now; do not reply to this notice.)",
145
- );
146
- return lines.join("\n");
147
- }
148
-
149
- export default function registerCapabilityNudge(pi: ExtensionAPI): void {
150
- let turnCount = 0;
151
-
152
- pi.on("before_agent_start", async (event) => {
153
- const skills = event.systemPromptOptions?.skills;
154
- turnCount++;
155
-
156
- let content: string;
157
- if (turnCount === 1) {
158
- let tools: ToolInfo[] | undefined;
159
- try {
160
- tools = pi.getAllTools();
161
- } catch {
162
- tools = undefined;
163
- }
164
- // Active set lets the orientation distinguish callable-now from gated.
165
- // getActiveTools() returns string[] (tool NAMES) — not ToolInfo[]. Use as-is.
166
- let activeToolNames: string[] | undefined;
167
- try {
168
- activeToolNames = pi.getActiveTools();
169
- } catch {
170
- activeToolNames = undefined;
171
- }
172
- content = buildOrientation(tools, skills, activeToolNames);
173
- } else if (turnCount % REMINDER_INTERVAL === 1) {
174
- // Fire reminder every REMINDER_INTERVAL turns (turn 11, 21, ...)
175
- const cwd = process.cwd();
176
- const gHint = graphifyHint(cwd);
177
- content = gHint
178
- ? `${CAPABILITY_REMINDER}\n${gHint}`
179
- : CAPABILITY_REMINDER;
180
- } else {
181
- return; // no nudge this turn
182
- }
183
-
184
- const existing = event.systemPrompt ?? "";
185
- const systemPrompt = existing ? `${existing}\n\n${content}` : content;
186
-
187
- return { systemPrompt };
188
- });
189
- }
@@ -1,17 +0,0 @@
1
- /**
2
- * nudge/ — model-steering reminders, wired by one registerNudges(pi).
3
- *
4
- * - tools — block raw bash that stands in for a native tool (reactive,
5
- * per tool_call, once per category). See tools.ts.
6
- * - capability — full-toolbelt one-liner + dynamic skill-name list on every
7
- * prompt (one hidden message). See capability.ts.
8
- */
9
-
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import registerCapabilityNudge from "./capability.ts";
12
- import registerToolsNudge from "./tools.ts";
13
-
14
- export default function registerNudges(pi: ExtensionAPI): void {
15
- registerToolsNudge(pi);
16
- registerCapabilityNudge(pi);
17
- }
@@ -1,157 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- classify,
4
- classifyCompound,
5
- nudgeReason,
6
- splitSegments,
7
- } from "./tools.ts";
8
-
9
- describe("classify", () => {
10
- test("maps cat/head/tail/less to read", () => {
11
- for (const c of ["cat foo.ts", "head -5 a", "tail -f log", "less x"]) {
12
- expect(classify(c)?.category).toBe("read");
13
- }
14
- });
15
-
16
- test("maps ls/tree to ls", () => {
17
- expect(classify("ls -la")?.category).toBe("ls");
18
- expect(classify("tree src")?.category).toBe("ls");
19
- });
20
-
21
- test("maps grep/rg to grep", () => {
22
- expect(classify("grep foo bar.ts")?.category).toBe("grep");
23
- expect(classify("rg pattern")?.category).toBe("grep");
24
- });
25
-
26
- test("maps find/fd to find", () => {
27
- expect(classify("find . -name x")?.category).toBe("find");
28
- expect(classify("fd '*.ts'")?.category).toBe("find");
29
- });
30
-
31
- test("maps sed -i to edit, but plain sed is allowed", () => {
32
- expect(classify("sed -i 's/a/b/' f")?.category).toBe("edit");
33
- expect(classify("sed 's/a/b/' f")).toBeUndefined();
34
- });
35
-
36
- test("strips env-var prefix before matching", () => {
37
- expect(classify("FOO=bar grep x y")?.category).toBe("grep");
38
- });
39
-
40
- test("unaliases leading backslash", () => {
41
- expect(classify("\\grep x y")?.category).toBe("grep");
42
- });
43
-
44
- test("ignores compound / piped / redirected commands", () => {
45
- expect(classify("cat a | grep b")).toBeUndefined();
46
- expect(classify("grep x y > out")).toBeUndefined();
47
- expect(classify("ls && echo done")).toBeUndefined();
48
- expect(classify("cat $(ls)")).toBeUndefined();
49
- expect(classify("ls; pwd")).toBeUndefined();
50
- });
51
-
52
- test("ignores non-tool commands and empty input", () => {
53
- expect(classify("git status")).toBeUndefined();
54
- expect(classify("npm test")).toBeUndefined();
55
- expect(classify("")).toBeUndefined();
56
- expect(classify(" ")).toBeUndefined();
57
- });
58
- });
59
-
60
- describe("splitSegments", () => {
61
- test("records the operator following each segment", () => {
62
- expect(splitSegments("cat x | jq .")).toEqual([
63
- { text: "cat x ", followedBy: "|" },
64
- { text: " jq .", followedBy: "" },
65
- ]);
66
- expect(splitSegments("cat x || ls y")).toEqual([
67
- { text: "cat x ", followedBy: "||" },
68
- { text: " ls y", followedBy: "" },
69
- ]);
70
- });
71
-
72
- test("drops empty segments", () => {
73
- expect(splitSegments("ls;")).toEqual([{ text: "ls", followedBy: ";" }]);
74
- });
75
- });
76
-
77
- describe("classifyCompound", () => {
78
- test("still classifies simple commands", () => {
79
- expect(classifyCompound("cat foo")?.category).toBe("read");
80
- expect(classifyCompound("git status")).toBeUndefined();
81
- });
82
-
83
- test("catches the chaining-dodge (|| && ;)", () => {
84
- // seg1 `cat x 2>/dev/null` has a redirect (opaque per-segment); seg2
85
- // `ls -la` is a clean stand-in — still nudged, as `ls`.
86
- expect(classifyCompound("cat x 2>/dev/null || ls -la")?.category).toBe(
87
- "ls",
88
- );
89
- // Pure clean read stand-in chained with another clean cmd.
90
- expect(classifyCompound("cat x || ls -la")?.category).toBe("read");
91
- expect(classifyCompound("ls -la && echo done")?.category).toBe("ls");
92
- expect(classifyCompound("ls; pwd")?.category).toBe("ls");
93
- expect(classifyCompound("true && grep foo bar.ts")?.category).toBe("grep");
94
- });
95
-
96
- test("exempts a segment that feeds a pipe (legit producer)", () => {
97
- expect(classifyCompound("cat x | jq .")).toBeUndefined();
98
- expect(classifyCompound("grep foo a | head")).toBeUndefined();
99
- });
100
-
101
- test("exempts redirects per-segment", () => {
102
- expect(classifyCompound("grep x y > out")).toBeUndefined();
103
- });
104
-
105
- test("exempts when no segment is a leading stand-in", () => {
106
- expect(classifyCompound("git log | cat")).toBeUndefined();
107
- expect(classifyCompound("npm test && npm run build")).toBeUndefined();
108
- });
109
-
110
- test("empty / whitespace", () => {
111
- expect(classifyCompound("")).toBeUndefined();
112
- expect(classifyCompound(" ")).toBeUndefined();
113
- });
114
- });
115
-
116
- describe("nudgeReason", () => {
117
- test("active tool: point straight at it, no toolbox", () => {
118
- const msg = nudgeReason(
119
- "Searching file contents via bash grep/rg.",
120
- "grep",
121
- true,
122
- );
123
- expect(msg).toContain("Use `grep` instead");
124
- expect(msg).toContain("function definitions");
125
- expect(msg).not.toContain("toolbox");
126
- });
127
-
128
- test("gated tool: route through toolbox enable, not a direct call", () => {
129
- const msg = nudgeReason(
130
- "Listing a directory via bash ls/tree.",
131
- "ls",
132
- false,
133
- );
134
- expect(msg).toContain('toolbox(action:"enable", name:"ls")');
135
- expect(msg).toContain("prompt-hidden");
136
- expect(msg).toContain("function definitions");
137
- // Must NOT imply the tool is directly callable by name (it's prompt-hidden).
138
- expect(msg).not.toContain("Use `ls` instead");
139
- });
140
-
141
- test("gated find tool names itself in the enable hint", () => {
142
- expect(
143
- nudgeReason("Locating files via bash find/fd.", "find", false),
144
- ).toContain('toolbox(action:"enable", name:"find")');
145
- });
146
-
147
- test("is a single short line — no inventory dump, no newlines", () => {
148
- const msg = nudgeReason(
149
- "Listing a directory via bash ls/tree.",
150
- "ls",
151
- false,
152
- );
153
- expect(msg).not.toContain("\n");
154
- expect(msg).not.toContain("Available tools");
155
- expect(msg.length).toBeLessThan(400);
156
- });
157
- });