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 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
@@ -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')}`);
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.27",
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"