roster-cli 0.1.3 → 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.
package/README.md CHANGED
@@ -25,21 +25,73 @@ npx roster-cli audit <dir>
25
25
 
26
26
  ## Usage
27
27
 
28
+ roster is one binary with three commands: `audit`, `doccheck`, and `usage`.
29
+
28
30
  ```sh
29
31
  roster audit <dir>
30
32
  roster audit <dir> --user
31
33
  roster audit <dir> --repo owner/name
32
34
  roster audit <dir> --html report.html
35
+ roster audit --plugin --enabled-only
36
+ roster doccheck README.md
37
+ roster usage --days 14 --user
33
38
  ```
34
39
 
35
- Full flag surface:
40
+ Full flag surface for `audit`:
36
41
 
37
42
  ```
38
43
  roster audit <dir> [--json] [--html <out>] [--user] [--plugin [name]]
39
- [--repo <owner/name[@ref]>] [--top <n>]
44
+ [--enabled-only] [--repo <owner/name[@ref]>] [--top <n>]
40
45
  [--fail-above <s>] [--no-fail]
41
46
  ```
42
47
 
48
+ `--enabled-only` (with `--plugin`) restricts the plugin-cache source to entries
49
+ active for the current project.[^enabled-only]
50
+
51
+ [^enabled-only]: Activation is judged purely by installation **scope**
52
+ (user-scope entries are always active; local/project-scope entries are
53
+ active only when the cwd is inside the pinning project). It does not read
54
+ per-project enable/disable state from `settings.json`.
55
+
56
+ ## doccheck
57
+
58
+ ```sh
59
+ roster doccheck README.md
60
+ roster doccheck docs/
61
+ roster doccheck # defaults to README.md + docs/**/*.md
62
+ ```
63
+
64
+ Scans fenced `sh`/`bash`/`shell` code blocks in markdown docs for commands
65
+ that would fail if a reader copy-pasted them: dead relative paths, missing
66
+ `npm run <script>` scripts, and scripts that exist on disk but lack the
67
+ executable bit.
68
+
69
+ To keep false positives at zero, it skips anything it can't verify cheaply:
70
+ absolute paths (`/plugin ...`), `npx ...` invocations, and bare global
71
+ binaries (`node`, `git`, ...) with no path separator.
72
+
73
+ Exits `1` if any finding is reported, `0` otherwise (`--json` for machine-
74
+ readable output).
75
+
76
+ ## usage
77
+
78
+ ```sh
79
+ roster usage
80
+ roster usage --days 14
81
+ roster usage --user
82
+ roster usage --plugin --json
83
+ ```
84
+
85
+ Aggregates how often each `subagent_type` was invoked (via the Agent/Task
86
+ tool) across Claude Code transcript files under `~/.claude` (override with
87
+ `ROSTER_CLAUDE_DIR`), within the last `--days` (default `30`).
88
+
89
+ Joining with `--user` and/or `--plugin` also reports:
90
+ - **unused** — agents present in the roster with zero observed invocations
91
+ - **ghosts** — invoked `subagent_type` values that don't match any roster agent
92
+
93
+ Always exits `0` — this is a reporting tool, not a gate.
94
+
43
95
  ## Rules
44
96
 
45
97
  | Rule | Description | Status |
@@ -55,11 +107,21 @@ roster audit <dir> [--json] [--html <out>] [--user] [--plugin [name]]
55
107
  `roster audit --repo` run against well-known public agent rosters (SHA-pinned,
56
108
  reproducible via `scripts/bench.sh`). Full reports: `docs/benchmarks/`.
57
109
 
58
- | roster | agents | top overlap pair (score) | no-tools % | fixed cost (est. tokens/turn) |
110
+ A weekly cron re-runs the benchmark suite against each roster's latest
111
+ upstream HEAD and opens a refresh PR with any changes.[^leaderboard-cron]
112
+
113
+ <!-- bench:start -->
114
+ | Repo | Agents | Top overlap pair | No-tools % | Fixed cost |
59
115
  | --- | --- | --- | --- | --- |
60
- | [msitarzewski/agency-agents](./docs/benchmarks/msitarzewski--agency-agents.md) | 277 | Backend Architect Backend Architect (0.878) | 93.9% | ~14024 |
61
- | [wshobson/agents](./docs/benchmarks/wshobson--agents.md) | 691 | api-scaffolding-backend-architect backend-api-security-backend-architect (1.000) | 97.8% | ~24853 |
62
- | [contains-studio/agents](./docs/benchmarks/contains-studio--agents.md) | 37 | content-creator instagram-curator (0.565) | 16.2% | ~8421 |
116
+ | [contains-studio/agents](https://github.com/contains-studio/agents) | 37 | content-creator <-> instagram-curator (0.565) | 16.2% | ~8421 tokens/turn |
117
+ | [msitarzewski/agency-agents](https://github.com/msitarzewski/agency-agents) | 277 | Backend Architect <-> Backend Architect (0.878) | 93.9% | ~14024 tokens/turn |
118
+ | [wshobson/agents](https://github.com/wshobson/agents) | 691 | api-scaffolding-backend-architect <-> backend-api-security-backend-architect (1.000) | 97.8% | ~24853 tokens/turn |
119
+ <!-- bench:end -->
120
+
121
+ [^leaderboard-cron]: Runs every Monday via `.github/workflows/leaderboard.yml`.
122
+ GitHub automatically disables scheduled workflows after 60 days of repo
123
+ inactivity — re-enable with a manual `workflow_dispatch` run if that
124
+ happens.
63
125
 
64
126
  Several top pairs score at or near 1.000 similarity (e.g. wshobson/agents has
65
127
  five pairs at a perfect 1.000) — these are near-duplicate agent files (same
@@ -82,12 +144,21 @@ Once installed:
82
144
  - **`roster-audit` skill** — triggers when you ask to audit an agent roster
83
145
  (overlap, missing harness/tools, routing ambiguity, cost); runs the bundled
84
146
  CLI and explains how to read the findings.
85
- - **`roster-drift.sh` hook** (`SessionStart`) on each session, diffs
86
- `.claude/agents/` against a cached snapshot and emits a short advisory if
87
- agents were added/removed/changed (`ROSTER_DRIFT_DISABLE=1` to opt out).
88
- The advisory goes to both Claude's session context (so it can offer an
89
- audit proactively) and stderr (so you see it in the terminal). Advisory
90
- onlynever blocks a session.
147
+ - **`roster-cleanup` skill** — triggers when you ask to clean up, prune, or
148
+ merge agents. Audits, classifies findings into concrete actions (delete /
149
+ move / merge / rename / uninstall / narrow tools), asks you to approve each
150
+ destructive step, executes only what you approved, then re-audits and
151
+ reports the delta.
152
+ - **`roster-drift.sh` hook** (`SessionStart`) on each session, diffs the
153
+ watched agent-md dir(s) against a cached snapshot and emits a short
154
+ advisory if agents were added/removed/changed
155
+ (`ROSTER_DRIFT_DISABLE=1` to opt out). By default it watches
156
+ `.claude/agents` plus, in a plugin-layout repo (one with a top-level
157
+ `.claude-plugin/plugin.json`), the root `agents/` dir; override with
158
+ `ROSTER_DRIFT_DIR` (colon-separated dir list). The advisory goes to both
159
+ Claude's session context (so it can offer an audit proactively) and
160
+ stderr (so you see it in the terminal). Advisory only — never blocks a
161
+ session.
91
162
 
92
163
  ## Contributing
93
164
 
package/dist/cli.js CHANGED
@@ -3,13 +3,21 @@ import { writeFileSync } from 'node:fs';
3
3
  import { sources } from './sources/index.js';
4
4
  import { rules } from './rules/index.js';
5
5
  import { renderers } from './render/index.js';
6
- const HELP_TEXT = `Usage: roster audit [<dir>] [options]
6
+ import { run as runDoccheck } from './doccheck.js';
7
+ import { run as runUsage } from './usage.js';
8
+ const HELP_TEXT = `Usage: roster <command> [<dir>] [options]
9
+
10
+ Commands:
11
+ audit Audit a roster for overlaps and issues
12
+ doccheck Check docs for drift (not yet implemented)
13
+ usage Report agent usage stats (not yet implemented)
7
14
 
8
15
  Options:
9
16
  --json Output machine-readable JSON
10
17
  --html <out> Write an HTML report to <out>
11
18
  --user Load agents from the user-level agent directory
12
19
  --plugin [name] Load agents from the plugin cache
20
+ --enabled-only With --plugin, only include entries active for the current project
13
21
  --repo <owner/name[@ref]> Load agents from a GitHub repo
14
22
  --top <n> Number of top overlapping pairs to report (default 10)
15
23
  --fail-above <score> Mark overlap findings above <score> as critical
@@ -22,6 +30,7 @@ function parseArgs(argv) {
22
30
  user: false,
23
31
  plugin: false,
24
32
  noFail: false,
33
+ enabledOnly: false,
25
34
  help: false,
26
35
  };
27
36
  let i = 0;
@@ -71,6 +80,10 @@ function parseArgs(argv) {
71
80
  result.noFail = true;
72
81
  i += 1;
73
82
  break;
83
+ case '--enabled-only':
84
+ result.enabledOnly = true;
85
+ i += 1;
86
+ break;
74
87
  default:
75
88
  if (result.dir === undefined && !arg.startsWith('--')) {
76
89
  result.dir = arg;
@@ -82,11 +95,15 @@ function parseArgs(argv) {
82
95
  return result;
83
96
  }
84
97
  export async function main(argv) {
85
- if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
98
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
86
99
  console.log(HELP_TEXT);
87
100
  return 0;
88
101
  }
89
102
  const [cmd, ...rest] = argv;
103
+ if (cmd === 'doccheck')
104
+ return runDoccheck(rest);
105
+ if (cmd === 'usage')
106
+ return runUsage(rest);
90
107
  if (cmd !== 'audit') {
91
108
  console.error(`unknown command: ${cmd}`);
92
109
  console.error(HELP_TEXT);
@@ -111,6 +128,9 @@ export async function main(argv) {
111
128
  console.error('audit requires a <dir> argument or one of --user / --plugin / --repo');
112
129
  return 1;
113
130
  }
131
+ if (parsed.enabledOnly && !parsed.plugin) {
132
+ console.error('--enabled-only has no effect without --plugin');
133
+ }
114
134
  let rendererId = 'cli';
115
135
  if (parsed.json)
116
136
  rendererId = 'json';
@@ -141,7 +161,12 @@ export async function main(argv) {
141
161
  console.error(`not implemented: source '${sourceId}'`);
142
162
  return 2;
143
163
  }
144
- agents.push(...(await source.load({ dir: parsed.dir, pluginName: parsed.pluginName, repo: parsed.repo })));
164
+ agents.push(...(await source.load({
165
+ dir: parsed.dir,
166
+ pluginName: parsed.pluginName,
167
+ repo: parsed.repo,
168
+ enabledOnly: parsed.enabledOnly,
169
+ })));
145
170
  }
146
171
  const findings = [];
147
172
  for (const rule of Object.values(rules)) {
@@ -0,0 +1,215 @@
1
+ import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const SHELL_LANGS = new Set(['sh', 'bash', 'shell']);
4
+ const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)\s*$/;
5
+ const HELP_TEXT = `Usage: roster doccheck [<file|dir> ...] [options]
6
+
7
+ Scans fenced sh/bash/shell code blocks in markdown docs for commands that
8
+ would fail if run: dead relative paths, missing npm run scripts, and
9
+ scripts that exist but lack the executable bit.
10
+
11
+ Arguments:
12
+ <file|dir> ... Markdown files or directories to scan (directories are
13
+ scanned recursively for *.md). Defaults to README.md and
14
+ docs/**/*.md, whichever exist, when omitted.
15
+
16
+ Options:
17
+ --json Output findings as JSON: { findings: [...] }
18
+ --help, -h Show this help text
19
+ `;
20
+ export async function run(argv) {
21
+ if (argv.includes('--help') || argv.includes('-h')) {
22
+ console.log(HELP_TEXT);
23
+ return 0;
24
+ }
25
+ const jsonMode = argv.includes('--json');
26
+ const positional = argv.filter((a) => a !== '--json');
27
+ const targets = resolveTargets(positional);
28
+ const scripts = loadPackageScripts();
29
+ const findings = [];
30
+ for (const target of targets) {
31
+ findings.push(...checkFile(target, scripts));
32
+ }
33
+ if (jsonMode) {
34
+ console.log(JSON.stringify({ findings }));
35
+ }
36
+ else if (findings.length === 0) {
37
+ console.log('doccheck: no findings');
38
+ }
39
+ else {
40
+ for (const f of findings) {
41
+ console.log(`${f.file}:${f.line} ${f.command} ${f.reason}`);
42
+ }
43
+ console.log(`doccheck: ${findings.length} finding(s)`);
44
+ }
45
+ return findings.length > 0 ? 1 : 0;
46
+ }
47
+ function resolveTargets(positional) {
48
+ if (positional.length === 0) {
49
+ const targets = [];
50
+ if (isFile('README.md'))
51
+ targets.push('README.md');
52
+ if (isDir('docs'))
53
+ targets.push(...collectMarkdownFiles('docs'));
54
+ return targets;
55
+ }
56
+ const targets = [];
57
+ for (const target of positional) {
58
+ if (isDir(target)) {
59
+ targets.push(...collectMarkdownFiles(target));
60
+ }
61
+ else if (isFile(target)) {
62
+ targets.push(target);
63
+ }
64
+ }
65
+ return targets;
66
+ }
67
+ function collectMarkdownFiles(dir) {
68
+ const result = [];
69
+ let entries;
70
+ try {
71
+ entries = readdirSync(dir, { withFileTypes: true });
72
+ }
73
+ catch {
74
+ return result;
75
+ }
76
+ for (const entry of entries) {
77
+ const full = path.join(dir, entry.name);
78
+ if (entry.isDirectory()) {
79
+ result.push(...collectMarkdownFiles(full));
80
+ }
81
+ else if (entry.isFile() && path.extname(entry.name).toLowerCase() === '.md') {
82
+ result.push(full);
83
+ }
84
+ }
85
+ return result;
86
+ }
87
+ function loadPackageScripts() {
88
+ try {
89
+ const raw = readFileSync(path.join(process.cwd(), 'package.json'), 'utf8');
90
+ const pkg = JSON.parse(raw);
91
+ return new Set(Object.keys(pkg.scripts ?? {}));
92
+ }
93
+ catch {
94
+ return new Set();
95
+ }
96
+ }
97
+ function checkFile(filePath, scripts) {
98
+ let content;
99
+ try {
100
+ content = readFileSync(filePath, 'utf8');
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ const findings = [];
106
+ for (const block of extractShellBlocks(content)) {
107
+ for (const { text, lineNumber } of block) {
108
+ const reason = checkCommandLine(text, scripts);
109
+ if (reason) {
110
+ findings.push({ file: filePath, line: lineNumber, command: text.trim(), reason });
111
+ }
112
+ }
113
+ }
114
+ return findings;
115
+ }
116
+ function extractShellBlocks(content) {
117
+ const lines = content.split(/\r?\n/);
118
+ const blocks = [];
119
+ let i = 0;
120
+ while (i < lines.length) {
121
+ const openMatch = FENCE_OPEN_RE.exec(lines[i]);
122
+ if (!openMatch) {
123
+ i += 1;
124
+ continue;
125
+ }
126
+ const fenceChar = openMatch[1][0];
127
+ const fenceLen = openMatch[1].length;
128
+ const lang = openMatch[2].toLowerCase();
129
+ const closeRe = new RegExp(`^\\s*[${fenceChar}]{${fenceLen},}\\s*$`);
130
+ let j = i + 1;
131
+ while (j < lines.length && !closeRe.test(lines[j])) {
132
+ j += 1;
133
+ }
134
+ if (SHELL_LANGS.has(lang)) {
135
+ const blockLines = [];
136
+ for (let k = i + 1; k < j; k += 1) {
137
+ blockLines.push({ text: lines[k], lineNumber: k + 1 });
138
+ }
139
+ blocks.push(blockLines);
140
+ }
141
+ i = j + 1;
142
+ }
143
+ return blocks;
144
+ }
145
+ function checkCommandLine(rawLine, scripts) {
146
+ const trimmed = rawLine.trim();
147
+ if (trimmed === '' || trimmed.startsWith('#'))
148
+ return null;
149
+ let tokens = trimmed.split(/\s+/).filter(Boolean);
150
+ while (tokens.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=\S*$/.test(tokens[0])) {
151
+ tokens = tokens.slice(1);
152
+ }
153
+ if (tokens.length === 0)
154
+ return null;
155
+ const cmd = tokens[0];
156
+ if (cmd === 'npm') {
157
+ if (tokens[1] === 'run' && tokens[2]) {
158
+ const scriptName = tokens[2];
159
+ if (!scripts.has(scriptName)) {
160
+ return `npm script not found: ${scriptName}`;
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+ if (cmd === 'npx')
166
+ return null;
167
+ if (cmd.startsWith('/'))
168
+ return null; // absolute path or a slash-command like /plugin
169
+ if (cmd.startsWith('./') || cmd.includes('/')) {
170
+ const resolved = path.join(process.cwd(), cmd);
171
+ if (!pathExists(resolved)) {
172
+ return `relative path not found: ${cmd}`;
173
+ }
174
+ if (!isExecutable(resolved)) {
175
+ return `not executable: ${cmd}`;
176
+ }
177
+ return null;
178
+ }
179
+ // No path separator and not npm/npx/absolute — treat as a global binary, skip.
180
+ return null;
181
+ }
182
+ function isFile(p) {
183
+ try {
184
+ return statSync(p).isFile();
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ function isDir(p) {
191
+ try {
192
+ return statSync(p).isDirectory();
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
198
+ function pathExists(p) {
199
+ try {
200
+ statSync(p);
201
+ return true;
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ function isExecutable(p) {
208
+ try {
209
+ accessSync(p, constants.X_OK);
210
+ return true;
211
+ }
212
+ catch {
213
+ return false;
214
+ }
215
+ }
@@ -38,7 +38,18 @@ function pickPreferred(a, b, cwd) {
38
38
  return aUser ? a : b;
39
39
  return compareVersions(a.version, b.version) >= 0 ? a : b;
40
40
  }
41
- function resolveActivePlugins(data, cwd) {
41
+ // --enabled-only: user-scope (or scope absent) entries are always active; a
42
+ // local/project-scope entry is only active for the project that pinned it —
43
+ // i.e. cwd is that project's directory or a subdirectory of it.
44
+ function passesEnabledOnly(entry, cwd) {
45
+ if (entry.scope !== 'local' && entry.scope !== 'project')
46
+ return true;
47
+ if (!entry.projectPath)
48
+ return false;
49
+ const projectPath = path.resolve(entry.projectPath);
50
+ return cwd === projectPath || cwd.startsWith(projectPath + path.sep);
51
+ }
52
+ function resolveActivePlugins(data, cwd, enabledOnly) {
42
53
  const byName = new Map();
43
54
  const skipped = [];
44
55
  for (const [key, entries] of Object.entries(data.plugins ?? {})) {
@@ -48,6 +59,8 @@ function resolveActivePlugins(data, cwd) {
48
59
  for (const entry of entries) {
49
60
  if (!entry.installPath)
50
61
  continue;
62
+ if (enabledOnly && !passesEnabledOnly(entry, cwd))
63
+ continue;
51
64
  const candidate = {
52
65
  name,
53
66
  marketplace,
@@ -122,7 +135,8 @@ export const pluginCacheSource = {
122
135
  }
123
136
  const cwd = path.resolve(opts?.cwd ?? process.cwd());
124
137
  const pluginNameFilter = opts?.pluginName;
125
- const activePlugins = resolveActivePlugins(data, cwd).filter((p) => !pluginNameFilter || p.name === pluginNameFilter);
138
+ const enabledOnly = opts?.enabledOnly ?? false;
139
+ const activePlugins = resolveActivePlugins(data, cwd, enabledOnly).filter((p) => !pluginNameFilter || p.name === pluginNameFilter);
126
140
  const agentLists = await Promise.all(activePlugins.map(async (plugin) => {
127
141
  const agentsDir = path.join(plugin.installPath, 'agents');
128
142
  if (!(await dirExists(agentsDir)))
package/dist/usage.js ADDED
@@ -0,0 +1,224 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readdir, stat } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { sources } from './sources/index.js';
7
+ const HELP_TEXT = `Usage: roster usage [options]
8
+
9
+ Reports how often each subagent_type has been invoked (via the Agent/Task
10
+ tool) across Claude Code transcript files.
11
+
12
+ Options:
13
+ --days <n> Only count invocations from the last <n> days (default 30)
14
+ --json Output machine-readable JSON
15
+ --user Join against the user-level agent roster (~/.claude/agents)
16
+ --plugin Join against the installed plugin-cache roster
17
+ --help, -h Show this help text
18
+
19
+ Env:
20
+ ROSTER_CLAUDE_DIR Transcripts root (default ~/.claude)
21
+ `;
22
+ function parseArgs(argv) {
23
+ const result = {
24
+ days: 30,
25
+ json: false,
26
+ user: false,
27
+ plugin: false,
28
+ help: false,
29
+ };
30
+ let i = 0;
31
+ while (i < argv.length) {
32
+ const arg = argv[i];
33
+ switch (arg) {
34
+ case '--help':
35
+ case '-h':
36
+ result.help = true;
37
+ i += 1;
38
+ break;
39
+ case '--json':
40
+ result.json = true;
41
+ i += 1;
42
+ break;
43
+ case '--user':
44
+ result.user = true;
45
+ i += 1;
46
+ break;
47
+ case '--plugin':
48
+ result.plugin = true;
49
+ i += 1;
50
+ break;
51
+ case '--days': {
52
+ const n = Number(argv[i + 1]);
53
+ if (Number.isFinite(n) && n > 0)
54
+ result.days = n;
55
+ i += 2;
56
+ break;
57
+ }
58
+ default:
59
+ i += 1;
60
+ break;
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+ /**
66
+ * Pure join between observed invocation counts and a roster's agent names.
67
+ * - unused: in the roster, but zero observed invocations.
68
+ * - ghosts: observed invocations for a subagent_type that isn't in the roster.
69
+ */
70
+ export function computeJoin(counts, rosterNames) {
71
+ const rosterSet = new Set(rosterNames);
72
+ const unused = rosterNames.filter((name) => !counts[name]);
73
+ const ghosts = Object.keys(counts).filter((name) => !rosterSet.has(name));
74
+ return { unused, ghosts };
75
+ }
76
+ async function findJsonlFiles(root) {
77
+ const projectsDir = path.join(root, 'projects');
78
+ let projectEntries;
79
+ try {
80
+ projectEntries = await readdir(projectsDir, { withFileTypes: true });
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ const files = [];
86
+ for (const projectEntry of projectEntries) {
87
+ if (!projectEntry.isDirectory())
88
+ continue;
89
+ const projectDir = path.join(projectsDir, projectEntry.name);
90
+ let fileEntries;
91
+ try {
92
+ fileEntries = await readdir(projectDir, { withFileTypes: true });
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ for (const fileEntry of fileEntries) {
98
+ if (fileEntry.isFile() && fileEntry.name.endsWith('.jsonl')) {
99
+ files.push(path.join(projectDir, fileEntry.name));
100
+ }
101
+ }
102
+ }
103
+ return files;
104
+ }
105
+ async function countAgentInvocations(filePath, cutoff, counts) {
106
+ const rl = createInterface({
107
+ input: createReadStream(filePath, { encoding: 'utf8' }),
108
+ crlfDelay: Infinity,
109
+ });
110
+ for await (const line of rl) {
111
+ if (!line.trim())
112
+ continue;
113
+ let record;
114
+ try {
115
+ record = JSON.parse(line);
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ if (typeof record !== 'object' || record === null)
121
+ continue;
122
+ const obj = record;
123
+ const timestamp = obj.timestamp;
124
+ if (typeof timestamp !== 'string')
125
+ continue;
126
+ const ts = new Date(timestamp);
127
+ if (Number.isNaN(ts.getTime()) || ts < cutoff)
128
+ continue;
129
+ if (obj.type !== 'assistant')
130
+ continue;
131
+ const message = obj.message;
132
+ const content = message?.content;
133
+ if (!Array.isArray(content))
134
+ continue;
135
+ for (const block of content) {
136
+ if (block &&
137
+ typeof block === 'object' &&
138
+ block.type === 'tool_use' &&
139
+ (block.name === 'Agent' || block.name === 'Task')) {
140
+ const input = block.input;
141
+ const subagentType = input?.subagent_type;
142
+ if (typeof subagentType === 'string') {
143
+ counts[subagentType] = (counts[subagentType] ?? 0) + 1;
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ async function loadRosterNames(user, plugin) {
150
+ const names = new Set();
151
+ if (user) {
152
+ const agents = await sources.user.load();
153
+ for (const agent of agents)
154
+ names.add(agent.name);
155
+ }
156
+ if (plugin) {
157
+ const agents = await sources['plugin-cache'].load();
158
+ for (const agent of agents)
159
+ names.add(agent.name);
160
+ }
161
+ return [...names];
162
+ }
163
+ function renderHuman(days, counts, unused, ghosts) {
164
+ const lines = [];
165
+ const rows = Object.entries(counts).sort((a, b) => b[1] - a[1]);
166
+ if (rows.length === 0) {
167
+ lines.push(`No agent invocations found in the last ${days} day(s).`);
168
+ }
169
+ else {
170
+ lines.push(`Agent usage (last ${days} day(s)):`);
171
+ for (const [name, count] of rows) {
172
+ lines.push(` ${count}\t${name}`);
173
+ }
174
+ }
175
+ if (unused.length > 0) {
176
+ lines.push('Unused (in roster, 0 invocations):');
177
+ for (const name of unused)
178
+ lines.push(` ${name}`);
179
+ }
180
+ if (ghosts.length > 0) {
181
+ lines.push('Ghost subagent_type (invoked, not in roster):');
182
+ for (const name of ghosts)
183
+ lines.push(` ${name}`);
184
+ }
185
+ return lines.join('\n');
186
+ }
187
+ export async function run(argv) {
188
+ const parsed = parseArgs(argv);
189
+ if (parsed.help) {
190
+ console.log(HELP_TEXT);
191
+ return 0;
192
+ }
193
+ const root = process.env.ROSTER_CLAUDE_DIR ?? path.join(os.homedir(), '.claude');
194
+ const cutoff = new Date(Date.now() - parsed.days * 24 * 60 * 60 * 1000);
195
+ const files = await findJsonlFiles(root);
196
+ const counts = {};
197
+ for (const filePath of files) {
198
+ let stats;
199
+ try {
200
+ stats = await stat(filePath);
201
+ }
202
+ catch {
203
+ continue;
204
+ }
205
+ if (stats.mtime < cutoff)
206
+ continue;
207
+ await countAgentInvocations(filePath, cutoff, counts);
208
+ }
209
+ let unused = [];
210
+ let ghosts = [];
211
+ if (parsed.user || parsed.plugin) {
212
+ const rosterNames = await loadRosterNames(parsed.user, parsed.plugin);
213
+ const joined = computeJoin(counts, rosterNames);
214
+ unused = joined.unused;
215
+ ghosts = joined.ghosts;
216
+ }
217
+ if (parsed.json) {
218
+ console.log(JSON.stringify({ days: parsed.days, counts, unused, ghosts }));
219
+ }
220
+ else {
221
+ console.log(renderHuman(parsed.days, counts, unused, ghosts));
222
+ }
223
+ return 0;
224
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-cli",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "Zero-runtime-dep static auditor for Claude Code agent rosters \u2014 overlap, harness gaps, routing, cost.",
5
5
  "type": "module",
6
6
  "bin": {