ship-safe 9.1.0 → 9.1.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.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Team Report Command
3
+ * ====================
4
+ *
5
+ * Converts raw Hermes Agent team output into a professional Ship Safe report.
6
+ * Strips ANSI codes and terminal chrome, parses structured FINDING: lines,
7
+ * and renders everything through Ship Safe's HTML reporter.
8
+ *
9
+ * USAGE:
10
+ * ship-safe team-report Read from stdin (pipe Hermes output)
11
+ * ship-safe team-report output.txt Read from file
12
+ * ship-safe team-report output.txt --html Save as HTML
13
+ * ship-safe team-report output.txt --json JSON output
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import chalk from 'chalk';
19
+ import * as output from '../utils/output.js';
20
+ import { printBanner } from '../utils/output.js';
21
+
22
+ // =============================================================================
23
+ // ANSI + TERMINAL NOISE STRIPPING
24
+ // =============================================================================
25
+
26
+ function stripAnsi(str) {
27
+ // Remove all ANSI escape sequences (colors, cursor moves, clears, etc.)
28
+ return str
29
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
30
+ .replace(/\x1b\][^\x07]*\x07/g, '')
31
+ .replace(/\x1b[()][AB012]/g, '')
32
+ .replace(/\x9b[0-9;]*[A-Za-z]/g, '');
33
+ }
34
+
35
+ function stripHermesChrome(text) {
36
+ const lines = text.split('\n');
37
+ const cleaned = [];
38
+ let inSplash = false;
39
+
40
+ for (const line of lines) {
41
+ const t = line.trim();
42
+
43
+ // Skip the Hermes splash box (╭─ ... ─╮ ... ╰─ ... ─╯)
44
+ if (t.startsWith('╭─') || t.startsWith('╰─')) { inSplash = !inSplash; continue; }
45
+ if (inSplash) continue;
46
+
47
+ // Skip raw system prompt instructions leaked into output
48
+ if (t.startsWith('EXACTLY this format') || t.startsWith('FINDING: {"severity"')) continue;
49
+ if (t.match(/^─{10,}$/)) continue;
50
+
51
+ // Skip Hermes warning lines
52
+ if (t.startsWith('⚠') && t.includes('hermes')) continue;
53
+ if (t.startsWith('⚠') && t.includes('OPENROUTER')) continue;
54
+ if (t.startsWith('⚠') && (t.includes('API call failed') || t.includes('credits'))) continue;
55
+ if (t.startsWith('⏱') || t.startsWith('❌')) continue;
56
+
57
+ // Skip terminal screen-clear sequences
58
+ if (t === '[2J' || t === '[H' || t === '[2J[H') continue;
59
+
60
+ cleaned.push(line);
61
+ }
62
+
63
+ return cleaned.join('\n');
64
+ }
65
+
66
+ // =============================================================================
67
+ // FINDING PARSER
68
+ // =============================================================================
69
+
70
+ function parseFindings(text) {
71
+ const findings = [];
72
+ const findingRegex = /^FINDING:\s*(\{.+\})\s*$/gm;
73
+ let match;
74
+
75
+ while ((match = findingRegex.exec(text)) !== null) {
76
+ try {
77
+ const f = JSON.parse(match[1]);
78
+ if (f.severity && f.title) findings.push(f);
79
+ } catch { /* skip malformed */ }
80
+ }
81
+
82
+ return findings;
83
+ }
84
+
85
+ // =============================================================================
86
+ // AGENT SECTION PARSER
87
+ // =============================================================================
88
+
89
+ function parseAgentSections(text) {
90
+ const sections = [];
91
+ // Matches: ### Agent Name (Role) — N finding(s)
92
+ const sectionRegex = /###\s+(.+?)\s*(?:\(([^)]+)\))?\s*[—–-]+\s*(\d+)\s*finding/gi;
93
+ let match;
94
+
95
+ while ((match = sectionRegex.exec(text)) !== null) {
96
+ sections.push({
97
+ name: match[1].trim(),
98
+ role: match[2]?.trim() || '',
99
+ count: parseInt(match[3], 10),
100
+ });
101
+ }
102
+
103
+ // Also collect bullet findings under each section
104
+ const bulletRegex = /\[(CRITICAL|HIGH|MEDIUM|LOW|INFO)\]\s+(.+?)\s*[—–-]+\s*(.+)/gi;
105
+ const bullets = [];
106
+ while ((match = bulletRegex.exec(text)) !== null) {
107
+ bullets.push({
108
+ severity: match[1].toLowerCase(),
109
+ title: match[2].trim(),
110
+ location: match[3].trim(),
111
+ });
112
+ }
113
+
114
+ return { sections, bullets };
115
+ }
116
+
117
+ // =============================================================================
118
+ // SYNTHESIS PARSER
119
+ // =============================================================================
120
+
121
+ function parseSynthesis(text) {
122
+ // Extract the Hermes synthesis block (inside ╭─ ⚕ Hermes ─╮ ... ╰─╯)
123
+ // After stripping chrome, look for the summary block
124
+ const lines = text.split('\n');
125
+ const synthesisLines = [];
126
+ let capturing = false;
127
+
128
+ for (const line of lines) {
129
+ const t = line.trim();
130
+
131
+ // The synthesis is the content after the agent section summary and before errors
132
+ if (t.match(/^Overall risk posture:/i)) { capturing = true; }
133
+ if (capturing) {
134
+ if (t.startsWith('⚠') || t.startsWith('❌') || t.startsWith('⏱')) break;
135
+ synthesisLines.push(line);
136
+ }
137
+ }
138
+
139
+ // Also look for risk posture statement
140
+ const riskMatch = text.match(/Overall risk posture:\s*(.+)/i);
141
+ const riskPosture = riskMatch ? riskMatch[1].trim() : null;
142
+
143
+ // Parse roadmap sections
144
+ const immediateMatch = text.match(/\*\*Immediate[^*]*\*\*:?\s*([^\n]+(?:\n(?!\*\*)[^\n]+)*)/i);
145
+ const shortTermMatch = text.match(/\*\*Short-term[^*]*\*\*:?\s*([^\n]+(?:\n(?!\*\*)[^\n]+)*)/i);
146
+ const longTermMatch = text.match(/\*\*Long-term[^*]*\*\*:?\s*([^\n]+(?:\n(?!\*\*)[^\n]+)*)/i);
147
+
148
+ return {
149
+ riskPosture,
150
+ synthesis: synthesisLines.join('\n').trim(),
151
+ roadmap: {
152
+ immediate: immediateMatch?.[1]?.trim() || null,
153
+ shortTerm: shortTermMatch?.[1]?.trim() || null,
154
+ longTerm: longTermMatch?.[1]?.trim() || null,
155
+ },
156
+ };
157
+ }
158
+
159
+ // =============================================================================
160
+ // TARGET PARSER
161
+ // =============================================================================
162
+
163
+ function parseTarget(text) {
164
+ const match = text.match(/assessments?\s+of\s+\*\*([^*]+)\*\*/i);
165
+ return match ? match[1].trim() : 'Unknown Target';
166
+ }
167
+
168
+ // =============================================================================
169
+ // HTML RENDERER
170
+ // =============================================================================
171
+
172
+ function generateHTML(target, findings, agentSections, synthesis, bullets) {
173
+ const date = new Date().toLocaleDateString('en-US', {
174
+ year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
175
+ });
176
+
177
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
178
+ for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
179
+
180
+ // Merge FINDING: JSON lines with bullet-parsed findings (bullets are fallback)
181
+ const allFindings = findings.length > 0 ? findings : bullets.map(b => ({
182
+ severity: b.severity,
183
+ title: b.title,
184
+ location: b.location,
185
+ remediation: '',
186
+ }));
187
+
188
+ // Recalculate counts from allFindings
189
+ const sevCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
190
+ for (const f of allFindings) sevCounts[f.severity] = (sevCounts[f.severity] || 0) + 1;
191
+
192
+ const riskColor = (rp) => {
193
+ if (!rp) return '#94a3b8';
194
+ const lc = rp.toLowerCase();
195
+ if (lc.includes('critical')) return '#dc2626';
196
+ if (lc.includes('high')) return '#f97316';
197
+ if (lc.includes('medium')) return '#eab308';
198
+ return '#22c55e';
199
+ };
200
+
201
+ const sevColors = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low: '#3b82f6', info: '#94a3b8' };
202
+
203
+ const findingRows = allFindings.map(f => `
204
+ <tr>
205
+ <td><span class="sev sev-${f.severity}">${f.severity.toUpperCase()}</span></td>
206
+ <td><code>${f.location || '—'}</code></td>
207
+ <td><strong>${f.title}</strong>${f.cve ? `<br><small>CVE: ${f.cve}</small>` : ''}</td>
208
+ <td><small>${f.remediation || '—'}</small></td>
209
+ </tr>`).join('');
210
+
211
+ const agentRows = agentSections.sections.map(s => `
212
+ <tr>
213
+ <td>${s.name}</td>
214
+ <td><code>${s.role || '—'}</code></td>
215
+ <td style="color:${s.count > 0 ? '#f97316' : '#22c55e'}">${s.count}</td>
216
+ </tr>`).join('');
217
+
218
+ const roadmap = synthesis.roadmap;
219
+ const roadmapHTML = (roadmap.immediate || roadmap.shortTerm || roadmap.longTerm) ? `
220
+ <h2>Remediation Roadmap</h2>
221
+ <table>
222
+ <tbody>
223
+ ${roadmap.immediate ? `<tr><td style="color:#dc2626;white-space:nowrap;font-weight:600">⚡ Immediate (24–48h)</td><td>${roadmap.immediate}</td></tr>` : ''}
224
+ ${roadmap.shortTerm ? `<tr><td style="color:#f97316;white-space:nowrap;font-weight:600">📅 Short-term (1–2 weeks)</td><td>${roadmap.shortTerm}</td></tr>` : ''}
225
+ ${roadmap.longTerm ? `<tr><td style="color:#eab308;white-space:nowrap;font-weight:600">🏗 Long-term (1–3 months)</td><td>${roadmap.longTerm}</td></tr>` : ''}
226
+ </tbody>
227
+ </table>` : '';
228
+
229
+ return `<!DOCTYPE html>
230
+ <html lang="en">
231
+ <head>
232
+ <meta charset="utf-8">
233
+ <meta name="viewport" content="width=device-width,initial-scale=1">
234
+ <title>Ship Safe Team Report — ${target}</title>
235
+ <style>
236
+ *{margin:0;padding:0;box-sizing:border-box}
237
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem}
238
+ .container{max-width:1100px;margin:0 auto}
239
+ .header{display:flex;align-items:center;gap:1rem;margin-bottom:2rem}
240
+ .logo{font-size:1.5rem;font-weight:800;color:#38bdf8;letter-spacing:-1px}
241
+ .badge{background:#1e293b;padding:3px 10px;border-radius:20px;font-size:0.75rem;color:#94a3b8;border:1px solid #334155}
242
+ h1{font-size:1.8rem;font-weight:700;color:#f1f5f9;margin-bottom:0.25rem}
243
+ h2{font-size:1.1rem;font-weight:600;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em}
244
+ .meta{color:#64748b;font-size:0.85rem;margin-bottom:2rem}
245
+ .risk-card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:1.5rem 2rem;margin-bottom:2rem;display:flex;align-items:center;gap:1.5rem}
246
+ .risk-label{font-size:0.75rem;text-transform:uppercase;color:#64748b;margin-bottom:0.25rem}
247
+ .risk-value{font-size:1.5rem;font-weight:700}
248
+ .risk-desc{color:#94a3b8;font-size:0.9rem;flex:1}
249
+ .stats{display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem;margin-bottom:2rem}
250
+ .stat{background:#1e293b;padding:1.25rem;border-radius:8px;text-align:center;border:1px solid #334155}
251
+ .stat-number{font-size:2rem;font-weight:bold}
252
+ .stat-label{color:#64748b;font-size:0.75rem;margin-top:0.25rem;text-transform:uppercase}
253
+ table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:8px;overflow:hidden;margin-bottom:2rem;border:1px solid #334155}
254
+ th{background:#334155;text-align:left;padding:0.75rem 1rem;font-size:0.75rem;text-transform:uppercase;color:#94a3b8;font-weight:600;letter-spacing:0.05em}
255
+ td{padding:0.75rem 1rem;border-top:1px solid #0f172a;font-size:0.85rem;vertical-align:top}
256
+ tr:hover{background:#263248}
257
+ code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.8rem;color:#38bdf8;word-break:break-all}
258
+ small{color:#64748b}
259
+ .sev{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em}
260
+ .sev-critical{background:#dc262622;color:#fca5a5;border:1px solid #dc262644}
261
+ .sev-high{background:#f9731622;color:#fdba74;border:1px solid #f9731644}
262
+ .sev-medium{background:#eab30822;color:#fde047;border:1px solid #eab30844}
263
+ .sev-low{background:#3b82f622;color:#93c5fd;border:1px solid #3b82f644}
264
+ .sev-info{background:#94a3b822;color:#cbd5e1;border:1px solid #94a3b844}
265
+ .empty{text-align:center;color:#22c55e;padding:2rem}
266
+ .footer{text-align:center;color:#334155;margin-top:3rem;padding-top:1.5rem;border-top:1px solid #1e293b;font-size:0.8rem}
267
+ .powered{color:#38bdf8}
268
+ </style>
269
+ </head>
270
+ <body>
271
+ <div class="container">
272
+
273
+ <div class="header">
274
+ <span class="logo">Ship Safe</span>
275
+ <span class="badge">Team Security Report</span>
276
+ <span class="badge">Powered by Hermes Agent</span>
277
+ </div>
278
+
279
+ <h1>${target}</h1>
280
+ <p class="meta">Generated ${date} · ${allFindings.length} finding${allFindings.length !== 1 ? 's' : ''} · ${agentSections.sections.length} agent${agentSections.sections.length !== 1 ? 's' : ''}</p>
281
+
282
+ ${synthesis.riskPosture ? `
283
+ <div class="risk-card">
284
+ <div>
285
+ <div class="risk-label">Overall Risk Posture</div>
286
+ <div class="risk-value" style="color:${riskColor(synthesis.riskPosture)}">${synthesis.riskPosture.split('—')[0].trim()}</div>
287
+ </div>
288
+ <div class="risk-desc">${synthesis.riskPosture.includes('—') ? synthesis.riskPosture.split('—').slice(1).join('—').trim() : ''}</div>
289
+ </div>` : ''}
290
+
291
+ <div class="stats">
292
+ <div class="stat"><div class="stat-number" style="color:#dc2626">${sevCounts.critical}</div><div class="stat-label">Critical</div></div>
293
+ <div class="stat"><div class="stat-number" style="color:#f97316">${sevCounts.high}</div><div class="stat-label">High</div></div>
294
+ <div class="stat"><div class="stat-number" style="color:#eab308">${sevCounts.medium}</div><div class="stat-label">Medium</div></div>
295
+ <div class="stat"><div class="stat-number" style="color:#3b82f6">${sevCounts.low}</div><div class="stat-label">Low</div></div>
296
+ <div class="stat"><div class="stat-number" style="color:#94a3b8">${sevCounts.info}</div><div class="stat-label">Info</div></div>
297
+ </div>
298
+
299
+ <h2>Findings</h2>
300
+ <table>
301
+ <thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Remediation</th></tr></thead>
302
+ <tbody>${findingRows || '<tr><td colspan="4" class="empty">No findings — clean!</td></tr>'}</tbody>
303
+ </table>
304
+
305
+ ${agentSections.sections.length > 0 ? `
306
+ <h2>Agent Team Summary</h2>
307
+ <table>
308
+ <thead><tr><th>Agent</th><th>Role</th><th>Findings</th></tr></thead>
309
+ <tbody>${agentRows}</tbody>
310
+ </table>` : ''}
311
+
312
+ ${roadmapHTML}
313
+
314
+ <div class="footer">
315
+ Secured by <span class="powered">Ship Safe</span> · shipsafecli.com · <code>npx ship-safe red-team .</code>
316
+ </div>
317
+ </div>
318
+ </body>
319
+ </html>`;
320
+ }
321
+
322
+ // =============================================================================
323
+ // MAIN COMMAND
324
+ // =============================================================================
325
+
326
+ export async function teamReportCommand(inputFile, options = {}) {
327
+ let raw;
328
+
329
+ if (inputFile) {
330
+ if (!fs.existsSync(inputFile)) {
331
+ output.error(`File not found: ${inputFile}`);
332
+ process.exit(1);
333
+ }
334
+ raw = fs.readFileSync(inputFile, 'utf-8');
335
+ } else {
336
+ // Read from stdin
337
+ raw = fs.readFileSync('/dev/stdin', 'utf-8');
338
+ }
339
+
340
+ // Clean the input
341
+ const stripped = stripAnsi(raw);
342
+ const cleaned = stripHermesChrome(stripped);
343
+
344
+ // Parse
345
+ const target = parseTarget(stripped);
346
+ const findings = parseFindings(cleaned);
347
+ const agentSections = parseAgentSections(cleaned);
348
+ const synthesis = parseSynthesis(cleaned);
349
+
350
+ const allFindings = findings.length > 0 ? findings : agentSections.bullets.map(b => ({
351
+ severity: b.severity,
352
+ title: b.title,
353
+ location: b.location,
354
+ remediation: '',
355
+ }));
356
+
357
+ if (options.json) {
358
+ console.log(JSON.stringify({ target, findings: allFindings, agentSections: agentSections.sections, synthesis }, null, 2));
359
+ return;
360
+ }
361
+
362
+ if (options.html !== undefined) {
363
+ const htmlPath = typeof options.html === 'string' ? options.html : 'team-report.html';
364
+ const html = generateHTML(target, findings, agentSections, synthesis, agentSections.bullets);
365
+ fs.writeFileSync(htmlPath, html, 'utf-8');
366
+ output.success(`Team report saved to ${htmlPath}`);
367
+ return;
368
+ }
369
+
370
+ // Terminal output
371
+ printBanner();
372
+ console.log(chalk.cyan.bold(' Team Security Report'));
373
+ console.log(chalk.gray(` Target: ${target}`));
374
+ console.log();
375
+
376
+ if (synthesis.riskPosture) {
377
+ const rp = synthesis.riskPosture;
378
+ const color = rp.toLowerCase().includes('critical') ? chalk.red.bold
379
+ : rp.toLowerCase().includes('high') ? chalk.yellow.bold
380
+ : rp.toLowerCase().includes('medium') ? chalk.yellow
381
+ : chalk.green;
382
+ console.log(` ${chalk.white.bold('Risk Posture:')} ${color(rp)}`);
383
+ console.log();
384
+ }
385
+
386
+ const sevColor = { critical: chalk.red.bold, high: chalk.yellow, medium: chalk.blue, low: chalk.gray, info: chalk.gray };
387
+ for (const f of allFindings) {
388
+ const col = sevColor[f.severity] || chalk.white;
389
+ console.log(` ${col(`[${f.severity.toUpperCase()}]`.padEnd(11))} ${chalk.white(f.title)}`);
390
+ if (f.location) console.log(` ${' '.repeat(11)} ${chalk.gray(f.location)}`);
391
+ if (f.remediation) console.log(` ${' '.repeat(11)} ${chalk.green('Fix:')} ${f.remediation.slice(0, 90)}`);
392
+ }
393
+
394
+ if (allFindings.length === 0) {
395
+ console.log(chalk.green(' No findings — clean!'));
396
+ }
397
+
398
+ console.log();
399
+ if (synthesis.roadmap.immediate) {
400
+ console.log(chalk.red.bold(' ⚡ Immediate (24–48h):'));
401
+ console.log(chalk.gray(` ${synthesis.roadmap.immediate}`));
402
+ }
403
+ if (synthesis.roadmap.shortTerm) {
404
+ console.log(chalk.yellow.bold(' 📅 Short-term (1–2 weeks):'));
405
+ console.log(chalk.gray(` ${synthesis.roadmap.shortTerm}`));
406
+ }
407
+ if (synthesis.roadmap.longTerm) {
408
+ console.log(chalk.white.bold(' 🏗 Long-term (1–3 months):'));
409
+ console.log(chalk.gray(` ${synthesis.roadmap.longTerm}`));
410
+ }
411
+
412
+ console.log();
413
+ console.log(chalk.gray(' Generate HTML report: ') + chalk.cyan(`ship-safe team-report ${inputFile || '<file>'} --html report.html`));
414
+ console.log();
415
+ }
@@ -50,6 +50,11 @@ export async function watchCommand(targetPath = '.', options = {}) {
50
50
  return watchConfigs(absolutePath);
51
51
  }
52
52
 
53
+ // Stateful mode: persistent K2.6 session (subset of deep)
54
+ if (options.stateful) {
55
+ return watchStateful(absolutePath, options);
56
+ }
57
+
53
58
  // Deep mode: run full orchestrator on changes
54
59
  if (options.deep) {
55
60
  return watchDeep(absolutePath, options);
@@ -289,6 +294,135 @@ function showWatchStatus(rootPath) {
289
294
  // DEEP WATCH MODE (full orchestrator)
290
295
  // =============================================================================
291
296
 
297
+ async function watchStateful(absolutePath, options = {}) {
298
+ const { StatefulWatcher } = await import('../agents/stateful-watcher.js');
299
+ const { ReconAgent } = await import('../agents/recon-agent.js');
300
+
301
+ const debounceMs = options.debounce || 2000;
302
+ const scoringEngine = new ScoringEngine();
303
+
304
+ console.log();
305
+ output.header('Ship Safe — Stateful Watch Mode (Kimi K2.6)');
306
+ console.log();
307
+ console.log(chalk.cyan(' Persistent security session — context builds over time'));
308
+ console.log(chalk.gray(` Debounce: ${debounceMs}ms`));
309
+ console.log(chalk.gray(' Press Ctrl+C to stop'));
310
+ console.log();
311
+
312
+ const watcher = StatefulWatcher.create(absolutePath, {
313
+ provider: options.provider || 'kimi',
314
+ model: options.model || 'kimi-k2.6',
315
+ verbose: options.verbose,
316
+ });
317
+
318
+ if (!watcher) {
319
+ output.error('Stateful watch requires MOONSHOT_API_KEY. Set it and retry.');
320
+ process.exit(1);
321
+ }
322
+
323
+ // Prime session with baseline
324
+ const reconAgent = new ReconAgent();
325
+ console.log(chalk.gray(' Building baseline...'));
326
+ let recon;
327
+ try {
328
+ const reconResult = await reconAgent.analyze({ rootPath: absolutePath });
329
+ recon = Array.isArray(reconResult) ? {} : reconResult;
330
+ } catch { recon = {}; }
331
+ const files = await reconAgent.discoverFiles(absolutePath);
332
+ await watcher.setBaseline(recon, files);
333
+ console.log(chalk.green(` Baseline set (${watcher.provider.name} / ${watcher.provider.model}). Watching...\n`));
334
+
335
+ let pendingFiles = new Set();
336
+ let debounceTimer = null;
337
+ let allFindings = [];
338
+
339
+ const dbDir = path.join(absolutePath, WATCH_DB_DIR);
340
+ const dbFile = path.join(dbDir, WATCH_DB_FILE);
341
+
342
+ const processChanges = async () => {
343
+ const changedFiles = [...pendingFiles];
344
+ pendingFiles.clear();
345
+ if (changedFiles.length === 0) return;
346
+
347
+ const timestamp = new Date().toLocaleTimeString();
348
+ console.log(chalk.gray(` [${timestamp}] ${changedFiles.length} file(s) changed — stateful scan...`));
349
+
350
+ try {
351
+ const newFindings = await watcher.analyzeChanges(changedFiles);
352
+
353
+ if (newFindings.length === 0) {
354
+ console.log(chalk.green(` [${timestamp}] ✔ Clean\n`));
355
+ } else {
356
+ allFindings = allFindings.concat(newFindings);
357
+ const scoreResult = scoringEngine.compute(allFindings);
358
+ const scoreColor = scoreResult.score >= 75 ? chalk.cyan : scoreResult.score >= 50 ? chalk.yellow : chalk.red;
359
+ console.log(` [${timestamp}] ${chalk.white(`${newFindings.length} new finding(s)`)}: Score ${scoreColor(`${scoreResult.score}/100`)}`);
360
+ for (const f of newFindings.filter(f => f.severity === 'critical' || f.severity === 'high')) {
361
+ const relFile = path.relative(absolutePath, f.file || '');
362
+ const sev = f.severity === 'critical' ? chalk.red.bold('!!') : chalk.yellow(' !');
363
+ console.log(` ${sev} ${f.title} — ${relFile}:${f.line}`);
364
+ }
365
+ console.log('');
366
+
367
+ // Persist
368
+ try {
369
+ if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
370
+ const stats = watcher.getStats();
371
+ fs.writeFileSync(dbFile, JSON.stringify({
372
+ mode: 'stateful',
373
+ lastScan: new Date().toISOString(),
374
+ scanCount: stats.scanCount,
375
+ provider: stats.provider,
376
+ model: stats.model,
377
+ findings: allFindings.map(f => ({
378
+ file: path.relative(absolutePath, f.file || ''),
379
+ line: f.line,
380
+ severity: f.severity,
381
+ rule: f.rule,
382
+ title: f.title,
383
+ })),
384
+ }, null, 2));
385
+ } catch { /* non-fatal */ }
386
+ }
387
+ } catch (err) {
388
+ console.log(chalk.red(` [${timestamp}] Scan error: ${err.message}\n`));
389
+ }
390
+ };
391
+
392
+ try {
393
+ const fsWatcher = fs.watch(absolutePath, { recursive: true }, (eventType, filename) => {
394
+ if (!filename) return;
395
+ const relPath = filename.replace(/\\/g, '/');
396
+ for (const skipDir of SKIP_DIRS) {
397
+ if (relPath.includes(`${skipDir}/`)) return;
398
+ }
399
+ const ext = path.extname(filename).toLowerCase();
400
+ if (SKIP_EXTENSIONS.has(ext)) return;
401
+ if (SKIP_FILENAMES.has(path.basename(filename))) return;
402
+ if (filename.endsWith('.min.js') || filename.endsWith('.min.css')) return;
403
+
404
+ const fullPath = path.join(absolutePath, filename);
405
+ if (!fs.existsSync(fullPath)) return;
406
+
407
+ pendingFiles.add(fullPath);
408
+ if (debounceTimer) clearTimeout(debounceTimer);
409
+ debounceTimer = setTimeout(processChanges, debounceMs);
410
+ });
411
+
412
+ process.on('SIGINT', () => {
413
+ fsWatcher.close();
414
+ const stats = watcher.getStats();
415
+ console.log(`\n Stateful watch stopped. ${stats.scanCount} scan(s), ${allFindings.length} total finding(s).\n`);
416
+ process.exit(0);
417
+ });
418
+
419
+ setInterval(() => {}, 1000 * 60 * 60);
420
+ } catch (err) {
421
+ output.error(`Stateful watch failed: ${err.message}`);
422
+ process.exit(1);
423
+ }
424
+ }
425
+
292
426
  async function watchDeep(absolutePath, options = {}) {
293
427
  const { buildOrchestratorAsync } = await import('../agents/index.js');
294
428
  const { ReconAgent } = await import('../agents/recon-agent.js');
@@ -360,10 +360,13 @@ const OPENAI_COMPATIBLE_PRESETS = {
360
360
  together: { baseUrl: 'https://api.together.xyz/v1/chat/completions', model: 'meta-llama/Llama-3-70b-chat-hf', envKey: 'TOGETHER_API_KEY' },
361
361
  mistral: { baseUrl: 'https://api.mistral.ai/v1/chat/completions', model: 'mistral-large-latest', envKey: 'MISTRAL_API_KEY' },
362
362
  cohere: { baseUrl: 'https://api.cohere.com/compatibility/v1/chat/completions', model: 'command-r-plus', envKey: 'COHERE_API_KEY' },
363
- deepseek: { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', envKey: 'DEEPSEEK_API_KEY' },
363
+ deepseek: { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-pro', envKey: 'DEEPSEEK_API_KEY' },
364
+ 'deepseek-flash': { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-flash', envKey: 'DEEPSEEK_API_KEY' },
364
365
  perplexity: { baseUrl: 'https://api.perplexity.ai/chat/completions', model: 'llama-3.1-sonar-large-128k-online', envKey: 'PERPLEXITY_API_KEY' },
365
366
  lmstudio: { baseUrl: 'http://localhost:1234/v1/chat/completions', model: null, envKey: null },
366
367
  xai: { baseUrl: 'https://api.x.ai/v1/chat/completions', model: 'grok-3-mini', envKey: 'XAI_API_KEY' },
368
+ kimi: { baseUrl: 'https://api.moonshot.ai/v1/chat/completions', model: 'kimi-k2.6', envKey: 'MOONSHOT_API_KEY' },
369
+ moonshot: { baseUrl: 'https://api.moonshot.ai/v1/chat/completions', model: 'kimi-k2.6', envKey: 'MOONSHOT_API_KEY' },
367
370
  // Gemma 4 via Ollama — runs fully local, no API key required
368
371
  // e4b: MoE 4B active params, ~8GB RAM; 27b: dense, ~20GB RAM
369
372
  gemma4: { baseUrl: 'http://localhost:11434/v1/chat/completions', model: 'gemma4:e4b', envKey: null },
@@ -375,6 +378,90 @@ class OpenAICompatibleProvider extends OpenAIProvider {
375
378
  super(apiKey, options);
376
379
  this.name = name;
377
380
  }
381
+
382
+ /** Models known to support OpenAI function calling reliably */
383
+ get supportsStructuredOutput() {
384
+ return /kimi|moonshot|gpt-4|grok|deepseek|mistral-large/i.test(this.model || '');
385
+ }
386
+
387
+ async complete(systemPrompt, userPrompt, options = {}) {
388
+ const body = {
389
+ model: options.model || this.model,
390
+ max_tokens: options.maxTokens || 2048,
391
+ messages: [
392
+ { role: 'system', content: systemPrompt },
393
+ { role: 'user', content: userPrompt },
394
+ ],
395
+ };
396
+ if (options.jsonMode) body.response_format = { type: 'json_object' };
397
+
398
+ const response = await fetch(this.baseUrl, {
399
+ method: 'POST',
400
+ headers: {
401
+ 'Authorization': `Bearer ${this.apiKey}`,
402
+ 'Content-Type': 'application/json',
403
+ },
404
+ body: JSON.stringify(body),
405
+ });
406
+
407
+ if (!response.ok) {
408
+ const errBody = await response.text().catch(() => '');
409
+ throw new Error(`${this.name} API error: HTTP ${response.status} ${errBody.slice(0, 200)}`);
410
+ }
411
+
412
+ const data = await response.json();
413
+ const msg = data.choices?.[0]?.message;
414
+ // Kimi K2.6 thinking mode: actual answer in `content`; `reasoning_content` is internal thinking only
415
+ // With jsonMode, rely only on content (json_object format guarantees it); otherwise fall back to reasoning
416
+ if (options.jsonMode) return msg?.content || '';
417
+ return msg?.content || msg?.reasoning_content || '';
418
+ }
419
+
420
+ /**
421
+ * Complete with structured output via OpenAI tool-use format.
422
+ * Used by DeepAnalyzer multi-tier pipeline on non-Anthropic providers.
423
+ */
424
+ async completeWithTools(systemPrompt, userPrompt, toolName, inputSchema, options = {}) {
425
+ const response = await fetch(this.baseUrl, {
426
+ method: 'POST',
427
+ headers: {
428
+ 'Authorization': `Bearer ${this.apiKey}`,
429
+ 'Content-Type': 'application/json',
430
+ },
431
+ body: JSON.stringify({
432
+ model: options.model || this.model,
433
+ max_tokens: options.maxTokens || 2048,
434
+ messages: [
435
+ { role: 'system', content: systemPrompt },
436
+ { role: 'user', content: userPrompt },
437
+ ],
438
+ tools: [{
439
+ type: 'function',
440
+ function: {
441
+ name: toolName,
442
+ description: `Report ${toolName} results`,
443
+ parameters: inputSchema,
444
+ },
445
+ }],
446
+ tool_choice: 'required',
447
+ }),
448
+ });
449
+
450
+ if (!response.ok) {
451
+ const body = await response.text().catch(() => '');
452
+ throw new Error(`${this.name} API error: HTTP ${response.status} ${body.slice(0, 200)}`);
453
+ }
454
+
455
+ const data = await response.json();
456
+ const toolCall = data.choices?.[0]?.message?.tool_calls?.[0];
457
+ if (!toolCall) return null;
458
+
459
+ try {
460
+ return JSON.parse(toolCall.function.arguments);
461
+ } catch {
462
+ return null;
463
+ }
464
+ }
378
465
  }
379
466
 
380
467
  // =============================================================================
@@ -439,7 +526,7 @@ export function createProvider(provider, apiKey, options = {}) {
439
526
  throw new Error(
440
527
  `Unknown LLM provider: "${provider}".\n` +
441
528
  `Built-in: anthropic, openai, google, ollama\n` +
442
- `Presets: groq, together, mistral, cohere, deepseek, perplexity, lmstudio, xai\n` +
529
+ `Presets: groq, together, mistral, cohere, deepseek, deepseek-flash, perplexity, lmstudio, xai, kimi\n` +
443
530
  `Custom: pass any name with --base-url <url>`
444
531
  );
445
532
  }
@@ -480,6 +567,8 @@ export function autoDetectProvider(rootPath, options = {}) {
480
567
  MISTRAL_API_KEY: 'mistral',
481
568
  DEEPSEEK_API_KEY: 'deepseek',
482
569
  XAI_API_KEY: 'xai',
570
+ MOONSHOT_API_KEY: 'kimi',
571
+ KIMI_API_KEY: 'kimi',
483
572
  };
484
573
 
485
574
  for (const [envVar, providerName] of Object.entries(envKeys)) {