life-pulse 2.3.0 → 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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # life-pulse
1
+ # everything is mac mini
2
2
 
3
3
  macOS life diagnostic — reads local data sources, generates actionable insights.
4
4
 
package/dist/agent.js CHANGED
@@ -147,26 +147,26 @@ One briefing. Organized by how much damage it does if ignored — not by app, no
147
147
 
148
148
  Chase down every thread. Be relentless. Don't surface the obvious stuff they'd find on their own — find the things about to fall through the cracks that they don't even know about yet.
149
149
 
150
- 1. PROMISES 🔴
150
+ 1. PROMISES
151
151
  Things ${name} said they'd do that are about to break or already have. This is the most important section. Always.
152
152
  Scan every outbound message for commitment language: "I'll send," "let me get back to you," "I'll loop in," "by Friday," "will do." Cross-reference against what was actually done. The gap = the briefing.
153
153
  Reputation compounds. Every dropped commitment is a withdrawal from an account that's very hard to refill.
154
154
  For each: who they promised, what specifically (not "follow up with Sarah" → "Send Sarah the revised deck she asked for Tuesday"), when it was due, and the one move to close it. Tight enough to do in 2 minutes or delegate in one sentence.
155
155
 
156
- 2. BLOCKERS 🟠
156
+ 2. BLOCKERS
157
157
  People quietly waiting on ${name}.
158
158
  Find everywhere they're the bottleneck. Approvals sitting untouched. Questions asked that they never saw. Docs shared "for review" that really mean "I can't move until you respond."
159
159
  Nobody says "you're blocking me" out loud. They just wait and quietly start resenting it. Find those moments before they fester.
160
160
  For each: who's stuck, what they need, how long they've been waiting. Then make the call — yes, no, or delegate — with a one-line reason. Don't present options. Recommend.
161
161
 
162
- 3. BUMPS 🟡
162
+ 3. BUMPS
163
163
  Relationships worth a touch. Offense, not defense.
164
164
  Conversations that went cold but shouldn't have. Intros offered but never made. Threads where someone shared something interesting and a reply would strengthen the relationship.
165
165
  The question: "Who would be pleasantly surprised to hear from ${name} today?"
166
166
  These aren't obligations — they're compound interest. A "congrats on the launch" text. A "saw this and thought of you" forward. That coffee they said they'd grab three weeks ago.
167
167
  For each: who, why now, and a suggested message they can send as-is or edit in 10 seconds.
168
168
 
169
- 4. ALPHA 🟢
169
+ 4. ALPHA
170
170
  Moves they'd be pissed they missed.
171
171
  Based on everything you know — what should they be doing that they haven't thought of?
172
172
  A competitor just shipped something relevant. Someone in their network just raised a round. Two people they know should know each other but don't. Something they do every week that you could just handle for them.
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { saveState, saveDecisions } from './state.js';
6
6
  // ProgressRenderer unused — removed
7
7
  import { InkProgress } from './ui/progress.js';
8
8
  import { addTodo, resolveTodos, pruneOld } from './todo.js';
9
- import { renderGreeting, renderContextLine, renderCRMList, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, GRN, MAG, CYN, RED, AMB, MID } from './tui.js';
9
+ import { renderIntro, renderReveal, renderCRMList, pickCard, renderSection, renderSectionHeader, renderHandled, renderDivider, renderBrief, renderArchetype, Spinner, destroyUI, MAG, CYN, RED, AMB, MID, DIM } from './tui.js';
10
10
  import { needsDiscovery, discoverPlatforms, savePlatforms } from './platforms.js';
11
11
  import { generateArchetype } from './archetype.js';
12
12
  import { runPermissionFlow, getMissingPermissions } from './permissions.js';
@@ -88,11 +88,18 @@ async function showCRM(apiKey, opts) {
88
88
  opts?.spinner?.stop();
89
89
  return false;
90
90
  }
91
- // Names appear INSTANTLY — no API call, just DB data
92
91
  opts?.spinner?.stop();
92
+ // The reveal — silence, then the numbers drop
93
+ const innerCircle = crm.threads.filter(t => t.threadTemp === 'hot' || t.threadTemp === 'warm' || t.msgs30d > 20).length;
94
+ const aboutToBreak = crm.threads.filter(t => t.waitingOnYou).length;
95
+ await renderReveal({
96
+ conversations: crm.threads.length,
97
+ innerCircle,
98
+ aboutToBreak,
99
+ });
93
100
  renderCRMList(crm.threads);
94
101
  // Enrich in background (for agent context + brief)
95
- opts?.spinner?.start('reading the room');
102
+ opts?.spinner?.start('understanding the room');
96
103
  const calendarContext = opts?.calendarContext ?? '';
97
104
  const hour = new Date().getHours();
98
105
  const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
@@ -132,23 +139,19 @@ async function main() {
132
139
  if (initCheckMode) {
133
140
  const status = runInitCheck();
134
141
  console.log();
135
- console.log(chalk.bold(' PRE-FLIGHT CHECK'));
136
- console.log();
137
142
  for (const c of status.checks) {
138
143
  const icon = c.passed ? chalk.green('✓') : chalk.red('✗');
139
- const detail = c.detail ? chalk.dim(` ${c.detail}`) : '';
140
- console.log(` ${icon} ${c.check}${detail}`);
144
+ console.log(` ${icon} ${c.check}`);
141
145
  }
142
146
  console.log();
143
- console.log(chalk.dim(` Mode: ${status.mode}`));
144
147
  console.log(chalk.dim(` ${status.recommendation}`));
145
148
  console.log();
146
149
  return;
147
150
  }
148
151
  // --router: run message router on YOUR Mac (routes to client Mac Minis)
149
152
  if (routerMode) {
150
- console.log(chalk.bold.hex('#7AA2F7')(' life-pulse router'));
151
- console.log(chalk.dim(' routing messages to client Mac Minis via Tailscale'));
153
+ console.log(chalk.bold.hex('#c0caf5')(' life-pulse'));
154
+ console.log(chalk.dim(' routing messages'));
152
155
  console.log();
153
156
  const router = startRouter();
154
157
  process.on('SIGINT', () => { router.stop(); process.exit(0); });
@@ -170,7 +173,7 @@ async function main() {
170
173
  if (testSmsMode) {
171
174
  const up = await isGatewayUp();
172
175
  if (!up) {
173
- console.log(chalk.red(' Gateway not running on :19877 — start daemon first'));
176
+ console.log(chalk.red(' not running — start life-pulse first'));
174
177
  return;
175
178
  }
176
179
  const phone = process.argv[process.argv.indexOf('--test-sms') + 1] || '+1234567890';
@@ -178,15 +181,16 @@ async function main() {
178
181
  const resp = await fetch('http://127.0.0.1:19877/inbound', {
179
182
  method: 'POST',
180
183
  headers: { 'Content-Type': 'application/json' },
181
- body: JSON.stringify({ phone, message: 'test message from life-pulse --test-sms' }),
184
+ body: JSON.stringify({ phone, message: 'test message from life-pulse' }),
182
185
  });
183
186
  const data = await resp.json();
184
- console.log(chalk.green(` Gateway responded: ${data.response || data.error}`));
185
- if (data.tools_used?.length)
186
- console.log(chalk.dim(` Tools: ${data.tools_used.join(', ')}`));
187
+ if (data.response)
188
+ console.log(chalk.green(` replied: ${data.response}`));
189
+ else
190
+ console.log(chalk.red(` failed: ${data.error}`));
187
191
  }
188
192
  catch (err) {
189
- console.log(chalk.red(` Failed: ${err instanceof Error ? err.message : String(err)}`));
193
+ console.log(chalk.red(` couldn't connect is life-pulse running?`));
190
194
  }
191
195
  return;
192
196
  }
@@ -203,8 +207,7 @@ async function main() {
203
207
  }
204
208
  // --daemon: check if already running
205
209
  if (daemonMode && isDaemonRunning()) {
206
- console.log(chalk.yellow(' life-pulse daemon already running'));
207
- console.log(chalk.dim(' use --health to check status'));
210
+ console.log(chalk.yellow(' already running'));
208
211
  return;
209
212
  }
210
213
  // --status: show session progress info and exit
@@ -212,32 +215,26 @@ async function main() {
212
215
  const progress = loadProgress();
213
216
  const timeSince = getTimeSinceLastSession();
214
217
  console.log();
215
- console.log(chalk.bold(' LIFE-PULSE STATUS'));
216
- console.log();
217
- console.log(` Sessions: ${progress.totalSessions}`);
218
- console.log(` First run: ${dayjs(progress.firstRun).format('MMM D, YYYY')}`);
219
- console.log(` Last session: ${timeSince.readable}`);
220
- console.log();
218
+ console.log(chalk.dim(` since ${dayjs(progress.firstRun).format('MMM D, YYYY')} · ${progress.totalSessions} sessions`));
219
+ console.log(chalk.dim(` last check-in ${timeSince.readable}`));
221
220
  const discoveries = getNewDiscoveries();
222
221
  if (discoveries.length) {
223
- console.log(` New discoveries: ${discoveries.length}`);
222
+ console.log();
224
223
  for (const d of discoveries.slice(0, 5)) {
225
- console.log(` · ${d.name}`);
224
+ console.log(chalk.dim(` ${d.name}`));
226
225
  }
227
226
  }
228
- // Skills summary
229
227
  const skills = scanForSkills();
230
- console.log(` Skills: ${skills.active.length} active, ${skills.available.length} available`);
231
- // Crash stats
228
+ if (skills.active.length) {
229
+ console.log(chalk.dim(` ${skills.active.length} skills`));
230
+ }
232
231
  const crashes = getCrashStats();
233
232
  if (crashes.consecutive > 0) {
234
- console.log(chalk.yellow(` Crashes: ${crashes.consecutive} consecutive failures`));
233
+ console.log(chalk.yellow(` something's wrong — ${crashes.consecutive} failures in a row`));
235
234
  }
236
235
  const missing = getMissingPermissions();
237
236
  if (missing.length) {
238
- console.log();
239
- console.log(` Missing permissions: ${missing.map(m => m.label).join(', ')}`);
240
- console.log(chalk.dim(' Run with --setup to configure'));
237
+ console.log(chalk.dim(` missing: ${missing.map(m => m.label).join(', ')}`));
241
238
  }
242
239
  console.log();
243
240
  return;
@@ -354,17 +351,13 @@ async function main() {
354
351
  </plist>`;
355
352
  writeFileSync(join(agentsDir, 'com.life-pulse.morning.plist'), morningPlist);
356
353
  writeFileSync(join(agentsDir, 'com.life-pulse.daemon.plist'), daemonPlist);
357
- console.log(chalk.green(' Installed:'));
358
- console.log(chalk.dim(` Morning brief: ${config.briefTime} daily`));
359
- console.log(chalk.dim(` Daemon: KeepAlive (auto-restart on crash)`));
354
+ console.log(chalk.dim(' running in the background'));
355
+ console.log(chalk.dim(` morning brief at ${config.briefTime}`));
360
356
  console.log();
361
- console.log(chalk.dim(' To activate:'));
362
- console.log(chalk.dim(' launchctl load ~/Library/LaunchAgents/com.life-pulse.daemon.plist'));
363
- console.log(chalk.dim(' launchctl load ~/Library/LaunchAgents/com.life-pulse.morning.plist'));
364
357
  return;
365
358
  }
366
359
  if (!key && !ANTHROPIC_KEY) {
367
- console.log(chalk.red('\n\n No API key. Set ANTHROPIC_API_KEY or OPENROUTER_API_KEY.\n'));
360
+ console.log(chalk.red('\n\n missing API key\n'));
368
361
  process.exit(1);
369
362
  }
370
363
  const interactive = process.stdin.isTTY && !jsonMode && !legacyMode;
@@ -377,8 +370,7 @@ async function main() {
377
370
  }
378
371
  catch { }
379
372
  if (interactive) {
380
- renderGreeting(userName);
381
- renderContextLine();
373
+ await renderIntro(userName);
382
374
  }
383
375
  const spinner = interactive ? new Spinner() : undefined;
384
376
  // ── First-run: permissions → discovery → archetype ──
@@ -389,7 +381,7 @@ async function main() {
389
381
  // Welcome message for first run
390
382
  if (isFirstRun && interactive) {
391
383
  console.log();
392
- console.log(chalk.bold.hex('#7AA2F7')(' Welcome to life-pulse!'));
384
+ console.log(chalk.bold.hex('#c0caf5')(' Welcome to life-pulse.'));
393
385
  console.log(chalk.dim(' Let me learn about your setup...\n'));
394
386
  }
395
387
  else if (timeSinceLastSession.days > 7 && interactive) {
@@ -397,10 +389,10 @@ async function main() {
397
389
  }
398
390
  if (process.stdin.isTTY)
399
391
  await runPermissionFlow();
400
- spinner?.start('discovering platforms');
392
+ spinner?.start('learning your world');
401
393
  const platformProfile = discoverPlatforms();
402
394
  // iCloud + local app discovery
403
- spinner?.update('scanning iCloud');
395
+ spinner?.update('looking deeper');
404
396
  const discoveredApps = runDiscovery();
405
397
  if (discoveredApps.length && interactive) {
406
398
  spinner?.stop();
@@ -409,7 +401,7 @@ async function main() {
409
401
  console.log();
410
402
  }
411
403
  // Skill discovery: map found apps → activatable skills
412
- spinner?.update('loading skills');
404
+ spinner?.update('seeing what you use');
413
405
  const skills = scanForSkills();
414
406
  if (skills.newlyFound.length && interactive) {
415
407
  spinner?.stop();
@@ -428,12 +420,12 @@ async function main() {
428
420
  const toolCount = platformProfile.cliTools.length;
429
421
  // CRM drip-feeds before archetype — contacts stream in one by one
430
422
  if (interactive) {
431
- spinner?.update('scanning messages');
423
+ spinner?.update('getting to know you');
432
424
  const calCtx = await calendarP;
433
425
  crmShown = await showCRM(ANTHROPIC_KEY, { calendarContext: calCtx, spinner });
434
426
  }
435
427
  if (ANTHROPIC_KEY) {
436
- spinner?.start('generating archetype');
428
+ spinner?.start('figuring out who you are');
437
429
  try {
438
430
  const archetype = await generateArchetype(platformProfile, ANTHROPIC_KEY);
439
431
  spinner?.stop();
@@ -459,10 +451,10 @@ async function main() {
459
451
  if (legacyMode) {
460
452
  spinner?.stop();
461
453
  if (!jsonMode)
462
- process.stdout.write(chalk.dim('\n scanning...'));
454
+ process.stdout.write(chalk.dim('\n pulling threads...'));
463
455
  const collected = await collectAll();
464
456
  if (!jsonMode)
465
- process.stdout.write(chalk.dim(` ${collected.sources.length} sources thinking...`));
457
+ process.stdout.write(chalk.dim(` ${collected.sources.length} sources... thinking`));
466
458
  const analysis = await analyzeWithLLM(collected.data, key);
467
459
  if (jsonMode) {
468
460
  console.log(JSON.stringify({ collected, analysis }, null, 2));
@@ -475,12 +467,12 @@ async function main() {
475
467
  // Agent mode (default): multi-turn investigation with Anthropic
476
468
  if (!ANTHROPIC_KEY) {
477
469
  spinner?.stop();
478
- console.log(chalk.red('\n\n Agent mode requires ANTHROPIC_API_KEY. Use --legacy for OpenRouter mode.\n'));
470
+ console.log(chalk.red('\n\n missing API key\n'));
479
471
  process.exit(1);
480
472
  }
481
473
  // ── CRM: show relationship map before agent runs ──
482
474
  if (interactive && !crmShown) {
483
- spinner?.start('scanning messages');
475
+ spinner?.start('getting to know you');
484
476
  const calCtx = await calendarP;
485
477
  await showCRM(ANTHROPIC_KEY, { calendarContext: calCtx, spinner });
486
478
  }
@@ -608,7 +600,7 @@ async function main() {
608
600
  ...(analysis.decisions || []),
609
601
  ].some((d) => d.options?.length);
610
602
  if (!hasActionable && !(analysis.alpha?.length)) {
611
- console.log(GRN(' All clear — nothing needs you right now.'));
603
+ console.log(DIM(' All clear — nothing needs you right now.'));
612
604
  console.log();
613
605
  }
614
606
  renderDivider();
@@ -626,7 +618,7 @@ async function main() {
626
618
  const tgToken = process.env.TELEGRAM_BOT_TOKEN;
627
619
  if (tgToken) {
628
620
  transports.register(new TelegramTransport(tgToken));
629
- console.log(chalk.dim(' Telegram bot active'));
621
+ // silent — Telegram just works
630
622
  }
631
623
  // Start transport layer for inbound messages
632
624
  transports.startAll(async (inbound) => {
@@ -660,7 +652,7 @@ async function main() {
660
652
  return result.response;
661
653
  }
662
654
  catch (err) {
663
- process.stderr.write(` conversation error: ${err instanceof Error ? err.message : String(err)}\n`);
655
+ process.stderr.write(` couldn't reply to ${contactName}\n`);
664
656
  }
665
657
  }
666
658
  return null;
@@ -673,16 +665,15 @@ async function main() {
673
665
  recordCrash(err.message, 'message-loop');
674
666
  },
675
667
  });
676
- console.log(chalk.dim(` Watching for messages (${transports.getAvailable().join(', ')})`));
677
- console.log();
668
+ if (!daemonMode) {
669
+ console.log(chalk.dim(' listening'));
670
+ console.log();
671
+ }
678
672
  // Start health server for daemon mode
679
673
  let gw = null;
680
674
  if (daemonMode) {
681
675
  startHealthServer();
682
- console.log(chalk.dim(` Health check: http://127.0.0.1:19876/health`));
683
- // Start inbound gateway (rply-mac-server pushes messages here via Tailscale)
684
676
  gw = startGateway(ANTHROPIC_KEY);
685
- console.log(chalk.dim(` Gateway: :${gw.port} (rply-mac-server → Tailscale → here)`));
686
677
  }
687
678
  // Generate knowledge bases from CRM on startup
688
679
  try {
package/dist/health.js CHANGED
@@ -45,7 +45,7 @@ export function startHealthServer() {
45
45
  healthServer.on('error', (e) => {
46
46
  if (e.code === 'EADDRINUSE') {
47
47
  // Another instance is running
48
- console.error('Health server port in use — another instance running?');
48
+ console.error(' already running');
49
49
  }
50
50
  });
51
51
  }
@@ -373,27 +373,27 @@ export function formatDiscoveryReport(discoveries) {
373
373
  if (!discoveries.length)
374
374
  return '';
375
375
  const lines = [];
376
- lines.push('📱 Here\'s what I found about you:\n');
376
+ lines.push(' here\'s what I found:\n');
377
377
  // Group by category
378
378
  const noteApps = discoveries.filter(d => ['Obsidian', 'Bear', 'Logseq', 'Apple Notes', 'Craft', 'iA Writer', 'Ulysses'].includes(d.app));
379
379
  const taskApps = discoveries.filter(d => ['Things 3', 'OmniFocus', 'Todoist'].includes(d.app));
380
380
  const otherApps = discoveries.filter(d => !noteApps.includes(d) && !taskApps.includes(d));
381
381
  if (noteApps.length) {
382
- lines.push(' 📝 Notes & Writing');
382
+ lines.push(' notes & writing');
383
383
  for (const app of noteApps) {
384
- const skill = app.skillId ? ' skill available' : '';
384
+ const skill = app.skillId ? ' skill available' : '';
385
385
  lines.push(` ${app.app}${skill}`);
386
386
  }
387
387
  }
388
388
  if (taskApps.length) {
389
- lines.push(' Task Management');
389
+ lines.push(' task management');
390
390
  for (const app of taskApps) {
391
- const skill = app.skillId ? ' skill available' : '';
391
+ const skill = app.skillId ? ' skill available' : '';
392
392
  lines.push(` ${app.app}${skill}`);
393
393
  }
394
394
  }
395
395
  if (otherApps.length) {
396
- lines.push(' 📦 Other Apps');
396
+ lines.push(' other');
397
397
  for (const app of otherApps) {
398
398
  lines.push(` ${app.app}`);
399
399
  }
@@ -401,7 +401,7 @@ export function formatDiscoveryReport(discoveries) {
401
401
  const skillCount = discoveries.filter(d => d.skillId).length;
402
402
  if (skillCount > 0) {
403
403
  lines.push('');
404
- lines.push(` 💡 ${skillCount} skill${skillCount === 1 ? '' : 's'} available to install`);
404
+ lines.push(` ${skillCount} skill${skillCount === 1 ? '' : 's'} available`);
405
405
  }
406
406
  return lines.join('\n');
407
407
  }
@@ -20,7 +20,7 @@ import { hasRequiredPermissions, getMissingPermissions } from './permissions.js'
20
20
  import { loadProgress, getTimeSinceLastSession } from './session-progress.js';
21
21
  import { getCrashStats } from './watchdog.js';
22
22
  import { getSkills } from './skill-loader.js';
23
- import { hasTailscale, isFunnelActive } from './tunnel.js';
23
+ import { hasTailscale } from './tunnel.js';
24
24
  const home = homedir();
25
25
  /** Run all pre-flight checks. Returns readiness status. */
26
26
  export function runInitCheck() {
@@ -62,32 +62,29 @@ export function runInitCheck() {
62
62
  }
63
63
  }
64
64
  checks.push({
65
- check: 'iMessage DB',
65
+ check: 'messages',
66
66
  passed: msgOk,
67
- detail: msgOk ? undefined : 'cannot read Messages database need Full Disk Access',
67
+ detail: msgOk ? undefined : 'can\'t read messagesneeds Full Disk Access',
68
68
  });
69
69
  // 4. API key available
70
70
  const hasKey = !!(process.env.ANTHROPIC_API_KEY || process.env.OPENROUTER_API_KEY);
71
71
  checks.push({
72
- check: 'API key',
72
+ check: 'intelligence',
73
73
  passed: hasKey,
74
- detail: hasKey ? undefined : 'set ANTHROPIC_API_KEY in ~/.config/life-pulse/.env',
75
74
  });
76
75
  // 5. Session progress integrity
77
76
  const progress = loadProgress();
78
77
  const progressOk = progress.version === 2;
79
78
  checks.push({
80
- check: 'session progress',
79
+ check: 'memory',
81
80
  passed: progressOk,
82
- detail: progressOk ? `${progress.totalSessions} sessions` : 'needs migration',
83
81
  });
84
82
  // 6. Crash state
85
83
  const crashes = getCrashStats();
86
84
  const crashOk = crashes.consecutive === 0;
87
85
  checks.push({
88
- check: 'crash state',
86
+ check: 'stability',
89
87
  passed: crashOk,
90
- detail: crashOk ? undefined : `${crashes.consecutive} consecutive failures`,
91
88
  });
92
89
  // 7. Skills loaded
93
90
  const skills = getSkills();
@@ -95,7 +92,6 @@ export function runInitCheck() {
95
92
  checks.push({
96
93
  check: 'skills',
97
94
  passed: activeSkills > 0,
98
- detail: `${activeSkills} active`,
99
95
  });
100
96
  // 8. Inbound gateway (port 19877)
101
97
  let gwOk = false;
@@ -107,16 +103,14 @@ export function runInitCheck() {
107
103
  }
108
104
  catch { }
109
105
  checks.push({
110
- check: 'gateway (:19877)',
106
+ check: 'messaging',
111
107
  passed: gwOk,
112
- detail: gwOk ? 'listening' : 'not running — start daemon',
113
108
  });
114
109
  // 9. Tailscale installed
115
110
  const tsOk = hasTailscale();
116
111
  checks.push({
117
- check: 'Tailscale',
112
+ check: 'network',
118
113
  passed: tsOk,
119
- detail: tsOk ? (isFunnelActive() ? 'funnel active' : 'installed, funnel not active') : 'not installed',
120
114
  });
121
115
  // Determine mode
122
116
  const timeSince = getTimeSinceLastSession();
@@ -134,12 +128,14 @@ export function runInitCheck() {
134
128
  const allPassed = checks.every(c => c.passed);
135
129
  if (allPassed) {
136
130
  recommendation = mode === 'first_run'
137
- ? 'Ready for first-run setup: permissions → discovery → archetype → agent'
138
- : `Ready. ${timeSince.readable} since last session. ${activeSkills} skills active.`;
131
+ ? 'ready for setup'
132
+ : 'everything looks good';
139
133
  }
140
134
  else {
141
135
  const failing = checks.filter(c => !c.passed);
142
- recommendation = `${failing.length} check(s) failing: ${failing.map(c => c.check).join(', ')}. Fix before running agent.`;
136
+ recommendation = failing.length === 1
137
+ ? `${failing[0].check} needs attention`
138
+ : `${failing.length} things need attention`;
143
139
  }
144
140
  return {
145
141
  ready: allPassed || (permOk && hasKey), // Can still run in degraded mode