phaedo-mcp 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -21
- package/agreement.js +48 -2
- package/consult-core.js +77 -3
- package/consult-runtime.js +60 -0
- package/consult.js +22 -1
- package/delegation-sync.js +6 -0
- package/fingerprint-source.js +34 -9
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +6 -2
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +71 -24
- package/package.json +19 -6
- package/phone-source.js +6 -1
- package/receipts.js +1 -1
- package/server.js +52 -62
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/vendor/capture/detectors.js +4 -4
- package/vendor/capture/transcript.js +34 -3
- package/vendor/extraction/persist.js +7 -2
- package/vendor/extraction/reconcile.js +46 -5
- package/vendor/extraction/rule-promotion.js +6 -40
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +4 -0
package/ship-actions.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Ship actions — which shell commands are a decision the subject would want consulted on.
|
|
2
|
+
//
|
|
3
|
+
// The precedent track's Phase 1 (docs/roadmap/precedent-and-sync-design-2026-09-13.md) is
|
|
4
|
+
// starved of outcome data because nothing consults: no receipt has been written since
|
|
5
|
+
// 2026-07-28. The decisions the subject actually delegates, every week, are the ones a
|
|
6
|
+
// coding agent takes on their behalf — merging a PR, deploying the relay, cutting a phone
|
|
7
|
+
// build. This module names those, so the PreToolUse hook (hooks/pre-ship.mjs) can consult
|
|
8
|
+
// before they run. Everything else a shell does is not a decision and is ignored.
|
|
9
|
+
//
|
|
10
|
+
// PURE: (command string) -> classification | null. No I/O.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
// One entry per class. `match` sees one simple command, already split out of a compound
|
|
15
|
+
// line and stripped of env assignments and `npx`. The descriptor is what the consult
|
|
16
|
+
// reasons about — magnitude is the stakes, reversible is whether it can be walked back.
|
|
17
|
+
const CLASSES = [
|
|
18
|
+
{
|
|
19
|
+
id: 'merge_pr',
|
|
20
|
+
// `gh pr merge` — reversible (a revert), medium. `--admin` skips required checks, which
|
|
21
|
+
// is a different act: high.
|
|
22
|
+
match: (w) => w[0] === 'gh' && w[1] === 'pr' && w[2] === 'merge',
|
|
23
|
+
describe: (w) => {
|
|
24
|
+
const num = w.slice(3).find((x) => /^#?\d+$/.test(x));
|
|
25
|
+
const admin = w.includes('--admin');
|
|
26
|
+
// `-R/--repo` names the repository when the agent is not inside it; the hook passes it
|
|
27
|
+
// through when it asks GitHub for the PR's check state (B6).
|
|
28
|
+
const ri = w.findIndex((x) => x === '-R' || x === '--repo');
|
|
29
|
+
const repo = ri >= 0 ? w[ri + 1] : (w.find((x) => x.startsWith('--repo='))?.split('=')[1]);
|
|
30
|
+
return {
|
|
31
|
+
magnitude: admin ? 'high' : 'medium',
|
|
32
|
+
reversible: true,
|
|
33
|
+
summary: `${admin ? 'admin-merge' : 'merge'} ${num ? `PR #${num.replace('#', '')}` : "this branch's PR"}`,
|
|
34
|
+
pr: num ? num.replace('#', '') : null,
|
|
35
|
+
repo: repo || null,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'deploy',
|
|
41
|
+
// `fly deploy` — production for every paired phone. Reversible (`fly releases` rollback)
|
|
42
|
+
// but high: a bad relay breaks sync for everyone until it is rolled back.
|
|
43
|
+
match: (w) => (w[0] === 'fly' || w[0] === 'flyctl') && w[1] === 'deploy',
|
|
44
|
+
describe: (w) => {
|
|
45
|
+
const i = w.findIndex((x) => x === '-a' || x === '--app');
|
|
46
|
+
const app = i >= 0 ? w[i + 1] : null;
|
|
47
|
+
return { magnitude: 'high', reversible: true, summary: `deploy${app ? ` ${app}` : ''} to Fly` };
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'app_build',
|
|
52
|
+
// `eas build` — a build artifact, nothing reaches a device yet. Low.
|
|
53
|
+
match: (w) => isEas(w[0]) && w[1] === 'build',
|
|
54
|
+
describe: (w) => ({ magnitude: 'low', reversible: true, summary: `phone build${platform(w)}` }),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'app_submit',
|
|
58
|
+
// `eas submit` (or `eas build --auto-submit`) — the build goes to TestFlight / the store.
|
|
59
|
+
match: (w) => isEas(w[0]) && (w[1] === 'submit' || (w[1] === 'build' && w.includes('--auto-submit'))),
|
|
60
|
+
describe: (w) => ({ magnitude: 'medium', reversible: true, summary: `submit phone build${platform(w)}` }),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'push_main',
|
|
64
|
+
// A direct push to the default branch, skipping review. A force-push there rewrites
|
|
65
|
+
// shared history: high, and not cleanly reversible.
|
|
66
|
+
match: (w) => w[0] === 'git' && w[1] === 'push' && w.slice(2).some((x) => /^\+?(?:[^:]*:)?(?:refs\/heads\/)?(?:main|master)$/.test(x)),
|
|
67
|
+
describe: (w) => {
|
|
68
|
+
const force = w.some((x) => x === '-f' || x === '--force' || x.startsWith('--force-with-lease') || x.startsWith('+'));
|
|
69
|
+
return force
|
|
70
|
+
? { magnitude: 'high', reversible: false, summary: 'force-push to main' }
|
|
71
|
+
: { magnitude: 'medium', reversible: true, summary: 'push directly to main' };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Order matters for one pair: `eas build --auto-submit` is a submit, not a build.
|
|
77
|
+
const ORDER = ['merge_pr', 'deploy', 'app_submit', 'app_build', 'push_main'];
|
|
78
|
+
|
|
79
|
+
function isEas(w0) { return w0 === 'eas' || w0 === 'eas-cli'; }
|
|
80
|
+
function platform(w) {
|
|
81
|
+
const i = w.findIndex((x) => x === '--platform' || x === '-p');
|
|
82
|
+
const p = i >= 0 ? w[i + 1] : (w.find((x) => x.startsWith('--platform='))?.split('=')[1]);
|
|
83
|
+
return p ? ` (${p})` : '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Split a shell line into simple commands on && || ; | and newlines. Not a shell parser —
|
|
87
|
+
// quotes are honoured only enough that `git commit -m "a && b"` does not split.
|
|
88
|
+
export function splitCommands(line) {
|
|
89
|
+
const out = [];
|
|
90
|
+
let cur = '';
|
|
91
|
+
let q = null;
|
|
92
|
+
for (let i = 0; i < line.length; i++) {
|
|
93
|
+
const c = line[i];
|
|
94
|
+
if (q) { cur += c; if (c === q) q = null; continue; }
|
|
95
|
+
if (c === '"' || c === "'") { q = c; cur += c; continue; }
|
|
96
|
+
if (c === '\n' || c === ';' || c === '|' || (c === '&' && line[i + 1] === '&')) {
|
|
97
|
+
if (c === '&' || (c === '|' && line[i + 1] === '|')) i++;
|
|
98
|
+
out.push(cur); cur = '';
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
cur += c;
|
|
102
|
+
}
|
|
103
|
+
out.push(cur);
|
|
104
|
+
return out.map((s) => s.trim()).filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Words of one simple command, with leading env assignments, `npx`/`npx --yes`, and a
|
|
108
|
+
// quoted-string's quotes removed.
|
|
109
|
+
export function words(cmd) {
|
|
110
|
+
const w = (cmd.match(/"[^"]*"|'[^']*'|\S+/g) || []).map((x) => x.replace(/^["']|["']$/g, ''));
|
|
111
|
+
while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift();
|
|
112
|
+
if (w[0] === 'npx') { w.shift(); while (w[0] && w[0].startsWith('-')) w.shift(); }
|
|
113
|
+
// A path to the binary (`/opt/homebrew/bin/gh`, `./node_modules/.bin/eas`) is the binary.
|
|
114
|
+
if (w[0]) w[0] = w[0].split('/').pop();
|
|
115
|
+
return w;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Stakes order for a line that chains several ship actions: irreversible first, then by
|
|
119
|
+
// magnitude. A line that merges and then deploys is consulted on the deploy — consulting on
|
|
120
|
+
// the merge would let the riskier half ride on the safer half's answer.
|
|
121
|
+
const MAG_RANK = { low: 0, medium: 1, high: 2 };
|
|
122
|
+
const stakes = (d) => (d.reversible === false ? 10 : 0) + (MAG_RANK[d.magnitude] ?? 1);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify a Bash command. Returns null when no part of it is a ship action, else the
|
|
126
|
+
* highest-stakes ship action in it (ties: the first), with `multiple` set when the line
|
|
127
|
+
* chains more than one.
|
|
128
|
+
*
|
|
129
|
+
* @returns {null | { class, summary, multiple, action_descriptor, action_binding }}
|
|
130
|
+
*/
|
|
131
|
+
export function classifyShipCommand(command) {
|
|
132
|
+
if (typeof command !== 'string' || !command.trim()) return null;
|
|
133
|
+
const hits = [];
|
|
134
|
+
for (const seg of splitCommands(command)) {
|
|
135
|
+
const w = words(seg);
|
|
136
|
+
if (!w.length) continue;
|
|
137
|
+
for (const id of ORDER) {
|
|
138
|
+
const k = CLASSES.find((c) => c.id === id);
|
|
139
|
+
if (k.match(w)) { hits.push({ id, seg, d: k.describe(w) }); break; }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!hits.length) return null;
|
|
143
|
+
const first = hits.reduce((best, h) => (stakes(h.d) > stakes(best.d) ? h : best));
|
|
144
|
+
return {
|
|
145
|
+
class: first.id,
|
|
146
|
+
summary: first.d.summary,
|
|
147
|
+
multiple: hits.length > 1,
|
|
148
|
+
// merge_pr only: which PR, and in which repo (null = the current branch's, here).
|
|
149
|
+
...(first.id === 'merge_pr' ? { pr: first.d.pr, repo: first.d.repo } : {}),
|
|
150
|
+
action_descriptor: {
|
|
151
|
+
domain: 'engineering',
|
|
152
|
+
reversible: first.d.reversible,
|
|
153
|
+
magnitude: first.d.magnitude,
|
|
154
|
+
summary: first.d.summary,
|
|
155
|
+
},
|
|
156
|
+
// §10.2 binding: the concrete call this descriptor claims to describe — a digest of the
|
|
157
|
+
// whole line, never the line itself beyond a short preview.
|
|
158
|
+
action_binding: {
|
|
159
|
+
tool_name: 'Bash',
|
|
160
|
+
arguments_hash: createHash('sha256').update(command).digest('hex'),
|
|
161
|
+
arguments_preview: first.seg.slice(0, 120),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
package/ship-readout.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ship-hook readout — what the Claude Code ship hook (hooks/pre-ship.mjs) has seen: how many
|
|
3
|
+
// merges / deploys / phone builds it consulted on, what Phaedo answered, what the subject
|
|
4
|
+
// said, and whether agreement has anything to score yet. The precedent track's Phase 1
|
|
5
|
+
// exit is "receipts gain marked outcomes in normal use"; this is how to see whether they do.
|
|
6
|
+
//
|
|
7
|
+
// npm run ship-readout # last 30 days
|
|
8
|
+
// npm run ship-readout -- --days 90
|
|
9
|
+
//
|
|
10
|
+
// All local: decrypts the receipt store with the local key; prints counts and the summary
|
|
11
|
+
// line each receipt already carries (never layer content).
|
|
12
|
+
|
|
13
|
+
import { computeAgreement } from './agreement.js';
|
|
14
|
+
import { AGENT_ID } from './hooks/pre-ship.mjs';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_DAYS = 30;
|
|
17
|
+
const PERMISSIVE = new Set(['proceed', 'proceed_with_note']);
|
|
18
|
+
const ASKING = new Set(['escalate', 'clarify', 'decline']);
|
|
19
|
+
|
|
20
|
+
// The action a hook receipt was about, from the summary ship-actions.js wrote.
|
|
21
|
+
export function actionKind(summary) {
|
|
22
|
+
const s = String(summary || '');
|
|
23
|
+
if (/^(admin-)?merge\b/.test(s)) return 'merge';
|
|
24
|
+
if (/^deploy\b/.test(s)) return 'deploy';
|
|
25
|
+
if (/^submit\b/.test(s)) return 'phone submit';
|
|
26
|
+
if (/^phone build\b/.test(s)) return 'phone build';
|
|
27
|
+
if (/push/.test(s)) return 'push to main';
|
|
28
|
+
return 'other';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* (receipts, { now, windowDays }) -> the readout, PURE. Only receipts the ship hook wrote
|
|
33
|
+
* count — the agreement figure here is the hook's own, not the whole store's.
|
|
34
|
+
*/
|
|
35
|
+
export function shipReadout(receipts, { now = Date.now(), windowDays = DEFAULT_DAYS } = {}) {
|
|
36
|
+
const since = now - windowDays * 24 * 60 * 60 * 1000;
|
|
37
|
+
const mine = (Array.isArray(receipts) ? receipts : []).filter((r) => r?.agent_id === AGENT_ID);
|
|
38
|
+
const inWindow = mine.filter((r) => { const t = Date.parse(r?.created_at); return Number.isFinite(t) && t >= since && t <= now; });
|
|
39
|
+
const kinds = {};
|
|
40
|
+
const answers = { go_ahead: 0, flagged: 0, unsure: 0 };
|
|
41
|
+
const outcomes = { approved: 0, rejected: 0, modified: 0, unmarked: 0 };
|
|
42
|
+
for (const r of inWindow) {
|
|
43
|
+
const k = actionKind(r.request?.action_descriptor?.summary);
|
|
44
|
+
kinds[k] = (kinds[k] || 0) + 1;
|
|
45
|
+
const sig = r.response?.signal;
|
|
46
|
+
if (PERMISSIVE.has(sig)) answers.go_ahead++; else if (ASKING.has(sig)) answers.flagged++; else answers.unsure++;
|
|
47
|
+
const a = r.user_action;
|
|
48
|
+
if (a === 'approved' || a === 'rejected' || a === 'modified') outcomes[a]++; else outcomes.unmarked++;
|
|
49
|
+
}
|
|
50
|
+
const agreement = computeAgreement(inWindow, { now, windowDays }).overall;
|
|
51
|
+
const newest = inWindow[inWindow.length - 1] || null;
|
|
52
|
+
return {
|
|
53
|
+
window_days: windowDays,
|
|
54
|
+
total_ever: mine.length,
|
|
55
|
+
consults: inWindow.length,
|
|
56
|
+
kinds,
|
|
57
|
+
answers,
|
|
58
|
+
outcomes,
|
|
59
|
+
agreement: { scored: agreement.scored, agreements: agreement.agreements, rate: agreement.rate },
|
|
60
|
+
newest: newest && {
|
|
61
|
+
at: newest.created_at,
|
|
62
|
+
summary: newest.request?.action_descriptor?.summary || null,
|
|
63
|
+
signal: newest.response?.signal || null,
|
|
64
|
+
user_action: newest.user_action || null,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
70
|
+
const KIND_PLURAL = { merge: 'merges', deploy: 'deploys', 'phone build': 'phone builds', 'phone submit': 'phone submits', 'push to main': 'pushes to main', other: 'others' };
|
|
71
|
+
const saidIt = (sig) => (PERMISSIVE.has(sig) ? 'go ahead' : ASKING.has(sig) ? 'flagged for you' : "didn't know");
|
|
72
|
+
|
|
73
|
+
// Plain lines for the terminal. No confidences, no protocol terms beyond the summary.
|
|
74
|
+
export function formatReadout(o) {
|
|
75
|
+
const lines = [`Ship hook — last ${o.window_days} days`];
|
|
76
|
+
if (!o.consults) {
|
|
77
|
+
lines.push(o.total_ever
|
|
78
|
+
? ` Nothing in this window (${plural(o.total_ever, 'consult')} before it). Try --days ${o.window_days * 3}.`
|
|
79
|
+
: ' No consults yet. Is the hook installed? (npm run setup:code-hook, then a new Claude Code session)');
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
const kinds = Object.entries(o.kinds).sort((a, b) => b[1] - a[1]).map(([k, n]) => plural(n, k, KIND_PLURAL[k]));
|
|
83
|
+
lines.push(` ${plural(o.consults, 'consult')}: ${kinds.join(', ')}`);
|
|
84
|
+
const ans = [`go ahead ${o.answers.go_ahead}×`, `flagged for you ${o.answers.flagged}×`];
|
|
85
|
+
if (o.answers.unsure) ans.push(`didn't know ${o.answers.unsure}×`);
|
|
86
|
+
lines.push(` Phaedo said: ${ans.join(', ')}`);
|
|
87
|
+
const { approved, rejected, modified, unmarked } = o.outcomes;
|
|
88
|
+
lines.push(` You said: yes ${approved}, no ${rejected}, changed it ${modified} — ${unmarked} not marked`);
|
|
89
|
+
lines.push(o.agreement.scored
|
|
90
|
+
? ` Phaedo matched you on ${o.agreement.agreements} of ${o.agreement.scored} decisions it could be scored on`
|
|
91
|
+
: ' Nothing to score yet (needs a "go ahead" you then answered)');
|
|
92
|
+
if (o.newest) {
|
|
93
|
+
const day = String(o.newest.at).slice(0, 10);
|
|
94
|
+
lines.push(` Newest: ${day} ${o.newest.summary || '(no summary)'} → Phaedo: ${saidIt(o.newest.signal)}${o.newest.user_action ? ` · you: ${o.newest.user_action}` : ''}`);
|
|
95
|
+
}
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (process.argv[1] && process.argv[1].endsWith('ship-readout.js')) {
|
|
100
|
+
const i = process.argv.indexOf('--days');
|
|
101
|
+
const days = i > 0 ? Number(process.argv[i + 1]) : DEFAULT_DAYS;
|
|
102
|
+
const { readReceipts } = await import('./receipts.js');
|
|
103
|
+
const { defaultStateDir } = await import('./cache.js');
|
|
104
|
+
const rs = await readReceipts(defaultStateDir());
|
|
105
|
+
console.log(formatReadout(shipReadout(rs, { windowDays: Number.isFinite(days) && days > 0 ? days : DEFAULT_DAYS })));
|
|
106
|
+
}
|
|
@@ -35,14 +35,14 @@ export const CORRECTION_PATTERNS = [
|
|
|
35
35
|
// Surface — tone
|
|
36
36
|
{ pattern: /\b(too formal|less formal|more casual|relax the tone|conversational(ly)?|informal)\b/i, layer: 'surface', signal: 'prefers_casual' },
|
|
37
37
|
{ pattern: /\b(too casual|more formal|professional(ly)?|keep it formal)\b/i, layer: 'surface', signal: 'prefers_formal' },
|
|
38
|
-
{ pattern: /\b(too roundabout|get to the point|be direct|skip the preamble|lead with|bottom.?line (up )?front)\b/i, layer: 'surface', signal: 'more_direct' },
|
|
38
|
+
{ pattern: /\b(too roundabout|get to the point|be (more )?direct|skip the preamble|lead with|bottom.?line (up )?front)\b/i, layer: 'surface', signal: 'more_direct' },
|
|
39
39
|
{ pattern: /\b(no (intro|preamble|opener)|skip the intro|don'?t start with|cut the opener|dive right in)\b/i, layer: 'surface', signal: 'no_preamble' },
|
|
40
40
|
{ pattern: /\b(no examples?|skip the examples?|I don'?t need examples?|without examples?)\b/i, layer: 'surface', signal: 'no_examples' },
|
|
41
41
|
{ pattern: /\b(give me an example|show me an example|illustrate|for example|can you show|concrete example)\b/i, layer: 'surface', signal: 'wants_examples' },
|
|
42
42
|
{ pattern: /\b(simpler words?|plain(er)? (language|english)|avoid jargon|less jargon|plain terms?)\b/i, layer: 'surface', signal: 'plainer_language' },
|
|
43
43
|
|
|
44
44
|
// Behavioral — autonomy and initiative
|
|
45
|
-
{ pattern: /\b(just do it|stop asking|don'?t (ask|check)|fewer questions?|just (go|start|write|build|do)|no need to ask)\b/i, layer: 'behavioral', signal: 'fewer_questions' },
|
|
45
|
+
{ pattern: /\b(just do it|stop asking|don'?t (ask|check)|fewer questions?|just (go ahead|start|write it|build it|do it)|no need to ask)\b/i, layer: 'behavioral', signal: 'fewer_questions' },
|
|
46
46
|
{ pattern: /\b(ask (me )?first|confirm before|check (with me|before)|get my (input|approval)|don'?t assume)\b/i, layer: 'behavioral', signal: 'wants_confirmation' },
|
|
47
47
|
{ pattern: /\b(show your (work|reasoning)|explain your reasoning|walk me through|how (did you|do you) (get|arrive))\b/i, layer: 'behavioral', signal: 'show_reasoning' },
|
|
48
48
|
{ pattern: /\b(just (give me|the) (answer|result)|skip the explanation|don'?t explain (how|why)|bottom line only)\b/i, layer: 'behavioral', signal: 'skip_reasoning' },
|
|
@@ -86,7 +86,7 @@ export const CORRECTION_PATTERNS = [
|
|
|
86
86
|
{ pattern: /\b(ballpark (is fine|estimate|answer)|rough estimate|in the ballpark|order of magnitude|approximate(ly)?( is fine)?)\b/i, layer: 'decision_and_risk', signal: 'rough_estimate_ok' },
|
|
87
87
|
|
|
88
88
|
// Creative — style and originality
|
|
89
|
-
{ pattern: /\b(too conventional|be (more )?creative|unexpected (approach|angle|idea)|think (differently|outside)
|
|
89
|
+
{ pattern: /\b(too conventional|be (more )?creative|unexpected (approach|angle|idea)|think (differently|outside))\b/i, layer: 'creative', signal: 'wants_creative' },
|
|
90
90
|
{ pattern: /\b(simpler|more elegant|too complex|less is more|strip (it )?down|over.?engineered|over.?complicated)\b/i, layer: 'creative', signal: 'wants_elegance' },
|
|
91
91
|
{ pattern: /\b(standard (approach|solution)|conventional|don'?t overthink|keep it (simple|straightforward|standard))\b/i, layer: 'creative', signal: 'wants_conventional' },
|
|
92
92
|
{ pattern: /\b(more original|surprise me|not the obvious|unconventional|different (angle|approach|way)|unexpected)\b/i, layer: 'creative', signal: 'wants_originality' },
|
|
@@ -95,7 +95,7 @@ export const CORRECTION_PATTERNS = [
|
|
|
95
95
|
{ pattern: /\b(don'?t explain what you'?re doing|just do it|skip the (meta|commentary|preamble)|no meta)\b/i, layer: 'big_picture', signal: 'skip_meta' },
|
|
96
96
|
{ pattern: /\b(show your reasoning|think out loud|walk me through your (logic|thinking|approach)|what'?s your (plan|approach|thinking))\b/i, layer: 'big_picture', signal: 'show_meta' },
|
|
97
97
|
{ pattern: /\b(what (assumptions?|are you assuming)|be transparent|tell me your reasoning|what'?s your (basis|rationale))\b/i, layer: 'big_picture', signal: 'wants_transparency' },
|
|
98
|
-
{ pattern: /\b(commit|stop second.?guessing|be confident|don'?t hedge|pick and go|just (pick|commit|decide))\b/i, layer: 'big_picture', signal: 'less_second_guessing' },
|
|
98
|
+
{ pattern: /\b(commit to (it|one|a decision|an answer)|stop second.?guessing|be confident|don'?t hedge|pick and go|just (pick|commit|decide))\b/i, layer: 'big_picture', signal: 'less_second_guessing' },
|
|
99
99
|
];
|
|
100
100
|
|
|
101
101
|
// Positive reinforcement — mirror of content.js
|
|
@@ -46,6 +46,29 @@ function isUserPrompt(rec) {
|
|
|
46
46
|
return rec && rec.type === 'user' && rec.message && rec.message.role !== 'assistant'
|
|
47
47
|
&& !isMachineAuthored(rec);
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
// WHO wrote a user-role line, when the transcript says (2026-10-20). Newer Claude Code writes
|
|
51
|
+
// `origin` on user records: `{kind: "human"}` for a typed turn, another kind (e.g.
|
|
52
|
+
// "task-notification") or null for text the app injects — CI-monitor events, background-task
|
|
53
|
+
// notices, side-session reports. Those carry plain prose, so isMachineAuthored above let them
|
|
54
|
+
// through and the detectors read an injected "do not stop to report" as the SUBJECT's
|
|
55
|
+
// correction. Measured on the dev Mac since 2026-09-20: 363 human lines, 33 task notifications,
|
|
56
|
+
// 21 unmarked lines in marked files (scripted probes) — all of the latter counted as Randy's.
|
|
57
|
+
// - a transcript that carries origin markers → only `kind: "human"` lines are the subject's;
|
|
58
|
+
// - a transcript with none (older format) → today's behaviour, minus prose that opens with an
|
|
59
|
+
// app envelope tag, which no person types.
|
|
60
|
+
// Includes Phaedo's OWN injected profile (`<phaedo_user_profile>`, the SessionStart hook's
|
|
61
|
+
// projection): it describes the subject in their words ("too long", "be concise"), so read as a
|
|
62
|
+
// prompt it scored the profile as the subject correcting the AI — a loop feeding itself.
|
|
63
|
+
const MACHINE_ENVELOPE = /^<(task-notification|ci-monitor-event|side-sessions-event|system-reminder|command-(name|message|args)|local-command-stdout|user-prompt-submit-hook|phaedo_user_profile|phaedo_fingerprint)\b/i;
|
|
64
|
+
function hasOriginMarkers(recs) {
|
|
65
|
+
return recs.some((r) => r && r.type === 'user' && Object.prototype.hasOwnProperty.call(r, 'origin'));
|
|
66
|
+
}
|
|
67
|
+
function isSubjectAuthored(rec, text, marked) {
|
|
68
|
+
if (MACHINE_ENVELOPE.test(text)) return false; // an app envelope is never typed, marked or not
|
|
69
|
+
if (marked) return !!(rec.origin && typeof rec.origin === 'object' && rec.origin.kind === 'human');
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
49
72
|
function isAssistant(rec) {
|
|
50
73
|
return rec && rec.type === 'assistant' && rec.message && !rec.isSidechain;
|
|
51
74
|
}
|
|
@@ -103,8 +126,10 @@ export function recordsToBatch(records, opts = {}) {
|
|
|
103
126
|
|| recs.find((r) => r && r.sessionId)?.sessionId
|
|
104
127
|
|| null;
|
|
105
128
|
|
|
129
|
+
const marked = hasOriginMarkers(recs);
|
|
106
130
|
const exchanges = [];
|
|
107
131
|
const user_prompts = []; // EVERY genuine user prompt (answered or not)
|
|
132
|
+
const user_prompt_times = []; // parallel to user_prompts: each prompt's timestamp (or null)
|
|
108
133
|
let pending = null; // the open user prompt awaiting a reply
|
|
109
134
|
let replyParts = [];
|
|
110
135
|
|
|
@@ -131,9 +156,15 @@ export function recordsToBatch(records, opts = {}) {
|
|
|
131
156
|
if (isUserPrompt(rec)) {
|
|
132
157
|
const text = contentText(rec.message.content);
|
|
133
158
|
if (!text) continue; // tool_result-only user line — not prose
|
|
159
|
+
if (!isSubjectAuthored(rec, text, marked)) continue; // app-injected prose — not the subject
|
|
160
|
+
// Pasted blocks are someone else's words — terminal output, a log, a document — that the
|
|
161
|
+
// subject quoted, not typed. The app wraps them; keep only what was typed around them.
|
|
162
|
+
const typed = text.replace(/<pasted_content\b[^>]*>[\s\S]*?<\/pasted_content\b[^>]*>/gi, ' ').trim();
|
|
163
|
+
if (!typed) continue;
|
|
134
164
|
flush(); // close any previous exchange first
|
|
135
|
-
user_prompts.push(
|
|
136
|
-
|
|
165
|
+
user_prompts.push(typed); // count Path-A per SENT prompt (extension parity)
|
|
166
|
+
user_prompt_times.push(rec.timestamp || null);
|
|
167
|
+
pending = { id: rec.uuid || `ex-${exchanges.length}`, text: typed, timestamp: rec.timestamp || null };
|
|
137
168
|
} else if (isAssistant(rec) && pending) {
|
|
138
169
|
const text = contentText(rec.message.content);
|
|
139
170
|
if (text) replyParts.push(text);
|
|
@@ -144,7 +175,7 @@ export function recordsToBatch(records, opts = {}) {
|
|
|
144
175
|
// exchanges = COMPLETE pairs (for the future model/Loop-2 path); user_prompts = every
|
|
145
176
|
// sent prompt (for Path-A counters — the web path counts at SEND time, so an
|
|
146
177
|
// interrupted / unanswered correction must still count, unlike exchange shipping).
|
|
147
|
-
return { exchange_batch_version: 'v0.1', session_local_id: sessionId, exchanges, user_prompts };
|
|
178
|
+
return { exchange_batch_version: 'v0.1', session_local_id: sessionId, exchanges, user_prompts, user_prompt_times };
|
|
148
179
|
}
|
|
149
180
|
|
|
150
181
|
// Convenience: raw JSONL text → batch in one call.
|
|
@@ -30,11 +30,16 @@ import { applyCaptureSources } from './capture-combine.js';
|
|
|
30
30
|
// — otherwise a delta write from one surface would clobber the accumulated counts
|
|
31
31
|
// of the others (delta reconcile is replace-by-key). Callers that hold a vault
|
|
32
32
|
// with capture sources MUST pass them on every delta-writing merge.
|
|
33
|
-
|
|
33
|
+
//
|
|
34
|
+
// `attr` (optional { sourceId, context }): which browser this pass ran in. Extraction evidence is
|
|
35
|
+
// then recorded per source (signal-ledger.js), so learning from several browsers adds up at sync
|
|
36
|
+
// instead of the newest copy replacing the rest.
|
|
37
|
+
export function mergeExtractionInto(fingerprint, update, now = new Date().toISOString(), captureSources = null, attr = null) {
|
|
34
38
|
const effective = (Array.isArray(captureSources) && captureSources.length)
|
|
35
39
|
? applyCaptureSources(update, captureSources, now)
|
|
36
40
|
: update;
|
|
37
|
-
const
|
|
41
|
+
const ropts = attr && attr.sourceId ? { now, sourceId: attr.sourceId, context: attr.context } : { now };
|
|
42
|
+
const merged = promoteRules(reconcileFingerprint(fingerprint || {}, effective, ropts), { now });
|
|
38
43
|
merged.updated_at = now; // bump for sync last-write-wins
|
|
39
44
|
return merged;
|
|
40
45
|
}
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
|
|
25
25
|
import { renderLayerSummary } from './fingerprint-update.js';
|
|
26
26
|
import { resolveDimension, valKey, authoredGovernedKeys } from './resolve.js';
|
|
27
|
+
// Per-source ledgers (multi-browser): a classic UMD file, loaded for its side effect and read off
|
|
28
|
+
// globalThis so the same file serves the popup's classic scripts, this module and Node.
|
|
29
|
+
import '../signal-ledger.js';
|
|
30
|
+
const SL = globalThis.PhaedoSignalLedger;
|
|
27
31
|
|
|
28
32
|
const CONFIDENCE_CAP = 0.95;
|
|
29
33
|
|
|
@@ -39,7 +43,12 @@ function unionArrays(a = [], b = []) {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
// Merge two signal objects with the same (field, polarity) key.
|
|
42
|
-
|
|
46
|
+
//
|
|
47
|
+
// `attr` ({ sourceId, context }, optional): WHO produced this increment. When given, the evidence
|
|
48
|
+
// is recorded in the signal's per-source ledger (signal-ledger.js) and the totals are re-derived
|
|
49
|
+
// from it, so a second browser's learning ADDS to this one's at sync instead of replacing it.
|
|
50
|
+
// Without it (tests, the desktop fold) the behaviour is exactly the pre-ledger accumulate.
|
|
51
|
+
function mergeSignal(existing, incoming, now, attr) {
|
|
43
52
|
// Delta signals carry Path A's CUMULATIVE count — a re-lift is a fresh
|
|
44
53
|
// snapshot of the same counters, not new evidence. Replace wholesale so
|
|
45
54
|
// re-running the lift every pass is idempotent (no count/confidence
|
|
@@ -47,6 +56,9 @@ function mergeSignal(existing, incoming, now) {
|
|
|
47
56
|
if (incoming.source === 'delta') {
|
|
48
57
|
return { ...incoming, signal_id: existing.signal_id || incoming.signal_id };
|
|
49
58
|
}
|
|
59
|
+
if (attr && attr.sourceId && SL && SL.isLedgered(incoming)) {
|
|
60
|
+
return SL.attribute(existing, incoming, attr.sourceId, { now, context: attr.context });
|
|
61
|
+
}
|
|
50
62
|
const observation_count = (existing.observation_count || 1) + (incoming.observation_count || 1);
|
|
51
63
|
// Value merge: arrays union; scalar/enum/string keep the higher-confidence
|
|
52
64
|
// value (a contradiction across passes resolves to the better-supported one).
|
|
@@ -86,15 +98,19 @@ const sigKey = s => `${s.field}::${s.polarity}${typeof s.domain === 'string' ? `
|
|
|
86
98
|
// Reconcile one incoming layer doc into an existing layer doc (or undefined).
|
|
87
99
|
// Returns a new layer object; does not mutate inputs. `suppress` (optional Set of
|
|
88
100
|
// "field"/"field::domain") withholds those dimensions from the rendered summary —
|
|
89
|
-
// the §4.7 injection-suppression of an open-conflict dimension.
|
|
90
|
-
export function reconcileLayer(existingLayer, incomingLayer, now, suppress) {
|
|
101
|
+
// the §4.7 injection-suppression of an open-conflict dimension. `attr` — see mergeSignal.
|
|
102
|
+
export function reconcileLayer(existingLayer, incomingLayer, now, suppress, attr) {
|
|
91
103
|
const base = existingLayer ? { ...existingLayer } : {};
|
|
92
104
|
const existingSignals = Array.isArray(base.signals) ? base.signals : [];
|
|
93
105
|
const byKey = new Map(existingSignals.map(s => [sigKey(s), s]));
|
|
106
|
+
const attributed = !!(attr && attr.sourceId && SL);
|
|
94
107
|
|
|
95
108
|
for (const inc of incomingLayer.signals || []) {
|
|
96
109
|
const k = sigKey(inc);
|
|
97
|
-
byKey.
|
|
110
|
+
if (byKey.has(k)) byKey.set(k, mergeSignal(byKey.get(k), inc, now, attr));
|
|
111
|
+
// A NEW signal is attributed too, or the same signal first seen independently on two
|
|
112
|
+
// browsers would carry no ledger on either and merge last-write-wins.
|
|
113
|
+
else byKey.set(k, attributed && SL.isLedgered(inc) ? SL.attribute(null, inc, attr.sourceId, { now, context: attr.context }) : inc);
|
|
98
114
|
}
|
|
99
115
|
|
|
100
116
|
const mergedSignals = Array.from(byKey.values());
|
|
@@ -248,12 +264,37 @@ export function reconcileFingerprint(fingerprint, update, opts = {}) {
|
|
|
248
264
|
...openConflictFieldKeys(conflicts, layerId),
|
|
249
265
|
...authoredGovernedKeys([...((prev && prev.signals) || []), ...(incomingLayer.signals || [])], rules, opts.domain || null),
|
|
250
266
|
]);
|
|
251
|
-
fp.layers[layerId] = reconcileLayer(prev, incomingLayer, now, suppress);
|
|
267
|
+
fp.layers[layerId] = reconcileLayer(prev, incomingLayer, now, suppress, opts.sourceId ? { sourceId: opts.sourceId, context: opts.context } : null);
|
|
252
268
|
}
|
|
253
269
|
if (conflicts && conflicts.length) fp.conflict_records = conflicts;
|
|
254
270
|
return fp;
|
|
255
271
|
}
|
|
256
272
|
|
|
273
|
+
// Re-render every layer's `summary` from its CURRENT signals, with the same suppression
|
|
274
|
+
// reconcileFingerprint applies (open conflicts + dimensions an authored rule governs).
|
|
275
|
+
//
|
|
276
|
+
// A cross-browser sync merges signals without running a pass (signal-ledger.js mergeLayers), and
|
|
277
|
+
// the injected context reads `summary`, not signals — so without this, evidence that just arrived
|
|
278
|
+
// from another browser would sit in storage and never reach a prompt until this browser's next
|
|
279
|
+
// extraction pass. Returns the same object when no summary changes.
|
|
280
|
+
export function rerenderSummaries(fingerprint, opts = {}) {
|
|
281
|
+
if (!fingerprint || !fingerprint.layers) return fingerprint;
|
|
282
|
+
const rules = Array.isArray(fingerprint.standing_rules) ? fingerprint.standing_rules : [];
|
|
283
|
+
let out = null;
|
|
284
|
+
for (const [layerId, layer] of Object.entries(fingerprint.layers)) {
|
|
285
|
+
if (!layer || !Array.isArray(layer.signals) || !layer.signals.length) continue;
|
|
286
|
+
const suppress = new Set([
|
|
287
|
+
...openConflictFieldKeys(fingerprint.conflict_records, layerId),
|
|
288
|
+
...authoredGovernedKeys(layer.signals, rules, opts.domain || null),
|
|
289
|
+
]);
|
|
290
|
+
const summary = renderLayerSummary(effectiveSignals(layer.signals).filter(s => s.source !== 'questionnaire'), suppress);
|
|
291
|
+
if (summary === layer.summary) continue;
|
|
292
|
+
out = out || { ...fingerprint, layers: { ...fingerprint.layers } };
|
|
293
|
+
out.layers[layerId] = { ...layer, summary };
|
|
294
|
+
}
|
|
295
|
+
return out || fingerprint;
|
|
296
|
+
}
|
|
297
|
+
|
|
257
298
|
// The (field / field::domain) keys of the OPEN conflicts on a layer — the dimensions
|
|
258
299
|
// the projection withholds (renderLayerSummary skips them).
|
|
259
300
|
function openConflictFieldKeys(records, layerId) {
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// edit the proposed text before endorsing, so `proposed_text` only needs to be a
|
|
15
15
|
// reasonable starting sentence.
|
|
16
16
|
|
|
17
|
+
import '../trait-phrases.js'; // UMD side-effect import → globalThis.PhaedoTraitPhrases
|
|
18
|
+
|
|
17
19
|
export const PROMOTION_FLOOR = 3; // §4.7 RESOLUTION_FLOOR — same "established" bar
|
|
18
20
|
const PROMOTABLE_TIERS = new Set(['observed', 'corroborated']); // never promote a self_reported guess
|
|
19
21
|
const DR_LAYER = 'decision_and_risk';
|
|
@@ -24,47 +26,11 @@ const uuid = () =>
|
|
|
24
26
|
|
|
25
27
|
const keyOf = (dim, val) => `${dim}::${JSON.stringify(val ?? null)}`;
|
|
26
28
|
|
|
27
|
-
// (dimension, value) →
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
evidence_threshold: { high: 'I want the main risks covered and the key facts nailed down before I commit.' },
|
|
31
|
-
ambiguity_posture: { ask: 'When a request is ambiguous, I ask one clarifying question before acting.',
|
|
32
|
-
default: 'When things are ambiguous I stay decisive and commit rather than hedge.',
|
|
33
|
-
delay: 'When things are ambiguous I would rather wait than commit prematurely.' },
|
|
34
|
-
speed_vs_quality: { quality: 'I trade speed for accuracy on work that matters.',
|
|
35
|
-
accuracy: 'I trade speed for accuracy on work that matters.',
|
|
36
|
-
speed: 'I favor moving fast and accept rough edges when speed matters.' },
|
|
37
|
-
reversible_vs_irreversible: { more_cautious: "On decisions I can't easily undo, I slow down and seek more input.",
|
|
38
|
-
consistent_style: 'I bring the same care to a decision whether or not it can be undone.' },
|
|
39
|
-
options_vs_recommendation: { single_recommendation: 'Give me a single clear recommendation, not a menu of options.',
|
|
40
|
-
option_set: 'I prefer to see a set of options and make the call myself.' },
|
|
41
|
-
// Surface (response/style) — the dimensions auto-overrides surface against.
|
|
42
|
-
response_length_pref: { short: 'Default to concise — keep responses tight.',
|
|
43
|
-
thorough: 'Default to thorough responses; go deep and cover the edge cases.',
|
|
44
|
-
medium: 'Default to medium-length responses — neither terse nor exhaustive.' },
|
|
45
|
-
tone_register: { casual: 'Default to a casual, conversational tone.',
|
|
46
|
-
formal: 'Default to a formal, professional tone.' },
|
|
47
|
-
};
|
|
48
|
-
const DIM_LABEL = {
|
|
49
|
-
evidence_threshold: 'how much evidence I need before committing',
|
|
50
|
-
ambiguity_posture: 'how I handle ambiguity',
|
|
51
|
-
speed_vs_quality: 'how I weigh speed against quality',
|
|
52
|
-
reversible_vs_irreversible: 'how I treat irreversible decisions',
|
|
53
|
-
options_vs_recommendation: 'whether I want options or a single call',
|
|
54
|
-
response_length_pref: 'response length',
|
|
55
|
-
tone_register: 'tone',
|
|
56
|
-
directness: 'directness',
|
|
57
|
-
jargon_tolerance: 'jargon level',
|
|
58
|
-
formatting_prefs: 'formatting',
|
|
59
|
-
autonomy_pref: 'how much autonomy I give the assistant',
|
|
60
|
-
reasoning_visibility: 'whether I want the reasoning shown',
|
|
61
|
-
};
|
|
29
|
+
// (dimension, value) → the candidate sentence, from the shared phrasebook (trait-phrases.js):
|
|
30
|
+
// one written sentence for every trait and value, so a suggestion never reads
|
|
31
|
+
// "I have a consistent way I handle … (0.85)". The subject can still edit before endorsing.
|
|
62
32
|
export function proposeText(dimension, value) {
|
|
63
|
-
|
|
64
|
-
const byDim = TEMPLATES[dimension];
|
|
65
|
-
if (byDim && v != null && byDim[v]) return byDim[v];
|
|
66
|
-
const label = DIM_LABEL[dimension] || dimension;
|
|
67
|
-
return v != null ? `I have a consistent way I handle ${label} (${v}).` : `I have a consistent way I handle ${label}.`;
|
|
33
|
+
return globalThis.PhaedoTraitPhrases.phrase(dimension, value);
|
|
68
34
|
}
|
|
69
35
|
|
|
70
36
|
function endorsedDimensions(fp) {
|