@nexrall/code-core 1.4.23 → 1.4.25

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.
@@ -8,10 +8,74 @@ export interface AgentType {
8
8
  /** System instructions (the markdown body below the frontmatter). */
9
9
  prompt: string;
10
10
  source: 'project' | 'global' | 'builtin' | 'plugin';
11
+ /**
12
+ * When true, this agent's write tools may only target TEST files.
13
+ *
14
+ * Needed because a tool allowlist is all-or-nothing per tool: granting
15
+ * `edit_file` grants it for every path. The `test-writer` agent must be able to
16
+ * write tests while being unable to "fix" production source to make a test
17
+ * pass — the single most common way a test-writing agent destroys signal — and
18
+ * a prompt instruction alone cannot guarantee that. Enforced in loop.ts's
19
+ * permission gate, where it is a real refusal rather than a request.
20
+ */
21
+ testFilesOnly?: boolean;
11
22
  }
12
- /** Discover all custom agent types (project overrides global overrides builtin). */
23
+ /**
24
+ * A problem found while loading an agent definition.
25
+ *
26
+ * These used to be silently swallowed. Every one of them changed what an agent
27
+ * could DO — a mistyped tool name removed a capability, a missing frontmatter
28
+ * block removed the allowlist entirely — and the user was told nothing.
29
+ */
30
+ export interface AgentWarning {
31
+ /** Absolute path of the definition file the problem was found in. */
32
+ file: string;
33
+ /** Agent name, when one could be determined. */
34
+ agent: string;
35
+ message: string;
36
+ }
37
+ /**
38
+ * Discover all agent types. Precedence: project > global > plugin > builtin.
39
+ *
40
+ * Results are returned in a STABLE order (builtins in declared order, then
41
+ * user-supplied ones alphabetically) rather than in discovery order. The
42
+ * system-prompt catalogue is built from this, and a set that reshuffles between
43
+ * runs would silently bust the prompt cache on the block it lives in — filesystem
44
+ * readdir order is not guaranteed to be stable across machines or platforms.
45
+ */
13
46
  export declare function loadAgentTypes(workDir: string): AgentType[];
14
- /** A compact catalogue injected into the system prompt so the model can pick a type. */
47
+ /**
48
+ * As `loadAgentTypes`, but also reports what was wrong with the definitions.
49
+ *
50
+ * Split in two so the common caller stays a one-liner while the CLI's `/agents`
51
+ * and the agent loop can surface problems. Warnings never hide an agent: a
52
+ * flawed definition still loads (fail-closed on PERMISSIONS, not on existence),
53
+ * because making a user's agent vanish over a typo is its own kind of silent
54
+ * failure.
55
+ */
56
+ export declare function loadAgentTypesWithWarnings(workDir: string): {
57
+ types: AgentType[];
58
+ warnings: AgentWarning[];
59
+ };
60
+ /** The tool names a hand-written allowlist may use (exported for validation + tests). */
61
+ export declare function knownToolNames(): string[];
62
+ /** The built-in agents, for clients that want to show them alongside user-defined ones. */
63
+ export declare function builtinAgents(): AgentType[];
64
+ /**
65
+ * One line per agent for the system prompt's <available_subagents> block.
66
+ *
67
+ * Reports each agent's CAPABILITY CLASS rather than enumerating its allowlist.
68
+ * The full lists (~15 tool names each) went into every single request while
69
+ * telling the model nothing it needs in order to choose: what matters when
70
+ * delegating is "can this one edit files?" and "what is it for?", not whether
71
+ * `get_hover` happens to be included. With six builtins the verbatim lists cost
72
+ * ~670 tokens per request, most of it near-identical boilerplate that also
73
+ * weakens the signal it was meant to carry.
74
+ *
75
+ * The per-agent description already states its own restrictions in prose, and the
76
+ * allowlist is enforced at the permission gate regardless of what is advertised
77
+ * here — so this is purely a summary, never the mechanism.
78
+ */
15
79
  export declare function summariseAgents(types: AgentType[]): string;
16
80
  export declare function findAgentType(types: AgentType[], name: string | undefined): AgentType | undefined;
17
81
  //# sourceMappingURL=agentTypes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"agentTypes.d.ts","sourceRoot":"","sources":["../../src/agent/agentTypes.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;CACrD;AAoFD,oFAAoF;AACpF,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,CAS3D;AAED,wFAAwF;AACxF,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,CAQ1D;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAIjG"}
1
+ {"version":3,"file":"agentTypes.d.ts","sourceRoot":"","sources":["../../src/agent/agentTypes.ts"],"names":[],"mappings":"AAiCA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;IACpD;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AA6QD;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AA0JD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,CAE3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,EAAE,YAAY,EAAE,CAAA;CAAE,CAqB5G;AAED,yFAAyF;AACzF,wBAAgB,cAAc,IAAI,MAAM,EAAE,CAEzC;AAED,2FAA2F;AAC3F,wBAAgB,aAAa,IAAI,SAAS,EAAE,CAE3C;AACD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,CAY1D;AAQD,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAIjG"}
@@ -34,6 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.loadAgentTypes = loadAgentTypes;
37
+ exports.loadAgentTypesWithWarnings = loadAgentTypesWithWarnings;
38
+ exports.knownToolNames = knownToolNames;
39
+ exports.builtinAgents = builtinAgents;
37
40
  exports.summariseAgents = summariseAgents;
38
41
  exports.findAgentType = findAgentType;
39
42
  const fs = __importStar(require("fs"));
@@ -43,11 +46,58 @@ const index_1 = require("../plugins/index");
43
46
  // ── Built-in agent types ──────────────────────────────────────────────────────
44
47
  // Shipped defaults; lowest precedence (project > global > builtin), so a user
45
48
  // can override any of them with a same-name .nexrall/agents/<name>.md file.
49
+ // Shared read-only tool set.
50
+ //
51
+ // An allowlist only ever GRANTS — a name that doesn't exist on the current client
52
+ // is simply never offered to the model, so listing the VS Code language-server
53
+ // tools alongside the CLI ones is safe and gives each agent the best available
54
+ // capability on whichever client it runs.
55
+ //
56
+ // This exists because it was previously inlined per agent, and the one agent that
57
+ // had it (`reviewer`) listed five VS Code-only tools, leaving it with just five
58
+ // usable tools when run from the CLI — a silent capability gap that is very easy
59
+ // to reintroduce by copy-pasting a list.
60
+ const READ_ONLY_TOOLS = [
61
+ // Universal
62
+ 'read_file', 'search_files', 'glob', 'list_directory', 'bash', 'bash_output',
63
+ // kill_shell belongs next to bash_output: an agent that can start a background
64
+ // process and poll it but never stop it leaks that process past its own
65
+ // lifetime. Stopping a shell you started is not a write to the repo.
66
+ 'kill_shell',
67
+ 'notebook_read', 'todo_write', 'todo_read',
68
+ // Skills are reusable prompt playbooks, and loop.ts advertises the skills
69
+ // catalogue to sub-agents at EVERY depth — so withholding the tool that loads
70
+ // one meant showing every sub-agent a menu it could not order from.
71
+ 'use_skill',
72
+ // VS Code language server (ignored on the CLI)
73
+ 'get_symbols', 'get_workspace_symbols', 'find_references', 'go_to_definition',
74
+ 'get_hover', 'get_diagnostics',
75
+ ];
76
+ /**
77
+ * Read-only + the network, for agents that must consult external sources.
78
+ *
79
+ * `web_search` is deliberately ABSENT despite the name of this list. It is a
80
+ * SERVER-SIDE tool: Anthropic executes it and returns the result inside the same
81
+ * assistant message, so loop.ts filters those blocks out before the permission
82
+ * gate ever runs. Listing it here would be theatre — it grants nothing (agents
83
+ * without it can still search) and denies nothing. Naming the absence is the
84
+ * only way to stop it being "helpfully" re-added.
85
+ */
86
+ const RESEARCH_TOOLS = [...READ_ONLY_TOOLS, 'fetch_url'];
87
+ /** Read-only + the write tools, for agents that produce code. */
88
+ const WRITE_TOOLS = [
89
+ ...READ_ONLY_TOOLS,
90
+ 'write_file', 'edit_file', 'multi_edit', 'create_directory', 'move_file', 'copy_file',
91
+ // notebook_edit is a write tool like any other, and allowsTestOnlyWrite already
92
+ // knows its shape (`source` is cell CONTENT, not a path). Omitting it just meant
93
+ // test-writer silently could not touch notebooks.
94
+ 'notebook_edit',
95
+ ];
46
96
  const BUILTIN_AGENTS = [
47
97
  {
48
98
  name: 'reviewer',
49
99
  description: 'Read-only code reviewer — finds correctness bugs, edge cases, and security issues in a diff or file set. Cannot modify files.',
50
- tools: ['read_file', 'search_files', 'glob', 'list_directory', 'bash', 'get_symbols', 'get_workspace_symbols', 'find_references', 'go_to_definition', 'get_diagnostics'],
100
+ tools: READ_ONLY_TOOLS,
51
101
  source: 'builtin',
52
102
  prompt: [
53
103
  'You are a meticulous senior code reviewer. You NEVER modify files — you only read, search, and report.',
@@ -64,23 +114,209 @@ const BUILTIN_AGENTS = [
64
114
  'then a final verdict (APPROVE or REQUEST CHANGES) with a one-paragraph rationale.',
65
115
  ].join('\n'),
66
116
  },
117
+ // Promoted from the security-audit plugin to a builtin.
118
+ //
119
+ // Leaving it plugin-only was indefensible next to `reviewer` being builtin:
120
+ // reviewer's own prompt already tells it to look for security issues, so
121
+ // security IS treated as default work — yet the specialist agent for it was
122
+ // invisible unless the user happened to know the plugin existed. For an agent
123
+ // that WRITES code, "you only get a security review if you knew to install
124
+ // something" is the wrong default.
125
+ {
126
+ name: 'security-auditor',
127
+ description: 'Read-only security auditor — hunts injection, authz, secrets, and validation flaws in a path or diff. Cannot modify files.',
128
+ tools: READ_ONLY_TOOLS,
129
+ model: 'pro',
130
+ source: 'builtin',
131
+ prompt: [
132
+ 'You are a security auditor. You find real, exploitable flaws — not style issues.',
133
+ '',
134
+ 'Method:',
135
+ '1. Map the attack surface FIRST: entry points (HTTP routes, message handlers, CLI args, file/network',
136
+ ' input, deserialization), then trace user-controlled data inward to where it is used.',
137
+ '2. For each finding: file:line, the flaw class, a one-line exploit scenario, and the concrete fix.',
138
+ '3. Grade severity honestly: Critical = remote compromise or data breach; High = auth bypass/IDOR;',
139
+ ' Medium = needs unusual preconditions; Low = hardening.',
140
+ '',
141
+ 'Classes worth the most attention, in order: injection (SQL/command/template/prototype), broken',
142
+ 'authz (missing ownership checks, IDOR, trusting client-supplied ids), secrets committed to source,',
143
+ 'path traversal, SSRF, unsafe deserialization, missing rate limits on expensive or auth endpoints,',
144
+ 'and crypto misuse (hand-rolled comparison, predictable randomness).',
145
+ '',
146
+ 'Hard rules:',
147
+ '- READ-ONLY: never modify, create or delete files. bash only for read-only inspection.',
148
+ '- NEVER print a discovered secret\'s value. Report its location and advise rotation.',
149
+ '- Distinguish EXPLOITABLE from theoretical, and say which one each finding is.',
150
+ '- "No issues found in scope X" is a valid, useful result. Do not pad the report to look thorough.',
151
+ ].join('\n'),
152
+ },
153
+ // The gap Claude Code fills with its built-in `Explore`: read-heavy codebase
154
+ // search that would otherwise flood the parent's context. Defaults to the
155
+ // cheapest model on purpose — "find every caller of X" has no need of a
156
+ // frontier model, and this is the agent most likely to be spawned in bulk.
157
+ {
158
+ name: 'explorer',
159
+ description: 'Fast read-only codebase explorer — locates files, symbols, and call sites and reports concise findings. Use to keep bulk searching out of the main context. Cannot modify files.',
160
+ tools: READ_ONLY_TOOLS,
161
+ model: 'turbo',
162
+ source: 'builtin',
163
+ prompt: [
164
+ 'You map code. You NEVER modify anything.',
165
+ '',
166
+ 'Method:',
167
+ '1. Prefer structural search over text search where available (get_workspace_symbols, find_references,',
168
+ ' go_to_definition); fall back to search_files/glob otherwise.',
169
+ '2. Read only the sections you need — use read_file with offset/limit on large files instead of',
170
+ ' pulling in thousands of lines.',
171
+ '3. Follow the real call graph rather than guessing from names.',
172
+ '',
173
+ 'Your ONLY output is a compact report: the file:line locations that matter, how they relate, and the',
174
+ 'direct answer to the question you were given. This exists to keep bulk search OUT of the parent\'s',
175
+ 'context, so do not paste large file contents back — cite locations and summarise. Say plainly when',
176
+ 'something does not exist; a confident wrong answer is far worse than "not found".',
177
+ ].join('\n'),
178
+ },
179
+ // Matches Claude Code's built-in `Plan`: research a change and return a
180
+ // strategy, deliberately WITHOUT write access so "make a plan" can never
181
+ // quietly become "start editing".
182
+ {
183
+ name: 'planner',
184
+ description: 'Read-only planning agent — researches a change and returns a concrete step-by-step implementation plan with risks and affected files. Cannot modify files.',
185
+ tools: RESEARCH_TOOLS,
186
+ model: 'pro',
187
+ source: 'builtin',
188
+ prompt: [
189
+ 'You produce implementation plans. You NEVER modify files — planning and doing are separate steps,',
190
+ 'and this agent exists so "plan it" cannot silently turn into "change it".',
191
+ '',
192
+ 'Method:',
193
+ '1. Read the actual code before proposing anything. No plan may rest on an assumed API shape.',
194
+ '2. Find every affected call site (find_references / search_files) and list them.',
195
+ '3. Order the steps so the tree stays working after each one — types, then implementation, then',
196
+ ' tests, then exports/registration.',
197
+ '',
198
+ 'Output:',
199
+ '- Goal, in one sentence.',
200
+ '- Numbered steps, each with the exact files touched and what changes in them.',
201
+ '- Risks + the specific thing that could break, and how it would be detected.',
202
+ '- How to verify (the exact test/build command for THIS project, taken from package.json/Makefile).',
203
+ '- Anything genuinely ambiguous, stated as an open question rather than a silent assumption.',
204
+ ].join('\n'),
205
+ },
206
+ // Promoted from the test-gen plugin. Needs write access — it produces test
207
+ // files — but is deliberately forbidden from touching source, because "make the
208
+ // tests pass" is the single most common way an agent destroys signal.
209
+ {
210
+ name: 'test-writer',
211
+ description: 'Writes tests that follow the project\'s existing conventions. May create/edit TEST files only — never production source.',
212
+ tools: WRITE_TOOLS,
213
+ // Enforced, not merely requested: the permission gate refuses a write whose
214
+ // path is not a test file. Without this the allowlist would grant edit_file
215
+ // for every path and the rule below would be a suggestion the model is free
216
+ // to rationalise its way past.
217
+ testFilesOnly: true,
218
+ source: 'builtin',
219
+ prompt: [
220
+ 'You write tests. You may create and edit TEST files only.',
221
+ '',
222
+ 'Hard rules — these are the ways test-writing agents destroy value, so they are non-negotiable:',
223
+ '- NEVER modify production source to make a test pass. If the code looks wrong, REPORT it and stop.',
224
+ '- NEVER weaken, delete or skip an existing assertion or test.',
225
+ '- A test that cannot fail is worse than no test. Every test must be able to fail for one clear reason.',
226
+ '',
227
+ 'Method:',
228
+ '1. Read the existing tests FIRST and copy their conventions exactly — runner, file naming, layout,',
229
+ ' assertion style, fixture/helper patterns. Never introduce a new framework.',
230
+ '2. Test observable behaviour and the contract, not private internals.',
231
+ '3. Cover the boring-but-real cases: empty input, null/undefined, unicode and non-BMP characters,',
232
+ ' boundaries, error paths, concurrency where it applies.',
233
+ '4. No sleeps or wall-clock dependence — those produce the flaky tests that get deleted later.',
234
+ '5. RUN the tests you wrote and report the real output. Never claim a test passes without running it.',
235
+ ].join('\n'),
236
+ },
237
+ // The DevOps gap — answered with a READ-ONLY advisor, not an operator.
238
+ //
239
+ // A "DevOps agent" with write/apply access is a genuinely different risk class
240
+ // from the others here: its mistakes are `kubectl delete`, a bad `terraform
241
+ // apply`, a broken deploy pipeline — often not revertible and affecting
242
+ // production rather than a working tree. So this one diagnoses and proposes a
243
+ // diff; a human applies it. That asymmetry is the whole design.
244
+ {
245
+ name: 'devops-advisor',
246
+ description: 'Read-only CI/CD, container, and infrastructure advisor — diagnoses pipelines, Dockerfiles, and k8s manifests and proposes concrete fixes as a diff. Never applies changes.',
247
+ tools: RESEARCH_TOOLS,
248
+ model: 'pro',
249
+ source: 'builtin',
250
+ prompt: [
251
+ 'You are an infrastructure and delivery advisor. You DIAGNOSE and PROPOSE. You never apply changes.',
252
+ '',
253
+ 'Hard rules:',
254
+ '- READ-ONLY, and stricter than the other read-only agents: bash is for INSPECTION only',
255
+ ' (git log/diff, cat, grep, `kubectl get/describe`, `docker images`, `terraform plan`).',
256
+ ' NEVER run anything that mutates infrastructure — no apply/delete/scale/rollout/restart/push,',
257
+ ' no `terraform apply`, no `helm upgrade`. If a fix needs such a command, WRITE IT OUT for a human.',
258
+ '- Never print secret values from env files, k8s Secrets or CI variables. Reference them by name.',
259
+ '',
260
+ 'Method:',
261
+ '1. Read what actually exists — workflow files, Dockerfiles, manifests, kustomize overlays, the',
262
+ ' deploy scripts — before drawing any conclusion. Never reason from what a stack "usually" looks like.',
263
+ '2. Follow the real path a change takes to production, and name the step that is broken or missing.',
264
+ '3. Check the failure modes that bite hardest: CI path filters that skip files a workload actually',
265
+ ' needs, image tags that do not match what is deployed, missing health probes, absent resource',
266
+ ' limits, secrets baked into images, ports/timeouts inconsistent between proxy and app, and',
267
+ ' migrations that must run before the new image is live.',
268
+ '',
269
+ 'Output: the diagnosis, the evidence (file:line or command output), the proposed change as a diff or',
270
+ 'exact file content, and the command a human should run to apply and verify it.',
271
+ ].join('\n'),
272
+ },
67
273
  ];
274
+ /**
275
+ * Every tool name a client may offer, for validating a hand-written allowlist.
276
+ *
277
+ * Deliberately a SEPARATE list rather than an import from tools/executor.ts:
278
+ * that module's TOOL_MAP is the CLI's local dispatch table and legitimately
279
+ * lacks `get_diagnostics` (intercepted by VS Code before the executor) and
280
+ * `web_search` (run server-side by Anthropic). Validating against it would
281
+ * reject two perfectly valid names. A canary test asserts every name used by the
282
+ * builtin agents appears here, so the two cannot drift apart unnoticed.
283
+ */
284
+ const KNOWN_TOOL_NAMES = new Set([
285
+ 'read_file', 'write_file', 'edit_file', 'multi_edit', 'list_directory', 'create_directory',
286
+ 'move_file', 'copy_file', 'delete_file', 'search_files', 'glob',
287
+ 'bash', 'bash_output', 'kill_shell',
288
+ 'notebook_read', 'notebook_edit',
289
+ 'todo_write', 'todo_read', 'memory_write', 'memory_read', 'use_skill',
290
+ 'fetch_url', 'web_search', 'generate_image', 'stock_photo', 'open_in_browser',
291
+ 'task',
292
+ 'get_symbols', 'get_workspace_symbols', 'find_references', 'go_to_definition',
293
+ 'get_hover', 'get_diagnostics',
294
+ ]);
295
+ /** Frontmatter keys this parser understands, for typo detection. */
296
+ const KNOWN_META_KEYS = new Set([
297
+ 'name', 'description', 'tools', 'model', 'test_files_only', 'testfilesonly',
298
+ ]);
299
+ const VALID_MODELS = ['turbo', 'pro', 'ultra'];
68
300
  function parseFrontmatter(raw) {
69
301
  const m = /^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
70
302
  if (!m)
71
- return { meta: {}, body: raw.trim() };
303
+ return { meta: {}, body: raw.trim(), ok: false };
72
304
  const meta = {};
73
305
  for (const line of m[1].split(/\r?\n/)) {
74
306
  const kv = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim());
75
307
  if (kv)
76
308
  meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, '');
77
309
  }
78
- return { meta, body: (m[2] ?? '').trim() };
310
+ return { meta, body: (m[2] ?? '').trim(), ok: true };
79
311
  }
80
312
  function parseModel(v) {
81
313
  const s = (v ?? '').toLowerCase();
82
314
  return s === 'turbo' || s === 'pro' || s === 'ultra' ? s : undefined;
83
315
  }
316
+ /** Parse a frontmatter boolean, accepting the usual truthy spellings. */
317
+ function parseBool(v) {
318
+ return /^(true|yes|1|on)$/i.test((v ?? '').trim());
319
+ }
84
320
  function parseToolList(v) {
85
321
  if (!v)
86
322
  return undefined;
@@ -91,62 +327,210 @@ function parseToolList(v) {
91
327
  .filter(Boolean);
92
328
  return tools.length ? tools : undefined;
93
329
  }
94
- function loadDir(dir, source, into) {
330
+ function loadDir(dir, source, into, warnings) {
95
331
  let entries;
96
332
  try {
97
- entries = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
333
+ entries = fs.readdirSync(dir, { withFileTypes: true });
98
334
  }
99
335
  catch {
336
+ // Overwhelmingly "no .nexrall/agents here", which is the normal case and not
337
+ // worth a word. A genuine permission error is rare enough that the cost of
338
+ // staying quiet is lower than warning on every repo that has no agents.
100
339
  return;
101
340
  }
102
- for (const file of entries) {
341
+ for (const entry of entries) {
342
+ const full = path.join(dir, entry.name);
343
+ // Recurse into subfolders so definitions can be organised (agents/review/…),
344
+ // matching Claude Code. Identity still comes only from the name field / filename.
345
+ if (entry.isDirectory()) {
346
+ loadDir(full, source, into, warnings);
347
+ continue;
348
+ }
349
+ if (!entry.name.endsWith('.md'))
350
+ continue;
351
+ let raw;
103
352
  try {
104
- const raw = fs.readFileSync(path.join(dir, file), 'utf-8');
105
- const { meta, body } = parseFrontmatter(raw);
106
- const name = (meta.name || path.basename(file, '.md')).trim();
107
- if (!name)
108
- continue;
109
- // Earlier tiers win (project > global > plugin).
110
- if (source !== 'project' && into.has(name))
111
- continue;
112
- into.set(name, {
113
- name,
114
- description: meta.description || `Custom ${name} agent`,
115
- tools: parseToolList(meta.tools),
116
- model: parseModel(meta.model),
117
- prompt: body,
118
- source,
353
+ raw = fs.readFileSync(full, 'utf-8');
354
+ }
355
+ catch (err) {
356
+ warnings.push({ file: full, agent: path.basename(entry.name, '.md'), message: `could not be read (${err.message}) — this agent was skipped` });
357
+ continue;
358
+ }
359
+ const { meta, body, ok } = parseFrontmatter(raw);
360
+ const name = (meta.name || path.basename(entry.name, '.md')).trim();
361
+ if (!name)
362
+ continue;
363
+ // Earlier tiers win (project > global > plugin).
364
+ if (source !== 'project' && into.has(name))
365
+ continue;
366
+ const tools = parseToolList(meta.tools);
367
+ let effectiveTools = tools;
368
+ // ── FAIL CLOSED on a definition we could not parse ────────────────────────
369
+ //
370
+ // A file with no `---` frontmatter block used to yield tools === undefined,
371
+ // which means "no allowlist" — i.e. FULL access, including write_file, bash
372
+ // and delete_file. So the worse the file, the more power it got: the exact
373
+ // inversion you do not want in a permission system. A malformed definition
374
+ // is now read-only, which is both the safe reading and, for anyone hand-
375
+ // writing an agent, almost always the intended one.
376
+ if (!ok) {
377
+ effectiveTools = READ_ONLY_TOOLS;
378
+ warnings.push({
379
+ file: full,
380
+ agent: name,
381
+ message: 'has no valid YAML frontmatter (a `---` block must be the first thing in the file), so no ' +
382
+ 'tool allowlist could be read. Treating it as READ-ONLY. Add frontmatter with a `tools:` line ' +
383
+ 'to grant more.',
119
384
  });
120
385
  }
121
- catch {
122
- /* skip unreadable / malformed definitions */
386
+ else {
387
+ if (!meta.description) {
388
+ warnings.push({
389
+ file: full,
390
+ agent: name,
391
+ message: 'has no `description:` — that text is the ONLY thing the model uses to decide when to delegate to this agent, so it will rarely be picked.',
392
+ });
393
+ }
394
+ if (meta.model !== undefined && parseModel(meta.model) === undefined) {
395
+ warnings.push({
396
+ file: full,
397
+ agent: name,
398
+ message: `has model: "${meta.model}", which is not valid — use one of ${VALID_MODELS.join(', ')}, or omit the line to inherit the current session's model.`,
399
+ });
400
+ }
401
+ // The highest-value warning of the lot. An allowlist only ever GRANTS, so a
402
+ // misspelled name is not an error anywhere — the tool is simply never
403
+ // permitted, and the model is told "Permission denied", which points it at
404
+ // the user rather than at the typo.
405
+ const unknown = (tools ?? []).filter((t) => !KNOWN_TOOL_NAMES.has(t) && !t.includes('__'));
406
+ if (unknown.length) {
407
+ warnings.push({
408
+ file: full,
409
+ agent: name,
410
+ message: `lists unknown tool name(s): ${unknown.join(', ')}. An allowlist only grants, so these silently do nothing and the agent cannot use them. Tool names are lower_snake_case (read_file, search_files, glob, bash).`,
411
+ });
412
+ }
413
+ const strayKeys = Object.keys(meta).filter((k) => !KNOWN_META_KEYS.has(k));
414
+ if (strayKeys.length) {
415
+ warnings.push({
416
+ file: full,
417
+ agent: name,
418
+ message: `has unrecognised frontmatter key(s): ${strayKeys.join(', ')} — these are ignored.`,
419
+ });
420
+ }
421
+ if (!body) {
422
+ warnings.push({
423
+ file: full,
424
+ agent: name,
425
+ message: 'has an empty body — the text below the frontmatter IS the agent\'s system prompt, so it currently has no instructions.',
426
+ });
427
+ }
123
428
  }
429
+ into.set(name, {
430
+ name,
431
+ description: meta.description || `Custom ${name} agent`,
432
+ tools: effectiveTools,
433
+ model: parseModel(meta.model),
434
+ prompt: body,
435
+ source,
436
+ // Exposed to user/plugin definitions too — `test_files_only: true` (or
437
+ // `testFilesOnly`) lets anyone build a test-writing agent that genuinely
438
+ // cannot touch production source, rather than only the builtin getting
439
+ // that guarantee.
440
+ ...(parseBool(meta.test_files_only ?? meta.testfilesonly) ? { testFilesOnly: true } : {}),
441
+ });
124
442
  }
125
443
  }
126
- /** Discover all custom agent types (project overrides global overrides builtin). */
444
+ /**
445
+ * Discover all agent types. Precedence: project > global > plugin > builtin.
446
+ *
447
+ * Results are returned in a STABLE order (builtins in declared order, then
448
+ * user-supplied ones alphabetically) rather than in discovery order. The
449
+ * system-prompt catalogue is built from this, and a set that reshuffles between
450
+ * runs would silently bust the prompt cache on the block it lives in — filesystem
451
+ * readdir order is not guaranteed to be stable across machines or platforms.
452
+ */
127
453
  function loadAgentTypes(workDir) {
454
+ return loadAgentTypesWithWarnings(workDir).types;
455
+ }
456
+ /**
457
+ * As `loadAgentTypes`, but also reports what was wrong with the definitions.
458
+ *
459
+ * Split in two so the common caller stays a one-liner while the CLI's `/agents`
460
+ * and the agent loop can surface problems. Warnings never hide an agent: a
461
+ * flawed definition still loads (fail-closed on PERMISSIONS, not on existence),
462
+ * because making a user's agent vanish over a typo is its own kind of silent
463
+ * failure.
464
+ */
465
+ function loadAgentTypesWithWarnings(workDir) {
128
466
  const out = new Map();
129
- loadDir(path.join(workDir, '.nexrall', 'agents'), 'project', out);
130
- loadDir(path.join(os.homedir(), '.nexrall', 'agents'), 'global', out);
467
+ const warnings = [];
468
+ loadDir(path.join(workDir, '.nexrall', 'agents'), 'project', out, warnings);
469
+ loadDir(path.join(os.homedir(), '.nexrall', 'agents'), 'global', out, warnings);
131
470
  for (const dir of (0, index_1.pluginAssetDirs)(workDir, 'agents'))
132
- loadDir(dir, 'plugin', out);
471
+ loadDir(dir, 'plugin', out, warnings);
133
472
  for (const agent of BUILTIN_AGENTS) {
134
473
  if (!out.has(agent.name))
135
474
  out.set(agent.name, agent);
136
475
  }
137
- return [...out.values()];
476
+ // Builtins first in their declared order (the common, cache-friendly case), then
477
+ // everything user-supplied alphabetically.
478
+ const builtinOrder = new Map(BUILTIN_AGENTS.map((a, i) => [a.name, i]));
479
+ const types = [...out.values()].sort((a, b) => {
480
+ const ai = builtinOrder.get(a.name);
481
+ const bi = builtinOrder.get(b.name);
482
+ if (ai !== undefined && bi !== undefined)
483
+ return ai - bi;
484
+ if (ai !== undefined)
485
+ return -1;
486
+ if (bi !== undefined)
487
+ return 1;
488
+ return a.name.localeCompare(b.name);
489
+ });
490
+ return { types, warnings };
491
+ }
492
+ /** The tool names a hand-written allowlist may use (exported for validation + tests). */
493
+ function knownToolNames() {
494
+ return [...KNOWN_TOOL_NAMES].sort();
495
+ }
496
+ /** The built-in agents, for clients that want to show them alongside user-defined ones. */
497
+ function builtinAgents() {
498
+ return BUILTIN_AGENTS;
138
499
  }
139
- /** A compact catalogue injected into the system prompt so the model can pick a type. */
500
+ /**
501
+ * One line per agent for the system prompt's <available_subagents> block.
502
+ *
503
+ * Reports each agent's CAPABILITY CLASS rather than enumerating its allowlist.
504
+ * The full lists (~15 tool names each) went into every single request while
505
+ * telling the model nothing it needs in order to choose: what matters when
506
+ * delegating is "can this one edit files?" and "what is it for?", not whether
507
+ * `get_hover` happens to be included. With six builtins the verbatim lists cost
508
+ * ~670 tokens per request, most of it near-identical boilerplate that also
509
+ * weakens the signal it was meant to carry.
510
+ *
511
+ * The per-agent description already states its own restrictions in prose, and the
512
+ * allowlist is enforced at the permission gate regardless of what is advertised
513
+ * here — so this is purely a summary, never the mechanism.
514
+ */
140
515
  function summariseAgents(types) {
141
516
  if (!types.length)
142
517
  return '';
143
518
  return types
144
519
  .map((t) => {
145
- const tools = t.tools ? ` (tools: ${t.tools.join(', ')})` : '';
146
- return `- ${t.name}: ${t.description}${tools}`;
520
+ const canWrite = !t.tools || t.tools.some((x) => WRITE_TOOL_HINTS.has(x));
521
+ const access = t.testFilesOnly
522
+ ? 'writes TEST files only'
523
+ : canWrite ? 'can modify files' : 'read-only';
524
+ const model = t.model ? `, ${t.model} model` : '';
525
+ return `- ${t.name} (${access}${model}): ${t.description}`;
147
526
  })
148
527
  .join('\n');
149
528
  }
529
+ /** Tool names that imply write access, for the summary line above. */
530
+ const WRITE_TOOL_HINTS = new Set([
531
+ 'write_file', 'edit_file', 'multi_edit', 'notebook_edit',
532
+ 'delete_file', 'move_file', 'copy_file',
533
+ ]);
150
534
  function findAgentType(types, name) {
151
535
  if (!name)
152
536
  return undefined;
@@ -6,6 +6,20 @@ export declare function resolveMaxIterations(optionValue: number | undefined, se
6
6
  * dozen lines.
7
7
  */
8
8
  export declare function createLimiter(max: number): <T>(fn: () => Promise<T>) => Promise<T>;
9
+ /**
10
+ * Thrown by a sub-agent's permission gate when the AGENT DEFINITION forbids a
11
+ * tool — as opposed to the user declining it.
12
+ *
13
+ * The distinction matters to the model, which is why this is an exception rather
14
+ * than a `false`: both used to collapse into "Permission denied by user", so an
15
+ * agent blocked by its own allowlist (very often a mistyped tool name) was told
16
+ * the human had refused. The rational response to that is to ask again, which
17
+ * can never succeed. Carrying a reason lets the tool_result say what is actually
18
+ * true and what to do instead.
19
+ */
20
+ export declare class ToolNotAllowedError extends Error {
21
+ constructor(message: string);
22
+ }
9
23
  /**
10
24
  * Reduce a sub-agent's message history to the text its parent should receive.
11
25
  *
@@ -47,6 +61,26 @@ export declare function compactionThresholds(): {
47
61
  export declare function estimateBodyBytes(messages: Message[]): number;
48
62
  /** Tools that mutate the filesystem — used by the verification nudge (GAP D). */
49
63
  export declare const WRITE_TOOL_NAMES: Set<string>;
64
+ /**
65
+ * May an agent restricted to `testFilesOnly` perform this tool call?
66
+ *
67
+ * A tool allowlist is all-or-nothing per tool: granting `edit_file` grants it for
68
+ * every path in the repo. The `test-writer` agent needs write access to produce
69
+ * tests, but must NOT be able to "fix" production source so a failing test goes
70
+ * green — the single most common way a test-writing agent destroys the signal it
71
+ * was asked to create. Its prompt says so; this makes it a refusal rather than a
72
+ * request.
73
+ *
74
+ * Pure + exported so the rules are testable directly, without running a real
75
+ * sub-agent.
76
+ *
77
+ * KNOWN LIMIT, stated rather than hidden: this gates the file TOOLS, not `bash`.
78
+ * A determined model could still write source via `bash: echo ... > src/x.ts`.
79
+ * Closing that means parsing shell redirection, which is not reliably doable — so
80
+ * this is a strong guardrail against the realistic failure mode, not a sandbox.
81
+ * Real isolation is the sandbox config (tools/sandbox.ts), a separate mechanism.
82
+ */
83
+ export declare function allowsTestOnlyWrite(tool: string, input: Record<string, unknown> | undefined): boolean;
50
84
  /** Heuristic: does a bash command look like it's running tests/build/lint/typecheck? (GAP D) */
51
85
  export declare const VERIFY_CMD_RE: RegExp;
52
86
  /**