phewsh 0.15.13 → 0.15.15
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/commands/hook.js +22 -0
- package/commands/session.js +74 -8
- package/lib/continuity.js +86 -0
- package/lib/history.js +40 -0
- package/package.json +1 -1
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/session.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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
|
|
1891
|
-
const
|
|
1892
|
-
console.log(` ${cream(h.id.padEnd(
|
|
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) {
|
|
@@ -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 };
|