ninja-terminals 2.3.1 → 2.3.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/agent-send.js ADDED
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('http');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const {
9
+ SESSION_FILE,
10
+ LEDGER_FILE,
11
+ VERIFICATION_LEDGER_FILE,
12
+ readRuntimeSession,
13
+ healthCheckSession,
14
+ requestJson,
15
+ appendLedgerEntry,
16
+ readLedgerEntries,
17
+ appendVerificationEntry,
18
+ readVerificationEntries,
19
+ } = require('./lib/runtime-session');
20
+
21
+ const USAGE = `
22
+ Usage:
23
+ agent-send --status
24
+ agent-send --task-status [terminal-id]
25
+ agent-send --ledger [N]
26
+ agent-send --output <terminal-id> [--lines N]
27
+ agent-send --verifications [N]
28
+ agent-send <terminal-id> <message...>
29
+
30
+ Sends a message to a Ninja Terminal via PTY stdin.
31
+
32
+ Environment:
33
+ NINJA_HOST Server host (default: localhost)
34
+ NINJA_PORT Server port (overrides ${SESSION_FILE})
35
+ NINJA_AUTH_TOKEN Auth token (or use ${SESSION_FILE} / ~/.ninja/token)
36
+
37
+ Files:
38
+ ${SESSION_FILE} Runtime session (port, host, token)
39
+ ${LEDGER_FILE} Dispatch history (NDJSON)
40
+ ${VERIFICATION_LEDGER_FILE} Output/status verification history (NDJSON)
41
+
42
+ Examples:
43
+ agent-send --status # Check process status (logged as verification)
44
+ agent-send --task-status # Check all terminals task status
45
+ agent-send --task-status 1 # Check terminal 1 task status
46
+ agent-send --ledger # Show last 20 dispatches
47
+ agent-send --output 1 # Read terminal 1 output (logged as verification)
48
+ agent-send --output 1 --lines 50 # Read last 50 lines
49
+ agent-send --verifications # Show verification ledger
50
+ agent-send 2 "Hello from agent"
51
+ `;
52
+
53
+ function getToken(session) {
54
+ // 1. Environment variable
55
+ if (process.env.NINJA_AUTH_TOKEN) {
56
+ return process.env.NINJA_AUTH_TOKEN;
57
+ }
58
+
59
+ // 2. Runtime session file written by Ninja Terminals after browser auth
60
+ if (session && session.authToken) {
61
+ return session.authToken;
62
+ }
63
+
64
+ // 3. Token file
65
+ const tokenPath = path.join(os.homedir(), '.ninja', 'token');
66
+ if (fs.existsSync(tokenPath)) {
67
+ return fs.readFileSync(tokenPath, 'utf8').trim();
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ function resolveTarget(session) {
74
+ const port = process.env.NINJA_PORT || session?.port;
75
+ const host = process.env.NINJA_HOST || session?.host || 'localhost';
76
+
77
+ if (!port) {
78
+ throw new Error(`No live Ninja Terminal session found.
79
+
80
+ Start Ninja Terminals first. Expected runtime file:
81
+ ${SESSION_FILE}
82
+
83
+ Or set NINJA_PORT explicitly.`);
84
+ }
85
+
86
+ return { host, port: parseInt(port, 10) };
87
+ }
88
+
89
+ async function printStatus(session, token) {
90
+ const target = resolveTarget(session);
91
+ const health = await healthCheckSession({ ...session, ...target });
92
+ if (!health.ok) {
93
+ throw new Error(`Ninja Terminal health check failed on ${target.host}:${target.port}: ${health.error}`);
94
+ }
95
+
96
+ console.log(`Ninja Terminal: http://${target.host}:${target.port}`);
97
+ console.log(`Session file: ${SESSION_FILE}`);
98
+ console.log(`Health: ok (${health.response.terminals} terminals)`);
99
+
100
+ if (!token) {
101
+ console.log('Auth: missing token. Run: ninja-login');
102
+ return;
103
+ }
104
+
105
+ const res = await requestJson({
106
+ host: target.host,
107
+ port: target.port,
108
+ path: '/api/terminals',
109
+ token,
110
+ });
111
+
112
+ if (res.statusCode === 200 && Array.isArray(res.body)) {
113
+ for (const t of res.body) {
114
+ console.log(`T${t.id} ${t.label || ''} ${t.status || 'unknown'} ${t.taskName ? `— ${t.taskName}` : ''}`.trim());
115
+ }
116
+ } else {
117
+ console.log(`Terminals: unavailable (HTTP ${res.statusCode})`);
118
+ }
119
+ }
120
+
121
+ async function main() {
122
+ const args = process.argv.slice(2);
123
+ const invokedAs = path.basename(process.argv[1] || '');
124
+ const session = readRuntimeSession();
125
+ const token = getToken(session);
126
+
127
+ if (invokedAs === 'ninja-status') {
128
+ await printStatus(session, token);
129
+ appendVerificationEntry({ type: 'status', command: 'ninja-status' });
130
+ return;
131
+ }
132
+
133
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
134
+ console.log(USAGE);
135
+ process.exit(args.length === 0 ? 1 : 0);
136
+ }
137
+
138
+ if (args[0] === '--status' || args[0] === 'status') {
139
+ await printStatus(session, token);
140
+ appendVerificationEntry({ type: 'status', command: 'agent-send --status' });
141
+ return;
142
+ }
143
+
144
+ if (args[0] === '--ledger' || args[0] === 'ledger') {
145
+ const limit = parseInt(args[1], 10) || 20;
146
+ const entries = readLedgerEntries(limit);
147
+ if (entries.length === 0) {
148
+ console.log('No dispatch ledger entries found.');
149
+ console.log(`Ledger file: ${LEDGER_FILE}`);
150
+ return;
151
+ }
152
+ console.log(`Last ${entries.length} dispatch(es):\n`);
153
+ for (const e of entries) {
154
+ const status = e.success ? '✓' : '✗';
155
+ const preview = e.messagePreview || '';
156
+ console.log(`${status} ${e.timestamp} T${e.terminalId} ${e.command || 'dispatch'}`);
157
+ if (preview) console.log(` "${preview.slice(0, 80)}${preview.length > 80 ? '...' : ''}"`);
158
+ if (!e.success && e.error) console.log(` Error: ${e.error}`);
159
+ }
160
+ return;
161
+ }
162
+
163
+ if (args[0] === '--output' || args[0] === 'output') {
164
+ const terminalId = args[1];
165
+ if (!terminalId) {
166
+ console.error('Error: --output requires terminal ID');
167
+ process.exit(1);
168
+ }
169
+ const linesIdx = args.indexOf('--lines');
170
+ const lines = linesIdx >= 0 ? parseInt(args[linesIdx + 1], 10) || 50 : 50;
171
+
172
+ const target = resolveTarget(session);
173
+ if (!token) {
174
+ console.error('Error: Auth token required for output read');
175
+ process.exit(1);
176
+ }
177
+
178
+ const res = await requestJson({
179
+ host: target.host,
180
+ port: target.port,
181
+ path: `/api/terminals/${terminalId}/output?lines=${lines}`,
182
+ token,
183
+ });
184
+
185
+ if (res.statusCode === 200 && res.body) {
186
+ const output = res.body.lines || res.body;
187
+ if (Array.isArray(output)) {
188
+ for (const line of output) {
189
+ console.log(line);
190
+ }
191
+ } else {
192
+ console.log(JSON.stringify(res.body, null, 2));
193
+ }
194
+ appendVerificationEntry({
195
+ type: 'output_read',
196
+ terminalId,
197
+ lines,
198
+ command: 'agent-send --output',
199
+ });
200
+ } else {
201
+ console.error(`Error: HTTP ${res.statusCode}`);
202
+ process.exit(1);
203
+ }
204
+ return;
205
+ }
206
+
207
+ if (args[0] === '--verifications' || args[0] === 'verifications') {
208
+ const limit = parseInt(args[1], 10) || 20;
209
+ const entries = readVerificationEntries(limit);
210
+ if (entries.length === 0) {
211
+ console.log('No verification ledger entries found.');
212
+ console.log(`Ledger file: ${VERIFICATION_LEDGER_FILE}`);
213
+ return;
214
+ }
215
+ console.log(`Last ${entries.length} verification(s):\n`);
216
+ for (const e of entries) {
217
+ console.log(`${e.timestamp} ${e.type} ${e.terminalId ? `T${e.terminalId}` : ''} ${e.command || ''}`.trim());
218
+ if (e.statusMarker) console.log(` Marker: ${e.statusMarker}`);
219
+ }
220
+ return;
221
+ }
222
+
223
+ if (args[0] === '--task-status' || args[0] === 'task-status') {
224
+ const terminalId = args[1];
225
+ const target = resolveTarget(session);
226
+
227
+ if (!token) {
228
+ console.error('Error: Auth token required for task-status');
229
+ process.exit(1);
230
+ }
231
+
232
+ const apiPath = terminalId
233
+ ? `/api/terminals/${terminalId}/task-status`
234
+ : '/api/terminals/task-status';
235
+
236
+ const res = await requestJson({
237
+ host: target.host,
238
+ port: target.port,
239
+ path: apiPath,
240
+ token,
241
+ });
242
+
243
+ if (res.statusCode === 200 && res.body) {
244
+ const data = Array.isArray(res.body) ? res.body : [res.body];
245
+
246
+ // Task status icons
247
+ const icons = {
248
+ done: '\u2713', // ✓
249
+ blocked: '\u26A0', // ⚠
250
+ error: '\u2717', // ✗
251
+ running: '\u25CB', // ○
252
+ pending: '\u25CB', // ○
253
+ unknown: '?',
254
+ };
255
+
256
+ console.log('');
257
+ for (const t of data) {
258
+ const taskIcon = icons[t.taskStatus] || '?';
259
+ const processLabel = (t.processStatus || 'unknown').toUpperCase();
260
+ const taskLabel = (t.taskStatus || 'unknown').toUpperCase();
261
+ console.log(`T${t.id} ${t.label || ''}`);
262
+ console.log(` PROCESS: ${processLabel}`);
263
+ console.log(` TASK: ${taskIcon} ${taskLabel}${t.message ? ` — ${t.message}` : ''}`);
264
+ if (t.marker) console.log(` Marker: ${t.marker}`);
265
+ console.log('');
266
+ }
267
+
268
+ appendVerificationEntry({
269
+ type: 'task_status',
270
+ terminalId: terminalId || 'all',
271
+ command: 'agent-send --task-status',
272
+ });
273
+ } else {
274
+ console.error(`Error: HTTP ${res.statusCode}`);
275
+ process.exit(1);
276
+ }
277
+ return;
278
+ }
279
+
280
+ if (args.length < 2) {
281
+ console.error('Error: Missing arguments. Need <terminal-id> <message>');
282
+ console.log(USAGE);
283
+ process.exit(1);
284
+ }
285
+
286
+ const terminalId = args[0];
287
+ const message = args.slice(1).join(' ');
288
+
289
+ if (!/^\d+$/.test(terminalId)) {
290
+ console.error(`Error: terminal-id must be numeric, got: ${terminalId}`);
291
+ process.exit(1);
292
+ }
293
+
294
+ if (!token) {
295
+ appendLedgerEntry({
296
+ terminalId,
297
+ messagePreview: message.slice(0, 160),
298
+ command: invokedAs === 'ninja-dispatch' ? 'ninja-dispatch' : 'agent-send',
299
+ success: false,
300
+ error: 'No auth token found',
301
+ });
302
+ console.error(`Error: No auth token found.
303
+
304
+ Run: ninja-login
305
+
306
+ Or set NINJA_AUTH_TOKEN environment variable.
307
+ `);
308
+ process.exit(1);
309
+ }
310
+
311
+ const { host, port } = resolveTarget(session);
312
+ const health = await healthCheckSession({ ...session, host, port });
313
+ if (!health.ok) {
314
+ appendLedgerEntry({
315
+ terminalId,
316
+ messagePreview: message.slice(0, 160),
317
+ host,
318
+ port,
319
+ command: invokedAs === 'ninja-dispatch' ? 'ninja-dispatch' : 'agent-send',
320
+ success: false,
321
+ error: `Health check failed: ${health.error}`,
322
+ });
323
+ throw new Error(`Ninja Terminal health check failed on ${host}:${port}: ${health.error}`);
324
+ }
325
+
326
+ // Build JSON body - server handles delayed Enter for proper Claude Code submission
327
+ const body = JSON.stringify({ text: message });
328
+
329
+ const options = {
330
+ hostname: host,
331
+ port: parseInt(port, 10),
332
+ path: `/api/terminals/${terminalId}/input`,
333
+ method: 'POST',
334
+ headers: {
335
+ 'Content-Type': 'application/json',
336
+ 'Content-Length': Buffer.byteLength(body),
337
+ 'Authorization': `Bearer ${token}`
338
+ }
339
+ };
340
+
341
+ const ledgerBase = {
342
+ terminalId,
343
+ messagePreview: message.slice(0, 160),
344
+ host,
345
+ port,
346
+ url: `http://${host}:${port}`,
347
+ cwd: session?.cwd || null,
348
+ command: invokedAs === 'ninja-dispatch' ? 'ninja-dispatch' : 'agent-send',
349
+ };
350
+
351
+ const req = http.request(options, (res) => {
352
+ let data = '';
353
+ res.on('data', chunk => { data += chunk; });
354
+ res.on('end', () => {
355
+ if (res.statusCode === 200) {
356
+ try {
357
+ const result = JSON.parse(data);
358
+ if (result.ok) {
359
+ appendLedgerEntry({ ...ledgerBase, success: true });
360
+ console.log(`Sent to terminal ${terminalId}`);
361
+ if (result.guidanceInjected) {
362
+ console.log('(guidance was injected)');
363
+ }
364
+ } else {
365
+ appendLedgerEntry({ ...ledgerBase, success: false, error: `Server rejected: ${data}` });
366
+ console.error('Server returned:', data);
367
+ process.exit(1);
368
+ }
369
+ } catch {
370
+ appendLedgerEntry({ ...ledgerBase, success: true });
371
+ console.log('Response:', data);
372
+ }
373
+ } else {
374
+ appendLedgerEntry({ ...ledgerBase, success: false, error: `HTTP ${res.statusCode}: ${data}` });
375
+ console.error(`HTTP ${res.statusCode}: ${data}`);
376
+ process.exit(1);
377
+ }
378
+ });
379
+ });
380
+
381
+ req.on('error', (err) => {
382
+ appendLedgerEntry({ ...ledgerBase, success: false, error: err.message });
383
+ console.error(`Connection error: ${err.message}`);
384
+ console.error(`Is Ninja Terminal running on ${host}:${port}?`);
385
+ process.exit(1);
386
+ });
387
+
388
+ req.write(body);
389
+ req.end();
390
+ }
391
+
392
+ main().catch((err) => {
393
+ console.error(`Error: ${err.message}`);
394
+ process.exit(1);
395
+ });
package/cli.js CHANGED
@@ -60,18 +60,33 @@ if (hasFlag('--setup')) {
60
60
 
61
61
  console.log('\n🥷 NINJA TERMINALS SETUP\n');
62
62
 
63
- // 1. Find or create .mcp.json
64
- const projectMcp = path.join(process.cwd(), '.mcp.json');
65
- const globalMcp = path.join(os.homedir(), '.mcp.json');
66
- const mcpPath = fs.existsSync(projectMcp) ? projectMcp : globalMcp;
63
+ // 1. Find or create .claude/settings.json (Claude Code format)
64
+ const projectClaudeDir = path.join(process.cwd(), '.claude');
65
+ const globalClaudeDir = path.join(os.homedir(), '.claude');
66
+ const projectSettings = path.join(projectClaudeDir, 'settings.json');
67
+ const globalSettings = path.join(globalClaudeDir, 'settings.json');
68
+
69
+ // Prefer project-level if .claude dir exists, else use global
70
+ let settingsPath, claudeDir;
71
+ if (fs.existsSync(projectClaudeDir)) {
72
+ settingsPath = projectSettings;
73
+ claudeDir = projectClaudeDir;
74
+ } else {
75
+ settingsPath = globalSettings;
76
+ claudeDir = globalClaudeDir;
77
+ // Create global .claude dir if needed
78
+ if (!fs.existsSync(claudeDir)) {
79
+ fs.mkdirSync(claudeDir, { recursive: true });
80
+ }
81
+ }
67
82
 
68
83
  let mcpConfig = { mcpServers: {} };
69
- if (fs.existsSync(mcpPath)) {
84
+ if (fs.existsSync(settingsPath)) {
70
85
  try {
71
- mcpConfig = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
86
+ mcpConfig = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
72
87
  if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
73
88
  } catch (e) {
74
- console.log(`⚠️ Could not parse ${mcpPath}, creating new config`);
89
+ console.log(`⚠️ Could not parse ${settingsPath}, creating new config`);
75
90
  }
76
91
  }
77
92
 
@@ -88,8 +103,8 @@ if (hasFlag('--setup')) {
88
103
  // Get npm root for copying orchestrator prompt
89
104
  const npmRoot = path.dirname(require.resolve('ninja-terminals/package.json'));
90
105
 
91
- fs.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + '\n');
92
- console.log(`✅ Added ninja-terminals to ${mcpPath}`);
106
+ fs.writeFileSync(settingsPath, JSON.stringify(mcpConfig, null, 2) + '\n');
107
+ console.log(`✅ Added ninja-terminals to ${settingsPath}`);
93
108
 
94
109
  // 3. Copy orchestrator prompt to CLAUDE.md
95
110
  const claudeMd = path.join(process.cwd(), 'CLAUDE.md');
@@ -139,7 +154,7 @@ if (hasFlag('--setup')) {
139
154
  }
140
155
 
141
156
  // Save updated config with all MCPs
142
- fs.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + '\n');
157
+ fs.writeFileSync(settingsPath, JSON.stringify(mcpConfig, null, 2) + '\n');
143
158
 
144
159
  // 5. Check for Claude in Chrome (optional but recommended)
145
160
  const chromeExt = mcpConfig.mcpServers['claude-in-chrome'];
@@ -0,0 +1,101 @@
1
+ /**
2
+ * VeryRealNames - Absurd but plausible name generator
3
+ * Names sound like they COULD be real... but aren't
4
+ */
5
+
6
+ const FIRST_NAMES = [
7
+ // Old-timey gems
8
+ 'Hank', 'Gertrude', 'Chester', 'Mildred', 'Barnaby', 'Ethel', 'Cornelius', 'Bertha',
9
+ 'Thaddeus', 'Gladys', 'Reginald', 'Edna', 'Archibald', 'Myrtle', 'Beauregard', 'Agatha',
10
+ 'Wilbur', 'Prudence', 'Mortimer', 'Hortense', 'Bartholomew', 'Eunice', 'Horatio', 'Blanche',
11
+ 'Cuthbert', 'Delphine', 'Engelbert', 'Geraldine', 'Humphrey', 'Imogene',
12
+ // Slightly unusual but real
13
+ 'Grover', 'Cletus', 'Melvin', 'Dorcas', 'Floyd', 'Beulah', 'Lester', 'Opal',
14
+ 'Merle', 'Velma', 'Elmer', 'Lurleen', 'Durwood', 'Wanda', 'Gus', 'Bernice',
15
+ 'Norbert', 'Doreen', 'Seymour', 'Fern', 'Murray', 'Irma', 'Roscoe', 'Hazel',
16
+ // Fancy yet awkward
17
+ 'Percival', 'Millicent', 'Fitzgerald', 'Clementine', 'Montgomery', 'Josephine',
18
+ 'Wellington', 'Cordelia', 'Pemberton', 'Henrietta', 'Thurgood', 'Philomena',
19
+ ];
20
+
21
+ const LAST_NAMES = [
22
+ // Food-adjacent
23
+ 'Pancely', 'Biscuitson', 'Crumbsworth', 'Gravyton', 'Stewbottom', 'Butterham',
24
+ 'Cheesley', 'Meatworth', 'Croutonski', 'Souperton', 'Hamsworth', 'Loafman',
25
+ 'Brisketson', 'Cabbagely', 'Noodleman', 'Porridge', 'Beefington', 'Dumpling',
26
+ // Animal-infused
27
+ 'Wombleton', 'Badgerly', 'Gooseman', 'Ducksworth', 'Weaselton', 'Moosebury',
28
+ 'Ferretson', 'Hedgewood', 'Squirrelman', 'Toadsworth', 'Pigglesworth', 'Newterson',
29
+ 'Crabshaw', 'Lobsterman', 'Clamsworthy', 'Oysterton',
30
+ // Body part blends
31
+ 'Finklebottom', 'Rumplesworth', 'Kneely', 'Elbowton', 'Thumbleton', 'Noseberg',
32
+ 'Earlington', 'Toesworth', 'Chinley', 'Knuckleton', 'Shoulderby', 'Ankleman',
33
+ // Silly suffixes
34
+ 'Wobbleford', 'Snickerton', 'Puddlesworth', 'Bumblesby', 'Giggleston', 'Fumbleby',
35
+ 'Tumblewood', 'Mumbleston', 'Bumbleworth', 'Crumbleford', 'Stumblebrook',
36
+ // Object-based
37
+ 'Lampsworth', 'Chairington', 'Tableson', 'Doorknobby', 'Carpetman', 'Curtainsby',
38
+ 'Bucketworth', 'Brickleton', 'Plungerby', 'Sockington',
39
+ ];
40
+
41
+ export interface GeneratedName {
42
+ firstName: string;
43
+ lastName: string;
44
+ fullName: string;
45
+ }
46
+
47
+ /**
48
+ * Generate a single absurd-but-plausible name
49
+ */
50
+ export function generateName(): GeneratedName {
51
+ const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
52
+ const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
53
+
54
+ return {
55
+ firstName,
56
+ lastName,
57
+ fullName: `${firstName} ${lastName}`,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Generate multiple unique names
63
+ */
64
+ export function generateNames(count: number): GeneratedName[] {
65
+ const names: GeneratedName[] = [];
66
+ const seen = new Set<string>();
67
+
68
+ const maxPossible = FIRST_NAMES.length * LAST_NAMES.length;
69
+ const targetCount = Math.min(count, maxPossible);
70
+
71
+ while (names.length < targetCount) {
72
+ const name = generateName();
73
+ if (!seen.has(name.fullName)) {
74
+ seen.add(name.fullName);
75
+ names.push(name);
76
+ }
77
+ }
78
+
79
+ return names;
80
+ }
81
+
82
+ /**
83
+ * Get all available first names
84
+ */
85
+ export function getFirstNames(): readonly string[] {
86
+ return FIRST_NAMES;
87
+ }
88
+
89
+ /**
90
+ * Get all available last names
91
+ */
92
+ export function getLastNames(): readonly string[] {
93
+ return LAST_NAMES;
94
+ }
95
+
96
+ /**
97
+ * Get total possible unique combinations
98
+ */
99
+ export function getTotalCombinations(): number {
100
+ return FIRST_NAMES.length * LAST_NAMES.length;
101
+ }
@@ -41,11 +41,13 @@ async function loadToolRatings() {
41
41
 
42
42
  /**
43
43
  * Generate tool guidance strings from ratings.
44
+ * Limited to top 5 warnings + top 5 recommendations to avoid flooding terminals.
44
45
  * @param {Map<string, object>} ratings
45
46
  * @returns {string[]}
46
47
  */
47
48
  function generateToolGuidance(ratings) {
48
- const guidance = [];
49
+ const warnings = [];
50
+ const recommendations = [];
49
51
 
50
52
  for (const [tool, stats] of ratings) {
51
53
  const { rating, composite, success_rate } = stats;
@@ -54,16 +56,24 @@ function generateToolGuidance(ratings) {
54
56
  // Low-rated tool: suggest avoidance
55
57
  const alt = TOOL_ALTERNATIVES[tool];
56
58
  if (alt) {
57
- guidance.push(`Avoid ${tool} for ${alt.useCase}, prefer ${alt.preferred} (${tool} has ${rating} rating: ${composite})`);
59
+ warnings.push({ tool, msg: `Avoid ${tool} for ${alt.useCase}, prefer ${alt.preferred} (${rating} rating)`, composite });
58
60
  } else {
59
- guidance.push(`Use ${tool} cautiously — ${rating} rating (${composite}), success rate: ${(success_rate * 100).toFixed(0)}%`);
61
+ warnings.push({ tool, msg: `Use ${tool} cautiously — ${rating} rating, success: ${(success_rate * 100).toFixed(0)}%`, composite });
60
62
  }
61
63
  } else if (rating === 'S' || rating === 'A') {
62
64
  // High-rated tool: recommend preference
63
- guidance.push(`Prefer ${tool} — reliable (${rating} rating: ${composite})`);
65
+ recommendations.push({ tool, msg: `Prefer ${tool} — ${rating} rating`, composite });
64
66
  }
65
67
  }
66
68
 
69
+ // Sort and limit: worst 5 warnings, best 5 recommendations
70
+ warnings.sort((a, b) => a.composite - b.composite);
71
+ recommendations.sort((a, b) => b.composite - a.composite);
72
+
73
+ const guidance = [];
74
+ for (const w of warnings.slice(0, 5)) guidance.push(w.msg);
75
+ for (const r of recommendations.slice(0, 5)) guidance.push(r.msg);
76
+
67
77
  return guidance;
68
78
  }
69
79