roster-cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sshworld
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # roster
2
+
3
+ Does your agent earn its context?
4
+
5
+ ## Philosophy
6
+
7
+ Most agent tooling optimizes personas — tone, role-play, instructions. roster
8
+ starts from a different premise: **structure earns value, not persona**. An
9
+ agent's context is a dependency, and dependencies churn — they overlap,
10
+ route badly, cost tokens, and rot. roster is a static analyzer for your agent
11
+ roster (skills, subagents, tool configs) that surfaces those problems before
12
+ they burn context in production.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ npm i -g roster-cli
18
+ ```
19
+
20
+ or run it without installing:
21
+
22
+ ```sh
23
+ npx roster-cli audit <dir>
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```sh
29
+ roster audit <dir>
30
+ roster audit <dir> --user
31
+ roster audit <dir> --repo owner/name
32
+ roster audit <dir> --html report.html
33
+ ```
34
+
35
+ Full flag surface:
36
+
37
+ ```
38
+ roster audit <dir> [--json] [--html <out>] [--user] [--plugin [name]]
39
+ [--repo <owner/name[@ref]>] [--top <n>]
40
+ [--fail-above <s>] [--no-fail]
41
+ ```
42
+
43
+ ## Rules
44
+
45
+ | Rule | Description | Status |
46
+ | --- | --- | --- |
47
+ | `overlap` | Detects agents/skills covering the same responsibility | stable |
48
+ | `harness` | Flags harness-incompatible or malformed configs | stable |
49
+ | `routing` | Checks routing/trigger ambiguity between agents | stable |
50
+ | `cost` | Estimates context/token cost of a roster | stable |
51
+ | `fluff` | Flags low-signal, filler instructions | experimental |
52
+
53
+ ## Benchmarks
54
+
55
+ `roster audit --repo` run against well-known public agent rosters (SHA-pinned,
56
+ reproducible via `scripts/bench.sh`). Full reports: `docs/benchmarks/`.
57
+
58
+ | roster | agents | top overlap pair (score) | no-tools % | fixed cost (est. tokens/turn) |
59
+ | --- | --- | --- | --- | --- |
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 |
63
+
64
+ Several top pairs score at or near 1.000 similarity (e.g. wshobson/agents has
65
+ five pairs at a perfect 1.000) — these are near-duplicate agent files (same
66
+ description/body reused across roles), not incidental topic overlap.
67
+
68
+ ## Use as a Claude Code plugin
69
+
70
+ roster also ships as a Claude Code plugin — a resident guard instead of a
71
+ one-off report.
72
+
73
+ Install via the bundled marketplace manifest:
74
+
75
+ ```sh
76
+ /plugin marketplace add sshworld/roster
77
+ /plugin install roster
78
+ ```
79
+
80
+ Once installed:
81
+
82
+ - **`roster-audit` skill** — triggers when you ask to audit an agent roster
83
+ (overlap, missing harness/tools, routing ambiguity, cost); runs the bundled
84
+ 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 prints a short advisory if
87
+ agents were added/removed/changed (`ROSTER_DRIFT_DISABLE=1` to opt out).
88
+ Advisory only — never blocks a session.
89
+
90
+ ## Contributing
91
+
92
+ See [CONTRIBUTING.md](./CONTRIBUTING.md).
93
+
94
+ ## License
95
+
96
+ [MIT](./LICENSE)
package/dist/cli.js ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync } from 'node:fs';
3
+ import { sources } from './sources/index.js';
4
+ import { rules } from './rules/index.js';
5
+ import { renderers } from './render/index.js';
6
+ const HELP_TEXT = `Usage: roster audit [<dir>] [options]
7
+
8
+ Options:
9
+ --json Output machine-readable JSON
10
+ --html <out> Write an HTML report to <out>
11
+ --user Load agents from the user-level agent directory
12
+ --plugin [name] Load agents from the plugin cache
13
+ --repo <owner/name[@ref]> Load agents from a GitHub repo
14
+ --top <n> Number of top overlapping pairs to report (default 10)
15
+ --fail-above <score> Mark overlap findings above <score> as critical
16
+ --no-fail Always exit 0, even with critical findings
17
+ --help, -h Show this help text
18
+ `;
19
+ function parseArgs(argv) {
20
+ const result = {
21
+ json: false,
22
+ user: false,
23
+ plugin: false,
24
+ noFail: false,
25
+ help: false,
26
+ };
27
+ let i = 0;
28
+ while (i < argv.length) {
29
+ const arg = argv[i];
30
+ switch (arg) {
31
+ case '--help':
32
+ case '-h':
33
+ result.help = true;
34
+ i += 1;
35
+ break;
36
+ case '--json':
37
+ result.json = true;
38
+ i += 1;
39
+ break;
40
+ case '--html':
41
+ result.html = argv[i + 1];
42
+ i += 2;
43
+ break;
44
+ case '--user':
45
+ result.user = true;
46
+ i += 1;
47
+ break;
48
+ case '--plugin':
49
+ result.plugin = true;
50
+ if (argv[i + 1] && !argv[i + 1].startsWith('--')) {
51
+ result.pluginName = argv[i + 1];
52
+ i += 2;
53
+ }
54
+ else {
55
+ i += 1;
56
+ }
57
+ break;
58
+ case '--repo':
59
+ result.repo = argv[i + 1];
60
+ i += 2;
61
+ break;
62
+ case '--top':
63
+ result.top = Number(argv[i + 1]);
64
+ i += 2;
65
+ break;
66
+ case '--fail-above':
67
+ result.failAbove = Number(argv[i + 1]);
68
+ i += 2;
69
+ break;
70
+ case '--no-fail':
71
+ result.noFail = true;
72
+ i += 1;
73
+ break;
74
+ default:
75
+ if (result.dir === undefined && !arg.startsWith('--')) {
76
+ result.dir = arg;
77
+ }
78
+ i += 1;
79
+ break;
80
+ }
81
+ }
82
+ return result;
83
+ }
84
+ export async function main(argv) {
85
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
86
+ console.log(HELP_TEXT);
87
+ return 0;
88
+ }
89
+ const [cmd, ...rest] = argv;
90
+ if (cmd !== 'audit') {
91
+ console.error(`unknown command: ${cmd}`);
92
+ console.error(HELP_TEXT);
93
+ return 1;
94
+ }
95
+ const parsed = parseArgs(rest);
96
+ if (parsed.help) {
97
+ console.log(HELP_TEXT);
98
+ return 0;
99
+ }
100
+ // 요청된 소스 전부 수집 — 복수 flag 는 병합 스캔 (출처는 sourceLabel 로 구분)
101
+ const wanted = [];
102
+ if (parsed.dir)
103
+ wanted.push('dir');
104
+ if (parsed.user)
105
+ wanted.push('user');
106
+ if (parsed.plugin)
107
+ wanted.push('plugin-cache');
108
+ if (parsed.repo)
109
+ wanted.push('github');
110
+ if (wanted.length === 0) {
111
+ console.error('audit requires a <dir> argument or one of --user / --plugin / --repo');
112
+ return 1;
113
+ }
114
+ let rendererId = 'cli';
115
+ if (parsed.json)
116
+ rendererId = 'json';
117
+ else if (parsed.html !== undefined) {
118
+ if (!parsed.html) {
119
+ console.error('--html requires an output file path');
120
+ return 1;
121
+ }
122
+ rendererId = 'html';
123
+ }
124
+ const renderer = renderers[rendererId];
125
+ if (!renderer) {
126
+ console.error(`unknown renderer: ${rendererId}`);
127
+ return 1;
128
+ }
129
+ if (renderer.stub) {
130
+ console.error(`not implemented: renderer '${rendererId}'`);
131
+ return 2;
132
+ }
133
+ const agents = [];
134
+ for (const sourceId of wanted) {
135
+ const source = sources[sourceId];
136
+ if (!source) {
137
+ console.error(`unknown source: ${sourceId}`);
138
+ return 1;
139
+ }
140
+ if (source.stub) {
141
+ console.error(`not implemented: source '${sourceId}'`);
142
+ return 2;
143
+ }
144
+ agents.push(...(await source.load({ dir: parsed.dir, pluginName: parsed.pluginName, repo: parsed.repo })));
145
+ }
146
+ const findings = [];
147
+ for (const rule of Object.values(rules)) {
148
+ if (rule.stub)
149
+ continue;
150
+ findings.push(...rule.run(agents, { top: parsed.top, failAbove: parsed.failAbove }));
151
+ }
152
+ const report = {
153
+ agents,
154
+ findings,
155
+ meta: { sourceLabels: [...new Set(agents.map((a) => a.sourceLabel))] },
156
+ };
157
+ const output = renderer.render(report, {});
158
+ if (rendererId === 'html') {
159
+ writeFileSync(parsed.html, output);
160
+ console.log(`HTML report written: ${parsed.html}`);
161
+ }
162
+ else {
163
+ console.log(output);
164
+ }
165
+ const hasCritical = findings.some((f) => f.severity === 'critical');
166
+ if (parsed.noFail)
167
+ return 0;
168
+ return hasCritical ? 1 : 0;
169
+ }
170
+ const isDirectRun = process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`;
171
+ if (isDirectRun) {
172
+ main(process.argv.slice(2)).then((code) => process.exit(code));
173
+ }
@@ -0,0 +1,6 @@
1
+ export class NotImplementedError extends Error {
2
+ constructor(id) {
3
+ super(`not implemented: ${id}`);
4
+ this.name = 'NotImplementedError';
5
+ }
6
+ }
@@ -0,0 +1,57 @@
1
+ import path from 'node:path';
2
+ function splitFrontmatter(raw) {
3
+ const normalized = raw.replace(/\r\n/g, '\n');
4
+ if (!normalized.startsWith('---\n') && normalized !== '---') {
5
+ return { frontmatter: {}, body: normalized };
6
+ }
7
+ const closingIndex = normalized.indexOf('\n---', 4);
8
+ if (closingIndex === -1) {
9
+ return { frontmatter: {}, body: normalized };
10
+ }
11
+ const rawFrontmatter = normalized.slice(4, closingIndex);
12
+ const afterClose = normalized.slice(closingIndex + 4);
13
+ const body = afterClose.startsWith('\n') ? afterClose.slice(1) : afterClose;
14
+ const frontmatter = {};
15
+ for (const line of rawFrontmatter.split('\n')) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed)
18
+ continue;
19
+ const sepIndex = trimmed.indexOf(':');
20
+ if (sepIndex === -1)
21
+ continue;
22
+ const key = trimmed.slice(0, sepIndex).trim();
23
+ const value = trimmed.slice(sepIndex + 1).trim();
24
+ if (key === 'name' || key === 'description' || key === 'tools' || key === 'model') {
25
+ frontmatter[key] = value;
26
+ }
27
+ }
28
+ return { frontmatter, body };
29
+ }
30
+ function parseTools(raw) {
31
+ if (raw === undefined || raw === '')
32
+ return undefined;
33
+ let value = raw.trim();
34
+ if (value.startsWith('[') && value.endsWith(']')) {
35
+ value = value.slice(1, -1);
36
+ }
37
+ const tools = value
38
+ .split(',')
39
+ .map((t) => t.trim().replace(/^['"]|['"]$/g, ''))
40
+ .filter(Boolean);
41
+ return tools.length > 0 ? tools : undefined;
42
+ }
43
+ function fallbackName(filePath) {
44
+ return path.basename(filePath, path.extname(filePath));
45
+ }
46
+ export function parseAgentMarkdown(raw, filePath, sourceLabel) {
47
+ const { frontmatter, body } = splitFrontmatter(raw);
48
+ return {
49
+ name: frontmatter.name?.trim() || fallbackName(filePath),
50
+ description: frontmatter.description?.trim() ?? '',
51
+ tools: parseTools(frontmatter.tools),
52
+ model: frontmatter.model?.trim() || undefined,
53
+ body: body.trim(),
54
+ sourceLabel,
55
+ filePath,
56
+ };
57
+ }
@@ -0,0 +1,28 @@
1
+ export const cliRenderer = {
2
+ id: 'cli',
3
+ render(report) {
4
+ const lines = [];
5
+ lines.push('Roster Audit Report');
6
+ lines.push(`Agents scanned: ${report.agents.length}`);
7
+ lines.push(`Sources: ${report.meta.sourceLabels.join(', ')}`);
8
+ lines.push('');
9
+ const overlapFindings = report.findings.filter((f) => f.ruleId === 'overlap');
10
+ lines.push(`Top overlapping pairs (${overlapFindings.length}):`);
11
+ if (overlapFindings.length === 0) {
12
+ lines.push(' (none)');
13
+ }
14
+ else {
15
+ for (const f of overlapFindings) {
16
+ const [a, b] = f.pair ?? ['?', '?'];
17
+ const scoreStr = f.score !== undefined ? f.score.toFixed(3) : '?';
18
+ const tag = f.severity === 'critical' ? ' [CRITICAL]' : '';
19
+ lines.push(` ${scoreStr} ${a} <-> ${b}${tag}`);
20
+ }
21
+ }
22
+ lines.push('');
23
+ const criticalCount = report.findings.filter((f) => f.severity === 'critical').length;
24
+ const warningCount = report.findings.filter((f) => f.severity === 'warning').length;
25
+ lines.push(`Findings: ${report.findings.length} total (${criticalCount} critical, ${warningCount} warning)`);
26
+ return lines.join('\n');
27
+ },
28
+ };
@@ -0,0 +1,90 @@
1
+ export function htmlStyles() {
2
+ return `
3
+ :root {
4
+ color-scheme: light;
5
+ --bg: #f7f7f9;
6
+ --card-bg: #ffffff;
7
+ --border: #e2e2e8;
8
+ --text: #1c1c22;
9
+ --muted: #6b6b76;
10
+ --accent: #3a5fd9;
11
+ --critical: #c0392b;
12
+ --warning: #b7791f;
13
+ --info: #2c7a4b;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ body {
17
+ margin: 0;
18
+ padding: 2rem;
19
+ background: var(--bg);
20
+ color: var(--text);
21
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
22
+ line-height: 1.5;
23
+ }
24
+ h1, h2, h3 { margin-top: 0; }
25
+ .card {
26
+ background: var(--card-bg);
27
+ border: 1px solid var(--border);
28
+ border-radius: 8px;
29
+ padding: 1.25rem 1.5rem;
30
+ margin-bottom: 1.5rem;
31
+ }
32
+ .header-stats {
33
+ display: flex;
34
+ gap: 2rem;
35
+ flex-wrap: wrap;
36
+ }
37
+ .stat { display: flex; flex-direction: column; }
38
+ .stat .value { font-size: 1.75rem; font-weight: 700; }
39
+ .stat .label { font-size: 0.85rem; color: var(--muted); }
40
+ table {
41
+ border-collapse: collapse;
42
+ width: 100%;
43
+ }
44
+ th, td {
45
+ text-align: left;
46
+ padding: 0.5rem 0.75rem;
47
+ border-bottom: 1px solid var(--border);
48
+ font-size: 0.9rem;
49
+ }
50
+ th { color: var(--muted); font-weight: 600; }
51
+ .score-cell {
52
+ font-weight: 600;
53
+ border-radius: 4px;
54
+ padding: 0.15rem 0.5rem;
55
+ display: inline-block;
56
+ }
57
+ .score-strong { background: #fbe4e1; color: var(--critical); }
58
+ .score-mid { background: #fdf0d5; color: var(--warning); }
59
+ .score-low { background: #e3f1e9; color: var(--info); }
60
+ details {
61
+ border: 1px solid var(--border);
62
+ border-radius: 6px;
63
+ padding: 0.5rem 0.75rem;
64
+ margin-bottom: 0.5rem;
65
+ }
66
+ summary {
67
+ cursor: pointer;
68
+ font-weight: 600;
69
+ }
70
+ .finding-row {
71
+ padding: 0.35rem 0;
72
+ border-bottom: 1px solid var(--border);
73
+ font-size: 0.9rem;
74
+ }
75
+ .finding-row:last-child { border-bottom: none; }
76
+ .severity-badge {
77
+ display: inline-block;
78
+ border-radius: 999px;
79
+ padding: 0.05rem 0.55rem;
80
+ font-size: 0.75rem;
81
+ font-weight: 700;
82
+ text-transform: uppercase;
83
+ margin-right: 0.5rem;
84
+ }
85
+ .severity-critical { background: #fbe4e1; color: var(--critical); }
86
+ .severity-warning { background: #fdf0d5; color: var(--warning); }
87
+ .severity-info { background: #e3f1e9; color: var(--info); }
88
+ .empty-note { color: var(--muted); font-style: italic; }
89
+ `.trim();
90
+ }
@@ -0,0 +1,168 @@
1
+ import { htmlStyles } from './html-styles.js';
2
+ export function escapeHtml(input) {
3
+ return input
4
+ .replace(/&/g, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&#39;');
9
+ }
10
+ function estimateTokens(agent) {
11
+ const text = `${agent.description}\n${agent.body}`;
12
+ return Math.ceil(text.length / 4);
13
+ }
14
+ function scoreClass(score) {
15
+ if (score >= 0.6)
16
+ return 'score-strong';
17
+ if (score >= 0.3)
18
+ return 'score-mid';
19
+ return 'score-low';
20
+ }
21
+ function renderHeaderCard(report) {
22
+ const costFinding = report.findings.find((f) => f.ruleId === 'cost');
23
+ const costRow = costFinding
24
+ ? `<div class="stat"><span class="value">${escapeHtml(costFinding.message)}</span><span class="label">Roster cost</span></div>`
25
+ : '';
26
+ return `
27
+ <section class="card">
28
+ <h1>Roster Audit Report</h1>
29
+ <div class="header-stats">
30
+ <div class="stat">
31
+ <span class="value">${report.agents.length}</span>
32
+ <span class="label">Agents scanned</span>
33
+ </div>
34
+ <div class="stat">
35
+ <span class="value">${escapeHtml(report.meta.sourceLabels.join(', ') || '(none)')}</span>
36
+ <span class="label">Sources</span>
37
+ </div>
38
+ ${costRow}
39
+ </div>
40
+ </section>
41
+ `;
42
+ }
43
+ function renderOverlapSection(report) {
44
+ const overlapFindings = report.findings.filter((f) => f.ruleId === 'overlap');
45
+ const rows = overlapFindings
46
+ .map((f) => {
47
+ const [a, b] = f.pair ?? ['?', '?'];
48
+ const score = f.score ?? 0;
49
+ const severityClass = f.severity === 'critical' ? 'severity-critical' : 'severity-info';
50
+ return `
51
+ <tr>
52
+ <td><span class="score-cell ${scoreClass(score)}">${score.toFixed(3)}</span></td>
53
+ <td>${escapeHtml(a)}</td>
54
+ <td>${escapeHtml(b)}</td>
55
+ <td><span class="severity-badge ${severityClass}">${escapeHtml(f.severity)}</span></td>
56
+ </tr>
57
+ `;
58
+ })
59
+ .join('');
60
+ const body = overlapFindings.length === 0
61
+ ? '<p class="empty-note">No overlap findings.</p>'
62
+ : `
63
+ <table>
64
+ <thead>
65
+ <tr><th>Score</th><th>Agent A</th><th>Agent B</th><th>Severity</th></tr>
66
+ </thead>
67
+ <tbody>${rows}</tbody>
68
+ </table>
69
+ `;
70
+ return `
71
+ <section class="card">
72
+ <h2>Overlap — top overlapping pairs (${overlapFindings.length})</h2>
73
+ ${body}
74
+ </section>
75
+ `;
76
+ }
77
+ function renderFindingsAccordion(report) {
78
+ const grouped = new Map();
79
+ for (const f of report.findings) {
80
+ if (!grouped.has(f.ruleId))
81
+ grouped.set(f.ruleId, []);
82
+ grouped.get(f.ruleId).push(f);
83
+ }
84
+ if (grouped.size === 0) {
85
+ return `
86
+ <section class="card">
87
+ <h2>Findings</h2>
88
+ <p class="empty-note">No findings.</p>
89
+ </section>
90
+ `;
91
+ }
92
+ const details = [...grouped.entries()]
93
+ .map(([ruleId, findings]) => {
94
+ const rows = findings
95
+ .map((f) => {
96
+ const severityClass = f.severity === 'critical' ? 'severity-critical' : f.severity === 'warning' ? 'severity-warning' : 'severity-info';
97
+ const target = f.agent ? escapeHtml(f.agent) : f.pair ? f.pair.map(escapeHtml).join(' <-> ') : '';
98
+ return `
99
+ <div class="finding-row">
100
+ <span class="severity-badge ${severityClass}">${escapeHtml(f.severity)}</span>
101
+ ${target ? `<strong>${target}</strong> — ` : ''}${escapeHtml(f.message)}
102
+ </div>
103
+ `;
104
+ })
105
+ .join('');
106
+ return `
107
+ <details>
108
+ <summary>${escapeHtml(ruleId)} (${findings.length})</summary>
109
+ ${rows}
110
+ </details>
111
+ `;
112
+ })
113
+ .join('');
114
+ return `
115
+ <section class="card">
116
+ <h2>Findings</h2>
117
+ ${details}
118
+ </section>
119
+ `;
120
+ }
121
+ function renderAgentTable(report) {
122
+ const rows = report.agents
123
+ .map((agent) => {
124
+ const hasTools = agent.tools !== undefined && agent.tools.length > 0;
125
+ return `
126
+ <tr>
127
+ <td>${escapeHtml(agent.name)}</td>
128
+ <td>${hasTools ? 'yes' : 'no'}</td>
129
+ <td>${agent.description.length}</td>
130
+ <td>${estimateTokens(agent)}</td>
131
+ </tr>
132
+ `;
133
+ })
134
+ .join('');
135
+ const body = `
136
+ <table>
137
+ <thead>
138
+ <tr><th>Name</th><th>Tools</th><th>Description length</th><th>Est. tokens</th></tr>
139
+ </thead>
140
+ <tbody>${rows || '<tr><td colspan="4" class="empty-note">No agents.</td></tr>'}</tbody>
141
+ </table>
142
+ `;
143
+ return `
144
+ <section class="card">
145
+ <h2>Agents (${report.agents.length})</h2>
146
+ ${body}
147
+ </section>
148
+ `;
149
+ }
150
+ export function renderHtmlTemplate(report) {
151
+ const sections = [
152
+ renderHeaderCard(report),
153
+ renderOverlapSection(report),
154
+ renderFindingsAccordion(report),
155
+ renderAgentTable(report),
156
+ ].join('\n');
157
+ return `<!DOCTYPE html>
158
+ <html lang="en">
159
+ <head>
160
+ <meta charset="UTF-8">
161
+ <title>Roster Audit Report</title>
162
+ <style>${htmlStyles()}</style>
163
+ </head>
164
+ <body>
165
+ ${sections}
166
+ </body>
167
+ </html>`;
168
+ }
@@ -0,0 +1,7 @@
1
+ import { renderHtmlTemplate } from './html-template.js';
2
+ export const htmlRenderer = {
3
+ id: 'html',
4
+ render(report) {
5
+ return renderHtmlTemplate(report);
6
+ },
7
+ };
@@ -0,0 +1,8 @@
1
+ import { cliRenderer } from './cli.js';
2
+ import { jsonRenderer } from './json.js';
3
+ import { htmlRenderer } from './html.js';
4
+ export const renderers = {
5
+ cli: cliRenderer,
6
+ json: jsonRenderer,
7
+ html: htmlRenderer,
8
+ };
@@ -0,0 +1,33 @@
1
+ const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
2
+ function findingTarget(finding) {
3
+ if (finding.agent !== undefined)
4
+ return finding.agent;
5
+ if (finding.pair !== undefined)
6
+ return finding.pair.join('::');
7
+ return '';
8
+ }
9
+ function sortAgents(agents) {
10
+ return [...agents].sort((a, b) => a.name.localeCompare(b.name));
11
+ }
12
+ function sortFindings(findings) {
13
+ return [...findings].sort((a, b) => {
14
+ const severityDiff = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
15
+ if (severityDiff !== 0)
16
+ return severityDiff;
17
+ const ruleIdDiff = a.ruleId.localeCompare(b.ruleId);
18
+ if (ruleIdDiff !== 0)
19
+ return ruleIdDiff;
20
+ return findingTarget(a).localeCompare(findingTarget(b));
21
+ });
22
+ }
23
+ export const jsonRenderer = {
24
+ id: 'json',
25
+ render(report) {
26
+ const stable = {
27
+ agents: sortAgents(report.agents),
28
+ findings: sortFindings(report.findings),
29
+ meta: report.meta,
30
+ };
31
+ return JSON.stringify(stable, null, 2);
32
+ },
33
+ };
@@ -0,0 +1,27 @@
1
+ function estimateTokens(text) {
2
+ return Math.ceil(text.length / 4);
3
+ }
4
+ export const costRule = {
5
+ id: 'cost',
6
+ description: 'Estimates the context-window cost of loading the full agent roster.',
7
+ run(agents) {
8
+ const findings = [];
9
+ let descriptionTokenTotal = 0;
10
+ for (const agent of agents) {
11
+ const tokens = estimateTokens(`${agent.description}${agent.body}`);
12
+ descriptionTokenTotal += estimateTokens(agent.description);
13
+ findings.push({
14
+ ruleId: 'cost',
15
+ severity: 'info',
16
+ agent: agent.name,
17
+ message: `${agent.name} estimated token cost ~${tokens} tokens (estimate)`,
18
+ });
19
+ }
20
+ findings.push({
21
+ ruleId: 'cost',
22
+ severity: 'info',
23
+ message: `roster fixed cost per turn ~${descriptionTokenTotal} tokens (estimate)`,
24
+ });
25
+ return findings;
26
+ },
27
+ };
@@ -0,0 +1,38 @@
1
+ const MIN_BODY_LINES = 20;
2
+ const NARRATIVE_RATIO_THRESHOLD = 0.7;
3
+ const IMPERATIVE_VERB_PATTERN = /^(run|check|verify|confirm|ensure|use|call|invoke|write|read|add|remove|update|create|delete|build|deploy|test|review|flag|report|notify|tag|push|trigger)\b/i;
4
+ const CHECKLIST_PATTERN = /^-\s*\[( |x|X)\]/;
5
+ const NUMBERED_STEP_PATTERN = /^\d+[.)]\s+/;
6
+ const CODE_FENCE_PATTERN = /^```/;
7
+ function isExecutableLine(line) {
8
+ const trimmed = line.trim();
9
+ if (trimmed === '')
10
+ return false;
11
+ return (IMPERATIVE_VERB_PATTERN.test(trimmed) ||
12
+ CHECKLIST_PATTERN.test(trimmed) ||
13
+ NUMBERED_STEP_PATTERN.test(trimmed) ||
14
+ CODE_FENCE_PATTERN.test(trimmed));
15
+ }
16
+ export const fluffRule = {
17
+ id: 'fluff',
18
+ description: 'Flags agent definitions with low information density (filler prose, vague instructions).',
19
+ run(agents) {
20
+ const findings = [];
21
+ for (const agent of agents) {
22
+ const lines = agent.body.split('\n').filter((l) => l.trim() !== '');
23
+ if (lines.length <= MIN_BODY_LINES)
24
+ continue;
25
+ const executableCount = lines.filter(isExecutableLine).length;
26
+ const narrativeRatio = (lines.length - executableCount) / lines.length;
27
+ if (narrativeRatio > NARRATIVE_RATIO_THRESHOLD) {
28
+ findings.push({
29
+ ruleId: 'fluff',
30
+ severity: 'info',
31
+ agent: agent.name,
32
+ message: `[experimental] ${agent.name} body 는 서사 비율 ${(narrativeRatio * 100).toFixed(0)}% — 실행성 낮음`,
33
+ });
34
+ }
35
+ }
36
+ return findings;
37
+ },
38
+ };
@@ -0,0 +1,24 @@
1
+ const WILDCARD_TOKENS = new Set(['*', 'all']);
2
+ function isWildcardTools(tools) {
3
+ return tools.some((t) => WILDCARD_TOKENS.has(t.trim().toLowerCase()));
4
+ }
5
+ export const harnessRule = {
6
+ id: 'harness',
7
+ description: 'Flags agents that reference tools without a matching harness/permission definition.',
8
+ run(agents) {
9
+ const findings = [];
10
+ for (const agent of agents) {
11
+ const noTools = agent.tools === undefined || agent.tools.length === 0;
12
+ const wildcard = agent.tools !== undefined && isWildcardTools(agent.tools);
13
+ if (noTools || wildcard) {
14
+ findings.push({
15
+ ruleId: 'harness',
16
+ severity: 'warning',
17
+ agent: agent.name,
18
+ message: '도구 제한 없음 — 전 도구 개방 (모자만 있는 agent)',
19
+ });
20
+ }
21
+ }
22
+ return findings;
23
+ },
24
+ };
@@ -0,0 +1,12 @@
1
+ import { overlapRule } from './overlap.js';
2
+ import { harnessRule } from './harness.js';
3
+ import { routingRule } from './routing.js';
4
+ import { costRule } from './cost.js';
5
+ import { fluffRule } from './fluff.js';
6
+ export const rules = {
7
+ overlap: overlapRule,
8
+ harness: harnessRule,
9
+ routing: routingRule,
10
+ cost: costRule,
11
+ fluff: fluffRule,
12
+ };
@@ -0,0 +1,82 @@
1
+ const DEFAULT_TOP = 10;
2
+ function tokenize(text) {
3
+ return text
4
+ .toLowerCase()
5
+ .split(/[^a-z0-9]+/)
6
+ .filter(Boolean);
7
+ }
8
+ function buildVectors(docs) {
9
+ const documentFrequency = new Map();
10
+ for (const doc of docs) {
11
+ for (const term of new Set(doc)) {
12
+ documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
13
+ }
14
+ }
15
+ const n = docs.length;
16
+ const idf = new Map();
17
+ for (const [term, df] of documentFrequency) {
18
+ idf.set(term, Math.log((n + 1) / (df + 1)) + 1);
19
+ }
20
+ return docs.map((doc) => {
21
+ const termFrequency = new Map();
22
+ for (const term of doc) {
23
+ termFrequency.set(term, (termFrequency.get(term) ?? 0) + 1);
24
+ }
25
+ const vector = new Map();
26
+ for (const [term, count] of termFrequency) {
27
+ const weight = (count / doc.length) * (idf.get(term) ?? 0);
28
+ vector.set(term, weight);
29
+ }
30
+ return vector;
31
+ });
32
+ }
33
+ function norm(vector) {
34
+ let sumSquares = 0;
35
+ for (const weight of vector.values())
36
+ sumSquares += weight * weight;
37
+ return Math.sqrt(sumSquares);
38
+ }
39
+ function dotProduct(a, b) {
40
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a];
41
+ let dot = 0;
42
+ for (const [term, weight] of small) {
43
+ const other = large.get(term);
44
+ if (other !== undefined)
45
+ dot += weight * other;
46
+ }
47
+ return dot;
48
+ }
49
+ export const overlapRule = {
50
+ id: 'overlap',
51
+ description: 'Computes TF-IDF cosine similarity between every pair of agents and reports the top-N most similar pairs.',
52
+ run(agents, opts) {
53
+ const top = opts?.top ?? DEFAULT_TOP;
54
+ const failAbove = opts?.failAbove;
55
+ const docs = agents.map((agent) => tokenize(`${agent.description}\n${agent.body}`));
56
+ const vectors = buildVectors(docs);
57
+ const norms = vectors.map(norm);
58
+ const pairs = [];
59
+ for (let i = 0; i < agents.length; i++) {
60
+ for (let j = i + 1; j < agents.length; j++) {
61
+ if (norms[i] === 0 || norms[j] === 0)
62
+ continue;
63
+ const score = dotProduct(vectors[i], vectors[j]) / (norms[i] * norms[j]);
64
+ pairs.push({ i, j, score });
65
+ }
66
+ }
67
+ pairs.sort((a, b) => b.score - a.score);
68
+ const topPairs = pairs.slice(0, top);
69
+ return topPairs.map(({ i, j, score }) => {
70
+ const a = agents[i].name;
71
+ const b = agents[j].name;
72
+ const critical = failAbove !== undefined && score > failAbove;
73
+ return {
74
+ ruleId: 'overlap',
75
+ severity: critical ? 'critical' : 'info',
76
+ pair: [a, b],
77
+ score,
78
+ message: `${a} and ${b} overlap (similarity ${score.toFixed(3)})`,
79
+ };
80
+ });
81
+ },
82
+ };
@@ -0,0 +1,41 @@
1
+ const MAX_DESCRIPTION_LENGTH = 400;
2
+ const TRIGGER_PATTERN = /(use when|use this|invoke|trigger|~할 때|~시 사용)|\b(when|if)\b.+\b(use|call|invoke)\b/i;
3
+ function hasTriggerSignal(description) {
4
+ return TRIGGER_PATTERN.test(description);
5
+ }
6
+ export const routingRule = {
7
+ id: 'routing',
8
+ description: 'Flags ambiguous routing between agents with overlapping trigger descriptions.',
9
+ run(agents) {
10
+ const findings = [];
11
+ for (const agent of agents) {
12
+ const description = agent.description.trim();
13
+ if (description === '') {
14
+ findings.push({
15
+ ruleId: 'routing',
16
+ severity: 'warning',
17
+ agent: agent.name,
18
+ message: 'description 부재 — 메인 모델이 위임 판단 불가',
19
+ });
20
+ continue;
21
+ }
22
+ if (!hasTriggerSignal(description)) {
23
+ findings.push({
24
+ ruleId: 'routing',
25
+ severity: 'info',
26
+ agent: agent.name,
27
+ message: 'description 에 위임 트리거 신호 없음 (use when/트리거 조건절 권장)',
28
+ });
29
+ }
30
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
31
+ findings.push({
32
+ ruleId: 'routing',
33
+ severity: 'info',
34
+ agent: agent.name,
35
+ message: `description 이 ${MAX_DESCRIPTION_LENGTH}자를 초과함 (로스터 목록 비대)`,
36
+ });
37
+ }
38
+ }
39
+ return findings;
40
+ },
41
+ };
@@ -0,0 +1,50 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseAgentMarkdown } from '../parse/agent-md.js';
4
+ // R0: well-known non-agent documentation basenames (extension stripped, case-insensitive).
5
+ // Recursive scans previously misdetected these as "agents" and produced spurious overlap
6
+ // findings between unrelated projects' README/CONTRIBUTING/etc files.
7
+ const EXCLUDED_DOC_BASENAMES = new Set([
8
+ 'readme',
9
+ 'contributing',
10
+ 'license',
11
+ 'security',
12
+ 'changelog',
13
+ 'quickstart',
14
+ 'install',
15
+ 'code_of_conduct',
16
+ ]);
17
+ export function isExcludedDocFile(filePath) {
18
+ const base = path.basename(filePath, path.extname(filePath)).toLowerCase();
19
+ return EXCLUDED_DOC_BASENAMES.has(base);
20
+ }
21
+ async function collectMarkdownFiles(root) {
22
+ const entries = await readdir(root, { withFileTypes: true });
23
+ const files = [];
24
+ for (const entry of entries) {
25
+ const fullPath = path.join(root, entry.name);
26
+ if (entry.isDirectory()) {
27
+ files.push(...(await collectMarkdownFiles(fullPath)));
28
+ }
29
+ else if (entry.isFile() && entry.name.endsWith('.md') && !isExcludedDocFile(fullPath)) {
30
+ files.push(fullPath);
31
+ }
32
+ }
33
+ return files;
34
+ }
35
+ export const dirSource = {
36
+ id: 'dir',
37
+ description: 'Recursively scans a local directory for agent markdown files.',
38
+ async load(opts) {
39
+ const dir = opts?.dir;
40
+ if (!dir) {
41
+ throw new Error('sources/dir: opts.dir is required');
42
+ }
43
+ const files = await collectMarkdownFiles(dir);
44
+ const agents = await Promise.all(files.map(async (filePath) => {
45
+ const raw = await readFile(filePath, 'utf8');
46
+ return parseAgentMarkdown(raw, filePath, `dir:${dir}`);
47
+ }));
48
+ return agents;
49
+ },
50
+ };
@@ -0,0 +1,72 @@
1
+ import { parseAgentMarkdown } from '../parse/agent-md.js';
2
+ import { isExcludedDocFile } from './dir.js';
3
+ function parseRepoSpec(repo) {
4
+ const atIndex = repo.lastIndexOf('@');
5
+ const withoutRef = atIndex === -1 ? repo : repo.slice(0, atIndex);
6
+ const ref = atIndex === -1 ? undefined : repo.slice(atIndex + 1);
7
+ const slashIndex = withoutRef.indexOf('/');
8
+ if (slashIndex === -1) {
9
+ throw new Error(`sources/github: invalid repo spec "${repo}", expected owner/name[@ref]`);
10
+ }
11
+ return {
12
+ owner: withoutRef.slice(0, slashIndex),
13
+ name: withoutRef.slice(slashIndex + 1),
14
+ ref,
15
+ };
16
+ }
17
+ function authHeaders() {
18
+ const token = process.env.GITHUB_TOKEN;
19
+ return token ? { Authorization: `Bearer ${token}` } : {};
20
+ }
21
+ async function githubJsonFetch(url) {
22
+ const res = await fetch(url, { headers: { Accept: 'application/vnd.github+json', ...authHeaders() } });
23
+ if (!res.ok) {
24
+ if (res.status === 404) {
25
+ throw new Error(`sources/github: 404 not found — ${url}`);
26
+ }
27
+ if (res.status === 403) {
28
+ throw new Error(`sources/github: 403 rate-limit (or forbidden) — ${url}. Set GITHUB_TOKEN to raise limits.`);
29
+ }
30
+ throw new Error(`sources/github: request failed (${res.status}) — ${url}`);
31
+ }
32
+ return res.json();
33
+ }
34
+ async function resolveDefaultBranch(owner, name) {
35
+ const data = (await githubJsonFetch(`https://api.github.com/repos/${owner}/${name}`));
36
+ if (!data.default_branch) {
37
+ throw new Error(`sources/github: could not resolve default branch for ${owner}/${name}`);
38
+ }
39
+ return data.default_branch;
40
+ }
41
+ function isCandidateMarkdownBlob(entry) {
42
+ return entry.type === 'blob' && entry.path.endsWith('.md') && !isExcludedDocFile(entry.path);
43
+ }
44
+ export const githubSource = {
45
+ id: 'github',
46
+ description: 'Loads agents from a GitHub repository (owner/name[@ref]).',
47
+ async load(opts) {
48
+ const repoSpec = opts?.repo;
49
+ if (!repoSpec) {
50
+ throw new Error('sources/github: opts.repo is required (owner/name[@ref])');
51
+ }
52
+ const { owner, name, ref: pinnedRef } = parseRepoSpec(repoSpec);
53
+ const ref = pinnedRef ?? (await resolveDefaultBranch(owner, name));
54
+ const treeUrl = `https://api.github.com/repos/${owner}/${name}/git/trees/${ref}?recursive=1`;
55
+ const tree = (await githubJsonFetch(treeUrl));
56
+ if (tree.truncated) {
57
+ console.warn(`sources/github: tree listing for ${owner}/${name}@${ref} was truncated by the GitHub API — some agent files may be missing`);
58
+ }
59
+ const sourceLabel = `github:${owner}/${name}@${ref}`;
60
+ const candidates = (tree.tree ?? []).filter(isCandidateMarkdownBlob);
61
+ const agents = await Promise.all(candidates.map(async (entry) => {
62
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${name}/${ref}/${entry.path}`;
63
+ const res = await fetch(rawUrl, { headers: authHeaders() });
64
+ if (!res.ok) {
65
+ throw new Error(`sources/github: failed to fetch ${rawUrl} (${res.status})`);
66
+ }
67
+ const raw = await res.text();
68
+ return parseAgentMarkdown(raw, entry.path, sourceLabel);
69
+ }));
70
+ return agents;
71
+ },
72
+ };
@@ -0,0 +1,10 @@
1
+ import { dirSource } from './dir.js';
2
+ import { userSource } from './user.js';
3
+ import { pluginCacheSource } from './plugin-cache.js';
4
+ import { githubSource } from './github.js';
5
+ export const sources = {
6
+ dir: dirSource,
7
+ user: userSource,
8
+ 'plugin-cache': pluginCacheSource,
9
+ github: githubSource,
10
+ };
@@ -0,0 +1,89 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseAgentMarkdown } from '../parse/agent-md.js';
4
+ import { isExcludedDocFile } from './dir.js';
5
+ async function readInstalledPlugins(pluginsRoot) {
6
+ const file = path.join(pluginsRoot, 'installed_plugins.json');
7
+ try {
8
+ const raw = await readFile(file, 'utf8');
9
+ return JSON.parse(raw);
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ function resolveActivePlugins(data) {
16
+ const active = [];
17
+ const seenInstallPaths = new Set();
18
+ for (const [key, entries] of Object.entries(data.plugins ?? {})) {
19
+ const sepIndex = key.indexOf('@');
20
+ const name = sepIndex === -1 ? key : key.slice(0, sepIndex);
21
+ const marketplace = sepIndex === -1 ? '' : key.slice(sepIndex + 1);
22
+ for (const entry of entries) {
23
+ if (!entry.installPath || seenInstallPaths.has(entry.installPath))
24
+ continue;
25
+ seenInstallPaths.add(entry.installPath);
26
+ active.push({ name, marketplace, version: entry.version, installPath: entry.installPath });
27
+ }
28
+ }
29
+ return active;
30
+ }
31
+ async function collectMarkdownFiles(root) {
32
+ let entries;
33
+ try {
34
+ entries = await readdir(root, { withFileTypes: true });
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ const files = [];
40
+ for (const entry of entries) {
41
+ const fullPath = path.join(root, entry.name);
42
+ if (entry.isDirectory()) {
43
+ files.push(...(await collectMarkdownFiles(fullPath)));
44
+ }
45
+ else if (entry.isFile() && entry.name.endsWith('.md') && !isExcludedDocFile(fullPath)) {
46
+ files.push(fullPath);
47
+ }
48
+ }
49
+ return files;
50
+ }
51
+ async function dirExists(dir) {
52
+ try {
53
+ return (await stat(dir)).isDirectory();
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ export const pluginCacheSource = {
60
+ id: 'plugin-cache',
61
+ description: 'Loads agents from the installed Claude Code plugin cache.',
62
+ async load(opts) {
63
+ const home = opts?.home ?? process.env.HOME;
64
+ if (!home) {
65
+ console.error('sources/plugin-cache: HOME is not set, no opts.home override provided — returning empty roster');
66
+ return [];
67
+ }
68
+ const pluginsRoot = path.join(home, '.claude', 'plugins');
69
+ const data = await readInstalledPlugins(pluginsRoot);
70
+ if (!data) {
71
+ console.error(`sources/plugin-cache: ${pluginsRoot}/installed_plugins.json not found — returning empty roster`);
72
+ return [];
73
+ }
74
+ const pluginNameFilter = opts?.pluginName;
75
+ const activePlugins = resolveActivePlugins(data).filter((p) => !pluginNameFilter || p.name === pluginNameFilter);
76
+ const agentLists = await Promise.all(activePlugins.map(async (plugin) => {
77
+ const agentsDir = path.join(plugin.installPath, 'agents');
78
+ if (!(await dirExists(agentsDir)))
79
+ return [];
80
+ const files = await collectMarkdownFiles(agentsDir);
81
+ const sourceLabel = `plugin:${plugin.name}@${plugin.version}`;
82
+ return Promise.all(files.map(async (filePath) => {
83
+ const raw = await readFile(filePath, 'utf8');
84
+ return parseAgentMarkdown(raw, filePath, sourceLabel);
85
+ }));
86
+ }));
87
+ return agentLists.flat();
88
+ },
89
+ };
@@ -0,0 +1,49 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseAgentMarkdown } from '../parse/agent-md.js';
4
+ import { isExcludedDocFile } from './dir.js';
5
+ async function collectMarkdownFiles(root) {
6
+ const entries = await readdir(root, { withFileTypes: true });
7
+ const files = [];
8
+ for (const entry of entries) {
9
+ const fullPath = path.join(root, entry.name);
10
+ if (entry.isDirectory()) {
11
+ files.push(...(await collectMarkdownFiles(fullPath)));
12
+ }
13
+ else if (entry.isFile() && entry.name.endsWith('.md') && !isExcludedDocFile(fullPath)) {
14
+ files.push(fullPath);
15
+ }
16
+ }
17
+ return files;
18
+ }
19
+ async function exists(dir) {
20
+ try {
21
+ const s = await stat(dir);
22
+ return s.isDirectory();
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ export const userSource = {
29
+ id: 'user',
30
+ description: 'Loads agents from the user-level Claude Code agent directory (~/.claude/agents).',
31
+ async load(opts) {
32
+ const home = opts?.home ?? process.env.HOME;
33
+ if (!home) {
34
+ console.error('sources/user: HOME is not set, no opts.home override provided — returning empty roster');
35
+ return [];
36
+ }
37
+ const agentsDir = path.join(home, '.claude', 'agents');
38
+ if (!(await exists(agentsDir))) {
39
+ console.error(`sources/user: ${agentsDir} does not exist — returning empty roster`);
40
+ return [];
41
+ }
42
+ const files = await collectMarkdownFiles(agentsDir);
43
+ const agents = await Promise.all(files.map(async (filePath) => {
44
+ const raw = await readFile(filePath, 'utf8');
45
+ return parseAgentMarkdown(raw, filePath, 'user');
46
+ }));
47
+ return agents;
48
+ },
49
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "roster-cli",
3
+ "version": "0.1.0",
4
+ "description": "Zero-runtime-dep static auditor for Claude Code agent rosters \u2014 overlap, harness gaps, routing, cost.",
5
+ "type": "module",
6
+ "bin": {
7
+ "roster": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "vitest run",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "devDependencies": {
21
+ "typescript": "^5.6.3",
22
+ "vitest": "^2.1.4",
23
+ "@types/node": "^22.9.0"
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/sshworld/roster.git"
29
+ },
30
+ "homepage": "https://github.com/sshworld/roster#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/sshworld/roster/issues"
33
+ },
34
+ "keywords": [
35
+ "claude-code",
36
+ "agents",
37
+ "audit",
38
+ "lint",
39
+ "roster",
40
+ "ai-agents",
41
+ "static-analysis"
42
+ ]
43
+ }