phewsh 0.14.1 → 0.14.3

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/bin/phewsh.js CHANGED
@@ -7,7 +7,7 @@ const command = args[0];
7
7
  const b = (s) => `\x1b[1m${s}\x1b[0m`; // bold
8
8
  const d = (s) => `\x1b[2m${s}\x1b[0m`; // dim
9
9
  const w = (s) => `\x1b[97m${s}\x1b[0m`; // bright white
10
- const g = (s) => `\x1b[38;2;152;164;158m${s}\x1b[0m`; // slate (matches ui.js)
10
+ const g = (s) => `\x1b[38;5;247m${s}\x1b[0m`; // slate, 256-color (matches ui.js — 24-bit breaks Apple Terminal)
11
11
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
12
12
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
13
13
 
@@ -21,7 +21,7 @@ const { gatherReceipts } = require('../lib/receipts-data');
21
21
  const b = (s) => `\x1b[1m${s}\x1b[0m`;
22
22
  const d = (s) => `\x1b[2m${s}\x1b[0m`;
23
23
  const w = (s) => `\x1b[97m${s}\x1b[0m`;
24
- const g = (s) => `\x1b[38;2;130;142;138m${s}\x1b[0m`;
24
+ const g = (s) => `\x1b[38;5;247m${s}\x1b[0m`;
25
25
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
26
26
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
27
27
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
@@ -11,7 +11,8 @@ const ui = require('../lib/ui');
11
11
 
12
12
  const CONFIG_DIR = path.join(os.homedir(), '.phewsh');
13
13
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
14
- const INTENT_DIR = path.join(process.cwd(), '.intent');
14
+ // Dynamic the session can chdir into a project from the root bootstrap
15
+ const intentDir = () => path.join(process.cwd(), '.intent');
15
16
 
16
17
  const { select, refreshSession: refreshSess } = require('../lib/supabase');
17
18
  const { readPPS } = require('../lib/pps');
@@ -19,6 +20,7 @@ const { push, pull, ensureValidToken } = require('./sync');
19
20
  const { HARNESSES, listHarnesses, runViaHarness } = require('../lib/harnesses');
20
21
  const { recordDecision, labelOutcome, pendingDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
21
22
  const { recordSessionEvent } = require('../lib/receipts-data');
23
+ const { recordProject, listProjects, scanForProjects, fmtAgo } = require('../lib/projects-index');
22
24
 
23
25
  // Brand palette shortcuts
24
26
  const { b, d, w, g, green, cyan, yellow,
@@ -27,13 +29,13 @@ const { b, d, w, g, green, cyan, yellow,
27
29
  // Sync awareness: compare local .intent/ timestamps with cloud updated_at
28
30
  async function checkSyncStatus(config) {
29
31
  if (!config?.supabaseUserId || !config?.supabaseAccessToken) return null;
30
- if (!fs.existsSync(INTENT_DIR)) return null;
32
+ if (!fs.existsSync(intentDir())) return null;
31
33
 
32
34
  try {
33
35
  const token = await ensureValidToken(config);
34
36
  if (!token) return null;
35
37
 
36
- const pps = readPPS(INTENT_DIR);
38
+ const pps = readPPS(intentDir());
37
39
  const cloudId = pps?.adapters?.phewsh?.cloud_id;
38
40
  const projectName = path.basename(process.cwd());
39
41
 
@@ -59,7 +61,7 @@ async function checkSyncStatus(config) {
59
61
  const localFiles = ['vision.md', 'plan.md', 'next.md'];
60
62
  let latestLocal = 0;
61
63
  for (const file of localFiles) {
62
- const filePath = path.join(INTENT_DIR, file);
64
+ const filePath = path.join(intentDir(), file);
63
65
  if (fs.existsSync(filePath)) {
64
66
  const mtime = fs.statSync(filePath).mtimeMs;
65
67
  if (mtime > latestLocal) latestLocal = mtime;
@@ -154,7 +156,7 @@ function loadIntentContext() {
154
156
  const files = ['vision.md', 'plan.md', 'next.md'];
155
157
  const loaded = [];
156
158
  for (const file of files) {
157
- const p = path.join(INTENT_DIR, file);
159
+ const p = path.join(intentDir(), file);
158
160
  if (fs.existsSync(p)) {
159
161
  loaded.push({ file, content: fs.readFileSync(p, 'utf-8') });
160
162
  }
@@ -255,7 +257,12 @@ async function main() {
255
257
  let intentFiles = loadIntentContext();
256
258
  let systemPrompt = buildSystemPrompt(intentFiles);
257
259
  const messages = [];
258
- const projectName = path.basename(process.cwd());
260
+ let projectName = path.basename(process.cwd());
261
+
262
+ // Index this project so `phewsh` from anywhere can offer it as a recent
263
+ if (intentFiles.length > 0) {
264
+ try { recordProject(process.cwd()); } catch { /* index is best-effort */ }
265
+ }
259
266
  let currentModel = DEFAULT_MODEL;
260
267
  let totalPromptTokens = 0;
261
268
  let totalCompletionTokens = 0;
@@ -267,6 +274,7 @@ async function main() {
267
274
  let sessionMode = null; // INTENT_MODES id once picked
268
275
  let awaitingOutcome = null; // decision id eligible for 1-4 labeling
269
276
  let awaitingFallback = null; // { input, fullSystem, options } after a route failure
277
+ let bootstrapChoices = null; // root-bootstrap menu entries when no project here
270
278
  let decisionsThisSession = 0;
271
279
 
272
280
  // ── The Exhale: animated brand reveal ──────────────────
@@ -283,6 +291,21 @@ async function main() {
283
291
  // PROJECT what am I in · ROUTE where typing goes · BACKUP what's ready if
284
292
  // the route hits a wall · WEB am I mirrored · RECORD what's accumulated
285
293
  let syncState = null;
294
+ const row = (label, value) => console.log(` ${slate(label.padEnd(9))}${value}`);
295
+
296
+ // realpath both sides — macOS /tmp and /var are symlinks into /private
297
+ const realPath = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
298
+ const tildify = (p) => {
299
+ const home = realPath(os.homedir());
300
+ const rp = realPath(p);
301
+ return rp.startsWith(home) ? '~' + rp.slice(home.length) : p;
302
+ };
303
+ let atHome = false;
304
+ let recents = [];
305
+
306
+ // Fail-soft render: corrupt data or a network hiccup may cost a row —
307
+ // it must never kill the session.
308
+ try {
286
309
  if (config?.supabaseUserId && intentFiles.length > 0) {
287
310
  syncState = await Promise.race([
288
311
  checkSyncStatus(config),
@@ -290,11 +313,19 @@ async function main() {
290
313
  ]);
291
314
  }
292
315
 
293
- const row = (label, value) => console.log(` ${slate(label.padEnd(9))}${value}`);
316
+ atHome = realPath(process.cwd()) === realPath(os.homedir());
317
+ recents = intentFiles.length === 0
318
+ ? listProjects().filter(p => realPath(p.path) !== realPath(process.cwd())).slice(0, 3)
319
+ : [];
294
320
 
295
- row('PROJECT', cream(projectName) + (intentFiles.length > 0
296
- ? slate(' · ') + teal('●') + sage(` .intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''}`)
297
- : slate(' · no memory yet ') + sage('/init')));
321
+ if (intentFiles.length > 0) {
322
+ row('PROJECT', cream(projectName) + slate(' · ') + teal('●')
323
+ + sage(` .intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''}`));
324
+ } else if (atHome || recents.length > 0) {
325
+ row('PROJECT', slate('none here — your projects are listed below'));
326
+ } else {
327
+ row('PROJECT', cream(projectName) + slate(' · no memory yet — ') + sage('/init'));
328
+ }
298
329
 
299
330
  row('ROUTE', route
300
331
  ? cream(routeLabel(route, config)) + (route.type === 'harness' ? slate(' · no API key needed') : '')
@@ -310,11 +341,29 @@ async function main() {
310
341
  : slate('none — install Codex or Gemini to cover usage limits'));
311
342
 
312
343
  if (config?.supabaseUserId) {
344
+ // Cloud project count, best-effort with a hard 2s budget — the cockpit
345
+ // never blocks on the network.
346
+ let cloudCount = null;
347
+ try {
348
+ const token = await Promise.race([
349
+ ensureValidToken(config),
350
+ new Promise(r => setTimeout(() => r(null), 2000)),
351
+ ]);
352
+ if (token) {
353
+ const rows = await Promise.race([
354
+ select('projects', `user_id=eq.${config.supabaseUserId}&select=id`, token),
355
+ new Promise(r => setTimeout(() => r(null), 2000)),
356
+ ]);
357
+ if (Array.isArray(rows)) cloudCount = rows.length;
358
+ }
359
+ } catch { /* offline — cockpit still renders */ }
360
+
313
361
  const syncLabel = syncState?.status === 'synced' ? teal('↕ ') + sage('mirrored')
314
362
  : syncState?.status === 'cloud-newer' ? ember('↓ ') + sage(`cloud newer (${syncState.ago}) — /pull`)
315
363
  : syncState?.status === 'local-newer' ? ember('↑ ') + sage('local ahead — /push')
316
364
  : sage('linked');
317
- row('WEB', cream(config.email || 'logged in') + slate(' · ') + syncLabel);
365
+ row('WEB', cream(config.email || 'logged in') + slate(' · ') + syncLabel
366
+ + (cloudCount !== null ? slate(' · ') + sage(`${cloudCount} cloud project${cloudCount !== 1 ? 's' : ''}`) : ''));
318
367
  } else {
319
368
  row('WEB', sage('local-only (works fine)') + slate(' · /login mirrors this at phewsh.com/intent'));
320
369
  }
@@ -328,6 +377,57 @@ async function main() {
328
377
  } else {
329
378
  row('RECORD', slate('empty — decisions and outcomes accumulate as you work'));
330
379
  }
380
+ } catch (cockpitErr) {
381
+ console.log(` ${slate('(cockpit row unavailable — ' + cockpitErr.message + ' · PHEWSH_DEBUG=1 phewsh for details)')}`);
382
+ if (process.env.PHEWSH_DEBUG) console.error(cockpitErr.stack);
383
+ }
384
+
385
+ // Chat-routable options as they exist on THIS machine — every usage hint
386
+ // derives from this so /use, /provider, and reality never disagree.
387
+ function useOptions() {
388
+ const opts = harnesses.filter(h => h.installed && h.headless).map(h => h.id);
389
+ if (config?.apiKey) opts.push('api');
390
+ return opts;
391
+ }
392
+
393
+ function showModeMenu() {
394
+ console.log(` ${b(cream('What are you trying to do?'))}`);
395
+ console.log(` ${teal('1')} ${sage('Build')} ${slate('·')} ${teal('2')} ${sage('Research')} ${slate('·')} ${teal('3')} ${sage('Decide')} ${slate('·')} ${teal('4')} ${sage('Review')} ${slate('·')} ${teal('5')} ${sage('Ask another model')}`);
396
+ console.log(` ${slate('pick a number, or just type — your context travels with every route')}`);
397
+ }
398
+
399
+ // Open a known project from the bootstrap menu: chdir, reload memory,
400
+ // back to the normal flow. The session is the cockpit; projects swap in.
401
+ function openProjectAt(dir) {
402
+ try { process.chdir(dir); } catch (err) {
403
+ console.log(` ${ember('!')} ${sage('Could not open ' + dir + ': ' + err.message)}`);
404
+ return;
405
+ }
406
+ projectName = path.basename(dir);
407
+ intentFiles = loadIntentContext();
408
+ systemPrompt = buildSystemPrompt(intentFiles);
409
+ try { recordProject(dir); } catch { /* best-effort */ }
410
+ bootstrapChoices = null;
411
+ console.log('');
412
+ console.log(` ${teal('●')} ${cream(projectName)} ${slate('·')} ${sage(`.intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''} loaded`)} ${slate('· via ' + routeLabel(route, config))}`);
413
+ console.log('');
414
+ showModeMenu();
415
+ console.log('');
416
+ }
417
+
418
+ function showBootstrapMenu(projects) {
419
+ console.log(` ${b(cream('Where do you want to work?'))}`);
420
+ bootstrapChoices = [];
421
+ for (const p of projects) {
422
+ bootstrapChoices.push({ kind: 'open', path: p.path });
423
+ console.log(` ${teal(String(bootstrapChoices.length))} ${cream(p.name)} ${slate('— ' + fmtAgo(p.lastOpened) + ' · ' + tildify(p.path))}`);
424
+ }
425
+ bootstrapChoices.push({ kind: 'init' });
426
+ const initN = bootstrapChoices.length;
427
+ bootstrapChoices.push({ kind: 'scan' });
428
+ console.log(` ${teal(String(initN))} ${sage('Start a project here')} ${slate('(/init)')} ${slate('·')} ${teal(String(initN + 1))} ${sage('Scan my folders for projects')}`);
429
+ console.log(` ${slate('pick a number, or just type to chat — no project required')}`);
430
+ }
331
431
 
332
432
  console.log('');
333
433
  if (!route) {
@@ -340,10 +440,10 @@ async function main() {
340
440
  console.log(` ${teal('/key')} ${sage('Set an API key (10 seconds)')}`);
341
441
  console.log(` ${teal('/tour')} ${sage('See what this does (nothing needed)')}`);
342
442
  console.log(` ${slate('Or install Claude Code / Codex — phewsh uses their login automatically.')}`);
443
+ } else if (intentFiles.length === 0 && (atHome || recents.length > 0)) {
444
+ showBootstrapMenu(recents);
343
445
  } else {
344
- console.log(` ${b(cream('What are you trying to do?'))}`);
345
- console.log(` ${teal('1')} ${sage('Build')} ${slate('·')} ${teal('2')} ${sage('Research')} ${slate('·')} ${teal('3')} ${sage('Decide')} ${slate('·')} ${teal('4')} ${sage('Review')} ${slate('·')} ${teal('5')} ${sage('Ask another model')}`);
346
- console.log(` ${slate('pick a number, or just type — your context travels with every route')}`);
446
+ showModeMenu();
347
447
  }
348
448
  console.log('');
349
449
 
@@ -493,6 +593,59 @@ async function main() {
493
593
  return;
494
594
  }
495
595
 
596
+ // Root bootstrap: a bare number opens a project, inits, or scans
597
+ if (bootstrapChoices && messages.length === 0 && /^[0-9]{1,2}$/.test(input)) {
598
+ const choice = bootstrapChoices[parseInt(input, 10) - 1];
599
+ if (!choice) {
600
+ console.log(` ${sage('Pick 1-' + bootstrapChoices.length)}`);
601
+ rl.prompt();
602
+ return;
603
+ }
604
+ if (choice.kind === 'open') {
605
+ openProjectAt(choice.path);
606
+ rl.prompt();
607
+ return;
608
+ }
609
+ if (choice.kind === 'init') {
610
+ bootstrapChoices = null;
611
+ try {
612
+ const { execSync } = require('child_process');
613
+ execSync('node ' + path.join(__dirname, 'intent.js') + ' --init', { stdio: 'inherit' });
614
+ intentFiles = loadIntentContext();
615
+ systemPrompt = buildSystemPrompt(intentFiles);
616
+ if (intentFiles.length > 0) {
617
+ try { recordProject(process.cwd()); } catch { /* best-effort */ }
618
+ console.log(` ${teal('●')} ${sage('Project started — context loaded:')} ${cream(intentFiles.map(f => f.file).join(', '))}`);
619
+ }
620
+ } catch (err) {
621
+ console.error(` ${ember('!')} ${sage('Init failed:')} ${err.message}`);
622
+ }
623
+ console.log('');
624
+ rl.prompt();
625
+ return;
626
+ }
627
+ if (choice.kind === 'scan') {
628
+ const spin = ui.spinner('scanning your usual folders');
629
+ const found = scanForProjects();
630
+ spin.stop();
631
+ if (found.length === 0) {
632
+ bootstrapChoices = null;
633
+ console.log(` ${sage('No .intent/ projects found in the usual folders.')}`);
634
+ console.log(` ${slate('cd into a project and run phewsh, or /init to start one here.')}`);
635
+ } else {
636
+ console.log(` ${teal('●')} ${sage(`Found ${found.length} project${found.length !== 1 ? 's' : ''}:`)}`);
637
+ bootstrapChoices = found.map(p => ({ kind: 'open', path: p.path }));
638
+ found.forEach((p, i) => {
639
+ console.log(` ${teal(String(i + 1))} ${cream(p.name)} ${slate('· ' + tildify(p.path))}`);
640
+ });
641
+ console.log(` ${slate('pick a number to open it')}`);
642
+ }
643
+ console.log('');
644
+ rl.prompt();
645
+ return;
646
+ }
647
+ }
648
+
496
649
  // A bare 1-5 on an empty conversation picks an intent mode
497
650
  if (messages.length === 0 && !awaitingOutcome && /^[1-5]$/.test(input)) {
498
651
  const n = parseInt(input, 10);
@@ -569,6 +722,7 @@ async function main() {
569
722
  console.log(` ${teal('/use')} ${slate('<route>')} ${sage('Switch: claude-code, codex, gemini, cursor, opencode, api')}`);
570
723
  console.log(` ${teal('/harnesses')} ${sage('Agent CLIs detected on this machine')}`);
571
724
  console.log(` ${teal('/provider')} ${sage('Current route + what\'s available')}`);
725
+ console.log(` ${teal('/fallback')} ${sage('What happens at a usage wall: ask or auto-switch')}`);
572
726
  console.log(` ${teal('/outcomes')} ${sage('Decision record — kept/reverted/superseded/failed')}`);
573
727
  console.log('');
574
728
  console.log(` ${cream('session')}`);
@@ -669,7 +823,7 @@ async function main() {
669
823
  }
670
824
 
671
825
  if (cmd === 'init') {
672
- if (fs.existsSync(path.join(INTENT_DIR, 'vision.md'))) {
826
+ if (fs.existsSync(path.join(intentDir(), 'vision.md'))) {
673
827
  console.log(`\n ${sage('.intent/ already exists in')} ${slate(process.cwd())}`);
674
828
  console.log(` ${sage('Use /reload to refresh context')}\n`);
675
829
  } else {
@@ -956,22 +1110,49 @@ async function main() {
956
1110
  ['Route', routeLabel(route, config), 'green'],
957
1111
  ];
958
1112
  for (const h of harnesses) {
959
- rows.push([h.label, h.installed ? `installed (${h.auth})` : 'not installed', h.installed ? 'green' : undefined]);
1113
+ if (!h.installed && !['aider', 'goose', 'amp', 'droid'].includes(h.id)) {
1114
+ rows.push([h.label, 'not installed']);
1115
+ continue;
1116
+ }
1117
+ if (!h.installed) continue; // hide the long tail of uninstalled extras
1118
+ const via = h.headless ? '' : ' · /work only';
1119
+ rows.push([h.label, `ready — ${h.role}${via}`, 'green']);
960
1120
  }
961
1121
  rows.push(['API key', config?.apiKey ? config.apiKey.slice(0, 8) + '... (' + (config.provider || 'anthropic') + ')' : 'not set — optional', config?.apiKey ? 'green' : 'yellow']);
962
- rows.push(['Fallback', config?.fallback === 'auto' ? 'auto-switch on failure' : 'ask before switching', 'peach']);
1122
+ rows.push(['Fallback', (config?.fallback === 'auto' ? 'auto-switch on failure' : 'ask before switching') + ' — /fallback to change', 'peach']);
963
1123
  if (route?.type === 'api') rows.push(['Model', MODELS[currentModel].name, 'cyan']);
964
1124
  ui.statusPanel('Provider', rows);
965
- console.log(` ${slate('switch:')} ${cream('/use <' + harnesses.filter(h => h.installed).map(h => h.id).concat(config?.apiKey ? ['api'] : []).join('|') + '>')}`);
1125
+ console.log(` ${sage('One terminal. Every AI worker. Shared project memory.')}`);
1126
+ console.log(` ${slate('switch:')} ${cream('/use <' + useOptions().join('|') + '>')} ${slate('· interactive tools: /work <hermes|pi>')}`);
966
1127
  console.log('');
967
1128
  rl.prompt();
968
1129
  return;
969
1130
  }
970
1131
 
1132
+ if (cmd === 'fallback') {
1133
+ const arg = cmdArg?.trim().toLowerCase();
1134
+ if (arg === 'ask' || arg === 'auto') {
1135
+ config = loadConfig() || {};
1136
+ config.fallback = arg;
1137
+ saveConfig(config);
1138
+ console.log(` ${teal('●')} ${sage('Fallback:')} ${cream(arg === 'auto' ? 'auto-switch to the next route on failure' : 'ask before switching')}`);
1139
+ console.log(` ${slate('either way your project context and record stay intact')}`);
1140
+ } else {
1141
+ console.log(` ${sage('Fallback is')} ${cream(config?.fallback === 'auto' ? 'auto-switch' : 'ask first')} ${slate('— when your route hits a usage wall, context travels to the next one.')}`);
1142
+ console.log(` ${sage('Usage:')} ${cream('/fallback ask')} ${slate('·')} ${cream('/fallback auto')}`);
1143
+ }
1144
+ rl.prompt();
1145
+ return;
1146
+ }
1147
+
971
1148
  if (cmd === 'use') {
972
1149
  if (!cmdArg) {
973
1150
  console.log(` ${sage('Current route:')} ${cream(routeLabel(route, config))}`);
974
- console.log(` ${sage('Usage:')} ${cream('/use <claude-code|codex|gemini|cursor|opencode|api>')}`);
1151
+ console.log(` ${sage('Usage:')} ${cream('/use <' + useOptions().join('|') + '>')}`);
1152
+ const workOnlyInstalled = harnesses.filter(h => h.installed && !h.headless);
1153
+ if (workOnlyInstalled.length > 0) {
1154
+ console.log(` ${slate('interactive tools: /work <' + workOnlyInstalled.map(h => h.id).join('|') + '>')}`);
1155
+ }
975
1156
  rl.prompt();
976
1157
  return;
977
1158
  }
@@ -1053,7 +1234,7 @@ async function main() {
1053
1234
  }
1054
1235
 
1055
1236
  if (cmd === 'watch') {
1056
- if (!fs.existsSync(INTENT_DIR)) {
1237
+ if (!fs.existsSync(intentDir())) {
1057
1238
  console.log(`\n ${ember('!')} ${sage('No .intent/ found. Run /init first.')}\n`);
1058
1239
  rl.prompt();
1059
1240
  return;
@@ -1229,5 +1410,7 @@ async function main() {
1229
1410
 
1230
1411
  main().catch(err => {
1231
1412
  console.error('\n Error:', err.message);
1413
+ if (process.env.PHEWSH_DEBUG) console.error('\n' + err.stack);
1414
+ else console.error(' (run PHEWSH_DEBUG=1 phewsh for the full trace)');
1232
1415
  process.exit(1);
1233
1416
  });
@@ -8,7 +8,7 @@ const { spawn } = require('child_process');
8
8
 
9
9
  const b = (s) => `\x1b[1m${s}\x1b[0m`;
10
10
  const w = (s) => `\x1b[97m${s}\x1b[0m`;
11
- const g = (s) => `\x1b[38;2;130;142;138m${s}\x1b[0m`;
11
+ const g = (s) => `\x1b[38;5;247m${s}\x1b[0m`;
12
12
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
13
13
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
14
14
 
package/lib/harnesses.js CHANGED
@@ -15,17 +15,17 @@ const { execSync, spawn } = require('child_process');
15
15
  // how to launch it interactively (detection + /work still fully supported —
16
16
  // never guess flags; a wrong invocation looks like phewsh being broken).
17
17
  const HARNESSES = {
18
- 'claude-code': { bin: 'claude', label: 'Claude Code', auth: 'Claude subscription / Console', args: (p) => ['-p', p, '--output-format', 'text'] },
19
- 'codex': { bin: 'codex', label: 'Codex CLI', auth: 'ChatGPT plan', args: (p) => ['exec', p] },
20
- 'gemini': { bin: 'gemini', label: 'Gemini CLI', auth: 'Google login', args: (p) => ['-p', p] },
21
- 'cursor': { bin: 'cursor-agent', label: 'Cursor Agent', auth: 'Cursor account', args: (p) => ['-p', p, '--output-format', 'text'] },
22
- 'opencode': { bin: 'opencode', label: 'OpenCode', auth: 'OpenCode Zen / configured', args: (p) => ['run', p] },
23
- 'hermes': { bin: 'hermes', label: 'Hermes', auth: 'Nous account', args: null },
24
- 'pi': { bin: 'pi', label: 'Pi', auth: 'Pi login', args: null },
25
- 'aider': { bin: 'aider', label: 'Aider', auth: 'configured keys', args: (p) => ['--message', p] },
26
- 'goose': { bin: 'goose', label: 'Goose', auth: 'Block / configured', args: (p) => ['run', '-t', p] },
27
- 'amp': { bin: 'amp', label: 'Amp', auth: 'Sourcegraph account', args: (p) => ['-x', p] },
28
- 'droid': { bin: 'droid', label: 'Droid', auth: 'Factory account', args: (p) => ['exec', p] },
18
+ 'claude-code': { bin: 'claude', label: 'Claude Code', role: 'writes code', auth: 'Claude subscription / Console', args: (p) => ['-p', p, '--output-format', 'text'] },
19
+ 'codex': { bin: 'codex', label: 'Codex CLI', role: 'reasons & reviews', auth: 'ChatGPT plan', args: (p) => ['exec', p] },
20
+ 'gemini': { bin: 'gemini', label: 'Gemini CLI', role: "another model's take", auth: 'Google login', args: (p) => ['-p', p] },
21
+ 'cursor': { bin: 'cursor-agent', label: 'Cursor Agent', role: 'edits files', auth: 'Cursor account', args: (p) => ['-p', p, '--output-format', 'text'] },
22
+ 'opencode': { bin: 'opencode', label: 'OpenCode', role: 'general agent', auth: 'OpenCode Zen / configured', args: (p) => ['run', p] },
23
+ 'hermes': { bin: 'hermes', label: 'Hermes', role: 'runs loops', auth: 'Nous account', args: null },
24
+ 'pi': { bin: 'pi', label: 'Pi', role: 'conversation', auth: 'Pi login', args: null },
25
+ 'aider': { bin: 'aider', label: 'Aider', role: 'pair-codes', auth: 'configured keys', args: (p) => ['--message', p] },
26
+ 'goose': { bin: 'goose', label: 'Goose', role: 'automates tasks', auth: 'Block / configured', args: (p) => ['run', '-t', p] },
27
+ 'amp': { bin: 'amp', label: 'Amp', role: 'agentic coding', auth: 'Sourcegraph account', args: (p) => ['-x', p] },
28
+ 'droid': { bin: 'droid', label: 'Droid', role: 'agentic coding', auth: 'Factory account', args: (p) => ['exec', p] },
29
29
  };
30
30
 
31
31
  function isInstalled(id) {
package/lib/outcomes.js CHANGED
@@ -19,7 +19,11 @@ const DECISIONS_FILE = path.join(OUTCOMES_DIR, 'decisions.json');
19
19
  const OUTCOMES = ['kept', 'reverted', 'superseded', 'failed'];
20
20
 
21
21
  function load() {
22
- try { return JSON.parse(fs.readFileSync(DECISIONS_FILE, 'utf-8')); } catch { return []; }
22
+ // Array-or-nothing: a corrupt/odd-shaped file must degrade, never throw
23
+ try {
24
+ const d = JSON.parse(fs.readFileSync(DECISIONS_FILE, 'utf-8'));
25
+ return Array.isArray(d) ? d : [];
26
+ } catch { return []; }
23
27
  }
24
28
 
25
29
  function save(decisions) {
@@ -122,7 +126,10 @@ const BYPASS_REASONS = [
122
126
  ];
123
127
 
124
128
  function loadBypasses() {
125
- try { return JSON.parse(fs.readFileSync(BYPASSES_FILE, 'utf-8')); } catch { return []; }
129
+ try {
130
+ const d = JSON.parse(fs.readFileSync(BYPASSES_FILE, 'utf-8'));
131
+ return Array.isArray(d) ? d : [];
132
+ } catch { return []; }
126
133
  }
127
134
 
128
135
  function recordBypass(reason, note = '') {
@@ -0,0 +1,95 @@
1
+ // Local project index — how phewsh knows your projects from anywhere.
2
+ //
3
+ // Every session opened in a project (or created via /init) records it here,
4
+ // so running `phewsh` at machine root becomes mission-control bootstrap
5
+ // ("where do you want to work?") instead of "no project found, goodbye."
6
+ //
7
+ // Storage: ~/.phewsh/projects.json. Local-first; web sync layers on top.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+
13
+ const INDEX_FILE = path.join(os.homedir(), '.phewsh', 'projects.json');
14
+
15
+ // Shallow-scanned roots when the user asks to find projects. One level deep,
16
+ // opt-in only — deep-scanning someone's machine uninvited is invasive.
17
+ const SCAN_ROOTS = [
18
+ path.join(os.homedir(), 'Documents', 'GitHub'),
19
+ path.join(os.homedir(), 'Projects'),
20
+ path.join(os.homedir(), 'projects'),
21
+ path.join(os.homedir(), 'repos'),
22
+ path.join(os.homedir(), 'Developer'),
23
+ path.join(os.homedir(), 'code'),
24
+ ];
25
+
26
+ function load() {
27
+ // Shape-or-nothing: corrupt index degrades to empty, never throws
28
+ try {
29
+ const i = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf-8'));
30
+ if (i && typeof i === 'object' && i.projects && typeof i.projects === 'object') return i;
31
+ return { projects: {} };
32
+ } catch { return { projects: {} }; }
33
+ }
34
+
35
+ function save(index) {
36
+ fs.mkdirSync(path.dirname(INDEX_FILE), { recursive: true });
37
+ fs.writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2));
38
+ }
39
+
40
+ /** Upsert the project at `dir` (called whenever a session opens one). */
41
+ function recordProject(dir, extra = {}) {
42
+ const index = load();
43
+ const key = path.resolve(dir);
44
+ index.projects[key] = {
45
+ ...(index.projects[key] || {}),
46
+ name: path.basename(key),
47
+ path: key,
48
+ lastOpened: new Date().toISOString(),
49
+ ...extra,
50
+ };
51
+ save(index);
52
+ }
53
+
54
+ /** Known projects, most recently opened first. Prunes paths that vanished. */
55
+ function listProjects() {
56
+ const index = load();
57
+ const alive = Object.values(index.projects).filter(p => {
58
+ try { return fs.existsSync(path.join(p.path, '.intent')); } catch { return false; }
59
+ });
60
+ return alive.sort((a, b) => String(b.lastOpened).localeCompare(String(a.lastOpened)));
61
+ }
62
+
63
+ /** Shallow scan: direct children of common roots that contain .intent/. */
64
+ function scanForProjects() {
65
+ const found = [];
66
+ const seen = new Set(); // realpath-dedupe — case-insensitive FS makes ~/Projects and ~/projects one dir
67
+ for (const root of SCAN_ROOTS) {
68
+ let entries;
69
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
70
+ for (const e of entries) {
71
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
72
+ const dir = path.join(root, e.name);
73
+ try {
74
+ if (fs.existsSync(path.join(dir, '.intent', 'vision.md'))) {
75
+ const real = fs.realpathSync(dir);
76
+ if (seen.has(real)) continue;
77
+ seen.add(real);
78
+ found.push({ name: e.name, path: real });
79
+ }
80
+ } catch { /* unreadable dir — skip */ }
81
+ }
82
+ }
83
+ return found;
84
+ }
85
+
86
+ function fmtAgo(ts) {
87
+ if (!ts) return '';
88
+ const mins = Math.floor((Date.now() - new Date(ts).getTime()) / 60000);
89
+ if (mins < 60) return `${mins}m ago`;
90
+ const hrs = Math.floor(mins / 60);
91
+ if (hrs < 24) return `${hrs}h ago`;
92
+ return `${Math.floor(hrs / 24)}d ago`;
93
+ }
94
+
95
+ module.exports = { INDEX_FILE, SCAN_ROOTS, recordProject, listProjects, scanForProjects, fmtAgo };
package/lib/ui.js CHANGED
@@ -3,18 +3,22 @@
3
3
  // Zero dependencies. Pure ANSI. The terminal breathes.
4
4
 
5
5
  // ── PHEWSH palette ───────────────────────────────────────
6
- // 24-bit color for terminals that support it (most modern ones do).
7
- // Fallback-safe: if 24-bit fails, the text still renders.
8
- const rgb = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
6
+ // 256-color (38;5;n), NOT 24-bit (38;2;r;g;b). Terminals without truecolor
7
+ // (Apple Terminal et al.) misparse 24-bit params as separate SGR codes —
8
+ // e.g. teal's red channel "100" became "bright black BACKGROUND" = grey
9
+ // boxes behind text. 256-color is a single param: renders the same
10
+ // everywhere. Comfy in every terminal beats precise in some.
11
+ const c256 = (n) => (s) => `\x1b[38;5;${n}m${s}\x1b[0m`;
12
+ const rgb = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`; // kept for callers; avoid for new UI
9
13
  const rgbBg = (r, g, b) => (s) => `\x1b[48;2;${r};${g};${b}m${s}\x1b[0m`;
10
14
 
11
15
  // Brand colors — relief, quiet, future
12
- const teal = rgb(100, 215, 195); // cool calm — primary
13
- const peach = rgb(255, 195, 145); // warm exhale — accent
14
- const sage = rgb(190, 208, 198); // quiet — secondary text
15
- const slate = rgb(152, 164, 158); // whisper — dim text (bright enough for dark terminals)
16
- const cream = rgb(240, 235, 225); // clarity — bright text
17
- const ember = rgb(220, 140, 90); // glow — warnings/energy
16
+ const teal = c256(79); // #5fd7af cool calm — primary
17
+ const peach = c256(216); // #ffaf87 warm exhale — accent
18
+ const sage = c256(151); // #afd7af quiet — secondary text
19
+ const slate = c256(247); // #9e9e9e whisper — dim but legible
20
+ const cream = c256(230); // #ffffd7 clarity — bright text
21
+ const ember = c256(173); // #d7875f glow — warnings/energy
18
22
 
19
23
  // Standard ANSI fallbacks (used where 24-bit might not render)
20
24
  const b = (s) => `\x1b[1m${s}\x1b[0m`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"