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 +1 -1
- package/commands/receipts.js +1 -1
- package/commands/session.js +203 -20
- package/commands/update.js +1 -1
- package/lib/harnesses.js +11 -11
- package/lib/outcomes.js +9 -2
- package/lib/projects-index.js +95 -0
- package/lib/ui.js +13 -9
- package/package.json +1 -1
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;
|
|
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
|
|
package/commands/receipts.js
CHANGED
|
@@ -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;
|
|
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`;
|
package/commands/session.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(` ${
|
|
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 <
|
|
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(
|
|
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
|
});
|
package/commands/update.js
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
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 =
|
|
13
|
-
const peach =
|
|
14
|
-
const sage =
|
|
15
|
-
const slate =
|
|
16
|
-
const cream =
|
|
17
|
-
const ember =
|
|
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`;
|