triage-ai 1.0.2
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 +209 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +633 -0
- package/dist/cli.js.map +1 -0
- package/dist/mcp-server.d.ts +24 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +411 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/memory.d.ts +40 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +241 -0
- package/dist/memory.js.map +1 -0
- package/dist/merge.d.ts +32 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +251 -0
- package/dist/merge.js.map +1 -0
- package/dist/models/base.d.ts +72 -0
- package/dist/models/base.d.ts.map +1 -0
- package/dist/models/base.js +342 -0
- package/dist/models/base.js.map +1 -0
- package/dist/models/claude.d.ts +23 -0
- package/dist/models/claude.d.ts.map +1 -0
- package/dist/models/claude.js +30 -0
- package/dist/models/claude.js.map +1 -0
- package/dist/models/codex.d.ts +25 -0
- package/dist/models/codex.d.ts.map +1 -0
- package/dist/models/codex.js +34 -0
- package/dist/models/codex.js.map +1 -0
- package/dist/models/gemini.d.ts +23 -0
- package/dist/models/gemini.d.ts.map +1 -0
- package/dist/models/gemini.js +32 -0
- package/dist/models/gemini.js.map +1 -0
- package/dist/patch.d.ts +40 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +183 -0
- package/dist/patch.js.map +1 -0
- package/dist/progress.d.ts +71 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +268 -0
- package/dist/progress.js.map +1 -0
- package/dist/report.d.ts +19 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +245 -0
- package/dist/report.js.map +1 -0
- package/dist/scanner.d.ts +64 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +645 -0
- package/dist/scanner.js.map +1 -0
- package/dist/setup.d.ts +52 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +252 -0
- package/dist/setup.js.map +1 -0
- package/dist/types.d.ts +153 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +203 -0
- package/dist/types.js.map +1 -0
- package/examples/claude-code-skill.md +22 -0
- package/examples/mcp-config.json +9 -0
- package/package.json +77 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* triage-ai CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Parses arguments, runs the full triage pipeline and renders progress
|
|
6
|
+
* via TriageProgress. Supports both interactive (TTY) and CI/piped modes.
|
|
7
|
+
*/
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import { resolve, join } from 'node:path';
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import which from 'which';
|
|
13
|
+
import { TriageProgress, plainTeamLine, plainReportLine } from './progress.js';
|
|
14
|
+
import { RepoScanner } from './scanner.js';
|
|
15
|
+
import { MergeEngine, mergedResultToDict } from './merge.js';
|
|
16
|
+
import { startMcpServer } from './mcp-server.js';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Version (aligned with package.json)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const VERSION = '1.0.2';
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Config path
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const CONFIG_DIR = join(homedir(), '.config', 'triage-ai');
|
|
25
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Auth / rate-limit error patterns (mirrors base.ts)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const AUTH_PATTERNS = [
|
|
30
|
+
/not logged in/i,
|
|
31
|
+
/login required/i,
|
|
32
|
+
/authenticate/i,
|
|
33
|
+
/api[_\s-]?key/i,
|
|
34
|
+
/ANTHROPIC_API_KEY/,
|
|
35
|
+
/GOOGLE_API_KEY/,
|
|
36
|
+
/OPENAI_API_KEY/,
|
|
37
|
+
/rate[_\s-]?limit/i,
|
|
38
|
+
/quota exceeded/i,
|
|
39
|
+
/too many requests/i,
|
|
40
|
+
/429/,
|
|
41
|
+
/unauthorized/i,
|
|
42
|
+
/forbidden/i,
|
|
43
|
+
/403/,
|
|
44
|
+
];
|
|
45
|
+
function detectAuthIssue(modelName, errorMsg) {
|
|
46
|
+
for (const pat of AUTH_PATTERNS) {
|
|
47
|
+
if (pat.test(errorMsg)) {
|
|
48
|
+
return _authHint(modelName, errorMsg);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function _authHint(modelName, errorMsg) {
|
|
54
|
+
const lower = errorMsg.toLowerCase();
|
|
55
|
+
const name = modelName.toLowerCase();
|
|
56
|
+
if (/rate.?limit|too many|429|quota/.test(lower)) {
|
|
57
|
+
const others = ['claude', 'gemini', 'codex'].filter((m) => m !== name).join(',');
|
|
58
|
+
return `rate limited — try again later or use --models ${others}`;
|
|
59
|
+
}
|
|
60
|
+
if (/unauthorized|forbidden|403/.test(lower)) {
|
|
61
|
+
return `access denied — check your API key or permissions`;
|
|
62
|
+
}
|
|
63
|
+
if (name === 'claude')
|
|
64
|
+
return 'not authenticated — run: claude auth login';
|
|
65
|
+
if (name === 'gemini')
|
|
66
|
+
return 'not authenticated — run: gemini auth login';
|
|
67
|
+
if (name === 'codex')
|
|
68
|
+
return 'not authenticated — run: codex (or set OPENAI_API_KEY)';
|
|
69
|
+
return 'not authenticated — check API key or run the CLI interactively to log in';
|
|
70
|
+
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// CLI tool detection
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
const KNOWN_TOOLS = [
|
|
75
|
+
{
|
|
76
|
+
name: 'Claude',
|
|
77
|
+
command: 'claude',
|
|
78
|
+
install_cmd: 'npm install -g @anthropic-ai/claude-code',
|
|
79
|
+
install_url: 'https://claude.ai/code',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'Gemini',
|
|
83
|
+
command: 'gemini',
|
|
84
|
+
install_cmd: 'npm install -g @google/gemini-cli',
|
|
85
|
+
install_url: 'https://github.com/google-gemini/gemini-cli',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'Codex',
|
|
89
|
+
command: 'codex',
|
|
90
|
+
install_cmd: 'npm install -g @openai/codex',
|
|
91
|
+
install_url: 'https://github.com/openai/codex',
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
async function detectTools(requested) {
|
|
95
|
+
const names = new Set(requested.map((n) => n.trim().toLowerCase()));
|
|
96
|
+
const tools = [];
|
|
97
|
+
for (const known of KNOWN_TOOLS) {
|
|
98
|
+
if (!names.has(known.command.toLowerCase()))
|
|
99
|
+
continue;
|
|
100
|
+
let foundPath = null;
|
|
101
|
+
try {
|
|
102
|
+
foundPath = await which(known.command);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// not found on PATH
|
|
106
|
+
}
|
|
107
|
+
tools.push({
|
|
108
|
+
...known,
|
|
109
|
+
path: foundPath,
|
|
110
|
+
available: foundPath !== null,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return tools;
|
|
114
|
+
}
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Model runner — dynamically imports model classes to avoid circular deps.
|
|
117
|
+
// All model classes extend BaseModel which has a concrete analyze() typed to
|
|
118
|
+
// ScanContext, so we use BaseModel as the shared interface.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
async function loadModel(modelName) {
|
|
121
|
+
const lower = modelName.toLowerCase().trim();
|
|
122
|
+
if (lower === 'claude') {
|
|
123
|
+
const { ClaudeModel } = await import('./models/claude.js');
|
|
124
|
+
return new ClaudeModel();
|
|
125
|
+
}
|
|
126
|
+
if (lower === 'gemini') {
|
|
127
|
+
const { GeminiModel } = await import('./models/gemini.js');
|
|
128
|
+
return new GeminiModel();
|
|
129
|
+
}
|
|
130
|
+
if (lower === 'codex') {
|
|
131
|
+
const { CodexModel } = await import('./models/codex.js');
|
|
132
|
+
return new CodexModel();
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`Unknown model: ${modelName}`);
|
|
135
|
+
}
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Config / memory helpers
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
function configExists() {
|
|
140
|
+
return existsSync(CONFIG_FILE);
|
|
141
|
+
}
|
|
142
|
+
function readConfig() {
|
|
143
|
+
if (!existsSync(CONFIG_FILE))
|
|
144
|
+
return {};
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function writeConfig(data) {
|
|
153
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
154
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
|
|
155
|
+
}
|
|
156
|
+
function clearMemory() {
|
|
157
|
+
const memFiles = ['CLAUDE.md', 'GEMINI.md', 'AGENTS.md'];
|
|
158
|
+
let cleared = 0;
|
|
159
|
+
for (const file of memFiles) {
|
|
160
|
+
const full = resolve(process.cwd(), file);
|
|
161
|
+
if (!existsSync(full))
|
|
162
|
+
continue;
|
|
163
|
+
const content = readFileSync(full, 'utf8');
|
|
164
|
+
const cleaned = content.replace(/<!-- triage-ai:start -->[\s\S]*?<!-- triage-ai:end -->\n?/g, '');
|
|
165
|
+
if (cleaned !== content) {
|
|
166
|
+
writeFileSync(full, cleaned, 'utf8');
|
|
167
|
+
cleared++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
console.log(`Cleared triage findings from ${cleared} memory file(s).`);
|
|
171
|
+
}
|
|
172
|
+
function writeMemory(merged, prompt) {
|
|
173
|
+
const allFindings = [
|
|
174
|
+
...merged.blockers,
|
|
175
|
+
...merged.high,
|
|
176
|
+
...merged.medium,
|
|
177
|
+
...merged.low,
|
|
178
|
+
];
|
|
179
|
+
if (allFindings.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
const lines = [
|
|
182
|
+
'<!-- triage-ai:start -->',
|
|
183
|
+
`## Triage Findings (${new Date().toISOString().slice(0, 10)})`,
|
|
184
|
+
'',
|
|
185
|
+
`**Prompt:** ${prompt}`,
|
|
186
|
+
'',
|
|
187
|
+
`**Summary:** ${allFindings.length} findings` +
|
|
188
|
+
(merged.consensus.length > 0
|
|
189
|
+
? `, ${merged.consensus.length} consensus`
|
|
190
|
+
: ''),
|
|
191
|
+
'',
|
|
192
|
+
];
|
|
193
|
+
for (const cluster of allFindings.slice(0, 20)) {
|
|
194
|
+
const rep = cluster.findings[0];
|
|
195
|
+
const models = [...cluster.models].join(', ');
|
|
196
|
+
lines.push(`- **[${rep?.severity ?? 'S3'}]** ${rep?.title ?? 'Unknown'} ` +
|
|
197
|
+
`(\`${rep?.location?.path ?? 'unknown'}:${rep?.location?.start_line ?? 0}\`) — ${models}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push('<!-- triage-ai:end -->');
|
|
200
|
+
const block = lines.join('\n') + '\n';
|
|
201
|
+
const memFiles = ['CLAUDE.md', 'GEMINI.md', 'AGENTS.md'];
|
|
202
|
+
for (const file of memFiles) {
|
|
203
|
+
const full = resolve(process.cwd(), file);
|
|
204
|
+
if (!existsSync(full))
|
|
205
|
+
continue;
|
|
206
|
+
const existing = readFileSync(full, 'utf8');
|
|
207
|
+
const replaced = existing.replace(/<!-- triage-ai:start -->[\s\S]*?<!-- triage-ai:end -->\n?/g, block);
|
|
208
|
+
writeFileSync(full, replaced === existing ? existing + '\n' + block : replaced, 'utf8');
|
|
209
|
+
}
|
|
210
|
+
console.log(`Saved ${Math.min(allFindings.length, 20)} findings to AI memory files.`);
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Setup wizard
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
async function runSetup() {
|
|
216
|
+
console.log('\ntriage-ai setup\n');
|
|
217
|
+
console.log('Checking for AI CLI tools...\n');
|
|
218
|
+
let tools = await detectTools(['claude', 'gemini', 'codex']);
|
|
219
|
+
for (const t of tools) {
|
|
220
|
+
const status = t.available
|
|
221
|
+
? ` ✓ ${t.name} found at ${t.path}`
|
|
222
|
+
: ` ✗ ${t.name} not installed`;
|
|
223
|
+
console.log(status);
|
|
224
|
+
}
|
|
225
|
+
let missing = tools.filter((t) => !t.available);
|
|
226
|
+
// Offer to install missing CLIs (TTY only)
|
|
227
|
+
if (missing.length > 0 && process.stdin.isTTY) {
|
|
228
|
+
const { createInterface } = await import('node:readline');
|
|
229
|
+
const { execSync } = await import('node:child_process');
|
|
230
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
231
|
+
const ask = (q) => new Promise((res) => rl.question(q, res));
|
|
232
|
+
console.log('');
|
|
233
|
+
for (const tool of missing) {
|
|
234
|
+
const answer = await ask(`Install ${tool.name}? (${tool.install_cmd}) [Y/n] `);
|
|
235
|
+
if (answer.trim().toLowerCase() !== 'n') {
|
|
236
|
+
console.log(`\nInstalling ${tool.name}...`);
|
|
237
|
+
try {
|
|
238
|
+
execSync(tool.install_cmd, { stdio: 'inherit', timeout: 120_000 });
|
|
239
|
+
console.log(` ✓ ${tool.name} installed\n`);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
console.log(` ✗ ${tool.name} install failed — run manually: ${tool.install_cmd}\n`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
rl.close();
|
|
247
|
+
// Re-detect after installs
|
|
248
|
+
tools = await detectTools(['claude', 'gemini', 'codex']);
|
|
249
|
+
missing = tools.filter((t) => !t.available);
|
|
250
|
+
}
|
|
251
|
+
else if (missing.length > 0) {
|
|
252
|
+
// Non-TTY: just show install commands
|
|
253
|
+
console.log('\nTo install missing tools:');
|
|
254
|
+
for (const tool of missing) {
|
|
255
|
+
console.log(` ${tool.install_cmd}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const available = tools.filter((t) => t.available);
|
|
259
|
+
if (available.length === 0) {
|
|
260
|
+
console.log('\nNo AI CLI tools found. Install at least one and re-run: triage-ai setup');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Auth reminder
|
|
264
|
+
console.log('\nRemember to sign in to each CLI before using triage-ai:');
|
|
265
|
+
for (const tool of available) {
|
|
266
|
+
const name = tool.command.toLowerCase();
|
|
267
|
+
if (name === 'claude')
|
|
268
|
+
console.log(' Claude: claude (follow the login prompts)');
|
|
269
|
+
if (name === 'gemini')
|
|
270
|
+
console.log(' Gemini: gemini (follow the login prompts)');
|
|
271
|
+
if (name === 'codex')
|
|
272
|
+
console.log(' Codex: codex (follow the login prompts, or set OPENAI_API_KEY)');
|
|
273
|
+
}
|
|
274
|
+
const config = {
|
|
275
|
+
models: available.map((t) => t.command).join(','),
|
|
276
|
+
last_setup: new Date().toISOString(),
|
|
277
|
+
cli_paths: Object.fromEntries(available.filter((t) => t.path).map((t) => [t.command, t.path])),
|
|
278
|
+
};
|
|
279
|
+
writeConfig(config);
|
|
280
|
+
console.log(`\nConfig saved. Run: triage-ai "your analysis prompt"`);
|
|
281
|
+
}
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Report generation
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
function generateMarkdownReport(merged, prompt, elapsedSec, modelNames) {
|
|
286
|
+
const totalFindings = merged.blockers.length +
|
|
287
|
+
merged.high.length +
|
|
288
|
+
merged.medium.length +
|
|
289
|
+
merged.low.length;
|
|
290
|
+
const lines = [
|
|
291
|
+
'# Triage Report',
|
|
292
|
+
'',
|
|
293
|
+
`**Prompt:** ${prompt}`,
|
|
294
|
+
`**Models:** ${modelNames.join(', ')}`,
|
|
295
|
+
`**Time:** ${elapsedSec.toFixed(1)}s`,
|
|
296
|
+
`**Total findings:** ${totalFindings} (${merged.consensus.length} consensus)`,
|
|
297
|
+
'',
|
|
298
|
+
];
|
|
299
|
+
if (Object.keys(merged.summaries).length > 0) {
|
|
300
|
+
lines.push('## Model Summaries', '');
|
|
301
|
+
for (const [model, summary] of Object.entries(merged.summaries)) {
|
|
302
|
+
lines.push(`**${model}:** ${summary}`, '');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const sections = [
|
|
306
|
+
['S0 — Blockers', merged.blockers],
|
|
307
|
+
['S1 — High', merged.high],
|
|
308
|
+
['S2 — Medium', merged.medium],
|
|
309
|
+
['S3 — Low', merged.low],
|
|
310
|
+
];
|
|
311
|
+
for (const [heading, clusters] of sections) {
|
|
312
|
+
if (clusters.length === 0)
|
|
313
|
+
continue;
|
|
314
|
+
lines.push(`## ${heading}`, '');
|
|
315
|
+
for (const cluster of clusters) {
|
|
316
|
+
const rep = cluster.findings[0];
|
|
317
|
+
if (!rep)
|
|
318
|
+
continue;
|
|
319
|
+
const consensus = cluster.models.size >= 2 ? ' ⚡ consensus' : '';
|
|
320
|
+
const models = [...cluster.models].join(', ');
|
|
321
|
+
lines.push(`### ${rep.title}${consensus}`, '', `**Location:** \`${rep.location.path}:${rep.location.start_line}\` ` +
|
|
322
|
+
`**Category:** ${rep.category} ` +
|
|
323
|
+
`**Confidence:** ${rep.confidence} ` +
|
|
324
|
+
`**Models:** ${models}`, '', rep.evidence, '', `**Recommendation:** ${rep.recommendation}`, '');
|
|
325
|
+
if (rep.patch) {
|
|
326
|
+
lines.push('```diff', rep.patch, '```', '');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (merged.conflicts.length > 0) {
|
|
331
|
+
lines.push('## Conflicts', '');
|
|
332
|
+
for (const conflict of merged.conflicts) {
|
|
333
|
+
lines.push(`- **${conflict.title}:** ${conflict.details}`);
|
|
334
|
+
}
|
|
335
|
+
lines.push('');
|
|
336
|
+
}
|
|
337
|
+
return lines.join('\n');
|
|
338
|
+
}
|
|
339
|
+
function generateJsonReport(merged, prompt, elapsedSec, modelNames) {
|
|
340
|
+
return JSON.stringify({
|
|
341
|
+
prompt,
|
|
342
|
+
models: modelNames,
|
|
343
|
+
elapsed_sec: elapsedSec,
|
|
344
|
+
...mergedResultToDict(merged),
|
|
345
|
+
}, null, 2);
|
|
346
|
+
}
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Main
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
async function main() {
|
|
351
|
+
const program = new Command();
|
|
352
|
+
program
|
|
353
|
+
.name('triage-ai')
|
|
354
|
+
.version(VERSION)
|
|
355
|
+
.description('Multi-model code triage — run Claude, Gemini and Codex in parallel')
|
|
356
|
+
.argument('[prompt]', 'Analysis prompt / question for the models')
|
|
357
|
+
.option('--models <list>', 'Comma-separated models to use', 'claude,gemini,codex')
|
|
358
|
+
.option('--diff-only', 'Send only git diff instead of full files', false)
|
|
359
|
+
.option('--max-files <n>', 'Maximum files to send per model', '30')
|
|
360
|
+
.option('--format <fmt>', 'Output format: md or json', 'md')
|
|
361
|
+
.option('--out <file>', 'Write report to file instead of stdout')
|
|
362
|
+
.option('--apply', 'Apply best-effort safe patches (creates git branch)', false)
|
|
363
|
+
.option('--dry-run', 'Show patches but do not apply them', false)
|
|
364
|
+
.option('--timeout <sec>', 'Timeout per model in seconds', '300')
|
|
365
|
+
.option('--nice <n>', 'Nice level for subprocess priority', '10')
|
|
366
|
+
.option('--results-dir <dir>', 'Directory for intermediate results', './triage_results')
|
|
367
|
+
.option('--remember', 'Save findings to AI memory files (CLAUDE.md, GEMINI.md, AGENTS.md)', false)
|
|
368
|
+
.option('--forget', 'Remove triage findings from AI memory files and exit', false)
|
|
369
|
+
.option('-v, --verbose', 'Verbose output', false)
|
|
370
|
+
.option('--mcp', 'Start MCP server instead of running triage', false);
|
|
371
|
+
// Setup sub-command — handle before parse so we can await it
|
|
372
|
+
if (process.argv[2] === 'setup') {
|
|
373
|
+
await runSetup();
|
|
374
|
+
process.exit(0);
|
|
375
|
+
}
|
|
376
|
+
program.parse(process.argv);
|
|
377
|
+
const opts = program.opts();
|
|
378
|
+
// --mcp: start MCP server and exit
|
|
379
|
+
if (opts.mcp) {
|
|
380
|
+
await startMcpServer();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// --forget: clear memory and exit
|
|
384
|
+
if (opts.forget) {
|
|
385
|
+
clearMemory();
|
|
386
|
+
process.exit(0);
|
|
387
|
+
}
|
|
388
|
+
// Coerce numeric options
|
|
389
|
+
const maxFiles = Math.max(1, parseInt(opts.maxFiles, 10) || 30);
|
|
390
|
+
const timeout = Math.max(30, parseInt(opts.timeout, 10) || 300);
|
|
391
|
+
const nice = parseInt(opts.nice, 10) || 10;
|
|
392
|
+
const format = opts.format === 'json' ? 'json' : 'md';
|
|
393
|
+
const promptArg = program.args[0];
|
|
394
|
+
// First-run detection
|
|
395
|
+
if (!configExists()) {
|
|
396
|
+
console.log('triage-ai: first run detected — running setup wizard\n');
|
|
397
|
+
await runSetup();
|
|
398
|
+
if (!promptArg)
|
|
399
|
+
process.exit(0);
|
|
400
|
+
}
|
|
401
|
+
if (!promptArg) {
|
|
402
|
+
console.error('Error: No prompt provided');
|
|
403
|
+
console.error('Usage: triage-ai "<your analysis prompt>"');
|
|
404
|
+
console.error(' triage-ai setup');
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
const prompt = promptArg;
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
// Progress display
|
|
410
|
+
// -------------------------------------------------------------------------
|
|
411
|
+
const progress = new TriageProgress();
|
|
412
|
+
progress.printHeader();
|
|
413
|
+
// -------------------------------------------------------------------------
|
|
414
|
+
// Phase 1: Intake
|
|
415
|
+
// -------------------------------------------------------------------------
|
|
416
|
+
progress.startPhase('intake', 'Intake');
|
|
417
|
+
const scanner = new RepoScanner();
|
|
418
|
+
progress.startSpinner('Scanning repository', 'discovering files…');
|
|
419
|
+
let context;
|
|
420
|
+
try {
|
|
421
|
+
// scanner.scan() takes positional args: (diffOnly, maxFiles, prompt)
|
|
422
|
+
context = scanner.scan(opts.diffOnly, maxFiles, prompt);
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
426
|
+
progress.stopSpinner('Scanning repository', 'failed', msg);
|
|
427
|
+
console.error(`Fatal: scanner failed — ${msg}`);
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
const fileCount = context.files.length;
|
|
431
|
+
const totalBytes = context.files.reduce((acc, f) => acc + Buffer.byteLength(f.content, 'utf8'), 0);
|
|
432
|
+
const kbStr = (totalBytes / 1024).toFixed(0) + ' KB';
|
|
433
|
+
const diffNote = context.has_diff ? ', diff mode' : '';
|
|
434
|
+
progress.stopSpinner('Scanning repository', 'done', `${fileCount} files${diffNote}`);
|
|
435
|
+
// Secret redaction count if the scanner exposes it
|
|
436
|
+
const redactedCount = context.redacted_count ?? 0;
|
|
437
|
+
if (redactedCount > 0) {
|
|
438
|
+
progress.addItem('Redacted secrets');
|
|
439
|
+
progress.updateItem('Redacted secrets', 'done', `${redactedCount} patterns masked`);
|
|
440
|
+
}
|
|
441
|
+
progress.addItem('Built context package');
|
|
442
|
+
progress.updateItem('Built context package', 'done', `${kbStr} across ${fileCount} files`);
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
// Phase 2: Triage Team
|
|
445
|
+
// -------------------------------------------------------------------------
|
|
446
|
+
progress.startPhase('team', 'Triage Team');
|
|
447
|
+
const modelNames = opts.models.split(',').map((m) => m.trim()).filter(Boolean);
|
|
448
|
+
const tools = await detectTools(modelNames);
|
|
449
|
+
for (const tool of tools) {
|
|
450
|
+
progress.addItem(tool.name);
|
|
451
|
+
if (tool.available) {
|
|
452
|
+
progress.updateItem(tool.name, 'done', `found at ${tool.path ?? tool.command}`);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
progress.updateItem(tool.name, 'skipped', 'not installed (skipping)');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const availableTools = tools.filter((t) => t.available);
|
|
459
|
+
if (!process.stdout.isTTY) {
|
|
460
|
+
process.stdout.write(plainTeamLine(tools.map((t) => ({ name: t.name, available: t.available }))) + '\n');
|
|
461
|
+
}
|
|
462
|
+
if (availableTools.length === 0) {
|
|
463
|
+
console.error('\nError: No AI CLI tools available.\n');
|
|
464
|
+
console.error('Install at least one of the following:\n');
|
|
465
|
+
for (const tool of tools) {
|
|
466
|
+
console.error(` ${tool.name}: ${tool.install_cmd}`);
|
|
467
|
+
console.error(` ${tool.install_url}`);
|
|
468
|
+
}
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
// -------------------------------------------------------------------------
|
|
472
|
+
// Phase 3: Assessment
|
|
473
|
+
// -------------------------------------------------------------------------
|
|
474
|
+
progress.startPhase('assessment', 'Assessment');
|
|
475
|
+
// Create results directory with timestamp
|
|
476
|
+
const timestamp = new Date()
|
|
477
|
+
.toISOString()
|
|
478
|
+
.replace(/[:.]/g, '-')
|
|
479
|
+
.replace('T', '_')
|
|
480
|
+
.slice(0, 19);
|
|
481
|
+
const resultsDir = resolve(opts.resultsDir, timestamp);
|
|
482
|
+
mkdirSync(resultsDir, { recursive: true });
|
|
483
|
+
if (opts.verbose) {
|
|
484
|
+
console.error(`Results directory: ${resultsDir}`);
|
|
485
|
+
}
|
|
486
|
+
const startTime = Date.now();
|
|
487
|
+
// Start a spinner per available model
|
|
488
|
+
for (const tool of availableTools) {
|
|
489
|
+
progress.startSpinner(tool.name, 'examining codebase…');
|
|
490
|
+
}
|
|
491
|
+
// Run all models in parallel; Promise.allSettled so one failure doesn't
|
|
492
|
+
// cancel the others.
|
|
493
|
+
const modelRuns = availableTools.map(async (tool) => {
|
|
494
|
+
const modelStart = Date.now();
|
|
495
|
+
try {
|
|
496
|
+
const model = await loadModel(tool.command);
|
|
497
|
+
const result = await model.analyze(prompt, context, resultsDir, timeout, nice);
|
|
498
|
+
const elapsed = ((Date.now() - modelStart) / 1000).toFixed(1) + 's';
|
|
499
|
+
progress.stopSpinner(tool.name, 'done', `${result.findings.length} findings (${elapsed})`);
|
|
500
|
+
return { tool, result, error: null };
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
504
|
+
const authMsg = detectAuthIssue(tool.name, msg);
|
|
505
|
+
progress.stopSpinner(tool.name, 'failed', authMsg ?? msg.slice(0, 100));
|
|
506
|
+
return { tool, result: null, error: msg };
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
const settled = await Promise.allSettled(modelRuns);
|
|
510
|
+
const successResults = [];
|
|
511
|
+
const failedModels = [];
|
|
512
|
+
for (const outcome of settled) {
|
|
513
|
+
if (outcome.status === 'rejected') {
|
|
514
|
+
failedModels.push({ name: 'unknown', error: String(outcome.reason) });
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const { tool, result, error } = outcome.value;
|
|
518
|
+
if (result) {
|
|
519
|
+
successResults.push(result);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
failedModels.push({ name: tool.name, error: error ?? 'unknown error' });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (successResults.length === 0) {
|
|
526
|
+
console.error('\nError: All models failed.\n');
|
|
527
|
+
for (const fm of failedModels) {
|
|
528
|
+
const authMsg = detectAuthIssue(fm.name, fm.error);
|
|
529
|
+
console.error(` ${fm.name}: ${authMsg ?? fm.error.slice(0, 200)}`);
|
|
530
|
+
}
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
// -------------------------------------------------------------------------
|
|
534
|
+
// Phase 4: Diagnosis
|
|
535
|
+
// -------------------------------------------------------------------------
|
|
536
|
+
progress.startPhase('diagnosis', 'Diagnosis');
|
|
537
|
+
const merger = new MergeEngine();
|
|
538
|
+
const merged = merger.merge(successResults);
|
|
539
|
+
const totalFindings = merged.blockers.length +
|
|
540
|
+
merged.high.length +
|
|
541
|
+
merged.medium.length +
|
|
542
|
+
merged.low.length;
|
|
543
|
+
progress.addItem('Clustered findings');
|
|
544
|
+
progress.updateItem('Clustered findings', 'done', `${totalFindings} unique issues from ${successResults.length} model${successResults.length === 1 ? '' : 's'}`);
|
|
545
|
+
if (merged.consensus.length > 0) {
|
|
546
|
+
progress.addItem('Consensus detected');
|
|
547
|
+
progress.updateItem('Consensus detected', 'done', `${merged.consensus.length} issue${merged.consensus.length === 1 ? '' : 's'} confirmed by 2+ models`);
|
|
548
|
+
}
|
|
549
|
+
if (merged.conflicts.length > 0) {
|
|
550
|
+
progress.addItem('Conflicts identified');
|
|
551
|
+
progress.updateItem('Conflicts identified', 'done', `${merged.conflicts.length} severity disagreement${merged.conflicts.length === 1 ? '' : 's'}`);
|
|
552
|
+
}
|
|
553
|
+
if (!process.stdout.isTTY) {
|
|
554
|
+
process.stdout.write(`[diag] ${totalFindings} issues, ${merged.consensus.length} consensus, ` +
|
|
555
|
+
`${merged.conflicts.length} conflict${merged.conflicts.length === 1 ? '' : 's'}\n`);
|
|
556
|
+
}
|
|
557
|
+
// -------------------------------------------------------------------------
|
|
558
|
+
// Phase 5: Report
|
|
559
|
+
// -------------------------------------------------------------------------
|
|
560
|
+
progress.startPhase('report', 'Report');
|
|
561
|
+
const elapsedSec = (Date.now() - startTime) / 1000;
|
|
562
|
+
const activeModelNames = successResults.map((r) => r.model);
|
|
563
|
+
const report = format === 'json'
|
|
564
|
+
? generateJsonReport(merged, prompt, elapsedSec, activeModelNames)
|
|
565
|
+
: generateMarkdownReport(merged, prompt, elapsedSec, activeModelNames);
|
|
566
|
+
if (opts.out) {
|
|
567
|
+
const outPath = resolve(opts.out);
|
|
568
|
+
writeFileSync(outPath, report, 'utf8');
|
|
569
|
+
progress.addItem('Saved report');
|
|
570
|
+
progress.updateItem('Saved report', 'done', outPath);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
progress.addItem('Generated report');
|
|
574
|
+
progress.updateItem('Generated report', 'done', `${merged.blockers.length} blockers, ${merged.high.length} high, ` +
|
|
575
|
+
`${merged.medium.length} medium, ${merged.low.length} low`);
|
|
576
|
+
}
|
|
577
|
+
// Save merged.json
|
|
578
|
+
const mergedPath = join(resultsDir, 'merged.json');
|
|
579
|
+
writeFileSync(mergedPath, JSON.stringify(mergedResultToDict(merged), null, 2), 'utf8');
|
|
580
|
+
progress.addItem('Saved to results dir');
|
|
581
|
+
progress.updateItem('Saved to results dir', 'done', 'merged.json + per-model outputs');
|
|
582
|
+
if (!process.stdout.isTTY) {
|
|
583
|
+
process.stdout.write(plainReportLine(merged.blockers.length, merged.high.length, merged.medium.length, merged.low.length) + '\n');
|
|
584
|
+
}
|
|
585
|
+
// -------------------------------------------------------------------------
|
|
586
|
+
// Patches
|
|
587
|
+
// -------------------------------------------------------------------------
|
|
588
|
+
if (merged.patches.length > 0 && (opts.dryRun || opts.apply)) {
|
|
589
|
+
if (opts.dryRun) {
|
|
590
|
+
console.log('\n=== Patches (dry-run) ===\n');
|
|
591
|
+
for (const patch of merged.patches) {
|
|
592
|
+
console.log(`--- ${patch.path} ---`);
|
|
593
|
+
console.log(patch.diff);
|
|
594
|
+
console.log();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else if (opts.apply) {
|
|
598
|
+
if (!context.is_git_repo) {
|
|
599
|
+
console.error('\nError: Cannot apply patches — not a git repository.');
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
console.log(`\n${merged.patches.length} patch(es) ready. Patch application coming soon.`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// -------------------------------------------------------------------------
|
|
606
|
+
// Memory
|
|
607
|
+
// -------------------------------------------------------------------------
|
|
608
|
+
if (opts.remember) {
|
|
609
|
+
progress.startPhase('memory', 'Memory');
|
|
610
|
+
progress.addItem('Saving findings');
|
|
611
|
+
writeMemory(merged, prompt);
|
|
612
|
+
progress.updateItem('Saving findings', 'done', 'written to AI memory files');
|
|
613
|
+
}
|
|
614
|
+
// -------------------------------------------------------------------------
|
|
615
|
+
// Final summary
|
|
616
|
+
// -------------------------------------------------------------------------
|
|
617
|
+
const totalSec = (Date.now() - startTime) / 1000;
|
|
618
|
+
progress.finish(totalSec, totalFindings, merged.consensus.length);
|
|
619
|
+
// Print report to stdout if not writing to file
|
|
620
|
+
if (!opts.out) {
|
|
621
|
+
console.log('\n' + report);
|
|
622
|
+
}
|
|
623
|
+
if (opts.verbose) {
|
|
624
|
+
console.error(`\nMerged results saved to: ${mergedPath}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// Run
|
|
628
|
+
main().catch((err) => {
|
|
629
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
630
|
+
console.error(`triage-ai: fatal error — ${msg}`);
|
|
631
|
+
process.exit(1);
|
|
632
|
+
});
|
|
633
|
+
//# sourceMappingURL=cli.js.map
|