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 +21 -0
- package/README.md +96 -0
- package/dist/cli.js +173 -0
- package/dist/core/types.js +6 -0
- package/dist/parse/agent-md.js +57 -0
- package/dist/render/cli.js +28 -0
- package/dist/render/html-styles.js +90 -0
- package/dist/render/html-template.js +168 -0
- package/dist/render/html.js +7 -0
- package/dist/render/index.js +8 -0
- package/dist/render/json.js +33 -0
- package/dist/rules/cost.js +27 -0
- package/dist/rules/fluff.js +38 -0
- package/dist/rules/harness.js +24 -0
- package/dist/rules/index.js +12 -0
- package/dist/rules/overlap.js +82 -0
- package/dist/rules/routing.js +41 -0
- package/dist/sources/dir.js +50 -0
- package/dist/sources/github.js +72 -0
- package/dist/sources/index.js +10 -0
- package/dist/sources/plugin-cache.js +89 -0
- package/dist/sources/user.js +49 -0
- package/package.json +43 -0
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,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, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, ''');
|
|
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,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
|
+
}
|