phewsh 0.15.27 → 0.15.29
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/outcomes.js +76 -42
- package/commands/session.js +92 -14
- package/lib/outcomes.js +76 -8
- package/lib/selfheal.js +152 -0
- package/lib/suggest.js +18 -2
- 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/outcomes.js
CHANGED
|
@@ -10,6 +10,8 @@ const {
|
|
|
10
10
|
OUTCOMES, recordDecision, labelOutcome,
|
|
11
11
|
pendingDecisions, recentDecisions, outcomeStats, bypassStats,
|
|
12
12
|
} = require('../lib/outcomes');
|
|
13
|
+
const learning = require('../lib/learning');
|
|
14
|
+
const continuity = require('../lib/continuity');
|
|
13
15
|
|
|
14
16
|
const { b, teal, peach, sage, slate, cream, ember, green } = ui;
|
|
15
17
|
|
|
@@ -42,76 +44,108 @@ function showBypasses() {
|
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
// Plain-language explanation of the contract — shown every time, because the
|
|
48
|
+
// #1 complaint was "I have no idea what this is for or how to use it."
|
|
49
|
+
function explainDeal() {
|
|
50
|
+
console.log(` ${b(cream('Outcomes'))} ${slate('— phewsh\'s memory of what actually worked')}`);
|
|
51
|
+
ui.divider('line');
|
|
52
|
+
console.log(` ${sage('Every call you route, phewsh logs. Tell it what you')} ${green('kept')} ${sage('vs')} ${ember('threw out')}${sage(',')}`);
|
|
53
|
+
console.log(` ${sage('and it learns which tool to trust for which job — and warns you before')}`);
|
|
54
|
+
console.log(` ${sage('you repeat something that flopped.')} ${slate('That labeled record is the one thing')}`);
|
|
55
|
+
console.log(` ${slate('your chat history doesn\'t have. Two taps now = smarter routing later.')}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The payoff, in their own data — so labeling feels like it buys something.
|
|
59
|
+
function showPayoff(stats) {
|
|
60
|
+
const lines = [];
|
|
61
|
+
try {
|
|
62
|
+
const best = learning.bestRoute(stats, { minSample: 2 });
|
|
63
|
+
if (best && best.keptRate >= 0.5) {
|
|
64
|
+
const label = continuity.labelFor(best.route) || best.route;
|
|
65
|
+
lines.push(`${green('●')} ${sage(label + ' keeps best for you — ')}${cream(Math.round(best.keptRate * 100) + '%')} ${sage(`(${best.kept}/${best.total}). phewsh leans there when it routes.`)}`);
|
|
66
|
+
}
|
|
67
|
+
} catch { /* best-effort */ }
|
|
68
|
+
const regrets = stats.reverted + stats.failed;
|
|
69
|
+
if (regrets > 0) {
|
|
70
|
+
lines.push(`${ember('●')} ${sage((regrets === 1 ? '1 call you marked a miss is' : regrets + ' calls you marked misses are') + ' remembered — ')}${cream('/recall')} ${sage('warns you before a repeat.')}`);
|
|
71
|
+
}
|
|
72
|
+
if (lines.length === 0) return;
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(` ${b(cream('What it already knows'))} ${slate('← this is the payoff')}`);
|
|
75
|
+
for (const l of lines) console.log(` ${l}`);
|
|
76
|
+
if (stats.judged < 4) console.log(` ${slate('judge a few more and clearer patterns surface here.')}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
45
79
|
function showStats() {
|
|
46
80
|
const stats = outcomeStats();
|
|
47
|
-
const labeled = stats.total - stats.pending;
|
|
48
81
|
|
|
49
82
|
console.log('');
|
|
50
|
-
|
|
51
|
-
ui.divider('line');
|
|
83
|
+
explainDeal();
|
|
52
84
|
|
|
53
85
|
if (stats.total === 0) {
|
|
54
|
-
console.log(
|
|
55
|
-
console.log(` ${
|
|
56
|
-
console.log(` ${slate('
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(` ${sage('Nothing logged yet — start routing work and it fills in.')}`);
|
|
88
|
+
console.log(` ${slate('In a session: just type, or use /use <tool>. Then tap 1-4 to judge it.')}`);
|
|
57
89
|
showBypasses();
|
|
58
90
|
console.log('');
|
|
59
91
|
return;
|
|
60
92
|
}
|
|
61
93
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
94
|
+
// The state of the record, in plain numbers that don't lie.
|
|
95
|
+
console.log('');
|
|
96
|
+
console.log(` ${b(cream('Your record'))}`);
|
|
97
|
+
console.log(` ${cream(String(stats.total))} ${sage('routed')} ${slate('·')} ${cream(String(stats.judged))} ${sage('you\'ve judged')} ${slate('·')} ${cream(String(stats.pending))} ${sage('not yet judged')}`);
|
|
98
|
+
if (stats.judged > 0) {
|
|
99
|
+
const bits = [green(`✓ ${stats.kept} kept`)];
|
|
100
|
+
if (stats.reverted) bits.push(ember(`↩ ${stats.reverted} reverted`));
|
|
101
|
+
if (stats.superseded) bits.push(peach(`⟳ ${stats.superseded} redone`));
|
|
102
|
+
if (stats.failed) bits.push(ember(`✗ ${stats.failed} flopped`));
|
|
103
|
+
console.log(` ${bits.join(slate(' · '))}`);
|
|
65
104
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (routes.length > 0) {
|
|
69
|
-
console.log('');
|
|
70
|
-
console.log(` ${b(cream('By route'))}`);
|
|
71
|
-
for (const [route, r] of routes.sort((a, b) => b[1].total - a[1].total)) {
|
|
72
|
-
const rate = r.total > 0 ? Math.round((r.kept / r.total) * 100) : 0;
|
|
73
|
-
console.log(` ${cream(route.padEnd(14))} ${sage(`${r.total} labeled`)} ${slate('·')} ${green(`${rate}% kept`)}`);
|
|
74
|
-
}
|
|
105
|
+
if (stats.autoFailed > 0) {
|
|
106
|
+
console.log(` ${slate(`(${stats.autoFailed} didn't complete — route errors phewsh logged for you, not your call)`)}`);
|
|
75
107
|
}
|
|
76
108
|
|
|
77
|
-
|
|
78
|
-
if (modes.length > 0) {
|
|
79
|
-
console.log('');
|
|
80
|
-
console.log(` ${b(cream('By mode'))}`);
|
|
81
|
-
for (const [mode, m] of modes.sort((a, b) => b[1].total - a[1].total)) {
|
|
82
|
-
const rate = m.total > 0 ? Math.round((m.kept / m.total) * 100) : 0;
|
|
83
|
-
console.log(` ${cream(mode.padEnd(14))} ${sage(`${m.total} labeled`)} ${slate('·')} ${green(`${rate}% kept`)}`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
109
|
+
showPayoff(stats);
|
|
86
110
|
|
|
87
|
-
|
|
88
|
-
|
|
111
|
+
// The ask — focused on recent, real calls, with a dead-simple next step.
|
|
112
|
+
const pending = pendingDecisions({ substantive: true }).slice(-6).reverse();
|
|
113
|
+
if (pending.length > 0) {
|
|
89
114
|
console.log('');
|
|
90
|
-
console.log(` ${b(cream('
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
console.log(` ${b(cream('Give a verdict'))} ${slate('— your recent calls (skip the trivial ones)')}`);
|
|
116
|
+
pending.forEach((d, i) => {
|
|
117
|
+
let s = (d.summary || '').replace(/\s+/g, ' ');
|
|
118
|
+
if (s.length > 44) s = s.slice(0, 43).trimEnd() + '…';
|
|
119
|
+
const via = (continuity.labelFor(d.route) || d.route).padEnd(12);
|
|
120
|
+
console.log(` ${teal(String(i + 1))} ${cream(s.padEnd(45))} ${slate(via)} ${slate(fmtAgo(d.ts))}`);
|
|
121
|
+
});
|
|
122
|
+
console.log('');
|
|
123
|
+
console.log(` ${teal('→')} ${sage('Walk through them:')} ${cream('phewsh outcomes label')} ${slate('· 20 seconds, Enter skips')}`);
|
|
124
|
+
console.log(` ${slate('or in a session, just tap')} ${cream('1-4')} ${slate('right after any answer.')}`);
|
|
94
125
|
}
|
|
95
126
|
|
|
96
127
|
showBypasses();
|
|
97
|
-
|
|
98
|
-
if (stats.pending > 0) {
|
|
99
|
-
console.log('');
|
|
100
|
-
console.log(` ${sage('Label pending decisions:')} ${cream('phewsh outcomes label')}`);
|
|
101
|
-
}
|
|
102
128
|
console.log('');
|
|
103
129
|
}
|
|
104
130
|
|
|
105
131
|
function labelInteractive() {
|
|
106
|
-
|
|
132
|
+
// Newest first, and capped — you judge best while the work is fresh, and a
|
|
133
|
+
// 76-item slog through last week's test prompts is exactly the chore that
|
|
134
|
+
// made this feel pointless. The recent calls carry the signal.
|
|
135
|
+
const CAP = 12;
|
|
136
|
+
const all = pendingDecisions({ substantive: true }).reverse(); // newest first
|
|
137
|
+
const pending = all.slice(0, CAP);
|
|
107
138
|
if (pending.length === 0) {
|
|
108
|
-
console.log(`\n ${teal('●')} ${sage('
|
|
139
|
+
console.log(`\n ${teal('●')} ${sage('Nothing substantive to judge — your real calls are all labeled.')}\n`);
|
|
109
140
|
return;
|
|
110
141
|
}
|
|
111
142
|
|
|
112
143
|
console.log('');
|
|
113
|
-
|
|
114
|
-
console.log(` ${
|
|
144
|
+
const more = all.length > CAP ? slate(` (most recent ${CAP}; ${all.length - CAP} older left alone)`) : '';
|
|
145
|
+
console.log(` ${b(cream(`Judging your recent call${pending.length !== 1 ? 's' : ''}`))}${more}`);
|
|
146
|
+
console.log(` ${slate('For each: what happened to it?')}`);
|
|
147
|
+
console.log(` ${green('1')} ${sage('kept it')} ${ember('2')} ${sage('undid it')} ${peach('3')} ${sage('redid it differently')} ${ember('4')} ${sage('it flopped')}`);
|
|
148
|
+
console.log(` ${slate('Enter = skip (judge later) · q = stop. This is what teaches phewsh.')}`);
|
|
115
149
|
ui.divider('line');
|
|
116
150
|
|
|
117
151
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
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
|
|
@@ -436,13 +438,16 @@ async function main() {
|
|
|
436
438
|
}
|
|
437
439
|
|
|
438
440
|
const oStats = outcomeStats();
|
|
439
|
-
const oLabeled = oStats.total - oStats.pending;
|
|
440
441
|
if (oStats.total > 0) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
442
|
+
// Plain language: what phewsh has logged vs what you've taught it. The
|
|
443
|
+
// kept-rate counts only the calls you actually judged (auto route-errors
|
|
444
|
+
// don't drag it down) — see /outcomes for the full picture + payoff.
|
|
445
|
+
const tail = oStats.judged > 0
|
|
446
|
+
? slate(' · ') + sage(`${Math.round((oStats.kept / oStats.judged) * 100)}% of what you judged, you kept`)
|
|
447
|
+
: slate(' · ') + peach('none judged yet — tell it what you kept');
|
|
448
|
+
row('RECORD', cream(`${oStats.total} routed`) + tail + slate(' — /outcomes'));
|
|
444
449
|
} else {
|
|
445
|
-
row('RECORD', slate('empty —
|
|
450
|
+
row('RECORD', slate('empty — phewsh starts logging what you route as you work'));
|
|
446
451
|
}
|
|
447
452
|
} catch (cockpitErr) {
|
|
448
453
|
console.log(` ${slate('(cockpit row unavailable — ' + cockpitErr.message + ' · PHEWSH_DEBUG=1 phewsh for details)')}`);
|
|
@@ -487,7 +492,8 @@ async function main() {
|
|
|
487
492
|
} catch { /* no settings = ambient off */ }
|
|
488
493
|
|
|
489
494
|
let pending = 0;
|
|
490
|
-
|
|
495
|
+
// Only substantive calls — never nag the user to "judge" a greeting.
|
|
496
|
+
try { pending = pendingDecisions({ project: projectName, substantive: true }).length; } catch { /* best-effort */ }
|
|
491
497
|
|
|
492
498
|
let installed = [];
|
|
493
499
|
try { installed = listHarnesses().filter(h => h.installed).map(h => h.id); } catch { /* best-effort */ }
|
|
@@ -507,10 +513,22 @@ async function main() {
|
|
|
507
513
|
turnsThisSession: Math.floor(messages.length / 2),
|
|
508
514
|
seqStale,
|
|
509
515
|
ambientOn,
|
|
516
|
+
commitsSinceIntent: (() => { try { return selfheal.commitsSinceIntent(); } catch { return 0; } })(),
|
|
510
517
|
bestKeeper,
|
|
511
518
|
};
|
|
512
519
|
}
|
|
513
520
|
|
|
521
|
+
// Self-healing continuity on entry: if .intent/ drifted ahead of CLAUDE.md
|
|
522
|
+
// since last time, quietly bring it current so every tool reading CLAUDE.md
|
|
523
|
+
// (this session included) sees today — the user never runs `seq -w` by hand.
|
|
524
|
+
function maybeHealOnEntry() {
|
|
525
|
+
try {
|
|
526
|
+
if (!selfheal.isStale()) return;
|
|
527
|
+
const h = selfheal.heal();
|
|
528
|
+
if (h.healed) console.log(` ${teal('↻')} ${sage('Synced your .intent/ into CLAUDE.md — kept current automatically')}`);
|
|
529
|
+
} catch { /* self-heal is a nicety, never a blocker */ }
|
|
530
|
+
}
|
|
531
|
+
|
|
514
532
|
// "Nothing lost" — surface where you left off, across every tool, so opening
|
|
515
533
|
// phewsh feels like resuming a thread rather than starting cold.
|
|
516
534
|
function showContinuity() {
|
|
@@ -547,6 +565,7 @@ async function main() {
|
|
|
547
565
|
console.log('');
|
|
548
566
|
console.log(` ${teal('●')} ${cream(projectName)} ${slate('·')} ${sage(`.intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''} loaded`)} ${slate('· via ' + routeLabel(route, config))}`);
|
|
549
567
|
console.log('');
|
|
568
|
+
maybeHealOnEntry();
|
|
550
569
|
showContinuity();
|
|
551
570
|
showModeMenu();
|
|
552
571
|
showInlineTip();
|
|
@@ -581,6 +600,7 @@ async function main() {
|
|
|
581
600
|
} else if (intentFiles.length === 0 && (atHome || recents.length > 0)) {
|
|
582
601
|
showBootstrapMenu(recents);
|
|
583
602
|
} else {
|
|
603
|
+
maybeHealOnEntry();
|
|
584
604
|
showContinuity();
|
|
585
605
|
showModeMenu();
|
|
586
606
|
showInlineTip();
|
|
@@ -628,7 +648,7 @@ async function main() {
|
|
|
628
648
|
});
|
|
629
649
|
lastTurnFailure = null;
|
|
630
650
|
awaitingOutcome = decisionId;
|
|
631
|
-
console.log(slate(` via ${HARNESSES[harnessId].label} ·
|
|
651
|
+
console.log(slate(` via ${HARNESSES[harnessId].label} · how'd it go? 1 kept · 2 undid · 3 redid · 4 flopped · or keep typing`));
|
|
632
652
|
return true;
|
|
633
653
|
} catch (err) {
|
|
634
654
|
turnInFlight = false;
|
|
@@ -637,7 +657,7 @@ async function main() {
|
|
|
637
657
|
console.log(`\n ${slate('cancelled — esc')}`);
|
|
638
658
|
return true; // user's call, not a failure: no fallback offer
|
|
639
659
|
}
|
|
640
|
-
try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
|
|
660
|
+
try { labelOutcome(decisionId, 'failed', null, { auto: true }); } catch { /* keep going */ }
|
|
641
661
|
recordSessionEvent(harnessId, projectName, 'task_complete', {
|
|
642
662
|
taskId: decisionId, success: false, summary: input.slice(0, 140),
|
|
643
663
|
});
|
|
@@ -668,7 +688,7 @@ async function main() {
|
|
|
668
688
|
if (result.promptTokens) totalPromptTokens += result.promptTokens;
|
|
669
689
|
if (result.completionTokens) totalCompletionTokens += result.completionTokens;
|
|
670
690
|
if (result.promptTokens || result.completionTokens) {
|
|
671
|
-
console.log(slate(` ${result.promptTokens || '?'}→${result.completionTokens || '?'} tokens · ${modelName(currentModel)} ·
|
|
691
|
+
console.log(slate(` ${result.promptTokens || '?'}→${result.completionTokens || '?'} tokens · ${modelName(currentModel)} · how'd it go? 1-4 or keep typing`));
|
|
672
692
|
}
|
|
673
693
|
awaitingOutcome = decisionId;
|
|
674
694
|
trackSap({
|
|
@@ -689,7 +709,7 @@ async function main() {
|
|
|
689
709
|
console.log(`\n ${slate('cancelled — esc')}`);
|
|
690
710
|
return true; // user's call, not a failure: no fallback offer
|
|
691
711
|
}
|
|
692
|
-
try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
|
|
712
|
+
try { labelOutcome(decisionId, 'failed', null, { auto: true }); } catch { /* keep going */ }
|
|
693
713
|
messages.pop();
|
|
694
714
|
console.error(`\n ${ember('!')} ${sage('API route failed')}${slate(' — ' + err.message.split('\n')[0])}`);
|
|
695
715
|
return false;
|
|
@@ -974,6 +994,25 @@ async function main() {
|
|
|
974
994
|
return;
|
|
975
995
|
}
|
|
976
996
|
|
|
997
|
+
// /wrap confirmation: y folds the shipped-work block into next.md + heals.
|
|
998
|
+
if (awaitingWrap) {
|
|
999
|
+
const { block } = awaitingWrap;
|
|
1000
|
+
awaitingWrap = null;
|
|
1001
|
+
if (/^(y|yes)$/i.test(input.trim())) {
|
|
1002
|
+
const r = selfheal.appendToNext(block);
|
|
1003
|
+
if (r.written) {
|
|
1004
|
+
console.log(` ${green('✓')} ${sage('Folded into .intent/' + r.file + ' — CLAUDE.md synced too. The record matches the work now.')}`);
|
|
1005
|
+
} else {
|
|
1006
|
+
console.log(` ${ember('!')} ${sage('Could not write .intent/next.md — left it untouched.')}`);
|
|
1007
|
+
}
|
|
1008
|
+
} else {
|
|
1009
|
+
console.log(` ${slate('Left .intent/ as-is.')}`);
|
|
1010
|
+
}
|
|
1011
|
+
console.log('');
|
|
1012
|
+
rl.prompt();
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
977
1016
|
// Any typed input supersedes a pending "did you mean" offer.
|
|
978
1017
|
if (pendingDidYouMean) pendingDidYouMean = null;
|
|
979
1018
|
|
|
@@ -1149,6 +1188,13 @@ async function main() {
|
|
|
1149
1188
|
if (global._phewshChildren) {
|
|
1150
1189
|
global._phewshChildren.forEach(c => { try { c.kill(); } catch {} });
|
|
1151
1190
|
}
|
|
1191
|
+
// Self-healing continuity: leave the next tool a current CLAUDE.md so
|
|
1192
|
+
// opening Claude Code / Codex here next reads today's intent — without
|
|
1193
|
+
// anyone remembering to run `seq -w`.
|
|
1194
|
+
try {
|
|
1195
|
+
const h = selfheal.heal();
|
|
1196
|
+
if (h.healed) console.log(` ${teal('↻')} ${sage("CLAUDE.md refreshed from .intent/ — you didn't have to")}`);
|
|
1197
|
+
} catch { /* self-heal must never block exit */ }
|
|
1152
1198
|
try { require('../lib/intro').farewell(); } catch { /* sign-off is a nicety */ }
|
|
1153
1199
|
console.log(` ${sage('session ended · ' + turns + ' exchanges · ' + (totalPromptTokens + totalCompletionTokens) + ' tokens')}`);
|
|
1154
1200
|
if (decisionsThisSession > 0) {
|
|
@@ -1221,6 +1267,37 @@ async function main() {
|
|
|
1221
1267
|
return;
|
|
1222
1268
|
}
|
|
1223
1269
|
|
|
1270
|
+
// ── /wrap ──────────────────────────────────────────
|
|
1271
|
+
// Fold what you've shipped (git commits since .intent/ was last updated)
|
|
1272
|
+
// back into next.md, then re-sync CLAUDE.md — so the record reflects the
|
|
1273
|
+
// work, not last week. Deterministic from commit subjects; no LLM needed,
|
|
1274
|
+
// so it works even with nothing connected. This is the cure for the drift
|
|
1275
|
+
// that ate phewsh's own dogfood.
|
|
1276
|
+
if (cmd === 'wrap') {
|
|
1277
|
+
const draft = selfheal.wrapDraft();
|
|
1278
|
+
console.log('');
|
|
1279
|
+
if (!draft) {
|
|
1280
|
+
console.log(` ${teal('●')} ${sage('Nothing to fold in — your .intent/ is already current with git (or this isn\'t a repo).')}`);
|
|
1281
|
+
console.log('');
|
|
1282
|
+
rl.prompt();
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
const n = draft.commits.length;
|
|
1286
|
+
console.log(` ${b(cream('Wrap'))} ${slate('— ' + n + ' commit' + (n !== 1 ? 's' : '') + ' shipped since .intent/ was last updated')}`);
|
|
1287
|
+
ui.divider('line');
|
|
1288
|
+
for (const s of draft.commits.slice(0, 12)) {
|
|
1289
|
+
let line = s.replace(/\s+/g, ' ');
|
|
1290
|
+
if (line.length > 70) line = line.slice(0, 69).trimEnd() + '…';
|
|
1291
|
+
console.log(` ${sage('+')} ${cream(line)}`);
|
|
1292
|
+
}
|
|
1293
|
+
if (n > 12) console.log(` ${slate('… and ' + (n - 12) + ' more')}`);
|
|
1294
|
+
ui.divider('line');
|
|
1295
|
+
console.log(` ${sage('Fold this into')} ${cream('.intent/next.md')} ${sage('and re-sync CLAUDE.md?')} ${slate('y/N')}`);
|
|
1296
|
+
awaitingWrap = { block: draft.block };
|
|
1297
|
+
rl.prompt();
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1224
1301
|
// ── /learn ─────────────────────────────────────────
|
|
1225
1302
|
// What the record has learned — kept-rates by tool and by mode, so the
|
|
1226
1303
|
// 100th decision is better-informed than the 1st. Honest: stays quiet
|
|
@@ -1295,6 +1372,7 @@ async function main() {
|
|
|
1295
1372
|
console.log(` ${cream('author .intent/')}`);
|
|
1296
1373
|
console.log(` ${teal('/init')} ${sage('Create .intent/ for this project')}`);
|
|
1297
1374
|
console.log(` ${teal('/intent')} ${sage('Pause and reflect — view or update .intent/ before moving on')}`);
|
|
1375
|
+
console.log(` ${teal('/wrap')} ${sage('Fold what you shipped (git) into .intent/ — keeps the record current')}`);
|
|
1298
1376
|
console.log(` ${teal('/clarify')} ${sage('Turn ideas into .intent/ artifacts')}`);
|
|
1299
1377
|
console.log(` ${teal('/gate')} ${sage('Set constraints (budget, time, skill)')}`);
|
|
1300
1378
|
console.log(` ${teal('/context')} ${sage('Show loaded .intent/ files')}`);
|
|
@@ -1317,7 +1395,7 @@ async function main() {
|
|
|
1317
1395
|
console.log(` ${teal('/harnesses')} ${sage('Agent CLIs detected on this machine')}`);
|
|
1318
1396
|
console.log(` ${teal('/provider')} ${sage('Current route + what\'s available')}`);
|
|
1319
1397
|
console.log(` ${teal('/fallback')} ${sage('What happens at a usage wall: ask or auto-switch')}`);
|
|
1320
|
-
console.log(` ${teal('/outcomes')} ${sage('
|
|
1398
|
+
console.log(` ${teal('/outcomes')} ${sage('What worked — tell phewsh, and it learns which tool to trust')}`);
|
|
1321
1399
|
console.log('');
|
|
1322
1400
|
console.log(` ${cream('session')}`);
|
|
1323
1401
|
console.log(` ${teal('/work')} ${slate('[harness]')} ${sage('Hand off to interactive Claude Code/Codex — outcome on return')}`);
|
|
@@ -1946,9 +2024,9 @@ async function main() {
|
|
|
1946
2024
|
messages.push({ role: 'user', content: cmdArg });
|
|
1947
2025
|
messages.push({ role: 'assistant', content: `[council of ${answers.length}]\n\n${answers.join('\n\n')}` });
|
|
1948
2026
|
awaitingOutcome = decisionId;
|
|
1949
|
-
console.log(slate(` council of ${answers.length} ·
|
|
2027
|
+
console.log(slate(` council of ${answers.length} · how'd it go? 1 kept · 2 undid · 3 redid · 4 flopped · or keep typing`));
|
|
1950
2028
|
} else {
|
|
1951
|
-
try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
|
|
2029
|
+
try { labelOutcome(decisionId, 'failed', null, { auto: true }); } catch { /* keep going */ }
|
|
1952
2030
|
console.log(` ${sage('Every council member failed — check')} ${cream('/provider')}`);
|
|
1953
2031
|
}
|
|
1954
2032
|
console.log('');
|
|
@@ -2193,7 +2271,7 @@ async function main() {
|
|
|
2193
2271
|
});
|
|
2194
2272
|
awaitingOutcome = decisionId;
|
|
2195
2273
|
console.log('');
|
|
2196
|
-
console.log(` ${teal('●')} ${sage('Back in phewsh.')} ${slate('
|
|
2274
|
+
console.log(` ${teal('●')} ${sage('Back in phewsh.')} ${slate("how'd it go? 1 kept · 2 undid · 3 redid · 4 flopped · or keep typing")}`);
|
|
2197
2275
|
console.log('');
|
|
2198
2276
|
rl.prompt();
|
|
2199
2277
|
return;
|
package/lib/outcomes.js
CHANGED
|
@@ -50,8 +50,18 @@ function recordDecision({ project, route, mode, summary }) {
|
|
|
50
50
|
return id;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Label a decision by id (or unambiguous id prefix). Returns the decision or null.
|
|
55
|
+
* @param {string} idOrPrefix
|
|
56
|
+
* @param {string} outcome one of OUTCOMES
|
|
57
|
+
* @param {string|null} why one-line reason (gold for reverted/failed)
|
|
58
|
+
* @param {object} [opts]
|
|
59
|
+
* @param {boolean} [opts.auto] the SYSTEM set this (e.g. a route errored), not
|
|
60
|
+
* the user's judgment. Kept separate so auto-failures never masquerade as
|
|
61
|
+
* "you tried it and reverted it" — that distinction is what made the old
|
|
62
|
+
* `/outcomes` read like phewsh was broken.
|
|
63
|
+
*/
|
|
64
|
+
function labelOutcome(idOrPrefix, outcome, why = null, opts = {}) {
|
|
55
65
|
if (!OUTCOMES.includes(outcome)) {
|
|
56
66
|
throw new Error(`Outcome must be one of: ${OUTCOMES.join(', ')}`);
|
|
57
67
|
}
|
|
@@ -60,6 +70,8 @@ function labelOutcome(idOrPrefix, outcome, why = null) {
|
|
|
60
70
|
if (matches.length !== 1) return null;
|
|
61
71
|
matches[0].outcome = outcome;
|
|
62
72
|
matches[0].labeledAt = new Date().toISOString();
|
|
73
|
+
if (opts.auto) matches[0].auto = true;
|
|
74
|
+
else delete matches[0].auto; // a human judgment overrides a prior auto-label
|
|
63
75
|
// One-line reason — the most valuable thing about a reverted/failed call.
|
|
64
76
|
// Feeds /recall ("you reverted this before — because …") and /learn.
|
|
65
77
|
if (why && String(why).trim()) matches[0].why = String(why).trim().slice(0, 200);
|
|
@@ -67,10 +79,24 @@ function labelOutcome(idOrPrefix, outcome, why = null) {
|
|
|
67
79
|
return matches[0];
|
|
68
80
|
}
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
// A routed line that isn't really a decision worth judging — greetings, one
|
|
83
|
+
// word fillers, or a command echo. Used to keep the "judge these" ask (and the
|
|
84
|
+
// nudge to label) focused on real work, not "hi". Conservative on purpose: only
|
|
85
|
+
// obvious noise, so genuine short prompts ("fix the bug") still count.
|
|
86
|
+
const TRIVIAL_RE = /^(hi|hey|hello|yo|sup|ok|okay|k|thanks|thx|ty|test|testing|cool|nice|great|good|yes|no|y|n|hi there)\b[!.\s]*$/i;
|
|
87
|
+
function looksTrivial(summary) {
|
|
88
|
+
const s = (summary || '').trim();
|
|
89
|
+
if (s.length < 3) return true;
|
|
90
|
+
if (TRIVIAL_RE.test(s)) return true;
|
|
91
|
+
if (/^(phewsh|\/)/.test(s) && s.split(/\s+/).length <= 3) return true; // command echo
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Unlabeled decisions, oldest first. `substantive` drops greetings/filler. */
|
|
96
|
+
function pendingDecisions({ project = null, substantive = false } = {}) {
|
|
72
97
|
return load()
|
|
73
98
|
.filter(d => !d.outcome && (!project || d.project === project))
|
|
99
|
+
.filter(d => !substantive || !looksTrivial(d.summary))
|
|
74
100
|
.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
75
101
|
}
|
|
76
102
|
|
|
@@ -82,31 +108,73 @@ function recentDecisions(limit = 10, { project = null } = {}) {
|
|
|
82
108
|
.slice(0, limit);
|
|
83
109
|
}
|
|
84
110
|
|
|
111
|
+
const MIGRATE_MARKER = path.join(OUTCOMES_DIR, '.auto-migrated-v1');
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One-time cleanup of records created before the `auto` flag existed. The only
|
|
115
|
+
* code path that ever wrote 'failed' with no reason was the turn runner
|
|
116
|
+
* reacting to a route error — a genuine human "this failed" verdict is always
|
|
117
|
+
* offered a one-line why. So legacy failed-without-why = a route error; tag it
|
|
118
|
+
* so it stops masquerading as the user's judgment. Idempotent (marker-guarded).
|
|
119
|
+
*/
|
|
120
|
+
function migrateLegacyAutoFailures() {
|
|
121
|
+
try {
|
|
122
|
+
if (fs.existsSync(MIGRATE_MARKER)) return;
|
|
123
|
+
const decisions = load();
|
|
124
|
+
let changed = false;
|
|
125
|
+
for (const d of decisions) {
|
|
126
|
+
if (d.outcome === 'failed' && !d.auto && !d.why) { d.auto = true; changed = true; }
|
|
127
|
+
}
|
|
128
|
+
if (changed) save(decisions);
|
|
129
|
+
fs.mkdirSync(OUTCOMES_DIR, { recursive: true });
|
|
130
|
+
fs.writeFileSync(MIGRATE_MARKER, new Date().toISOString());
|
|
131
|
+
} catch { /* migration is best-effort — never block the record */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Was this outcome set by the SYSTEM (a route errored) rather than the human? */
|
|
135
|
+
function isAutoLabel(d) {
|
|
136
|
+
return d.auto === true;
|
|
137
|
+
}
|
|
138
|
+
|
|
85
139
|
/**
|
|
86
140
|
* The Day-14 view: totals, per-route reliability, per-mode patterns.
|
|
87
141
|
* Only labeled decisions count toward outcome rates — pending is honest noise.
|
|
88
142
|
*/
|
|
89
143
|
function outcomeStats({ project = null } = {}) {
|
|
144
|
+
migrateLegacyAutoFailures();
|
|
90
145
|
const all = load().filter(d => !project || d.project === project);
|
|
91
146
|
const labeled = all.filter(d => d.outcome);
|
|
147
|
+
// A route that errored is auto-labeled 'failed' — that's a system event, not
|
|
148
|
+
// the user judging the work. Split it out so the headline reflects the user's
|
|
149
|
+
// actual calls (kept/reverted/superseded + failures they themselves marked).
|
|
150
|
+
const judged = labeled.filter(d => !isAutoLabel(d));
|
|
151
|
+
const autoFailed = labeled.filter(isAutoLabel).length;
|
|
92
152
|
|
|
93
153
|
const stats = {
|
|
94
154
|
total: all.length,
|
|
95
155
|
pending: all.length - labeled.length,
|
|
156
|
+
judged: judged.length, // decisions the human actually labeled
|
|
157
|
+
autoFailed, // route errors phewsh recorded for you
|
|
96
158
|
kept: 0, reverted: 0, superseded: 0, failed: 0,
|
|
97
159
|
byRoute: {},
|
|
98
160
|
byMode: {},
|
|
99
161
|
};
|
|
100
162
|
|
|
101
|
-
|
|
163
|
+
// Outcome rates count the human's judgments only — auto route-errors don't
|
|
164
|
+
// tank a tool's kept-rate (they're tracked separately in byRoute.errored).
|
|
165
|
+
for (const d of judged) {
|
|
102
166
|
stats[d.outcome]++;
|
|
103
|
-
const r = (stats.byRoute[d.route] ||= { total: 0, kept: 0, reverted: 0, superseded: 0, failed: 0 });
|
|
167
|
+
const r = (stats.byRoute[d.route] ||= { total: 0, kept: 0, reverted: 0, superseded: 0, failed: 0, errored: 0 });
|
|
104
168
|
r.total++; r[d.outcome]++;
|
|
105
169
|
if (d.mode) {
|
|
106
|
-
const m = (stats.byMode[d.mode] ||= { total: 0, kept: 0, reverted: 0, superseded: 0, failed: 0 });
|
|
170
|
+
const m = (stats.byMode[d.mode] ||= { total: 0, kept: 0, reverted: 0, superseded: 0, failed: 0, errored: 0 });
|
|
107
171
|
m.total++; m[d.outcome]++;
|
|
108
172
|
}
|
|
109
173
|
}
|
|
174
|
+
for (const d of labeled.filter(isAutoLabel)) {
|
|
175
|
+
const r = (stats.byRoute[d.route] ||= { total: 0, kept: 0, reverted: 0, superseded: 0, failed: 0, errored: 0 });
|
|
176
|
+
r.errored = (r.errored || 0) + 1;
|
|
177
|
+
}
|
|
110
178
|
|
|
111
179
|
return stats;
|
|
112
180
|
}
|
|
@@ -165,5 +233,5 @@ function bypassStats() {
|
|
|
165
233
|
module.exports = {
|
|
166
234
|
OUTCOMES, DECISIONS_FILE, BYPASS_REASONS, BYPASSES_FILE,
|
|
167
235
|
recordDecision, labelOutcome, pendingDecisions, recentDecisions, outcomeStats,
|
|
168
|
-
recordBypass, bypassStats,
|
|
236
|
+
isAutoLabel, looksTrivial, recordBypass, bypassStats,
|
|
169
237
|
};
|
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
|
|
|
@@ -55,12 +56,14 @@ function suggestAll(s = {}) {
|
|
|
55
56
|
});
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
// 2.
|
|
59
|
+
// 2. Some real calls judged, others not — a verdict or two is what teaches
|
|
60
|
+
// phewsh which tool to trust. Payoff-framed, no scary backlog number (the
|
|
61
|
+
// big count read as a chore, not a payoff).
|
|
59
62
|
if (pendingOutcomes >= 3) {
|
|
60
63
|
out.push({
|
|
61
64
|
id: 'label-outcomes',
|
|
62
65
|
priority: 90,
|
|
63
|
-
message:
|
|
66
|
+
message: `Tell phewsh what worked — a quick verdict teaches it which tool to trust for you.`,
|
|
64
67
|
command: '/outcomes',
|
|
65
68
|
why: 'Labeling kept/reverted is the dataset; it weights future routing and recall.',
|
|
66
69
|
});
|
|
@@ -77,6 +80,19 @@ function suggestAll(s = {}) {
|
|
|
77
80
|
});
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
// 3b. Code shipped but .intent/ never caught up — the deeper drift that ate
|
|
84
|
+
// phewsh's own dogfood (versions shipped while next.md stayed stale).
|
|
85
|
+
// CLAUDE.md self-heals, but the *intent* itself is behind the work.
|
|
86
|
+
if (hasIntentDir && commitsSinceIntent >= 3) {
|
|
87
|
+
out.push({
|
|
88
|
+
id: 'intent-behind-code',
|
|
89
|
+
priority: 85,
|
|
90
|
+
message: `${commitsSinceIntent} commits since your .intent/ was updated — the record is behind the work.`,
|
|
91
|
+
command: '/wrap',
|
|
92
|
+
why: 'Folds what you shipped into next.md/status.md so continuity reflects reality, not last week.',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
// 4. Multiple harnesses installed, only leaning on one — council is free leverage.
|
|
81
97
|
const others = installedHarnesses.filter(h => h !== route);
|
|
82
98
|
if (installedHarnesses.length >= 2 && turnsThisSession >= 3 && others.length >= 1) {
|