phewsh 0.14.6 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/bin/phewsh.js +3 -0
- package/commands/ambient.js +238 -0
- package/commands/clarify.js +11 -0
- package/commands/hook.js +120 -0
- package/commands/session.js +293 -19
- package/lib/harnesses.js +15 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,6 +43,20 @@ state with the bridges (`~/.phewsh/`). No second install.
|
|
|
43
43
|
Rule of thumb: `phewsh serve` = dispatch tasks *to* your agents.
|
|
44
44
|
`phewsh mcp setup` = your agents pull tasks *from* PHEWSH mid-session.
|
|
45
45
|
|
|
46
|
+
## Ambient — continuity without launching phewsh
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
phewsh ambient on
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A consent screen shows exactly what changes (two hook entries in Claude
|
|
53
|
+
Code's settings), then: every Claude Code session in a project with
|
|
54
|
+
`.intent/` starts pre-briefed, and every session leaves a one-line
|
|
55
|
+
metadata breadcrumb (never transcript contents) in
|
|
56
|
+
`~/.phewsh/ambient-sessions.jsonl`. `phewsh ambient status` shows the
|
|
57
|
+
full ledger; `phewsh ambient off` removes everything. Mission control
|
|
58
|
+
is optional. Continuity is not.
|
|
59
|
+
|
|
46
60
|
## Live Execution
|
|
47
61
|
|
|
48
62
|
Connect the web app to your local machine for real-time task execution:
|
package/bin/phewsh.js
CHANGED
|
@@ -71,6 +71,8 @@ const COMMANDS = {
|
|
|
71
71
|
serve: () => require('../commands/serve')(),
|
|
72
72
|
sequence: () => require('../commands/sequence')(),
|
|
73
73
|
seq: () => require('../commands/sequence')(),
|
|
74
|
+
ambient: () => require('../commands/ambient')(),
|
|
75
|
+
hook: () => require('../commands/hook')(),
|
|
74
76
|
help: showHelp,
|
|
75
77
|
version: showVersion,
|
|
76
78
|
};
|
|
@@ -103,6 +105,7 @@ function showHelp() {
|
|
|
103
105
|
console.log(` ${cyan('push/pull')} ${g('Manual sync to/from phewsh.com/intent')}`);
|
|
104
106
|
console.log(` ${cyan('serve')} ${g('Execution bridge — run from phewsh.com/intent')}`);
|
|
105
107
|
console.log(` ${cyan('mcp')} ${g('Connect AI agents via MCP protocol')}`);
|
|
108
|
+
console.log(` ${cyan('ambient')} ${g('Continuity without launching phewsh — enhance your other tools')}`);
|
|
106
109
|
console.log(` ${cyan('receipts')} ${g('Proof trail — what agents actually did, with evidence')}`);
|
|
107
110
|
console.log(` ${cyan('outcomes')} ${g('Decision record — what was kept, reverted, or failed')}`);
|
|
108
111
|
console.log(` ${cyan('bypass')} ${g('Went around phewsh? Record why — 10 seconds, no guilt')}`);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// phewsh ambient — continuity without launching phewsh.
|
|
2
|
+
//
|
|
3
|
+
// Principle (Jun 12): ambient before primary. Installing phewsh should make
|
|
4
|
+
// the tools you already use better — context arrives, sessions leave
|
|
5
|
+
// breadcrumbs — without you ever typing `phewsh`.
|
|
6
|
+
//
|
|
7
|
+
// Constraint (non-negotiable): radical transparency. Nothing changes
|
|
8
|
+
// without a consent screen that names every file and every line. The
|
|
9
|
+
// ledger (~/.phewsh/ambient.json) records exactly what was applied and
|
|
10
|
+
// how to undo it. Trust matters more than automation here.
|
|
11
|
+
//
|
|
12
|
+
// phewsh ambient — status: detected tools, what's enhanced
|
|
13
|
+
// phewsh ambient on [--yes] — consent screen, then apply
|
|
14
|
+
// phewsh ambient off — remove everything, update ledger
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const readline = require('readline');
|
|
20
|
+
const { listHarnesses } = require('../lib/harnesses');
|
|
21
|
+
|
|
22
|
+
const PHEWSH_DIR = path.join(os.homedir(), '.phewsh');
|
|
23
|
+
const LEDGER_FILE = path.join(PHEWSH_DIR, 'ambient.json');
|
|
24
|
+
const CLAUDE_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
|
|
25
|
+
|
|
26
|
+
const HOOK_START = { type: 'command', command: 'phewsh hook session-start' };
|
|
27
|
+
const HOOK_END = { type: 'command', command: 'phewsh hook session-end' };
|
|
28
|
+
|
|
29
|
+
// ANSI helpers (256-color per cli/lib/ui.js palette rules)
|
|
30
|
+
const b = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
31
|
+
const teal = (s) => `\x1b[38;5;79m${s}\x1b[0m`;
|
|
32
|
+
const sage = (s) => `\x1b[38;5;151m${s}\x1b[0m`;
|
|
33
|
+
const slate = (s) => `\x1b[38;5;247m${s}\x1b[0m`;
|
|
34
|
+
const cream = (s) => `\x1b[38;5;230m${s}\x1b[0m`;
|
|
35
|
+
const peach = (s) => `\x1b[38;5;216m${s}\x1b[0m`;
|
|
36
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
37
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
38
|
+
|
|
39
|
+
function loadLedger() {
|
|
40
|
+
try { return JSON.parse(fs.readFileSync(LEDGER_FILE, 'utf-8')); } catch { return { version: 1, applied: {}, seenHarnesses: [] }; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function saveLedger(ledger) {
|
|
44
|
+
if (!fs.existsSync(PHEWSH_DIR)) fs.mkdirSync(PHEWSH_DIR, { recursive: true });
|
|
45
|
+
fs.writeFileSync(LEDGER_FILE, JSON.stringify(ledger, null, 2));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadClaudeSettings() {
|
|
49
|
+
try { return JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, 'utf-8')); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasHook(settings, eventName, command) {
|
|
53
|
+
const entries = settings?.hooks?.[eventName];
|
|
54
|
+
if (!Array.isArray(entries)) return false;
|
|
55
|
+
return entries.some(e => (e.hooks || []).some(h => h.command === command));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function claudeApplied() {
|
|
59
|
+
const s = loadClaudeSettings();
|
|
60
|
+
return !!s && hasHook(s, 'SessionStart', HOOK_START.command) && hasHook(s, 'SessionEnd', HOOK_END.command);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function applyClaudeHooks() {
|
|
64
|
+
const settings = loadClaudeSettings() || {};
|
|
65
|
+
settings.hooks = settings.hooks || {};
|
|
66
|
+
const changes = [];
|
|
67
|
+
for (const [event, hook] of [['SessionStart', HOOK_START], ['SessionEnd', HOOK_END]]) {
|
|
68
|
+
settings.hooks[event] = settings.hooks[event] || [];
|
|
69
|
+
if (!hasHook(settings, event, hook.command)) {
|
|
70
|
+
settings.hooks[event].push({ hooks: [hook] });
|
|
71
|
+
changes.push(`hooks.${event} += "${hook.command}"`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (changes.length > 0) {
|
|
75
|
+
fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
|
|
76
|
+
}
|
|
77
|
+
return changes;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function removeClaudeHooks() {
|
|
81
|
+
const settings = loadClaudeSettings();
|
|
82
|
+
if (!settings?.hooks) return [];
|
|
83
|
+
const removed = [];
|
|
84
|
+
for (const [event, hook] of [['SessionStart', HOOK_START], ['SessionEnd', HOOK_END]]) {
|
|
85
|
+
const entries = settings.hooks[event];
|
|
86
|
+
if (!Array.isArray(entries)) continue;
|
|
87
|
+
const before = entries.length;
|
|
88
|
+
settings.hooks[event] = entries
|
|
89
|
+
.map(e => ({ ...e, hooks: (e.hooks || []).filter(h => h.command !== hook.command) }))
|
|
90
|
+
.filter(e => e.hooks.length > 0);
|
|
91
|
+
if (settings.hooks[event].length !== before) removed.push(`hooks.${event} -= "${hook.command}"`);
|
|
92
|
+
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
|
93
|
+
}
|
|
94
|
+
if (removed.length > 0) fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
|
|
95
|
+
return removed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function showConsentScreen(harnesses) {
|
|
99
|
+
console.log('');
|
|
100
|
+
console.log(` ${b(cream('PHEWSH ambient'))} ${sage('— continuity without launching phewsh')}`);
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log(` ${sage('Detected on this machine:')}`);
|
|
103
|
+
for (const h of harnesses.filter(h => h.installed)) {
|
|
104
|
+
const note = h.id === 'claude-code' ? teal('enhanceable now') : slate('detection only in v1 — `phewsh sequence` + `phewsh mcp setup` work today');
|
|
105
|
+
console.log(` ${green('✓')} ${cream(h.label.padEnd(14))} ${note}`);
|
|
106
|
+
}
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log(` ${b('Claude Code enhancements:')}`);
|
|
109
|
+
console.log(` ${teal('Context sync')} ${sage('SessionStart hook — when a project has')} ${cream('.intent/')}${sage(', a short brief')}`);
|
|
110
|
+
console.log(` ${sage('(vision, next steps, constraints) is injected at session start.')}`);
|
|
111
|
+
console.log(` ${teal('Session capture')} ${sage('SessionEnd hook — appends one metadata line (time, project, cwd)')}`);
|
|
112
|
+
console.log(` ${sage('to')} ${cream('~/.phewsh/ambient-sessions.jsonl')}${sage('.')} ${b(sage('Never transcript contents.'))}`);
|
|
113
|
+
console.log('');
|
|
114
|
+
console.log(` ${peach('Exactly what changes:')} ${sage('two hook entries in')} ${cream(CLAUDE_SETTINGS)}${sage('. Nothing else is touched.')}`);
|
|
115
|
+
console.log(` ${sage('Undo anytime:')} ${cream('phewsh ambient off')} ${sage('· full record:')} ${cream('phewsh ambient status')}`);
|
|
116
|
+
console.log('');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function confirm(question) {
|
|
120
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
121
|
+
return new Promise(resolve => {
|
|
122
|
+
rl.question(question, answer => {
|
|
123
|
+
rl.close();
|
|
124
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function turnOn(skipConfirm) {
|
|
130
|
+
const harnesses = listHarnesses();
|
|
131
|
+
showConsentScreen(harnesses);
|
|
132
|
+
|
|
133
|
+
if (claudeApplied()) {
|
|
134
|
+
console.log(` ${green('Already applied.')} ${sage('Status:')} ${cream('phewsh ambient status')}`);
|
|
135
|
+
console.log('');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!skipConfirm) {
|
|
140
|
+
const ok = await confirm(` ${b('Apply?')} ${slate('[y/N] ')}`);
|
|
141
|
+
if (!ok) {
|
|
142
|
+
console.log(` ${sage('Nothing changed.')}`);
|
|
143
|
+
console.log('');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const changes = applyClaudeHooks();
|
|
149
|
+
const ledger = loadLedger();
|
|
150
|
+
ledger.applied['claude-code'] = {
|
|
151
|
+
at: new Date().toISOString(),
|
|
152
|
+
file: CLAUDE_SETTINGS,
|
|
153
|
+
changes,
|
|
154
|
+
captures: '~/.phewsh/ambient-sessions.jsonl — timestamp, project, cwd only',
|
|
155
|
+
undo: 'phewsh ambient off',
|
|
156
|
+
};
|
|
157
|
+
ledger.seenHarnesses = harnesses.filter(h => h.installed).map(h => h.id);
|
|
158
|
+
saveLedger(ledger);
|
|
159
|
+
|
|
160
|
+
console.log('');
|
|
161
|
+
console.log(` ${green('●')} ${b('Ambient is on.')}`);
|
|
162
|
+
changes.forEach(c => console.log(` ${teal('+')} ${slate(c)}`));
|
|
163
|
+
console.log('');
|
|
164
|
+
console.log(` ${sage('Next Claude Code session in a project with')} ${cream('.intent/')} ${sage('starts pre-briefed.')}`);
|
|
165
|
+
console.log(` ${sage('You never have to launch phewsh for this to work.')}`);
|
|
166
|
+
console.log('');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function turnOff() {
|
|
170
|
+
const removed = removeClaudeHooks();
|
|
171
|
+
const ledger = loadLedger();
|
|
172
|
+
delete ledger.applied['claude-code'];
|
|
173
|
+
saveLedger(ledger);
|
|
174
|
+
console.log('');
|
|
175
|
+
if (removed.length > 0) {
|
|
176
|
+
console.log(` ${green('●')} ${b('Ambient is off.')}`);
|
|
177
|
+
removed.forEach(c => console.log(` ${peach('-')} ${slate(c)}`));
|
|
178
|
+
console.log(` ${sage('Breadcrumb log kept at')} ${cream('~/.phewsh/ambient-sessions.jsonl')} ${sage('— delete it if you want; phewsh never will.')}`);
|
|
179
|
+
} else {
|
|
180
|
+
console.log(` ${sage('Nothing was applied — nothing to remove.')}`);
|
|
181
|
+
}
|
|
182
|
+
console.log('');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function status() {
|
|
186
|
+
const harnesses = listHarnesses();
|
|
187
|
+
const ledger = loadLedger();
|
|
188
|
+
console.log('');
|
|
189
|
+
console.log(` ${b(cream('PHEWSH ambient'))} ${sage('— status')}`);
|
|
190
|
+
console.log('');
|
|
191
|
+
for (const h of harnesses.filter(h => h.installed)) {
|
|
192
|
+
if (h.id === 'claude-code') {
|
|
193
|
+
const on = claudeApplied();
|
|
194
|
+
console.log(` ${on ? green('●') : yellow('○')} ${cream(h.label.padEnd(14))} ${on ? teal('ambient on') : sage('not enhanced — phewsh ambient on')}`);
|
|
195
|
+
const entry = ledger.applied['claude-code'];
|
|
196
|
+
if (entry) {
|
|
197
|
+
console.log(` ${slate('applied ' + entry.at)}`);
|
|
198
|
+
(entry.changes || []).forEach(c => console.log(` ${slate('· ' + c + ' (' + entry.file + ')')}`));
|
|
199
|
+
console.log(` ${slate('· captures: ' + entry.captures)}`);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
console.log(` ${slate('○')} ${cream(h.label.padEnd(14))} ${slate('detected — v1 enhances Claude Code only')}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// New harnesses since last apply — the re-offer.
|
|
207
|
+
const seen = ledger.seenHarnesses || [];
|
|
208
|
+
const fresh = harnesses.filter(h => h.installed && !seen.includes(h.id));
|
|
209
|
+
if (seen.length > 0 && fresh.length > 0) {
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log(` ${peach('New since last time:')} ${cream(fresh.map(h => h.label).join(', '))}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Recent breadcrumbs — show the user exactly what ambient has recorded.
|
|
215
|
+
const logFile = path.join(PHEWSH_DIR, 'ambient-sessions.jsonl');
|
|
216
|
+
try {
|
|
217
|
+
const lines = fs.readFileSync(logFile, 'utf-8').trim().split('\n');
|
|
218
|
+
console.log('');
|
|
219
|
+
console.log(` ${sage('Last ambient breadcrumbs (' + lines.length + ' total):')}`);
|
|
220
|
+
lines.slice(-3).forEach(l => {
|
|
221
|
+
try {
|
|
222
|
+
const e = JSON.parse(l);
|
|
223
|
+
console.log(` ${slate(e.ts + ' · ' + e.event + ' · ' + (e.project || path.basename(e.cwd || '?')))}`);
|
|
224
|
+
} catch { /* skip bad line */ }
|
|
225
|
+
});
|
|
226
|
+
} catch { /* no breadcrumbs yet */ }
|
|
227
|
+
console.log('');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function main() {
|
|
231
|
+
const sub = process.argv[3] || 'status';
|
|
232
|
+
const skipConfirm = process.argv.includes('--yes');
|
|
233
|
+
if (sub === 'on') return turnOn(skipConfirm);
|
|
234
|
+
if (sub === 'off') return turnOff();
|
|
235
|
+
return status();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = main;
|
package/commands/clarify.js
CHANGED
|
@@ -110,6 +110,17 @@ function writeViews(intentDir, pps) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
async function main() {
|
|
113
|
+
// ESC backs out cleanly at any point — nothing half-written, no error.
|
|
114
|
+
if (process.stdin.isTTY) {
|
|
115
|
+
readline.emitKeypressEvents(process.stdin);
|
|
116
|
+
process.stdin.on('keypress', (str, key) => {
|
|
117
|
+
if (key && key.name === 'escape') {
|
|
118
|
+
console.log('\n\n stopped — esc. Nothing changed.\n');
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
if (args.includes('--help') || args.includes('-h')) {
|
|
114
125
|
console.log(`
|
|
115
126
|
😮💨🤫 phewsh clarify
|
package/commands/hook.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// phewsh hook — runtime endpoints for ambient harness hooks.
|
|
2
|
+
//
|
|
3
|
+
// These are invoked BY other tools (Claude Code hooks), not by people.
|
|
4
|
+
// Contract: fast, silent when there's nothing to say, and they never
|
|
5
|
+
// read or store transcript contents — metadata only. What gets written
|
|
6
|
+
// is documented in `phewsh ambient status`.
|
|
7
|
+
//
|
|
8
|
+
// phewsh hook session-start stdout → injected into the agent's context
|
|
9
|
+
// phewsh hook session-end stdin (hook JSON) → metadata breadcrumb
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
|
|
15
|
+
const PHEWSH_DIR = path.join(os.homedir(), '.phewsh');
|
|
16
|
+
const AMBIENT_LOG = path.join(PHEWSH_DIR, 'ambient-sessions.jsonl');
|
|
17
|
+
const INTENT_DIR = path.join(process.cwd(), '.intent');
|
|
18
|
+
|
|
19
|
+
function readIfExists(p, maxBytes = 16384) {
|
|
20
|
+
try { return fs.readFileSync(p, 'utf-8').slice(0, maxBytes); } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function projectName() {
|
|
24
|
+
const meta = readIfExists(path.join(INTENT_DIR, 'project.json'));
|
|
25
|
+
if (meta) {
|
|
26
|
+
try { const m = JSON.parse(meta); if (m.name) return m.name; } catch { /* fall through */ }
|
|
27
|
+
}
|
|
28
|
+
return path.basename(process.cwd());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function appendBreadcrumb(event, extra = {}) {
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.existsSync(PHEWSH_DIR)) fs.mkdirSync(PHEWSH_DIR, { recursive: true });
|
|
34
|
+
const line = JSON.stringify({
|
|
35
|
+
ts: new Date().toISOString(),
|
|
36
|
+
event,
|
|
37
|
+
cwd: process.cwd(),
|
|
38
|
+
project: fs.existsSync(INTENT_DIR) ? projectName() : null,
|
|
39
|
+
source: 'claude-code-ambient',
|
|
40
|
+
...extra,
|
|
41
|
+
});
|
|
42
|
+
fs.appendFileSync(AMBIENT_LOG, line + '\n');
|
|
43
|
+
} catch { /* breadcrumbs must never break the host tool */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function firstLines(text, n) {
|
|
47
|
+
let body = text;
|
|
48
|
+
// Strip YAML frontmatter — metadata, not context.
|
|
49
|
+
if (body.startsWith('---')) {
|
|
50
|
+
const end = body.indexOf('\n---', 3);
|
|
51
|
+
if (end !== -1) body = body.slice(end + 4);
|
|
52
|
+
}
|
|
53
|
+
return body.split('\n')
|
|
54
|
+
.filter(l => l.trim())
|
|
55
|
+
.filter(l => !/^#+\s*$/.test(l.trim()))
|
|
56
|
+
.slice(0, n)
|
|
57
|
+
.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sessionStart() {
|
|
61
|
+
if (!fs.existsSync(path.join(INTENT_DIR, 'vision.md')) &&
|
|
62
|
+
!fs.existsSync(path.join(INTENT_DIR, 'plan.md'))) {
|
|
63
|
+
// No .intent/ here — stay silent, cost the host nothing.
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const parts = [];
|
|
68
|
+
parts.push(`# Project brief (from .intent/ — PHEWSH continuity layer)`);
|
|
69
|
+
parts.push(`Project: ${projectName()}`);
|
|
70
|
+
|
|
71
|
+
const meta = readIfExists(path.join(INTENT_DIR, 'project.json'));
|
|
72
|
+
if (meta) {
|
|
73
|
+
try {
|
|
74
|
+
const m = JSON.parse(meta);
|
|
75
|
+
if (m.tldr) parts.push(`TLDR: ${m.tldr}`);
|
|
76
|
+
if (m.constraints) {
|
|
77
|
+
const c = Object.entries(m.constraints).map(([k, v]) => `${k}: ${v}`).join(' · ');
|
|
78
|
+
if (c) parts.push(`Constraints: ${c}`);
|
|
79
|
+
}
|
|
80
|
+
} catch { /* skip meta */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const vision = readIfExists(path.join(INTENT_DIR, 'vision.md'));
|
|
84
|
+
if (vision) parts.push(`\n## Vision (excerpt)\n${firstLines(vision, 8)}`);
|
|
85
|
+
|
|
86
|
+
const next = readIfExists(path.join(INTENT_DIR, 'next.md'));
|
|
87
|
+
if (next) parts.push(`\n## Next (excerpt)\n${firstLines(next, 8)}`);
|
|
88
|
+
|
|
89
|
+
const status = readIfExists(path.join(INTENT_DIR, 'status.md'));
|
|
90
|
+
if (status) parts.push(`\n## Status (excerpt)\n${firstLines(status, 5)}`);
|
|
91
|
+
|
|
92
|
+
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
|
+
|
|
94
|
+
process.stdout.write(parts.join('\n') + '\n');
|
|
95
|
+
appendBreadcrumb('session-start');
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sessionEnd() {
|
|
100
|
+
let stdin = '';
|
|
101
|
+
process.stdin.on('data', d => { stdin += d.toString(); });
|
|
102
|
+
process.stdin.on('end', () => {
|
|
103
|
+
let reason = null;
|
|
104
|
+
try { reason = JSON.parse(stdin).reason || null; } catch { /* metadata only; fine */ }
|
|
105
|
+
appendBreadcrumb('session-end', reason ? { reason } : {});
|
|
106
|
+
process.exit(0);
|
|
107
|
+
});
|
|
108
|
+
// If the host never closes stdin, don't hang it.
|
|
109
|
+
setTimeout(() => { appendBreadcrumb('session-end'); process.exit(0); }, 1500);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function main() {
|
|
113
|
+
const event = process.argv[3];
|
|
114
|
+
if (event === 'session-start') return sessionStart();
|
|
115
|
+
if (event === 'session-end') return sessionEnd();
|
|
116
|
+
// Unknown event: exit silently — hooks must never error the host tool.
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = main;
|
package/commands/session.js
CHANGED
|
@@ -17,7 +17,7 @@ const intentDir = () => path.join(process.cwd(), '.intent');
|
|
|
17
17
|
const { select, refreshSession: refreshSess } = require('../lib/supabase');
|
|
18
18
|
const { readPPS } = require('../lib/pps');
|
|
19
19
|
const { push, pull, ensureValidToken } = require('./sync');
|
|
20
|
-
const { HARNESSES, listHarnesses, runViaHarness } = require('../lib/harnesses');
|
|
20
|
+
const { HARNESSES, listHarnesses, runViaHarness, cancelActive } = require('../lib/harnesses');
|
|
21
21
|
const { recordDecision, labelOutcome, pendingDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
|
|
22
22
|
const { recordSessionEvent } = require('../lib/receipts-data');
|
|
23
23
|
const { recordProject, listProjects, scanForProjects, fmtAgo } = require('../lib/projects-index');
|
|
@@ -198,21 +198,28 @@ function buildHarnessPrompt(messages, input) {
|
|
|
198
198
|
return `Conversation so far:\n\n${transcript}\n\n---\n\nUser: ${input}\n\nRespond to the last user message.`;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
async function streamChat(apiKey, messages, systemPrompt, modelId) {
|
|
201
|
+
async function streamChat(apiKey, messages, systemPrompt, modelId, opts = {}) {
|
|
202
202
|
const body = { model: modelId, max_tokens: 2048, messages, stream: true };
|
|
203
203
|
if (systemPrompt) body.system = systemPrompt;
|
|
204
204
|
|
|
205
205
|
const spin = ui.spinner('thinking');
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
'
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
207
|
+
let response;
|
|
208
|
+
try {
|
|
209
|
+
response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: {
|
|
212
|
+
'x-api-key': apiKey,
|
|
213
|
+
'anthropic-version': '2023-06-01',
|
|
214
|
+
'content-type': 'application/json',
|
|
215
|
+
},
|
|
216
|
+
body: JSON.stringify(body),
|
|
217
|
+
signal: opts.signal,
|
|
218
|
+
});
|
|
219
|
+
} catch (err) {
|
|
220
|
+
spin.stop();
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
216
223
|
|
|
217
224
|
if (!response.ok) {
|
|
218
225
|
spin.stop();
|
|
@@ -274,6 +281,31 @@ async function main() {
|
|
|
274
281
|
}
|
|
275
282
|
let currentModel = DEFAULT_MODEL;
|
|
276
283
|
let harnessModel = null; // pass-through preference; the harness validates it
|
|
284
|
+
let liveModelsCache = null; // fetched once per session from the provider's own list
|
|
285
|
+
|
|
286
|
+
// Discovery, not hardcoding: the provider's models endpoint is the truth.
|
|
287
|
+
async function fetchLiveModels() {
|
|
288
|
+
if (liveModelsCache) return liveModelsCache;
|
|
289
|
+
try {
|
|
290
|
+
if (config?.provider === 'openrouter') {
|
|
291
|
+
const res = await fetch('https://openrouter.ai/api/v1/models', { signal: AbortSignal.timeout(4000) });
|
|
292
|
+
if (res.ok) {
|
|
293
|
+
const data = await res.json();
|
|
294
|
+
liveModelsCache = (data.data || []).map(m => m.id);
|
|
295
|
+
}
|
|
296
|
+
} else if (config?.apiKey) {
|
|
297
|
+
const res = await fetch('https://api.anthropic.com/v1/models?limit=100', {
|
|
298
|
+
headers: { 'x-api-key': config.apiKey, 'anthropic-version': '2023-06-01' },
|
|
299
|
+
signal: AbortSignal.timeout(4000),
|
|
300
|
+
});
|
|
301
|
+
if (res.ok) {
|
|
302
|
+
const data = await res.json();
|
|
303
|
+
liveModelsCache = (data.data || []).map(m => m.id);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} catch { /* offline or no key — aliases still work */ }
|
|
307
|
+
return liveModelsCache;
|
|
308
|
+
}
|
|
277
309
|
let totalPromptTokens = 0;
|
|
278
310
|
let totalCompletionTokens = 0;
|
|
279
311
|
|
|
@@ -466,7 +498,9 @@ async function main() {
|
|
|
466
498
|
});
|
|
467
499
|
decisionsThisSession++;
|
|
468
500
|
try {
|
|
501
|
+
turnInFlight = true;
|
|
469
502
|
const output = await runViaHarness(harnessId, fullSystem, buildHarnessPrompt(messages, input), { model: harnessModel });
|
|
503
|
+
turnInFlight = false;
|
|
470
504
|
messages.push({ role: 'user', content: input });
|
|
471
505
|
messages.push({ role: 'assistant', content: (output || '').trim() });
|
|
472
506
|
recordSessionEvent(harnessId, projectName, 'task_complete', {
|
|
@@ -476,6 +510,12 @@ async function main() {
|
|
|
476
510
|
console.log(slate(` via ${HARNESSES[harnessId].label} · outcome? 1 kept · 2 reverted · 3 superseded · 4 failed · or keep typing`));
|
|
477
511
|
return true;
|
|
478
512
|
} catch (err) {
|
|
513
|
+
turnInFlight = false;
|
|
514
|
+
if (userCancelled) {
|
|
515
|
+
userCancelled = false;
|
|
516
|
+
console.log(`\n ${slate('cancelled — esc')}`);
|
|
517
|
+
return true; // user's call, not a failure: no fallback offer
|
|
518
|
+
}
|
|
479
519
|
try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
|
|
480
520
|
recordSessionEvent(harnessId, projectName, 'task_complete', {
|
|
481
521
|
taskId: decisionId, success: false, summary: input.slice(0, 140),
|
|
@@ -494,7 +534,11 @@ async function main() {
|
|
|
494
534
|
messages.push({ role: 'user', content: input });
|
|
495
535
|
console.log('');
|
|
496
536
|
try {
|
|
497
|
-
|
|
537
|
+
turnInFlight = true;
|
|
538
|
+
turnAbort = new AbortController();
|
|
539
|
+
const result = await streamChat(config.apiKey, messages, fullSystem, modelId(currentModel), { signal: turnAbort.signal });
|
|
540
|
+
turnInFlight = false;
|
|
541
|
+
turnAbort = null;
|
|
498
542
|
messages.push({ role: 'assistant', content: result.content });
|
|
499
543
|
if (result.promptTokens) totalPromptTokens += result.promptTokens;
|
|
500
544
|
if (result.completionTokens) totalCompletionTokens += result.completionTokens;
|
|
@@ -512,6 +556,14 @@ async function main() {
|
|
|
512
556
|
});
|
|
513
557
|
return true;
|
|
514
558
|
} catch (err) {
|
|
559
|
+
turnInFlight = false;
|
|
560
|
+
turnAbort = null;
|
|
561
|
+
if (userCancelled || err.name === 'AbortError') {
|
|
562
|
+
userCancelled = false;
|
|
563
|
+
messages.pop();
|
|
564
|
+
console.log(`\n ${slate('cancelled — esc')}`);
|
|
565
|
+
return true; // user's call, not a failure: no fallback offer
|
|
566
|
+
}
|
|
515
567
|
try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
|
|
516
568
|
messages.pop();
|
|
517
569
|
console.error(`\n ${ember('!')} ${sage('API route failed')}${slate(' — ' + err.message.split('\n')[0])}`);
|
|
@@ -561,6 +613,71 @@ async function main() {
|
|
|
561
613
|
historySize: 100,
|
|
562
614
|
});
|
|
563
615
|
|
|
616
|
+
// Live input coloring — like Claude Code: text stays normal, and only a
|
|
617
|
+
// RECOGNIZED leading /command (or @harness) token turns teal (peach for @)
|
|
618
|
+
// so you know it registered. Arguments stay plain. TTY-only, fail-soft.
|
|
619
|
+
const KNOWN_COMMANDS = new Set([
|
|
620
|
+
'quit', 'exit', 'q', 'help', 'h', 'init', 'intent', 'clarify', 'model',
|
|
621
|
+
'models', 'council', 'all', 'provider', 'route', 'use', 'work', 'run',
|
|
622
|
+
'clear', 'status', 'key', 'login', 'export', 'push', 'pull', 'serve',
|
|
623
|
+
'sync', 'harnesses', 'fallback', 'outcomes', 'tour', 'update', 'upgrade',
|
|
624
|
+
'agents', 'context', 'gate', 'reload', 'sequence', 'setup', 'system', 'watch',
|
|
625
|
+
]);
|
|
626
|
+
const installedIds = harnesses.filter(h => h.installed).map(h => h.id);
|
|
627
|
+
let turnAbort = null; // AbortController while an API turn streams
|
|
628
|
+
let turnInFlight = false; // any route — ESC cancels
|
|
629
|
+
let userCancelled = false; // distinguishes esc from real failures
|
|
630
|
+
|
|
631
|
+
function colorizeInput(cur) {
|
|
632
|
+
const tok = cur.slice(1).split(/\s/)[0].toLowerCase();
|
|
633
|
+
if (!tok) return null;
|
|
634
|
+
if (cur[0] === '/' && KNOWN_COMMANDS.has(tok)) {
|
|
635
|
+
return `\x1b[38;5;79m/${cur.slice(1, 1 + tok.length)}\x1b[0m${cur.slice(1 + tok.length)}`;
|
|
636
|
+
}
|
|
637
|
+
if (cur[0] === '@' && installedIds.some(id => id === tok || id.startsWith(tok))) {
|
|
638
|
+
return `\x1b[38;5;216m@${cur.slice(1, 1 + tok.length)}\x1b[0m${cur.slice(1 + tok.length)}`;
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (process.stdout.isTTY && typeof rl._writeToOutput === 'function') {
|
|
644
|
+
const origWrite = rl._writeToOutput.bind(rl);
|
|
645
|
+
rl._writeToOutput = function (s) {
|
|
646
|
+
try {
|
|
647
|
+
const cur = rl.line || '';
|
|
648
|
+
if (typeof s === 'string' && cur && s.includes(cur)) {
|
|
649
|
+
const colored = colorizeInput(cur);
|
|
650
|
+
if (colored) s = s.split(cur).join(colored);
|
|
651
|
+
}
|
|
652
|
+
} catch { /* never break input */ }
|
|
653
|
+
origWrite(s);
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (process.stdin.isTTY) {
|
|
658
|
+
process.stdin.on('keypress', (str, key) => {
|
|
659
|
+
try {
|
|
660
|
+
// ESC: cancel an in-flight turn, or clear the input line.
|
|
661
|
+
if (key && key.name === 'escape') {
|
|
662
|
+
if (turnInFlight) {
|
|
663
|
+
userCancelled = true;
|
|
664
|
+
if (turnAbort) turnAbort.abort();
|
|
665
|
+
cancelActive();
|
|
666
|
+
} else if (rl.line) {
|
|
667
|
+
rl.line = '';
|
|
668
|
+
rl.cursor = 0;
|
|
669
|
+
rl._refreshLine();
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
// Re-render so token coloring tracks edits (and un-colors when it
|
|
674
|
+
// stops matching a known command).
|
|
675
|
+
const cur = rl.line || '';
|
|
676
|
+
if (cur[0] === '/' || cur[0] === '@') rl._refreshLine();
|
|
677
|
+
} catch { /* never break input */ }
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
564
681
|
rl.prompt();
|
|
565
682
|
|
|
566
683
|
rl.on('line', async (line) => {
|
|
@@ -713,6 +830,7 @@ async function main() {
|
|
|
713
830
|
console.log('');
|
|
714
831
|
console.log(` ${cream('author .intent/')}`);
|
|
715
832
|
console.log(` ${teal('/init')} ${sage('Create .intent/ for this project')}`);
|
|
833
|
+
console.log(` ${teal('/intent')} ${sage('Pause and reflect — view or update .intent/ before moving on')}`);
|
|
716
834
|
console.log(` ${teal('/clarify')} ${sage('Turn ideas into .intent/ artifacts')}`);
|
|
717
835
|
console.log(` ${teal('/gate')} ${sage('Set constraints (budget, time, skill)')}`);
|
|
718
836
|
console.log(` ${teal('/context')} ${sage('Show loaded .intent/ files')}`);
|
|
@@ -740,6 +858,7 @@ async function main() {
|
|
|
740
858
|
console.log(` ${cream('session')}`);
|
|
741
859
|
console.log(` ${teal('/work')} ${slate('[harness]')} ${sage('Hand off to interactive Claude Code/Codex — outcome on return')}`);
|
|
742
860
|
console.log(` ${teal('/run')} ${slate('<prompt>')} ${sage('One-shot prompt (no history)')}`);
|
|
861
|
+
console.log(` ${teal('esc')} ${sage('Cancel a running turn · clear the input line')}`);
|
|
743
862
|
console.log(` ${teal('/clear')} ${sage('Clear conversation')}`);
|
|
744
863
|
console.log(` ${teal('/status')} ${sage('Session stats')}`);
|
|
745
864
|
console.log(` ${teal('/quit')} ${sage('Exit')}`);
|
|
@@ -1085,16 +1204,54 @@ async function main() {
|
|
|
1085
1204
|
if (cmd === 'models') {
|
|
1086
1205
|
console.log('');
|
|
1087
1206
|
ui.divider('line');
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1207
|
+
if (route?.type === 'harness') {
|
|
1208
|
+
const h = HARNESSES[route.id];
|
|
1209
|
+
console.log(` ${b(cream('Models'))} ${sage('— via ' + h.label)}`);
|
|
1210
|
+
ui.divider('line');
|
|
1211
|
+
if (h.modelHints) {
|
|
1212
|
+
// Aliases the harness resolves to its own current versions —
|
|
1213
|
+
// stable names, so this list can't go stale.
|
|
1214
|
+
console.log(` ${cream('default'.padEnd(12))} ${sage(h.label + "'s own default")}${!harnessModel ? ` ${teal('●')}` : ''}`);
|
|
1215
|
+
h.modelHints.forEach(m => {
|
|
1216
|
+
const active = harnessModel === m ? ` ${teal('●')}` : '';
|
|
1217
|
+
console.log(` ${cream(m.padEnd(12))} ${sage('latest ' + m.charAt(0).toUpperCase() + m.slice(1))}${active}`);
|
|
1218
|
+
});
|
|
1219
|
+
if (harnessModel && !h.modelHints.includes(harnessModel)) {
|
|
1220
|
+
console.log(` ${cream(harnessModel.padEnd(12))} ${sage('(pass-through)')} ${teal('●')}`);
|
|
1221
|
+
}
|
|
1222
|
+
console.log(`\n ${sage('Switch:')} ${cream('/model <name>')} ${slate('— any full model id also works; ' + h.label + ' validates')}`);
|
|
1223
|
+
} else {
|
|
1224
|
+
console.log(` ${sage('Current preference:')} ${cream(harnessModel || h.label + ' default')}`);
|
|
1225
|
+
console.log(` ${sage(h.label + ' owns its model list —')} ${cream('/model <anything it accepts>')} ${slate('passes through; it validates')}`);
|
|
1226
|
+
}
|
|
1227
|
+
console.log('');
|
|
1228
|
+
rl.prompt();
|
|
1229
|
+
return;
|
|
1093
1230
|
}
|
|
1094
|
-
|
|
1231
|
+
// API route: ask the provider for its real list.
|
|
1232
|
+
const providerName = config?.provider === 'openrouter' ? 'OpenRouter' : 'Anthropic';
|
|
1233
|
+
const live = await fetchLiveModels();
|
|
1234
|
+
if (live && live.length > 0) {
|
|
1235
|
+
console.log(` ${b(cream('Available models'))} ${sage('— live from ' + providerName)}`);
|
|
1236
|
+
ui.divider('line');
|
|
1237
|
+
const shown = live.slice(0, 24);
|
|
1238
|
+
shown.forEach(id => {
|
|
1239
|
+
const active = modelId(currentModel) === id ? ` ${teal('●')}` : '';
|
|
1240
|
+
console.log(` ${cream(id)}${active}`);
|
|
1241
|
+
});
|
|
1242
|
+
if (live.length > shown.length) console.log(` ${slate('… +' + (live.length - shown.length) + ' more — /model <id> takes any of them')}`);
|
|
1243
|
+
} else {
|
|
1244
|
+
console.log(` ${b(cream('Available models'))} ${slate('(offline — shortcuts only; any id still passes through)')}`);
|
|
1245
|
+
ui.divider('line');
|
|
1246
|
+
for (const [key, model] of Object.entries(MODELS)) {
|
|
1247
|
+
const active = key === currentModel ? ` ${teal('●')}` : '';
|
|
1248
|
+
console.log(` ${cream(key.padEnd(16))} ${sage(model.name)}${active}`);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (!MODELS[currentModel] && !live?.includes(modelId(currentModel))) {
|
|
1095
1252
|
console.log(` ${cream(String(currentModel).padEnd(16))} ${sage('(pass-through)')} ${teal('●')}`);
|
|
1096
1253
|
}
|
|
1097
|
-
console.log(`\n ${sage('Switch with:')} ${cream('/model <name>')} ${slate('—
|
|
1254
|
+
console.log(`\n ${sage('Switch with:')} ${cream('/model <name>')} ${slate('— shortcuts: ' + Object.keys(MODELS).map(k => k.replace('claude-', '')).join(' · '))}\n`);
|
|
1098
1255
|
rl.prompt();
|
|
1099
1256
|
return;
|
|
1100
1257
|
}
|
|
@@ -1150,6 +1307,115 @@ async function main() {
|
|
|
1150
1307
|
return;
|
|
1151
1308
|
}
|
|
1152
1309
|
|
|
1310
|
+
if (cmd === 'intent') {
|
|
1311
|
+
// Pause and reflect before moving forward — the samurai check.
|
|
1312
|
+
// View what the project says it is; update it when reality moved.
|
|
1313
|
+
const artifacts = ['vision', 'plan', 'next', 'status'];
|
|
1314
|
+
const intentDir = path.join(process.cwd(), '.intent');
|
|
1315
|
+
|
|
1316
|
+
// Full markdown render — **bold**, *italic*, `code`, [links] become
|
|
1317
|
+
// terminal formatting, not literal symbols. `base` keeps the line's
|
|
1318
|
+
// color after each inline reset.
|
|
1319
|
+
const inlineMd = (t, base) => t
|
|
1320
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, x) => `\x1b[1m\x1b[38;5;230m${x}\x1b[0m${base}`)
|
|
1321
|
+
.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, (_, p, x) => `${p}\x1b[3m${x}\x1b[23m`)
|
|
1322
|
+
.replace(/`([^`]+)`/g, (_, x) => `\x1b[38;5;79m${x}\x1b[0m${base}`)
|
|
1323
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, x, u) => `\x1b[4m\x1b[38;5;79m${x}\x1b[0m${base} \x1b[2m(${u})\x1b[22m`);
|
|
1324
|
+
const C = { sage: '\x1b[38;5;151m', slate: '\x1b[38;5;247m' };
|
|
1325
|
+
const renderMd = (raw) => {
|
|
1326
|
+
let body = raw;
|
|
1327
|
+
if (body.startsWith('---')) {
|
|
1328
|
+
const end = body.indexOf('\n---', 3);
|
|
1329
|
+
if (end !== -1) body = body.slice(end + 4);
|
|
1330
|
+
}
|
|
1331
|
+
return body.trim().split('\n').map(l => {
|
|
1332
|
+
if (/^#{1,2}\s/.test(l)) return `\n ${b(teal(inlineMd(l.replace(/^#+\s*/, ''), '')))}`;
|
|
1333
|
+
if (/^#{3,}\s/.test(l)) return ` ${cream(inlineMd(l.replace(/^#+\s*/, ''), ''))}`;
|
|
1334
|
+
if (/^\s*[-*]\s/.test(l)) return ` ${teal('·')} ${C.sage}${inlineMd(l.replace(/^\s*[-*]\s*/, ''), C.sage)}\x1b[0m`;
|
|
1335
|
+
if (/^\s*\d+\.\s/.test(l)) return ` ${C.sage}${inlineMd(l.trim(), C.sage)}\x1b[0m`;
|
|
1336
|
+
if (/^---+\s*$/.test(l)) return ` ${slate('─'.repeat(40))}`;
|
|
1337
|
+
return ` ${C.slate}${inlineMd(l, C.slate)}\x1b[0m`;
|
|
1338
|
+
}).join('\n');
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
if (!fs.existsSync(intentDir)) {
|
|
1342
|
+
console.log(` ${sage('No .intent/ here yet.')} ${cream('/init')} ${sage('creates it — that is the whole point.')}`);
|
|
1343
|
+
rl.prompt();
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const sub = (cmdArg || '').trim().toLowerCase();
|
|
1348
|
+
|
|
1349
|
+
if (sub.startsWith('view')) {
|
|
1350
|
+
const which = sub.split(/\s+/)[1];
|
|
1351
|
+
const targets = which && artifacts.includes(which) ? [which] : artifacts;
|
|
1352
|
+
for (const a of targets) {
|
|
1353
|
+
const p = path.join(intentDir, `${a}.md`);
|
|
1354
|
+
if (!fs.existsSync(p)) continue;
|
|
1355
|
+
console.log('');
|
|
1356
|
+
ui.divider('line');
|
|
1357
|
+
console.log(` ${b(cream(a.toUpperCase()))} ${slate('.intent/' + a + '.md')}`);
|
|
1358
|
+
ui.divider('line');
|
|
1359
|
+
console.log(renderMd(fs.readFileSync(p, 'utf-8')));
|
|
1360
|
+
}
|
|
1361
|
+
console.log('');
|
|
1362
|
+
console.log(` ${slate('moved on since this was written?')} ${cream('/intent update')}`);
|
|
1363
|
+
console.log('');
|
|
1364
|
+
rl.prompt();
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (sub === 'update') {
|
|
1369
|
+
// Reflect first: what does the record say happened since last update?
|
|
1370
|
+
console.log('');
|
|
1371
|
+
console.log(` ${b(cream('Before updating — what actually happened:'))}`);
|
|
1372
|
+
try {
|
|
1373
|
+
const { recentDecisions } = require('../lib/outcomes');
|
|
1374
|
+
const recent = recentDecisions(5, { project: projectName });
|
|
1375
|
+
if (recent.length > 0) {
|
|
1376
|
+
recent.forEach(d => {
|
|
1377
|
+
const mark = d.outcome === 'kept' ? green('✓') : d.outcome ? yellow('~') : slate('·');
|
|
1378
|
+
console.log(` ${mark} ${sage((d.summary || '').slice(0, 70))} ${slate('[' + (d.outcome || 'unlabeled') + ']')}`);
|
|
1379
|
+
});
|
|
1380
|
+
} else {
|
|
1381
|
+
console.log(` ${slate('no decisions recorded yet')}`);
|
|
1382
|
+
}
|
|
1383
|
+
} catch { console.log(` ${slate('record unavailable')}`); }
|
|
1384
|
+
console.log('');
|
|
1385
|
+
console.log(` ${teal('●')} ${sage('Handing you to the guided update')} ${slate('— exit to come back to phewsh')}`);
|
|
1386
|
+
console.log('');
|
|
1387
|
+
rl.pause();
|
|
1388
|
+
const { spawnSync } = require('child_process');
|
|
1389
|
+
spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'phewsh.js'), 'clarify'], { stdio: 'inherit' });
|
|
1390
|
+
rl.resume();
|
|
1391
|
+
console.log('');
|
|
1392
|
+
console.log(` ${teal('●')} ${sage('Back in phewsh.')} ${slate('/intent view to see the result — agents pick it up automatically')}`);
|
|
1393
|
+
console.log('');
|
|
1394
|
+
rl.prompt();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// No arg: the pause. Where the project stands, in one screen.
|
|
1399
|
+
const present = artifacts.filter(a => fs.existsSync(path.join(intentDir, `${a}.md`)));
|
|
1400
|
+
const stale = present.map(a => {
|
|
1401
|
+
const raw = fs.readFileSync(path.join(intentDir, `${a}.md`), 'utf-8');
|
|
1402
|
+
const m = raw.match(/^updated:\s*(\S+)/m);
|
|
1403
|
+
return { a, updated: m ? m[1] : null };
|
|
1404
|
+
});
|
|
1405
|
+
console.log('');
|
|
1406
|
+
console.log(` ${b(cream('INTENT'))} ${slate('— .intent/ in ' + projectName)}`);
|
|
1407
|
+
stale.forEach(({ a, updated }) => {
|
|
1408
|
+
const age = updated ? slate('updated ' + updated) : slate('no date');
|
|
1409
|
+
console.log(` ${teal('·')} ${cream(a.padEnd(8))} ${age}`);
|
|
1410
|
+
});
|
|
1411
|
+
console.log('');
|
|
1412
|
+
console.log(` ${cream('/intent view')} ${slate('[vision|plan|next|status]')} ${sage('read it, rendered')}`);
|
|
1413
|
+
console.log(` ${cream('/intent update')} ${sage('reflect on the record, then guided rewrite')}`);
|
|
1414
|
+
console.log('');
|
|
1415
|
+
rl.prompt();
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1153
1419
|
if (cmd === 'council' || cmd === 'all') {
|
|
1154
1420
|
// One prompt, every installed harness, in parallel. Different
|
|
1155
1421
|
// models disagreeing is the signal — and which answer you KEEP
|
|
@@ -1180,9 +1446,17 @@ async function main() {
|
|
|
1180
1446
|
console.log(` ${slate(members.map(m => m.label).join(' · '))}`);
|
|
1181
1447
|
|
|
1182
1448
|
const prompt = buildHarnessPrompt(messages, cmdArg);
|
|
1449
|
+
turnInFlight = true;
|
|
1183
1450
|
const settled = await Promise.allSettled(members.map(m =>
|
|
1184
1451
|
runViaHarness(m.id, councilSystem, prompt, { quiet: true })
|
|
1185
1452
|
));
|
|
1453
|
+
turnInFlight = false;
|
|
1454
|
+
if (userCancelled) {
|
|
1455
|
+
userCancelled = false;
|
|
1456
|
+
console.log(`\n ${slate('council cancelled — esc')}\n`);
|
|
1457
|
+
rl.prompt();
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1186
1460
|
|
|
1187
1461
|
const answers = [];
|
|
1188
1462
|
settled.forEach((r, i) => {
|
package/lib/harnesses.js
CHANGED
|
@@ -20,7 +20,7 @@ const { execSync, spawn } = require('child_process');
|
|
|
20
20
|
// list of its own, so it can never go stale. Harnesses without a known
|
|
21
21
|
// model flag ignore the preference and use their own config.
|
|
22
22
|
const HARNESSES = {
|
|
23
|
-
'claude-code': { bin: 'claude', label: 'Claude Code', role: 'writes code', auth: 'Claude subscription / Console', models: true, args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
|
|
23
|
+
'claude-code': { bin: 'claude', label: 'Claude Code', role: 'writes code', auth: 'Claude subscription / Console', models: true, modelHints: ['sonnet', 'opus', 'haiku', 'fable'], args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
|
|
24
24
|
'codex': { bin: 'codex', label: 'Codex CLI', role: 'reasons & reviews', auth: 'ChatGPT plan', models: true, args: (p, m) => ['exec', ...(m ? ['-m', m] : []), p] },
|
|
25
25
|
'gemini': { bin: 'gemini', label: 'Gemini CLI', role: "another model's take", auth: 'Google login', models: true, args: (p, m) => ['-p', p, ...(m ? ['-m', m] : [])] },
|
|
26
26
|
'cursor': { bin: 'cursor-agent', label: 'Cursor Agent', role: 'edits files', auth: 'Cursor account', models: true, args: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])] },
|
|
@@ -36,6 +36,17 @@ const HARNESSES = {
|
|
|
36
36
|
'droid': { bin: 'droid', label: 'Droid', role: 'agentic coding', auth: 'Factory account', args: (p) => ['exec', p] },
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
// In-flight harness children — so ESC in the session can cancel a turn.
|
|
40
|
+
const ACTIVE_CHILDREN = new Set();
|
|
41
|
+
|
|
42
|
+
function cancelActive() {
|
|
43
|
+
let n = 0;
|
|
44
|
+
for (const c of ACTIVE_CHILDREN) {
|
|
45
|
+
try { c.kill('SIGTERM'); n++; } catch { /* already gone */ }
|
|
46
|
+
}
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
function isInstalled(id) {
|
|
40
51
|
const h = HARNESSES[id];
|
|
41
52
|
if (!h) return false;
|
|
@@ -68,6 +79,8 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
|
|
|
68
79
|
|
|
69
80
|
return new Promise((resolve, reject) => {
|
|
70
81
|
const child = spawn(h.bin, h.args(prompt, model), { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
82
|
+
ACTIVE_CHILDREN.add(child);
|
|
83
|
+
child.on('close', () => ACTIVE_CHILDREN.delete(child));
|
|
71
84
|
// Some harnesses (codex exec, gemini) wait for stdin EOF before running.
|
|
72
85
|
child.stdin.end();
|
|
73
86
|
|
|
@@ -87,4 +100,4 @@ function runViaHarness(id, systemPrompt, userPrompt, opts = {}) {
|
|
|
87
100
|
});
|
|
88
101
|
}
|
|
89
102
|
|
|
90
|
-
module.exports = { HARNESSES, isInstalled, detectInstalled, listHarnesses, runViaHarness };
|
|
103
|
+
module.exports = { HARNESSES, isInstalled, detectInstalled, listHarnesses, runViaHarness, cancelActive };
|