phewsh 0.15.13 → 0.15.19

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
@@ -30,7 +30,7 @@ function showBrand() {
30
30
  console.log('');
31
31
  console.log(` ${b(w('█▀█ █░█ █▀▀ █░█ █▀ █░█'))}`);
32
32
  console.log(` ${b(w('█▀▀ █▀█ ██▄ ▀▄▀ ▄█ █▀█'))}`);
33
- console.log(` ${g('.intent/ your project\'s working memory for every AI tool.')}`);
33
+ console.log(` ${g('Keep all your AI tools. phewsh is the one memory they share.')}`);
34
34
  console.log('');
35
35
 
36
36
  // Context-aware hint
@@ -49,7 +49,7 @@ function showBrand() {
49
49
  const COMMANDS = {
50
50
  session: () => require('../commands/session'),
51
51
  intent: () => require('../commands/intent'),
52
- clarify: () => require('../commands/clarify'),
52
+ clarify: () => require('../commands/clarify').run(),
53
53
  push: () => require('../commands/push'),
54
54
  pull: () => require('../commands/pull'),
55
55
  link: () => require('../commands/link'),
@@ -45,10 +45,52 @@ async function askForInput() {
45
45
  });
46
46
  }
47
47
 
48
- async function callClarifyAPI(apiKey, raw, existing) {
49
- const isRefine = !!(existing?.intent?.goal);
48
+ // The guided walk the five strongest nodes of the web's 12-node Intent
49
+ // Compass, asked one at a time. The web compass helps the user *see* what
50
+ // they're building; this brings that to the terminal. Not a form: every
51
+ // question is skippable, and the point is to help you think, not interrogate.
52
+ const GUIDE_NODES = [
53
+ { id: 'purpose', title: 'Purpose', directive: 'the core reason this exists',
54
+ q: 'What outcome are you really after — and why does this need to exist?' },
55
+ { id: 'audience', title: 'Audience', directive: 'the people this serves',
56
+ q: 'Who is this for? Who feels it most when it works?' },
57
+ { id: 'method', title: 'Method', directive: 'the mechanism and approach',
58
+ q: 'How does it actually work — the core mechanism or approach?' },
59
+ { id: 'scope', title: 'Scope', directive: 'boundaries, in and out',
60
+ q: "What's in — and just as important, what's deliberately out, for now?" },
61
+ { id: 'differentiation', title: 'Edge', directive: 'what makes this yours',
62
+ q: 'What would be lost if someone else built this instead of you?' },
63
+ ];
64
+
65
+ function ask(rl, question) {
66
+ return new Promise((resolve) => rl.question(question, (a) => resolve((a || '').trim())));
67
+ }
50
68
 
51
- const systemPrompt = `You are a project compiler. Your job is to extract clean, structured intent from messy human input.
69
+ // rl is injectable so the walk can be driven deterministically in tests.
70
+ async function askGuided(rl = readline.createInterface({ input: process.stdin, output: process.stdout })) {
71
+ console.log('\n Five quick questions to align your own thinking first —');
72
+ console.log(' a sentence or two each. Blank skips. (esc stops, nothing saved.)\n');
73
+ const answers = [];
74
+ for (let i = 0; i < GUIDE_NODES.length; i++) {
75
+ const n = GUIDE_NODES[i];
76
+ console.log(` ${i + 1}/${GUIDE_NODES.length} ${n.title} — ${n.directive}`);
77
+ const a = await ask(rl, ` ${n.q}\n > `);
78
+ if (a) answers.push({ ...n, answer: a });
79
+ console.log('');
80
+ }
81
+ rl.close();
82
+ return answers;
83
+ }
84
+
85
+ // Label each answer by its node so the compiler keeps the structure the walk
86
+ // surfaced (a Purpose answer informs the goal, Scope informs constraints, etc.)
87
+ function assembleRaw(answers) {
88
+ return answers.map((a) => `${a.title} (${a.directive}): ${a.answer}`).join('\n');
89
+ }
90
+
91
+ function buildClarifySystemPrompt(existing) {
92
+ const isRefine = !!(existing?.intent?.goal);
93
+ return `You are a project compiler. Your job is to extract clean, structured intent from messy human input.
52
94
 
53
95
  Return ONLY valid JSON — no markdown, no explanation, no commentary. The JSON must match this exact schema:
54
96
 
@@ -73,7 +115,26 @@ Rules:
73
115
  - tasks: 3-7 concrete next actions, specific enough to act on immediately
74
116
  - type options: "do" (manual action), "copy" (command to run), "open" (URL to visit), "install" (package to install)
75
117
  ${isRefine ? '\nThis is a refinement of existing intent. The previous goal was: ' + existing.intent.goal : ''}`;
118
+ }
76
119
 
120
+ // Pull the first valid JSON object out of model output — harnesses may wrap it
121
+ // in prose or code fences; the API returns it clean. Throws if none parses.
122
+ function extractJson(text) {
123
+ const candidates = [];
124
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
125
+ if (fence) candidates.push(fence[1].trim());
126
+ const first = text.indexOf('{');
127
+ const last = text.lastIndexOf('}');
128
+ if (first !== -1 && last > first) candidates.push(text.slice(first, last + 1));
129
+ candidates.push(text.trim());
130
+ for (const c of candidates) {
131
+ try { return JSON.parse(c); } catch { /* try next candidate */ }
132
+ }
133
+ throw new Error('could not parse a project spec from the model output');
134
+ }
135
+
136
+ async function callClarifyAPI(apiKey, raw, existing) {
137
+ const systemPrompt = buildClarifySystemPrompt(existing);
77
138
  const response = await fetch('https://api.anthropic.com/v1/messages', {
78
139
  method: 'POST',
79
140
  headers: {
@@ -95,11 +156,17 @@ ${isRefine ? '\nThis is a refinement of existing intent. The previous goal was:
95
156
  }
96
157
 
97
158
  const data = await response.json();
98
- const text = data.content?.[0]?.text || '';
159
+ return extractJson(data.content?.[0]?.text || '');
160
+ }
99
161
 
100
- // Strip markdown code fences if present
101
- const clean = text.replace(/^```(?:json)?\n?/m, '').replace(/\n?```$/m, '').trim();
102
- return JSON.parse(clean);
162
+ // No API key? Compile through an installed harness instead — pass-through, so
163
+ // clarify works for anyone with Claude Code / Codex / etc., no key required.
164
+ async function callClarifyViaHarness(harnessId, raw, existing) {
165
+ const { runViaHarness } = require('../lib/harnesses');
166
+ const systemPrompt = buildClarifySystemPrompt(existing) +
167
+ '\n\nReturn ONLY the JSON object — no prose, no code fences, before or after.';
168
+ const out = await runViaHarness(harnessId, systemPrompt, raw, { quiet: true });
169
+ return extractJson(out || '');
103
170
  }
104
171
 
105
172
  function writeViews(intentDir, pps) {
@@ -126,17 +193,22 @@ async function main() {
126
193
  😮‍💨🤫 phewsh clarify
127
194
 
128
195
  Usage:
129
- phewsh clarify Interactive: describe your project, get structured PPS
196
+ phewsh clarify Guided: a 5-question walk that aligns your thinking, then compiles
197
+ phewsh clarify --freeform Free-form: describe it all in one messy blob
130
198
  phewsh clarify --text "..." Inline: pass raw text directly
131
199
  phewsh clarify --update Refine existing PPS with new input
132
200
 
133
201
  What it does:
134
- Takes messy, buzzword-heavy input and compiles it into a structured project spec (PPS).
202
+ Walks you through the five strongest nodes of the Intent Compass
203
+ Purpose, Audience, Method, Scope, Edge — one question at a time, so the
204
+ terminal helps you *think*, not just compile. Then turns your answers
205
+ into a structured project spec (PPS):
135
206
  Writes .intent/pps.json as the source of truth.
136
207
  Generates vision.md, plan.md, next.md as human-readable views.
137
208
 
138
209
  Requires:
139
- phewsh login --set-key Set your Anthropic API key first
210
+ An installed agent CLI (Claude Code, Codex, Gemini…) — phewsh uses its
211
+ login, no key. Or run "phewsh login --set-key" to use an Anthropic API key.
140
212
 
141
213
  Examples:
142
214
  phewsh clarify
@@ -147,8 +219,13 @@ async function main() {
147
219
  }
148
220
 
149
221
  const config = loadConfig();
150
- if (!config?.apiKey) {
151
- console.log('\n No API key found. Run `phewsh login --set-key` first.\n');
222
+ // Pass-through: with no API key, compile through an installed harness
223
+ // (Claude Code, Codex, …) the same login the rest of phewsh rides on.
224
+ const harnessId = config?.apiKey ? null : require('../lib/harnesses').detectInstalled();
225
+ if (!config?.apiKey && !harnessId) {
226
+ console.log('\n Nothing to compile with yet. Either:');
227
+ console.log(' • install an agent CLI (Claude Code, Codex, Gemini…) — phewsh uses its login, or');
228
+ console.log(' • run `phewsh login --set-key` to add an API key.\n');
152
229
  process.exit(1);
153
230
  }
154
231
 
@@ -161,13 +238,25 @@ async function main() {
161
238
 
162
239
  console.log('\n 😮‍💨🤫 phewsh clarify\n');
163
240
 
241
+ const freeform = args.includes('--freeform') || args.includes('-f');
164
242
  let raw = rawFromFlag;
165
243
  if (!raw) {
166
244
  if (!process.stdin.isTTY) {
167
245
  console.error('\n Pipe input or use --text "your description"\n');
168
246
  process.exit(1);
169
247
  }
170
- raw = await askForInput();
248
+ if (freeform) {
249
+ raw = await askForInput();
250
+ } else {
251
+ // Guided is the default interactive path: help the user think first.
252
+ const answers = await askGuided();
253
+ raw = assembleRaw(answers);
254
+ if (!raw) {
255
+ // Skipped every question — fall back to a single free-form description.
256
+ console.log(' No problem — describe it your own way instead.');
257
+ raw = await askForInput();
258
+ }
259
+ }
171
260
  }
172
261
 
173
262
  if (!raw) {
@@ -175,11 +264,15 @@ async function main() {
175
264
  return;
176
265
  }
177
266
 
178
- console.log('\n Compiling...\n');
267
+ const { HARNESSES } = require('../lib/harnesses');
268
+ const via = harnessId ? ` via ${HARNESSES[harnessId]?.label || harnessId}` : '';
269
+ console.log(`\n Compiling your intent into a spec${via}...\n`);
179
270
 
180
271
  let extracted;
181
272
  try {
182
- extracted = await callClarifyAPI(config.apiKey, raw, existing);
273
+ extracted = harnessId
274
+ ? await callClarifyViaHarness(harnessId, raw, existing)
275
+ : await callClarifyAPI(config.apiKey, raw, existing);
183
276
  } catch (err) {
184
277
  console.error('\n Clarify failed:', err.message, '\n');
185
278
  process.exit(1);
@@ -238,7 +331,11 @@ async function main() {
238
331
  `);
239
332
  }
240
333
 
241
- main().catch(err => {
242
- console.error('\n Error:', err.message);
243
- process.exit(1);
244
- });
334
+ if (require.main === module) {
335
+ main().catch(err => {
336
+ console.error('\n Error:', err.message);
337
+ process.exit(1);
338
+ });
339
+ }
340
+
341
+ module.exports = { run: main, GUIDE_NODES, assembleRaw, askGuided, extractJson };
package/commands/hook.js CHANGED
@@ -12,10 +12,27 @@ const fs = require('fs');
12
12
  const path = require('path');
13
13
  const os = require('os');
14
14
 
15
+ const continuity = require('../lib/continuity');
16
+
15
17
  const PHEWSH_DIR = path.join(os.homedir(), '.phewsh');
16
18
  const AMBIENT_LOG = path.join(PHEWSH_DIR, 'ambient-sessions.jsonl');
19
+ const DECISIONS_FILE = path.join(PHEWSH_DIR, 'outcomes', 'decisions.json');
17
20
  const INTENT_DIR = path.join(process.cwd(), '.intent');
18
21
 
22
+ // "Where you left off, across every tool" — drawn from phewsh's own decision
23
+ // record (not the host transcript). This is what makes opening Claude Code
24
+ // standalone feel like resuming: it sees the thread Codex (or phewsh) left.
25
+ function continuityBrief(project) {
26
+ try {
27
+ const decisions = JSON.parse(fs.readFileSync(DECISIONS_FILE, 'utf-8'));
28
+ const line = continuity.continuityLine(decisions, { project });
29
+ if (!line) return null;
30
+ const tools = continuity.toolsInThread(decisions, { project });
31
+ const span = tools >= 2 ? ` (${tools} tools, one thread)` : '';
32
+ return `Continuity${span}: you were ${line}. Nothing's lost — continue it.`;
33
+ } catch { return null; }
34
+ }
35
+
19
36
  function readIfExists(p, maxBytes = 16384) {
20
37
  try { return fs.readFileSync(p, 'utf-8').slice(0, maxBytes); } catch { return null; }
21
38
  }
@@ -89,6 +106,11 @@ function sessionStart() {
89
106
  const status = readIfExists(path.join(INTENT_DIR, 'status.md'));
90
107
  if (status) parts.push(`\n## Status (excerpt)\n${firstLines(status, 5)}`);
91
108
 
109
+ // Decisions are tagged with the cwd basename (how recordDecision keys them),
110
+ // not the project.json display name — match on that so the thread connects.
111
+ const cont = continuityBrief(path.basename(process.cwd()));
112
+ if (cont) parts.push(`\n## Continuity (across your tools)\n${cont}`);
113
+
92
114
  parts.push(`\n(Brief injected by PHEWSH ambient from .intent/. Honor the constraints above. The human can run \`phewsh\` for mission control — council, outcomes, the decision record.)`);
93
115
 
94
116
  process.stdout.write(parts.join('\n') + '\n');
package/commands/serve.js CHANGED
@@ -219,6 +219,13 @@ function json(req, res, data, status = 200) {
219
219
  }
220
220
 
221
221
  function main() {
222
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
223
+ console.log('\n phewsh serve — local execution bridge for phewsh.com/intent');
224
+ console.log(' Runs a loopback server so the web workspace can dispatch to your');
225
+ console.log(' installed agents. Stays running until you stop it (ctrl+c).');
226
+ console.log('\n Usage: phewsh serve [--port <n>] (default 7483)\n');
227
+ return;
228
+ }
222
229
  const port = getPort();
223
230
  const runtimes = detectRuntimes();
224
231
  const hasClaudeCode = runtimes.find(r => r.id === 'claude-code')?.connected;
@@ -18,9 +18,11 @@ const { select, refreshSession: refreshSess } = require('../lib/supabase');
18
18
  const { readPPS } = require('../lib/pps');
19
19
  const { push, pull, ensureValidToken } = require('./sync');
20
20
  const { HARNESSES, listHarnesses, runViaHarness, cancelActive } = require('../lib/harnesses');
21
- const { recordDecision, labelOutcome, pendingDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
21
+ const { recordDecision, labelOutcome, pendingDecisions, recentDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
22
22
  const { suggest, suggestAll } = require('../lib/suggest');
23
+ const continuity = require('../lib/continuity');
23
24
  const { closest } = require('../lib/closest');
25
+ const cmdHistory = require('../lib/history');
24
26
  const { recordSessionEvent } = require('../lib/receipts-data');
25
27
  const configFile = require('../lib/config-file');
26
28
  const { createFailureTracker, createLineDispatcher } = require('../lib/session-input');
@@ -499,6 +501,19 @@ async function main() {
499
501
  };
500
502
  }
501
503
 
504
+ // "Nothing lost" — surface where you left off, across every tool, so opening
505
+ // phewsh feels like resuming a thread rather than starting cold.
506
+ function showContinuity() {
507
+ try {
508
+ const decisions = recentDecisions(50, { project: projectName });
509
+ const line = continuity.continuityLine(decisions, { project: projectName });
510
+ if (!line) return;
511
+ const tools = continuity.toolsInThread(decisions, { project: projectName });
512
+ const span = tools >= 2 ? slate(` · ${tools} tools, one thread`) : '';
513
+ console.log(` ${teal('↻')} ${sage('Picking up — ' + line)}${span} ${slate('· /thread')}`);
514
+ } catch { /* continuity is a nicety, never a blocker */ }
515
+ }
516
+
502
517
  // One subtle line under the menu — the single highest-leverage nudge, if any.
503
518
  function showInlineTip() {
504
519
  let tip = null;
@@ -522,6 +537,7 @@ async function main() {
522
537
  console.log('');
523
538
  console.log(` ${teal('●')} ${cream(projectName)} ${slate('·')} ${sage(`.intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''} loaded`)} ${slate('· via ' + routeLabel(route, config))}`);
524
539
  console.log('');
540
+ showContinuity();
525
541
  showModeMenu();
526
542
  showInlineTip();
527
543
  console.log('');
@@ -555,6 +571,7 @@ async function main() {
555
571
  } else if (intentFiles.length === 0 && (atHome || recents.length > 0)) {
556
572
  showBootstrapMenu(recents);
557
573
  } else {
574
+ showContinuity();
558
575
  showModeMenu();
559
576
  showInlineTip();
560
577
  }
@@ -702,6 +719,7 @@ async function main() {
702
719
  output: process.stdout,
703
720
  prompt: ` ${teal('phewsh')} ${sage('>')} `,
704
721
  historySize: 100,
722
+ history: cmdHistory.loadForReadline(100), // up-arrow remembers across sessions
705
723
  });
706
724
  const promptText = ` phewsh > `;
707
725
  let lastPaste = null;
@@ -752,7 +770,7 @@ async function main() {
752
770
  'clear', 'status', 'key', 'login', 'export', 'push', 'pull', 'serve',
753
771
  'sync', 'harnesses', 'fallback', 'outcomes', 'tour', 'update', 'upgrade',
754
772
  'agents', 'context', 'gate', 'reload', 'sequence', 'seq', 'setup', 'system', 'watch',
755
- 'next', 'recommend', 'guide',
773
+ 'next', 'recommend', 'guide', 'thread', 'continuity',
756
774
  ]);
757
775
  const installedIds = harnesses.filter(h => h.installed).map(h => h.id);
758
776
  let turnAbort = null; // AbortController while an API turn streams
@@ -1106,6 +1124,40 @@ async function main() {
1106
1124
  return;
1107
1125
  }
1108
1126
 
1127
+ // ── /thread ────────────────────────────────────────
1128
+ // The cross-tool thread: one continuous record of your work, whichever
1129
+ // tool ran each step. The "nothing lost" proof, made visible.
1130
+ if (cmd === 'thread' || cmd === 'continuity') {
1131
+ const decisions = recentDecisions(50, { project: projectName });
1132
+ const thread = continuity.threadFor(decisions, { project: projectName });
1133
+ console.log('');
1134
+ if (thread.length === 0) {
1135
+ console.log(` ${teal('●')} ${sage('No thread yet for')} ${cream(projectName)}${sage('.')} ${slate('Do something — every action joins the thread, whichever tool runs it.')}`);
1136
+ console.log('');
1137
+ rl.prompt();
1138
+ return;
1139
+ }
1140
+ const tools = continuity.toolsInThread(decisions, { project: projectName });
1141
+ console.log(` ${b(cream('Your thread'))} ${slate('— ' + projectName + ' · phewsh remembers across every tool')}`);
1142
+ ui.divider('line');
1143
+ for (const d of thread.slice(0, 12)) {
1144
+ const ago = continuity.agoText(d.ts).padEnd(9);
1145
+ const via = continuity.labelFor(d.route).padEnd(13);
1146
+ const oc = d.outcome
1147
+ ? (d.outcome === 'kept' ? green('kept') : d.outcome === 'superseded' ? peach(d.outcome) : ember(d.outcome))
1148
+ : slate('open');
1149
+ let s = (d.summary || '').replace(/\s+/g, ' ');
1150
+ if (s.length > 46) s = s.slice(0, 45).trimEnd() + '…';
1151
+ console.log(` ${slate(ago)} ${sage(via)} ${cream(s || '—')} ${oc}`);
1152
+ }
1153
+ ui.divider('line');
1154
+ const span = tools >= 2 ? `${tools} tools, one thread` : `${tools} tool`;
1155
+ console.log(` ${sage(thread.length + ' action' + (thread.length !== 1 ? 's' : '') + ' · ' + span + ' · nothing re-explained')}`);
1156
+ console.log('');
1157
+ rl.prompt();
1158
+ return;
1159
+ }
1160
+
1109
1161
  if (cmd === 'help' || cmd === 'h') {
1110
1162
  const wantsAll = /^(all|more|full|everything)$/i.test(cmdArg.trim());
1111
1163
 
@@ -1134,6 +1186,7 @@ async function main() {
1134
1186
  console.log('');
1135
1187
  console.log(` ${cream('not sure what to do?')}`);
1136
1188
  console.log(` ${teal('/next')} ${sage("phewsh reads your state and hands back the next step worth taking")}`);
1189
+ console.log(` ${teal('/thread')} ${sage('where you left off — your work across every tool, one record')}`);
1137
1190
  console.log('');
1138
1191
  console.log(` ${cream('author .intent/')}`);
1139
1192
  console.log(` ${teal('/init')} ${sage('Create .intent/ for this project')}`);
@@ -1883,14 +1936,27 @@ async function main() {
1883
1936
 
1884
1937
  if (cmd === 'harnesses' || cmd === 'agents') {
1885
1938
  console.log('');
1886
- console.log(` ${b(cream('Agent CLIs on this machine'))} ${slate('— each carries its own login, no API key')}`);
1939
+ console.log(` ${b(cream('Your AI tools'))} ${slate('— phewsh keeps them all, aligned. You never pick just one.')}`);
1887
1940
  ui.divider('line');
1888
- for (const h of harnesses) {
1941
+ // Installed first, then the rest so the table also teaches what exists.
1942
+ const sorted = [...harnesses].sort((a, b) => (b.installed - a.installed));
1943
+ let lastGroup = null;
1944
+ for (const h of sorted) {
1945
+ const group = h.installed ? 'in' : 'out';
1946
+ if (group !== lastGroup) {
1947
+ console.log(` ${slate(h.installed ? 'on this machine — context routes straight through their login:' : 'available — install any of these and phewsh picks it up:')}`);
1948
+ lastGroup = group;
1949
+ }
1889
1950
  const active = route?.type === 'harness' && route.id === h.id ? ` ${teal('● active')}` : '';
1890
- const mode = h.headless ? '' : slate(' · interactive (/work)');
1891
- const status = h.installed ? green('installed') : slate('not installed');
1892
- console.log(` ${cream(h.id.padEnd(12))} ${sage(h.label.padEnd(14))} ${status}${mode}${active}`);
1951
+ const dot = h.installed ? green('') : slate('');
1952
+ const mode = h.headless ? '' : slate(' · /work only');
1953
+ console.log(` ${dot} ${cream(h.id.padEnd(11))} ${sage((h.role || h.label).padEnd(20))} ${slate(h.label)}${mode}${active}`);
1893
1954
  }
1955
+ ui.divider('line');
1956
+ console.log(` ${sage('keep your tools, keep one record:')}`);
1957
+ console.log(` ${teal('/use')} ${slate('<id>')} ${sage('route your typing through that tool')}`);
1958
+ console.log(` ${teal('@<id>')} ${slate('<msg>')} ${sage('one message to one tool — context stays shared')}`);
1959
+ console.log(` ${teal('/council')} ${slate('<q>')} ${sage('ask every installed tool at once, keep the best answer')}`);
1894
1960
  console.log('');
1895
1961
  rl.prompt();
1896
1962
  return;
@@ -2131,7 +2197,7 @@ async function main() {
2131
2197
  }
2132
2198
 
2133
2199
  const lineDispatcher = createLineDispatcher(handleInput, {
2134
- onBatch: ({ input, lines }) => collapsePastedEcho(lines, input),
2200
+ onBatch: ({ input, lines }) => { collapsePastedEcho(lines, input); cmdHistory.append(input); },
2135
2201
  onNoop: () => {
2136
2202
  // Bare Enter accepts a pending "did you mean" suggestion.
2137
2203
  if (pendingDidYouMean) {
package/commands/sync.js CHANGED
@@ -256,7 +256,19 @@ async function link(config, token, cloudId) {
256
256
  console.log(' Run `phewsh push` to sync.\n');
257
257
  }
258
258
 
259
+ function isAuthError(err) {
260
+ const m = (err && err.message || '').toLowerCase();
261
+ return m.includes('jwt') || m.includes('expired') || m.includes('401') || m.includes('unauthorized');
262
+ }
263
+
259
264
  async function main(direction = 'push') {
265
+ const argv = process.argv.slice(3);
266
+ if (argv.includes('--help') || argv.includes('-h')) {
267
+ console.log(`\n phewsh ${direction === 'pull' ? 'pull' : direction === 'link' ? 'link <cloud-id>' : 'push'} — sync .intent/ with phewsh.com/intent`);
268
+ console.log(` push .intent/ → cloud pull cloud → .intent/ link adopt a cloud project\n`);
269
+ return;
270
+ }
271
+
260
272
  const config = loadConfig();
261
273
  if (!config?.supabaseUserId) {
262
274
  console.log('\n Not logged in. Run `phewsh login` first.\n');
@@ -269,18 +281,28 @@ async function main(direction = 'push') {
269
281
  process.exit(1);
270
282
  }
271
283
 
272
- if (direction === 'pull') {
273
- await pull(config, token);
274
- } else if (direction === 'link') {
275
- const args = process.argv.slice(3);
276
- const cloudId = args[1];
277
- if (!cloudId) {
278
- console.log('\n Usage: phewsh link <cloud-project-id>\n');
279
- process.exit(1);
284
+ // The token can still be rejected server-side (refresh token also expired).
285
+ // Convert that into the same friendly nudge instead of a raw stack trace.
286
+ try {
287
+ if (direction === 'pull') {
288
+ await pull(config, token);
289
+ } else if (direction === 'link') {
290
+ const cloudId = argv[0] && !argv[0].startsWith('-') ? argv[0] : process.argv[4];
291
+ if (!cloudId) {
292
+ console.log('\n Usage: phewsh link <cloud-project-id>\n');
293
+ process.exit(1);
294
+ }
295
+ await link(config, token, cloudId);
296
+ } else {
297
+ await push(config, token);
280
298
  }
281
- await link(config, token, cloudId);
282
- } else {
283
- await push(config, token);
299
+ } catch (err) {
300
+ if (isAuthError(err)) {
301
+ console.log('\n Session expired. Run `phewsh login` to re-authenticate.\n');
302
+ } else {
303
+ console.log(`\n ${direction} failed: ${err.message}\n`);
304
+ }
305
+ process.exit(1);
284
306
  }
285
307
  }
286
308
 
@@ -0,0 +1,86 @@
1
+ // Continuity — make "nothing lost across tools" visible.
2
+ //
3
+ // Every routed action phewsh records is tagged with the tool that ran it
4
+ // (claude-code, codex, gemini…). Read newest-first, that list IS a thread of
5
+ // your work across every tool. This module turns it into something a human
6
+ // (front door, /thread) or a standalone harness (ambient brief) can feel:
7
+ // "last you were doing X, via Codex, 3h ago — keep going."
8
+ //
9
+ // Pure + deterministic: feed it the decision records, get back strings.
10
+
11
+ const ROUTE_LABELS = {
12
+ 'claude-code': 'Claude Code',
13
+ codex: 'Codex',
14
+ gemini: 'Gemini',
15
+ cursor: 'Cursor',
16
+ opencode: 'OpenCode',
17
+ grok: 'Grok',
18
+ kiro: 'Kiro',
19
+ copilot: 'Copilot',
20
+ hermes: 'Hermes',
21
+ pi: 'Pi',
22
+ aider: 'Aider',
23
+ goose: 'Goose',
24
+ amp: 'Amp',
25
+ droid: 'Droid',
26
+ api: 'direct API',
27
+ council: 'council',
28
+ };
29
+
30
+ function labelFor(route, labeler) {
31
+ if (labeler) { const l = labeler(route); if (l) return l; }
32
+ return ROUTE_LABELS[route] || route || 'a tool';
33
+ }
34
+
35
+ function agoText(ts, now = Date.now()) {
36
+ const then = new Date(ts).getTime();
37
+ if (isNaN(then)) return '';
38
+ const s = Math.max(0, Math.floor((now - then) / 1000));
39
+ if (s < 45) return 'just now';
40
+ const m = Math.floor(s / 60);
41
+ if (m < 60) return `${m}m ago`;
42
+ const h = Math.floor(m / 60);
43
+ if (h < 24) return `${h}h ago`;
44
+ const d = Math.floor(h / 24);
45
+ return `${d}d ago`;
46
+ }
47
+
48
+ /** Decisions for a project (or all), newest first. */
49
+ function threadFor(decisions, { project = null } = {}) {
50
+ return (decisions || [])
51
+ .filter(d => d && d.ts && (!project || d.project === project))
52
+ .sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
53
+ }
54
+
55
+ /** The single most recent action — "where you left off", or null. */
56
+ function lastLeftOff(decisions, { project = null } = {}) {
57
+ const t = threadFor(decisions, { project });
58
+ if (!t.length) return null;
59
+ const d = t[0];
60
+ return {
61
+ summary: (d.summary || '').trim(),
62
+ route: d.route,
63
+ ts: d.ts,
64
+ outcome: d.outcome || null,
65
+ };
66
+ }
67
+
68
+ /** A one-line "picking up where you left off" string, or null. */
69
+ function continuityLine(decisions, { project = null, now = Date.now(), labeler = null, maxLen = 52 } = {}) {
70
+ const last = lastLeftOff(decisions, { project });
71
+ if (!last) return null;
72
+ let s = last.summary.replace(/\s+/g, ' ');
73
+ if (s.length > maxLen) s = s.slice(0, maxLen - 1).trimEnd() + '…';
74
+ const via = labelFor(last.route, labeler);
75
+ const ago = agoText(last.ts, now);
76
+ const when = ago ? ` · ${ago}` : '';
77
+ return s ? `last: “${s}” · via ${via}${when}` : `last action via ${via}${when}`;
78
+ }
79
+
80
+ /** How many distinct tools appear in the thread (the "across tools" proof). */
81
+ function toolsInThread(decisions, { project = null } = {}) {
82
+ const set = new Set(threadFor(decisions, { project }).map(d => d.route).filter(Boolean));
83
+ return set.size;
84
+ }
85
+
86
+ module.exports = { agoText, threadFor, lastLeftOff, continuityLine, toolsInThread, labelFor, ROUTE_LABELS };
package/lib/history.js ADDED
@@ -0,0 +1,40 @@
1
+ // Persistent command history — so up-arrow remembers across sessions, like
2
+ // every serious shell and harness. Stored at ~/.phewsh/history, newest last.
3
+ //
4
+ // Never persists anything secret: lines that set an API key are skipped, so a
5
+ // key typed at the prompt can't linger on disk.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ const FILE = path.join(os.homedir(), '.phewsh', 'history');
12
+
13
+ const SECRET = /^\/key\b/i; // /key <token> — never write the token to disk
14
+
15
+ /** Load up to `max` recent entries, oldest→newest (file order). */
16
+ function load(max = 100, file = FILE) {
17
+ try {
18
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(l => l.trim().length);
19
+ return lines.slice(-max);
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ /** Most-recent-first, the order Node's readline `history` option expects. */
26
+ function loadForReadline(max = 100, file = FILE) {
27
+ return load(max, file).reverse();
28
+ }
29
+
30
+ /** Append one submitted line. No-ops on blank or secret-bearing input. */
31
+ function append(line, file = FILE) {
32
+ if (!line || !line.trim()) return;
33
+ if (SECRET.test(line.trim())) return;
34
+ try {
35
+ fs.mkdirSync(path.dirname(file), { recursive: true });
36
+ fs.appendFileSync(file, line.replace(/\r?\n/g, ' ') + '\n');
37
+ } catch { /* read-only home — in-session history still works */ }
38
+ }
39
+
40
+ module.exports = { load, loadForReadline, append, FILE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.13",
3
+ "version": "0.15.19",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"