@chimix/agentscan 0.5.0 → 0.6.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 (3) hide show
  1. package/README.md +38 -22
  2. package/dist/cli.js +52 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  </p>
6
6
 
7
7
  <p align="center">
8
- <img src="https://img.shields.io/badge/checks-26-111111?style=flat-square" alt="26 checks">
9
- <img src="https://img.shields.io/badge/tests-226%20passing-111111?style=flat-square" alt="226 tests">
8
+ <img src="https://img.shields.io/badge/checks-27-111111?style=flat-square" alt="27 checks">
9
+ <img src="https://img.shields.io/badge/tests-235%20passing-111111?style=flat-square" alt="235 tests">
10
10
  <img src="https://img.shields.io/badge/network-none-111111?style=flat-square" alt="No network">
11
11
  <img src="https://img.shields.io/badge/writes-none-111111?style=flat-square" alt="Writes nothing">
12
12
  <img src="https://img.shields.io/badge/runs%20on-node%20%C2%B7%20bun-111111?style=flat-square" alt="Node or Bun">
@@ -15,7 +15,7 @@
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
- <strong>26 checks &middot; 4.1k lines &middot; 0 network calls &middot; every check sourced to a published spec line</strong><br>
18
+ <strong>27 checks &middot; 4.2k lines &middot; 0 network calls &middot; every check sourced to a published spec line</strong><br>
19
19
  <sub>Alpha. An earlier build reported 37 findings across 17 real projects of which <strong>25 were false</strong> — two checks had been written from what real projects looked like instead of from the spec. Both were deleted, and every check that survived is recorded in <a href="docs/spec/">docs/spec/</a> with the URL it came from and the date it was read. That story is the reason this tool exists in its current shape.</sub>
20
20
  </p>
21
21
 
@@ -64,7 +64,7 @@ No AI, no network, no heuristics. Read the config, read the disk, compare:
64
64
  ```
65
65
  1. Discover .claude/ .agents/ .mcp.json AGENTS.md skills-lock.json
66
66
  2. Extract immutable facts — never re-read during checking
67
- 3. Check 26 checks, each against one published spec line
67
+ 3. Check 27 checks, each against one published spec line
68
68
  4. Report text · --json · --output prompt (handoff for a fixing agent)
69
69
  ```
70
70
 
@@ -156,7 +156,7 @@ bunx agentscan check # text report (cwd)
156
156
  bunx agentscan check ./my-app # explicit root
157
157
  bunx agentscan check --json # machine-readable
158
158
  bunx agentscan check --quiet # summary only
159
- bunx agentscan check --verbose # include KEEP and info-severity findings
159
+ bunx agentscan check --verbose # include KEEP, info findings, and each finding's id
160
160
  bunx agentscan check --fail-on warning
161
161
  bunx agentscan check --global # also scan global skill dirs
162
162
 
@@ -175,7 +175,7 @@ Flags for `check`:
175
175
  | `--copy` | Also copy the report to the system clipboard |
176
176
  | `--no-color` | Never colour, even on a terminal (`NO_COLOR=1` does the same) |
177
177
  | `--quiet` | Summary line only |
178
- | `--verbose` | Show KEEP + info-severity findings |
178
+ | `--verbose` | Show KEEP + info-severity findings, and print each finding's id |
179
179
  | `--fail-on <level>` | `never` (default) · `warning` · `error` |
180
180
  | `--fail-under <0-100>` | Fail when the score drops below this floor |
181
181
  | `--global` | Also scan `~/.claude/skills` and `~/.codex/skills` (see below) |
@@ -186,14 +186,14 @@ Flags for `check`:
186
186
  ## Sample output
187
187
 
188
188
  ```text
189
- ┌─────┐ 40/100 broken · touchagency
190
- │ ✕ ✕ │ ██████████░░░░░░░░░░░░░░░
191
- │ ⌒ │ 6 error · 4 info hidden (--verbose)
189
+ ┌─────┐ 34/100 broken · touchagency
190
+ │ ✕ ✕ │ █████████░░░░░░░░░░░░░░░░
191
+ │ ⌒ │ 6 error · 2 warning · 11 info hidden (--verbose)
192
192
  └─────┘
193
193
 
194
- agentscan v0.5.0 — touchagency
194
+ agentscan v0.6.0 — touchagency
195
195
 
196
- Scanned: 46 deps · 54 skills · 1 mcp · 2 agents · packageManager=bun
196
+ Scanned: 46 deps · 108 skills · 1 mcp · 2 agents · packageManager=bun
197
197
 
198
198
  ERROR rule:hook.missing-script ×6
199
199
  Hook points at a script that does not exist
@@ -202,7 +202,12 @@ ERROR rule:hook.missing-script ×6
202
202
  PreToolUse @ .claude/settings.json · .claude/hooks/protect-artists-json.js
203
203
  PreToolUse @ .claude/settings.json · .claude/hooks/protect-env.js
204
204
 
205
- Summary: 6 error · 4 info hidden (--verbose) · score 40/100 broken
205
+ WARN rule:agent.invalid-name ×2
206
+ 2 findings
207
+ .claude/agents/marketing-seo-specialist.md
208
+ .claude/agents/testing-accessibility-auditor.md
209
+
210
+ Summary: 6 error · 2 warning · 11 info hidden (--verbose) · score 34/100 broken
206
211
  ```
207
212
 
208
213
  On a terminal the header is coloured green, yellow or red and the face tracks
@@ -218,7 +223,7 @@ JSON shape (abridged):
218
223
 
219
224
  ```json
220
225
  {
221
- "version": "0.5.0",
226
+ "version": "0.6.0",
222
227
  "root": "/path/to/project",
223
228
  "factsSummary": {
224
229
  "packageManager": "bun",
@@ -389,8 +394,9 @@ This is the only place agentscan starts a subprocess, and it runs only under
389
394
 
390
395
  `ignoreRules` disables a check project-wide, which is too blunt when a single
391
396
  hook uses an exotic launcher. `ignoreFindings` takes exact finding ids — the
392
- same strings `agentscan explain` accepts, so the value you already have from
393
- reading the report is the value you paste:
397
+ same strings `agentscan explain` accepts. `check --verbose` prints the id under
398
+ each finding, and `--json` carries it as `.findings[].id`, so the value you copy
399
+ out of the report is the value you paste:
394
400
 
395
401
  ```json
396
402
  { "ignoreFindings": ["hook.missing-script:hook:PreToolUse:./bin/wrapper"] }
@@ -401,21 +407,22 @@ reading the report is the value you paste:
401
407
  Every check lives in `src/checks/` and runs on every `check`. `agentscan rules`
402
408
  lists all of them with their ids; `agentscan explain <id>` details any finding.
403
409
 
404
- Most validate one discovered item against its own file on disk. The five
405
- `budget.*` entries at the bottom judge aggregate size instead —
406
- they are all **info**, they are sourced to `docs/spec/thresholds.md`, and they
407
- are retuned through `thresholds` in `.agentscanrc.json` rather than switched
408
- off.
410
+ Most validate one discovered item against its own file on disk. The four
411
+ `budget.*` entries at the bottom judge aggregate size instead, as does
412
+ `skill.description-budget` — all five are **info**, all five are sourced to
413
+ `docs/spec/thresholds.md`, and all five are retuned through `thresholds` in
414
+ `.agentscanrc.json` rather than switched off.
409
415
 
410
416
  | id | Severity | Catches |
411
417
  |----|----------|---------|
412
418
  | `config.unreadable` | error | A config file that is not valid JSON, so whatever it declares is silently not in effect |
419
+ | `scan.truncated` | info | A file past the scan cap, so the checks that read its body saw only a prefix — about this tool's reach, not about your project |
413
420
  | `hook.missing-script` | error | A registered hook whose script does not exist — it never runs |
414
421
  | `hook.unknown-event` | error | A hook registered under an event name that is never dispatched |
415
422
  | `agent.missing-frontmatter` | error | An agent definition with no `---` block |
416
423
  | `agent.missing-description` | error | Agent frontmatter has no `description` |
417
424
  | `agent.missing-name` | error | Agent frontmatter has no `name` |
418
- | `agent.invalid-name` | error | Agent name is not lowercase letters, numbers, and hyphens |
425
+ | `agent.invalid-name` | warning | Agent name is not lowercase letters, numbers, and hyphens |
419
426
  | `agent.duplicate-name` | error | Multiple agent files declare the same name |
420
427
  | `mcp.no-launch` | error | An MCP server with neither `command` nor `url` — unlaunchable by schema |
421
428
  | `mcp.command-missing` | error | An MCP `command` that is a path-like value whose file does not exist on disk |
@@ -431,6 +438,10 @@ off.
431
438
  | `skill.not-in-lock` | info | A skill on disk that the lockfile does not track — local and unpinned |
432
439
  | `skill.description-budget` | info | Skill names + descriptions exceed the startup character budget |
433
440
  | `skill.no-lockfile` | info | Skills present with no lockfile at all (only with `requireLock`) |
441
+ | `budget.agents-md` | info | `AGENTS.md` past the point where added lines stop helping (>150) |
442
+ | `budget.claude-md` | info | `CLAUDE.md` past the instruction count a model reliably follows (>200) |
443
+ | `budget.agents` | info | More agent definitions than a focused set (>8 in `.claude/agents`) |
444
+ | `budget.mcp` | info | More MCP servers than the point where tool selection degrades (>5) |
434
445
 
435
446
  Agent definitions are checked for structure only. An agent's frontmatter `name`
436
447
  is not compared to the filename; it must simply be a valid lowercase identifier
@@ -478,7 +489,12 @@ When adding a check, add its spec line there first.
478
489
  - **Runtime split.** Source development and the composite Action use Bun; the
479
490
  published `dist/cli.js` runs on Node 20.11+ or Bun. The source entrypoint uses
480
491
  Bun's `import.meta.dir` / `import.meta.main` and is not the Node entrypoint.
481
- - Policy files are read up to 100 KB, so `policyLines` undercounts past that.
492
+ - **Bounded reads.** A `SKILL.md` is read to 64 KB and a policy file to 100 KB,
493
+ so `skill.broken-reference` and `policyLines` see only that much of a larger
494
+ file. This is reported — `scan.truncated`, at `info` — rather than left
495
+ silent, and it says nothing about the file being valid: it is, and the tools
496
+ that read it see all of it. Reporting the cap as `config.unreadable` at
497
+ severity error is a defect this tool shipped and [plan 019](plans/019-findings-say-true-things.md) fixed.
482
498
 
483
499
  ## Development
484
500
 
package/dist/cli.js CHANGED
@@ -7120,9 +7120,9 @@ function checkAgents(facts) {
7120
7120
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(agent.frontmatterName)) {
7121
7121
  out.push(make("agent.invalid-name", `agent:${agent.name}`, {
7122
7122
  action: "warn",
7123
- severity: "error",
7123
+ severity: "warning",
7124
7124
  message: `Agent name "${agent.frontmatterName}" is not a valid identifier`,
7125
- reason: "Agent names use lowercase letters, numbers, and hyphens so dispatch can address them reliably.",
7125
+ reason: "The subagent reference specifies a unique identifier in lowercase letters and hyphens. A name outside that shape is not documented to load reliably, and a `:` in it is documented not to load at all. See docs/spec/agents.md.",
7126
7126
  evidence: [{ kind: "agent", value: agent.path }],
7127
7127
  suggest: "Use lowercase letters, numbers, and hyphens in the `name` field"
7128
7128
  }));
@@ -7156,6 +7156,18 @@ function safeDetail(detail) {
7156
7156
  }
7157
7157
  function checkConfigErrors(facts) {
7158
7158
  return facts.configErrors.map((err) => {
7159
+ if (err.kind === "truncated") {
7160
+ return make("scan.truncated", `scan:${err.path}`, {
7161
+ action: "warn",
7162
+ severity: "info",
7163
+ message: `${basename2(err.path)} is larger than the scan cap — only its first bytes were read`,
7164
+ reason: "The file itself is fine; agentscan reads a bounded prefix of it. Checks that read the body — skill.broken-reference, and the AGENTS.md / CLAUDE.md line counts — see only that prefix, so they can undercount on this file.",
7165
+ evidence: [
7166
+ { kind: "config", value: err.path },
7167
+ { kind: "detail", value: err.detail }
7168
+ ]
7169
+ });
7170
+ }
7159
7171
  const what = err.kind === "invalid-json" ? "is not valid JSON" : err.kind === "unreadable" ? "could not be read" : "has an unexpected shape";
7160
7172
  return make("config.unreadable", `config:${err.path}#${err.kind}:${safeDetail(err.detail)}`, {
7161
7173
  action: "warn",
@@ -7335,6 +7347,7 @@ function checkMcp(facts) {
7335
7347
  }
7336
7348
 
7337
7349
  // src/checks/skills.ts
7350
+ import { dirname } from "node:path";
7338
7351
  function skillEvidence(skill, value) {
7339
7352
  const evidence = [{ kind: "skill", value }];
7340
7353
  if (skill.source === "global") {
@@ -7345,6 +7358,9 @@ function skillEvidence(skill, value) {
7345
7358
  function skillSubject(skill) {
7346
7359
  return `skill:${skill.instanceId ?? skill.id}`;
7347
7360
  }
7361
+ function skillRoot(skill) {
7362
+ return dirname(skill.path.replaceAll("\\", "/"));
7363
+ }
7348
7364
  function checkSkillStructure(facts) {
7349
7365
  const out = [];
7350
7366
  for (const skill of facts.skills) {
@@ -7467,9 +7483,7 @@ function checkDuplicateDescriptions(facts) {
7467
7483
  if (skill.runtime === "agents") {
7468
7484
  continue;
7469
7485
  }
7470
- const path = skill.path.replaceAll("\\", "/");
7471
- const namespace = path.includes(".claude/skills/") ? "claude" : path.includes(".agents/skills/") ? "agents" : skill.source;
7472
- const key = `${namespace}\x00${skill.description.trim().replace(/\s+/g, " ").toLowerCase()}`;
7486
+ const key = `${skillRoot(skill)}\x00${skill.description.trim().replace(/\s+/g, " ").toLowerCase()}`;
7473
7487
  if (key.length === 0) {
7474
7488
  continue;
7475
7489
  }
@@ -7502,23 +7516,36 @@ function checkDescriptionBudget(facts, options) {
7502
7516
  return [];
7503
7517
  }
7504
7518
  const project = facts.skills.filter((s) => s.source === "project" && s.hasSkillMd && s.description !== undefined);
7505
- const bytes = project.reduce((sum, s) => sum + Buffer.byteLength(s.id) + Buffer.byteLength(s.description ?? ""), 0);
7506
- if (bytes <= ceiling) {
7507
- return [];
7519
+ const byRoot = new Map;
7520
+ for (const s of project) {
7521
+ const root = skillRoot(s);
7522
+ const bucket = byRoot.get(root);
7523
+ if (bucket === undefined)
7524
+ byRoot.set(root, [s]);
7525
+ else
7526
+ bucket.push(s);
7508
7527
  }
7509
- return [
7510
- make("skill.description-budget", "skills:description-budget", {
7528
+ const out = [];
7529
+ for (const [root, skills] of byRoot) {
7530
+ const bytes = skills.reduce((sum, s) => sum + Buffer.byteLength(s.id) + Buffer.byteLength(s.description ?? ""), 0);
7531
+ if (bytes <= ceiling) {
7532
+ continue;
7533
+ }
7534
+ const where = root.startsWith(`${facts.root}/`) ? root.slice(facts.root.length + 1) : root;
7535
+ const subject = byRoot.size === 1 ? "skills:description-budget" : `skills:description-budget:${where}`;
7536
+ out.push(make("skill.description-budget", subject, {
7511
7537
  action: "warn",
7512
7538
  severity: "info",
7513
- message: `Skill descriptions total ${bytes} bytes across ${project.length} skills (over ${ceiling})`,
7539
+ message: `Skill descriptions total ${bytes} bytes across ${skills.length} skills in ${where} (over ${ceiling})`,
7514
7540
  reason: "Names and descriptions for every skill are loaded at startup, within a budget of roughly 1-2% of the context window. Past it they are truncated, and a skill whose description is cut short stops being matched for the tasks it was written for.",
7515
7541
  evidence: [
7516
7542
  { kind: "count", value: `descriptionBytes=${bytes}` },
7517
7543
  { kind: "threshold", value: String(ceiling) }
7518
7544
  ],
7519
7545
  suggest: "Shorten the longest descriptions, or remove skills this project does not use"
7520
- })
7521
- ];
7546
+ }));
7547
+ }
7548
+ return out;
7522
7549
  }
7523
7550
  // src/checks/registry.ts
7524
7551
  var STRUCTURAL_CHECKS = [
@@ -7526,6 +7553,10 @@ var STRUCTURAL_CHECKS = [
7526
7553
  id: "config.unreadable",
7527
7554
  description: "Config file is not valid JSON or cannot be read"
7528
7555
  },
7556
+ {
7557
+ id: "scan.truncated",
7558
+ description: "File is larger than the scan cap, so body checks read only a prefix of it"
7559
+ },
7529
7560
  {
7530
7561
  id: "hook.unknown-event",
7531
7562
  description: "Hook registered under an event name that is never dispatched"
@@ -11812,8 +11843,7 @@ function readFrontmatter(skillMdPath, errors3) {
11812
11843
  text = text.replace(/\r\n/g, `
11813
11844
  `);
11814
11845
  if (truncated) {
11815
- errors3.push({ path: skillMdPath, kind: "unexpected-shape", detail: `file exceeds ${SKILL_MD_CAP} byte scan cap` });
11816
- return { hasFrontmatter: true, unparseable: true };
11846
+ errors3.push({ path: skillMdPath, kind: "truncated", detail: `file exceeds ${SKILL_MD_CAP} byte scan cap` });
11817
11847
  }
11818
11848
  } catch (err) {
11819
11849
  errors3.push({
@@ -12255,7 +12285,7 @@ function discoverPolicyFiles(root, policyFiles, errors3) {
12255
12285
  const text = result.buf.subarray(0, POLICY_CAP).toString("utf8");
12256
12286
  out.push({ path: filePath, text });
12257
12287
  if (result.truncated)
12258
- errors3.push({ path: filePath, kind: "unexpected-shape", detail: `file exceeds ${POLICY_CAP} byte scan cap` });
12288
+ errors3.push({ path: filePath, kind: "truncated", detail: `file exceeds ${POLICY_CAP} byte scan cap` });
12259
12289
  } catch (err) {
12260
12290
  errors3.push({
12261
12291
  path: filePath,
@@ -12969,7 +12999,7 @@ function renderText(args) {
12969
12999
  lines.push(ink.dim(`Scanned: ${formatStack(facts)}`));
12970
13000
  lines.push("");
12971
13001
  for (const group of groupByRule(visible)) {
12972
- lines.push(...formatGroup(group, facts.root, ink));
13002
+ lines.push(...formatGroup(group, facts.root, ink, verbose));
12973
13003
  lines.push("");
12974
13004
  }
12975
13005
  lines.push(summary);
@@ -13029,7 +13059,7 @@ var SEVERITY_TONE = {
13029
13059
  warning: "yellow",
13030
13060
  info: undefined
13031
13061
  };
13032
- function formatGroup(group, root, ink) {
13062
+ function formatGroup(group, root, ink, verbose) {
13033
13063
  const first = group[0];
13034
13064
  const count = group.length;
13035
13065
  const out = [];
@@ -13041,6 +13071,9 @@ function formatGroup(group, root, ink) {
13041
13071
  const where = f.evidence.filter((e) => e.kind !== "count" && e.kind !== "threshold").map((e) => shortPath(e.value, root));
13042
13072
  const location = where.length > 0 ? where.join(" · ") : f.subject;
13043
13073
  out.push(ink.dim(` ${safe(shortPath(location, root))}`));
13074
+ if (verbose) {
13075
+ out.push(ink.dim(` id: ${safe(f.id)}`));
13076
+ }
13044
13077
  }
13045
13078
  return out;
13046
13079
  }
@@ -13102,7 +13135,7 @@ function formatSummary(findings, verbose, ink) {
13102
13135
  }
13103
13136
 
13104
13137
  // src/version.ts
13105
- var VERSION = "0.5.0";
13138
+ var VERSION = "0.6.0";
13106
13139
 
13107
13140
  // src/commands/check.ts
13108
13141
  async function runCheck(options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chimix/agentscan",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Find broken agent configuration in a project — hooks whose scripts are gone, MCP servers misconfigured by schema, skills that disagree with their lockfile",
5
5
  "type": "module",
6
6
  "bin": {