phewsh 0.15.27 → 0.15.28
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 +10 -1
- package/commands/session.js +74 -0
- package/lib/selfheal.js +152 -0
- package/lib/suggest.js +14 -0
- package/package.json +1 -1
package/commands/hook.js
CHANGED
|
@@ -124,7 +124,16 @@ function sessionEnd() {
|
|
|
124
124
|
process.stdin.on('end', () => {
|
|
125
125
|
let reason = null;
|
|
126
126
|
try { reason = JSON.parse(stdin).reason || null; } catch { /* metadata only; fine */ }
|
|
127
|
-
|
|
127
|
+
// Self-heal: if this project's .intent/ drifted ahead of CLAUDE.md during
|
|
128
|
+
// the session, bring it current now — so the next tool to open here (phewsh
|
|
129
|
+
// or any agent) reads today's intent without anyone running `seq -w`. Silent
|
|
130
|
+
// and deterministic; failures never touch the host tool.
|
|
131
|
+
let healed = false;
|
|
132
|
+
try {
|
|
133
|
+
const selfheal = require('../lib/selfheal');
|
|
134
|
+
if (selfheal.isStale()) healed = selfheal.heal().healed;
|
|
135
|
+
} catch { /* a hook must never error the host */ }
|
|
136
|
+
appendBreadcrumb('session-end', { ...(reason ? { reason } : {}), ...(healed ? { healed: true } : {}) });
|
|
128
137
|
process.exit(0);
|
|
129
138
|
});
|
|
130
139
|
// If the host never closes stdin, don't hang it.
|
package/commands/session.js
CHANGED
|
@@ -21,6 +21,7 @@ const { HARNESSES, listHarnesses, runViaHarness, cancelActive } = require('../li
|
|
|
21
21
|
const { recordDecision, labelOutcome, pendingDecisions, recentDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
|
|
22
22
|
const { suggest, suggestAll } = require('../lib/suggest');
|
|
23
23
|
const continuity = require('../lib/continuity');
|
|
24
|
+
const selfheal = require('../lib/selfheal');
|
|
24
25
|
const learning = require('../lib/learning');
|
|
25
26
|
const recall = require('../lib/recall');
|
|
26
27
|
const { closest } = require('../lib/closest');
|
|
@@ -335,6 +336,7 @@ async function main() {
|
|
|
335
336
|
let sessionMode = null; // INTENT_MODES id once picked
|
|
336
337
|
let awaitingOutcome = null; // decision id eligible for 1-4 labeling
|
|
337
338
|
let awaitingWhy = null; // { id, outcome } — next line is the reason
|
|
339
|
+
let awaitingWrap = null; // { block } — y/N to fold shipped work into next.md
|
|
338
340
|
let awaitingFallback = null; // { input, fullSystem, options } after a route failure
|
|
339
341
|
let bootstrapChoices = null; // root-bootstrap menu entries when no project here
|
|
340
342
|
let nextChoices = null; // ranked /next suggestions awaiting a numeric pick
|
|
@@ -507,10 +509,22 @@ async function main() {
|
|
|
507
509
|
turnsThisSession: Math.floor(messages.length / 2),
|
|
508
510
|
seqStale,
|
|
509
511
|
ambientOn,
|
|
512
|
+
commitsSinceIntent: (() => { try { return selfheal.commitsSinceIntent(); } catch { return 0; } })(),
|
|
510
513
|
bestKeeper,
|
|
511
514
|
};
|
|
512
515
|
}
|
|
513
516
|
|
|
517
|
+
// Self-healing continuity on entry: if .intent/ drifted ahead of CLAUDE.md
|
|
518
|
+
// since last time, quietly bring it current so every tool reading CLAUDE.md
|
|
519
|
+
// (this session included) sees today — the user never runs `seq -w` by hand.
|
|
520
|
+
function maybeHealOnEntry() {
|
|
521
|
+
try {
|
|
522
|
+
if (!selfheal.isStale()) return;
|
|
523
|
+
const h = selfheal.heal();
|
|
524
|
+
if (h.healed) console.log(` ${teal('↻')} ${sage('Synced your .intent/ into CLAUDE.md — kept current automatically')}`);
|
|
525
|
+
} catch { /* self-heal is a nicety, never a blocker */ }
|
|
526
|
+
}
|
|
527
|
+
|
|
514
528
|
// "Nothing lost" — surface where you left off, across every tool, so opening
|
|
515
529
|
// phewsh feels like resuming a thread rather than starting cold.
|
|
516
530
|
function showContinuity() {
|
|
@@ -547,6 +561,7 @@ async function main() {
|
|
|
547
561
|
console.log('');
|
|
548
562
|
console.log(` ${teal('●')} ${cream(projectName)} ${slate('·')} ${sage(`.intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''} loaded`)} ${slate('· via ' + routeLabel(route, config))}`);
|
|
549
563
|
console.log('');
|
|
564
|
+
maybeHealOnEntry();
|
|
550
565
|
showContinuity();
|
|
551
566
|
showModeMenu();
|
|
552
567
|
showInlineTip();
|
|
@@ -581,6 +596,7 @@ async function main() {
|
|
|
581
596
|
} else if (intentFiles.length === 0 && (atHome || recents.length > 0)) {
|
|
582
597
|
showBootstrapMenu(recents);
|
|
583
598
|
} else {
|
|
599
|
+
maybeHealOnEntry();
|
|
584
600
|
showContinuity();
|
|
585
601
|
showModeMenu();
|
|
586
602
|
showInlineTip();
|
|
@@ -974,6 +990,25 @@ async function main() {
|
|
|
974
990
|
return;
|
|
975
991
|
}
|
|
976
992
|
|
|
993
|
+
// /wrap confirmation: y folds the shipped-work block into next.md + heals.
|
|
994
|
+
if (awaitingWrap) {
|
|
995
|
+
const { block } = awaitingWrap;
|
|
996
|
+
awaitingWrap = null;
|
|
997
|
+
if (/^(y|yes)$/i.test(input.trim())) {
|
|
998
|
+
const r = selfheal.appendToNext(block);
|
|
999
|
+
if (r.written) {
|
|
1000
|
+
console.log(` ${green('✓')} ${sage('Folded into .intent/' + r.file + ' — CLAUDE.md synced too. The record matches the work now.')}`);
|
|
1001
|
+
} else {
|
|
1002
|
+
console.log(` ${ember('!')} ${sage('Could not write .intent/next.md — left it untouched.')}`);
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
console.log(` ${slate('Left .intent/ as-is.')}`);
|
|
1006
|
+
}
|
|
1007
|
+
console.log('');
|
|
1008
|
+
rl.prompt();
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
977
1012
|
// Any typed input supersedes a pending "did you mean" offer.
|
|
978
1013
|
if (pendingDidYouMean) pendingDidYouMean = null;
|
|
979
1014
|
|
|
@@ -1149,6 +1184,13 @@ async function main() {
|
|
|
1149
1184
|
if (global._phewshChildren) {
|
|
1150
1185
|
global._phewshChildren.forEach(c => { try { c.kill(); } catch {} });
|
|
1151
1186
|
}
|
|
1187
|
+
// Self-healing continuity: leave the next tool a current CLAUDE.md so
|
|
1188
|
+
// opening Claude Code / Codex here next reads today's intent — without
|
|
1189
|
+
// anyone remembering to run `seq -w`.
|
|
1190
|
+
try {
|
|
1191
|
+
const h = selfheal.heal();
|
|
1192
|
+
if (h.healed) console.log(` ${teal('↻')} ${sage("CLAUDE.md refreshed from .intent/ — you didn't have to")}`);
|
|
1193
|
+
} catch { /* self-heal must never block exit */ }
|
|
1152
1194
|
try { require('../lib/intro').farewell(); } catch { /* sign-off is a nicety */ }
|
|
1153
1195
|
console.log(` ${sage('session ended · ' + turns + ' exchanges · ' + (totalPromptTokens + totalCompletionTokens) + ' tokens')}`);
|
|
1154
1196
|
if (decisionsThisSession > 0) {
|
|
@@ -1221,6 +1263,37 @@ async function main() {
|
|
|
1221
1263
|
return;
|
|
1222
1264
|
}
|
|
1223
1265
|
|
|
1266
|
+
// ── /wrap ──────────────────────────────────────────
|
|
1267
|
+
// Fold what you've shipped (git commits since .intent/ was last updated)
|
|
1268
|
+
// back into next.md, then re-sync CLAUDE.md — so the record reflects the
|
|
1269
|
+
// work, not last week. Deterministic from commit subjects; no LLM needed,
|
|
1270
|
+
// so it works even with nothing connected. This is the cure for the drift
|
|
1271
|
+
// that ate phewsh's own dogfood.
|
|
1272
|
+
if (cmd === 'wrap') {
|
|
1273
|
+
const draft = selfheal.wrapDraft();
|
|
1274
|
+
console.log('');
|
|
1275
|
+
if (!draft) {
|
|
1276
|
+
console.log(` ${teal('●')} ${sage('Nothing to fold in — your .intent/ is already current with git (or this isn\'t a repo).')}`);
|
|
1277
|
+
console.log('');
|
|
1278
|
+
rl.prompt();
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const n = draft.commits.length;
|
|
1282
|
+
console.log(` ${b(cream('Wrap'))} ${slate('— ' + n + ' commit' + (n !== 1 ? 's' : '') + ' shipped since .intent/ was last updated')}`);
|
|
1283
|
+
ui.divider('line');
|
|
1284
|
+
for (const s of draft.commits.slice(0, 12)) {
|
|
1285
|
+
let line = s.replace(/\s+/g, ' ');
|
|
1286
|
+
if (line.length > 70) line = line.slice(0, 69).trimEnd() + '…';
|
|
1287
|
+
console.log(` ${sage('+')} ${cream(line)}`);
|
|
1288
|
+
}
|
|
1289
|
+
if (n > 12) console.log(` ${slate('… and ' + (n - 12) + ' more')}`);
|
|
1290
|
+
ui.divider('line');
|
|
1291
|
+
console.log(` ${sage('Fold this into')} ${cream('.intent/next.md')} ${sage('and re-sync CLAUDE.md?')} ${slate('y/N')}`);
|
|
1292
|
+
awaitingWrap = { block: draft.block };
|
|
1293
|
+
rl.prompt();
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1224
1297
|
// ── /learn ─────────────────────────────────────────
|
|
1225
1298
|
// What the record has learned — kept-rates by tool and by mode, so the
|
|
1226
1299
|
// 100th decision is better-informed than the 1st. Honest: stays quiet
|
|
@@ -1295,6 +1368,7 @@ async function main() {
|
|
|
1295
1368
|
console.log(` ${cream('author .intent/')}`);
|
|
1296
1369
|
console.log(` ${teal('/init')} ${sage('Create .intent/ for this project')}`);
|
|
1297
1370
|
console.log(` ${teal('/intent')} ${sage('Pause and reflect — view or update .intent/ before moving on')}`);
|
|
1371
|
+
console.log(` ${teal('/wrap')} ${sage('Fold what you shipped (git) into .intent/ — keeps the record current')}`);
|
|
1298
1372
|
console.log(` ${teal('/clarify')} ${sage('Turn ideas into .intent/ artifacts')}`);
|
|
1299
1373
|
console.log(` ${teal('/gate')} ${sage('Set constraints (budget, time, skill)')}`);
|
|
1300
1374
|
console.log(` ${teal('/context')} ${sage('Show loaded .intent/ files')}`);
|
package/lib/selfheal.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Self-healing continuity — phewsh keeps CLAUDE.md current from .intent/ so the
|
|
2
|
+
// user never has to run `seq -w` by hand. This is the deterministic half of the
|
|
3
|
+
// trust promise: every time phewsh runs (and when an ambient session ends), if
|
|
4
|
+
// .intent/ has drifted ahead of CLAUDE.md, we re-sequence it silently.
|
|
5
|
+
//
|
|
6
|
+
// Pure-ish and safe: never throws, never blocks the host. Returns a small
|
|
7
|
+
// result the caller can render ("✓ kept your CLAUDE.md current — you didn't
|
|
8
|
+
// have to"). The LLM-assisted half (drafting next.md/status.md from a session)
|
|
9
|
+
// lives elsewhere; this layer needs no model and works fully offline.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const INTENT_FILES_RE = /\.(md|json)$/;
|
|
15
|
+
|
|
16
|
+
// Newest mtime among .intent/ artifacts, or 0 if none / unreadable.
|
|
17
|
+
function newestIntentMs(intentDir) {
|
|
18
|
+
try {
|
|
19
|
+
return fs.readdirSync(intentDir)
|
|
20
|
+
.filter(f => INTENT_FILES_RE.test(f))
|
|
21
|
+
.reduce((m, f) => {
|
|
22
|
+
try { return Math.max(m, fs.statSync(path.join(intentDir, f)).mtimeMs); }
|
|
23
|
+
catch { return m; }
|
|
24
|
+
}, 0);
|
|
25
|
+
} catch { return 0; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Has .intent/ drifted ahead of CLAUDE.md's synced block?
|
|
30
|
+
* Returns false when there's no .intent/ or no CLAUDE.md (nothing to heal).
|
|
31
|
+
* @param {string} [cwd]
|
|
32
|
+
*/
|
|
33
|
+
function isStale(cwd = process.cwd()) {
|
|
34
|
+
try {
|
|
35
|
+
const claudePath = path.join(cwd, 'CLAUDE.md');
|
|
36
|
+
const intentDir = path.join(cwd, '.intent');
|
|
37
|
+
if (!fs.existsSync(claudePath) || !fs.existsSync(intentDir)) return false;
|
|
38
|
+
const claudeT = fs.statSync(claudePath).mtimeMs;
|
|
39
|
+
const newest = newestIntentMs(intentDir);
|
|
40
|
+
return newest > claudeT + 1000; // >1s newer = real drift, not a co-write race
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Re-sequence .intent/ → CLAUDE.md if (and only if) it has drifted. Uses the
|
|
46
|
+
* exact same path as `phewsh seq claude -w`, so the auto-heal and the manual
|
|
47
|
+
* command can never diverge.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} [opts]
|
|
50
|
+
* @param {string} [opts.cwd]
|
|
51
|
+
* @param {boolean} [opts.force] resync even if not detected stale
|
|
52
|
+
* @returns {{ healed: boolean, writeResult?: string, chunks?: number, sources?: number, reason?: string }}
|
|
53
|
+
*/
|
|
54
|
+
function heal({ cwd = process.cwd(), force = false } = {}) {
|
|
55
|
+
try {
|
|
56
|
+
if (!force && !isStale(cwd)) return { healed: false, reason: 'fresh' };
|
|
57
|
+
// Require lazily — the sequencer pulls in parsers/emitters we don't want to
|
|
58
|
+
// load on every bare command.
|
|
59
|
+
const { sequence } = require('./sequencer');
|
|
60
|
+
const prev = process.cwd();
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
if (cwd !== prev) process.chdir(cwd);
|
|
64
|
+
result = sequence({ target: 'claude-md', write: true });
|
|
65
|
+
} finally {
|
|
66
|
+
if (cwd !== prev) { try { process.chdir(prev); } catch { /* best-effort */ } }
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
healed: result.writeResult === 'updated' || result.writeResult === 'created',
|
|
70
|
+
writeResult: result.writeResult,
|
|
71
|
+
chunks: Array.isArray(result.chunks) ? result.chunks.length : undefined,
|
|
72
|
+
sources: Array.isArray(result.sources) ? result.sources.length : undefined,
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { healed: false, reason: err && err.message ? err.message : 'error' };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The deeper drift: code ships but .intent/ content never gets updated (the
|
|
80
|
+
// exact failure that ate phewsh's own dogfood — 16 versions shipped while
|
|
81
|
+
// next.md stayed days stale). Count git commits authored AFTER the newest
|
|
82
|
+
// .intent/ narrative file, so phewsh can notice "you've shipped but your intent
|
|
83
|
+
// is behind" and offer to fold it in. Best-effort: 0 if not a git repo / no git.
|
|
84
|
+
const NARRATIVE_FILES = ['vision.md', 'plan.md', 'status.md', 'next.md', 'narrative.md'];
|
|
85
|
+
|
|
86
|
+
function newestNarrativeMs(intentDir) {
|
|
87
|
+
return NARRATIVE_FILES.reduce((m, f) => {
|
|
88
|
+
try { return Math.max(m, fs.statSync(path.join(intentDir, f)).mtimeMs); }
|
|
89
|
+
catch { return m; }
|
|
90
|
+
}, 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function commitsSinceIntent(cwd = process.cwd()) {
|
|
94
|
+
try {
|
|
95
|
+
const intentDir = path.join(cwd, '.intent');
|
|
96
|
+
if (!fs.existsSync(intentDir)) return 0;
|
|
97
|
+
const since = newestNarrativeMs(intentDir);
|
|
98
|
+
if (!since) return 0;
|
|
99
|
+
const { execFileSync } = require('child_process');
|
|
100
|
+
const iso = new Date(since).toISOString();
|
|
101
|
+
// execFile (no shell) with args as an array — the date is machine-generated,
|
|
102
|
+
// and array args mean there's no shell to inject into regardless.
|
|
103
|
+
const out = execFileSync('git', ['log', `--since=${iso}`, '--oneline'], {
|
|
104
|
+
cwd, encoding: 'utf-8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
105
|
+
});
|
|
106
|
+
return out.split('\n').filter(l => l.trim()).length;
|
|
107
|
+
} catch { return 0; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Build a "what shipped since .intent/ was last updated" draft block straight
|
|
111
|
+
// from git commit subjects — deterministic, no LLM. This is the offline floor
|
|
112
|
+
// of `/wrap`: even with nothing connected, phewsh can fold real work into the
|
|
113
|
+
// record. (An LLM pass can later prose-ify this; the commits are the truth.)
|
|
114
|
+
function wrapDraft(cwd = process.cwd()) {
|
|
115
|
+
try {
|
|
116
|
+
const intentDir = path.join(cwd, '.intent');
|
|
117
|
+
if (!fs.existsSync(intentDir)) return null;
|
|
118
|
+
const since = newestNarrativeMs(intentDir);
|
|
119
|
+
if (!since) return null;
|
|
120
|
+
const { execFileSync } = require('child_process');
|
|
121
|
+
const iso = new Date(since).toISOString();
|
|
122
|
+
const raw = execFileSync('git', ['log', `--since=${iso}`, '--pretty=format:%s'], {
|
|
123
|
+
cwd, encoding: 'utf-8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
124
|
+
});
|
|
125
|
+
const commits = raw.split('\n')
|
|
126
|
+
.map(l => l.trim())
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.filter(s => !/^Merge (branch|pull|remote)/i.test(s)); // drop merge noise
|
|
129
|
+
if (commits.length === 0) return null;
|
|
130
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
131
|
+
const block = `\n## ✅ Shipped since last update (folded in ${date})\n`
|
|
132
|
+
+ commits.map(s => `- ${s}`).join('\n') + '\n';
|
|
133
|
+
return { commits, block, date };
|
|
134
|
+
} catch { return null; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Append a block to a .intent/ narrative file (default next.md), then heal so
|
|
138
|
+
// CLAUDE.md reflects it immediately. Returns { written: boolean, file }.
|
|
139
|
+
function appendToNext(block, { cwd = process.cwd(), file = 'next.md' } = {}) {
|
|
140
|
+
try {
|
|
141
|
+
const target = path.join(cwd, '.intent', file);
|
|
142
|
+
if (!fs.existsSync(target)) return { written: false, file };
|
|
143
|
+
fs.appendFileSync(target, block.endsWith('\n') ? block : block + '\n');
|
|
144
|
+
heal({ cwd, force: true });
|
|
145
|
+
return { written: true, file };
|
|
146
|
+
} catch { return { written: false, file }; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
isStale, heal, newestIntentMs, newestNarrativeMs,
|
|
151
|
+
commitsSinceIntent, wrapDraft, appendToNext,
|
|
152
|
+
};
|
package/lib/suggest.js
CHANGED
|
@@ -38,6 +38,7 @@ function suggestAll(s = {}) {
|
|
|
38
38
|
turnsThisSession = 0,
|
|
39
39
|
seqStale = false,
|
|
40
40
|
ambientOn = false,
|
|
41
|
+
commitsSinceIntent = 0,
|
|
41
42
|
bestKeeper = null, // { route, label, keptRate, total } from the record, or null
|
|
42
43
|
} = s;
|
|
43
44
|
|
|
@@ -77,6 +78,19 @@ function suggestAll(s = {}) {
|
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// 3b. Code shipped but .intent/ never caught up — the deeper drift that ate
|
|
82
|
+
// phewsh's own dogfood (versions shipped while next.md stayed stale).
|
|
83
|
+
// CLAUDE.md self-heals, but the *intent* itself is behind the work.
|
|
84
|
+
if (hasIntentDir && commitsSinceIntent >= 3) {
|
|
85
|
+
out.push({
|
|
86
|
+
id: 'intent-behind-code',
|
|
87
|
+
priority: 85,
|
|
88
|
+
message: `${commitsSinceIntent} commits since your .intent/ was updated — the record is behind the work.`,
|
|
89
|
+
command: '/wrap',
|
|
90
|
+
why: 'Folds what you shipped into next.md/status.md so continuity reflects reality, not last week.',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
// 4. Multiple harnesses installed, only leaning on one — council is free leverage.
|
|
81
95
|
const others = installedHarnesses.filter(h => h !== route);
|
|
82
96
|
if (installedHarnesses.length >= 2 && turnsThisSession >= 3 && others.length >= 1) {
|