phewsh 0.15.26 → 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 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
- appendBreadcrumb('session-end', reason ? { reason } : {});
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.
@@ -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
@@ -492,18 +494,37 @@ async function main() {
492
494
  let installed = [];
493
495
  try { installed = listHarnesses().filter(h => h.installed).map(h => h.id); } catch { /* best-effort */ }
494
496
 
497
+ let bestKeeper = null;
498
+ try {
499
+ const br = learning.bestRoute(outcomeStats({ project: projectName }), { minSample: 4 });
500
+ if (br) bestKeeper = { route: br.route, label: continuity.labelFor(br.route), keptRate: br.keptRate, total: br.total };
501
+ } catch { /* best-effort */ }
502
+
495
503
  return {
496
504
  hasIntentDir: fs.existsSync(path.join(process.cwd(), '.intent')),
497
505
  intentFileCount: intentFiles.length,
498
506
  pendingOutcomes: pending,
499
507
  installedHarnesses: installed,
500
- route,
508
+ route: route?.id || route,
501
509
  turnsThisSession: Math.floor(messages.length / 2),
502
510
  seqStale,
503
511
  ambientOn,
512
+ commitsSinceIntent: (() => { try { return selfheal.commitsSinceIntent(); } catch { return 0; } })(),
513
+ bestKeeper,
504
514
  };
505
515
  }
506
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
+
507
528
  // "Nothing lost" — surface where you left off, across every tool, so opening
508
529
  // phewsh feels like resuming a thread rather than starting cold.
509
530
  function showContinuity() {
@@ -540,6 +561,7 @@ async function main() {
540
561
  console.log('');
541
562
  console.log(` ${teal('●')} ${cream(projectName)} ${slate('·')} ${sage(`.intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''} loaded`)} ${slate('· via ' + routeLabel(route, config))}`);
542
563
  console.log('');
564
+ maybeHealOnEntry();
543
565
  showContinuity();
544
566
  showModeMenu();
545
567
  showInlineTip();
@@ -574,6 +596,7 @@ async function main() {
574
596
  } else if (intentFiles.length === 0 && (atHome || recents.length > 0)) {
575
597
  showBootstrapMenu(recents);
576
598
  } else {
599
+ maybeHealOnEntry();
577
600
  showContinuity();
578
601
  showModeMenu();
579
602
  showInlineTip();
@@ -967,6 +990,25 @@ async function main() {
967
990
  return;
968
991
  }
969
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
+
970
1012
  // Any typed input supersedes a pending "did you mean" offer.
971
1013
  if (pendingDidYouMean) pendingDidYouMean = null;
972
1014
 
@@ -1094,10 +1136,23 @@ async function main() {
1094
1136
  if (n === 5) {
1095
1137
  console.log('');
1096
1138
  console.log(` ${b(cream('Ask another model — switch the route'))}`);
1139
+ let mStats = null, best = null;
1140
+ try {
1141
+ mStats = outcomeStats({ project: projectName });
1142
+ best = learning.bestRoute(mStats, { minSample: 3 });
1143
+ } catch { /* best-effort */ }
1144
+ // Only celebrate a route that actually holds up (≥50% kept) — a star on
1145
+ // a poor rate would be dishonest. The badge shows regardless.
1146
+ const starRoute = best && best.keptRate >= 0.5 ? best.route : null;
1097
1147
  for (const h of harnesses) {
1098
- if (h.installed) console.log(` ${teal('/use ' + h.id.padEnd(12))} ${sage(h.label)} ${slate('(' + h.auth + ')')}`);
1148
+ if (!h.installed) continue;
1149
+ const badge = mStats ? learning.keptBadge(mStats, h.id) : '';
1150
+ const star = starRoute === h.id ? ` ${green('★ keeps best')}` : '';
1151
+ const rec = badge ? ` ${slate('· ' + badge)}` : '';
1152
+ console.log(` ${teal('/use ' + h.id.padEnd(12))} ${sage(h.label)} ${slate('(' + h.auth + ')')}${rec}${star}`);
1099
1153
  }
1100
1154
  if (config?.apiKey) console.log(` ${teal('/use api'.padEnd(17))} ${sage('Direct API')} ${slate('(your key)')}`);
1155
+ if (starRoute) console.log(` ${slate('★ = highest kept-rate in your record so far')}`);
1101
1156
  console.log('');
1102
1157
  rl.prompt();
1103
1158
  return;
@@ -1129,6 +1184,13 @@ async function main() {
1129
1184
  if (global._phewshChildren) {
1130
1185
  global._phewshChildren.forEach(c => { try { c.kill(); } catch {} });
1131
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 */ }
1132
1194
  try { require('../lib/intro').farewell(); } catch { /* sign-off is a nicety */ }
1133
1195
  console.log(` ${sage('session ended · ' + turns + ' exchanges · ' + (totalPromptTokens + totalCompletionTokens) + ' tokens')}`);
1134
1196
  if (decisionsThisSession > 0) {
@@ -1201,6 +1263,37 @@ async function main() {
1201
1263
  return;
1202
1264
  }
1203
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
+
1204
1297
  // ── /learn ─────────────────────────────────────────
1205
1298
  // What the record has learned — kept-rates by tool and by mode, so the
1206
1299
  // 100th decision is better-informed than the 1st. Honest: stays quiet
@@ -1275,6 +1368,7 @@ async function main() {
1275
1368
  console.log(` ${cream('author .intent/')}`);
1276
1369
  console.log(` ${teal('/init')} ${sage('Create .intent/ for this project')}`);
1277
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')}`);
1278
1372
  console.log(` ${teal('/clarify')} ${sage('Turn ideas into .intent/ artifacts')}`);
1279
1373
  console.log(` ${teal('/gate')} ${sage('Set constraints (budget, time, skill)')}`);
1280
1374
  console.log(` ${teal('/context')} ${sage('Show loaded .intent/ files')}`);
@@ -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,8 @@ function suggestAll(s = {}) {
38
38
  turnsThisSession = 0,
39
39
  seqStale = false,
40
40
  ambientOn = false,
41
+ commitsSinceIntent = 0,
42
+ bestKeeper = null, // { route, label, keptRate, total } from the record, or null
41
43
  } = s;
42
44
 
43
45
  const out = [];
@@ -76,6 +78,19 @@ function suggestAll(s = {}) {
76
78
  });
77
79
  }
78
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
+
79
94
  // 4. Multiple harnesses installed, only leaning on one — council is free leverage.
80
95
  const others = installedHarnesses.filter(h => h !== route);
81
96
  if (installedHarnesses.length >= 2 && turnsThisSession >= 3 && others.length >= 1) {
@@ -88,6 +103,18 @@ function suggestAll(s = {}) {
88
103
  });
89
104
  }
90
105
 
106
+ // 4b. The record has a clear favorite that isn't your current route —
107
+ // auto-weighting: lean on what actually holds for you.
108
+ if (bestKeeper && bestKeeper.route !== route && bestKeeper.total >= 4 && bestKeeper.keptRate >= 0.6) {
109
+ out.push({
110
+ id: 'prefer-best-route',
111
+ priority: 55,
112
+ message: `${bestKeeper.label || bestKeeper.route} keeps best in your record (${Math.round(bestKeeper.keptRate * 100)}%, ${bestKeeper.total} labeled).`,
113
+ command: `/use ${bestKeeper.route}`,
114
+ why: 'The record weights routing — lean on the tool whose work you actually keep.',
115
+ });
116
+ }
117
+
91
118
  // 5. Ambient off — continuity that costs nothing once turned on.
92
119
  if (hasIntentDir && !ambientOn && installedHarnesses.includes('claude-code')) {
93
120
  out.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.26",
3
+ "version": "0.15.28",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"