phaedo-mcp 0.3.7 → 0.3.14
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 +102 -13
- package/agent-hooks.js +160 -0
- package/consult-core.js +44 -7
- package/escalation.js +28 -0
- package/fingerprint-source.js +14 -4
- package/history-import.js +202 -0
- package/hooks/agent-hook.mjs +160 -0
- package/hooks/pre-ship.mjs +155 -26
- package/hooks/session-start.mjs +8 -2
- package/install.mjs +31 -2
- package/learned.js +227 -0
- package/package.json +15 -3
- package/precedent.js +267 -0
- package/pull.js +14 -1
- package/receipts-sync.js +6 -1
- package/rules-files.js +278 -0
- package/server.js +11 -6
- package/server.json +2 -2
- package/ship-actions.js +151 -2
- package/ship-readout.js +12 -2
- package/vendor/trait-phrases.js +2 -0
- package/weekly.js +63 -0
package/learned.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — "here's what I learned about you: keep it or remove it."
|
|
3
|
+
//
|
|
4
|
+
// Everything Phaedo inferred from how the subject works (evidence_basis `observed` — not what
|
|
5
|
+
// they said in the interview, not their rules) as one plain sentence each, from the shared
|
|
6
|
+
// phrasebook (vendor/trait-phrases.js). Remove one and it stops shaping answers and decisions on
|
|
7
|
+
// this computer: the forget list is applied every time the profile loads (fingerprint-source.js),
|
|
8
|
+
// so a trait the sessions keep showing stays forgotten. Keep one and it isn't asked about again.
|
|
9
|
+
//
|
|
10
|
+
// npm run learned # what Phaedo learned, numbered; new ones marked
|
|
11
|
+
// npm run learned -- --remove 3,5
|
|
12
|
+
// npm run learned -- --keep 1,2 # or --keep all
|
|
13
|
+
// npm run learned -- --forgotten # what you removed
|
|
14
|
+
// npm run learned -- --restore 2 # (number from --forgotten)
|
|
15
|
+
//
|
|
16
|
+
// The list file holds a field name and a digest of the value per item — never the text.
|
|
17
|
+
// PURE functions above the CLI; `now` and the state are passed in.
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'crypto';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
|
22
|
+
import './vendor/trait-phrases.js'; // UMD → globalThis.PhaedoTraitPhrases
|
|
23
|
+
import { isMainModule } from './lib/is-main.js';
|
|
24
|
+
|
|
25
|
+
const T = globalThis.PhaedoTraitPhrases;
|
|
26
|
+
export const STATE_FILE = 'learned.json';
|
|
27
|
+
export const SHOW_CHARS = 160;
|
|
28
|
+
|
|
29
|
+
const canon = (field, value) => {
|
|
30
|
+
try { return T.canonical(field, value); } catch { return value; }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A stable key for a (field, value): the field in the clear, the value only as a digest. */
|
|
34
|
+
export function itemKey(field, value) {
|
|
35
|
+
const v = JSON.stringify(canon(field, value) ?? null);
|
|
36
|
+
return `${field}#${createHash('sha256').update(v).digest('hex').slice(0, 12)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sentence(field, value) {
|
|
40
|
+
let s;
|
|
41
|
+
try { s = T.phrase(field, value); } catch { s = ''; }
|
|
42
|
+
return String(s || `${String(field).replace(/_/g, ' ')}: ${JSON.stringify(value)}`).trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Shorten a long list sentence for the screen: "Terms I use: a, b, c … (+40 more)." */
|
|
46
|
+
export function shorten(text, max = SHOW_CHARS) {
|
|
47
|
+
if (text.length <= max) return text;
|
|
48
|
+
const cut = text.slice(0, max);
|
|
49
|
+
const at = cut.lastIndexOf(', ');
|
|
50
|
+
const shown = at > max / 2 ? cut.slice(0, at) : cut.trimEnd();
|
|
51
|
+
const more = (text.slice(shown.length).match(/, /g) || []).length;
|
|
52
|
+
return `${shown}${more ? ` … (+${more} more)` : ' …'}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What Phaedo learned, merged by choice: [{ key, keys, field, layer, text, times, contradicts }].
|
|
57
|
+
* Values that are the same choice (0.8 and 0.85 on a scale) are one item; `keys` holds every
|
|
58
|
+
* value's key, so removing the item forgets all of them. `times` sums the observations.
|
|
59
|
+
* `contradicts` lists the other items for the same field that are a different choice — never
|
|
60
|
+
* for list traits (bullets and headings can both be true). Ordered by layer, then most-seen. PURE.
|
|
61
|
+
*/
|
|
62
|
+
export function learnedItems(vd) {
|
|
63
|
+
const layers = vd?.phaedo_fingerprint?.layers || {};
|
|
64
|
+
const items = [];
|
|
65
|
+
for (const [layer, l] of Object.entries(layers)) {
|
|
66
|
+
for (const s of Array.isArray(l?.signals) ? l.signals : []) {
|
|
67
|
+
if (!s || s.evidence_basis !== 'observed' || !s.field) continue;
|
|
68
|
+
const k = itemKey(s.field, s.value);
|
|
69
|
+
const times = Number(s.observation_count) || 1;
|
|
70
|
+
const same = items.find((it) => it.field === s.field && (it.keys.includes(k) || (!Array.isArray(s.value) && sameChoice(s.field, it.value, s.value))));
|
|
71
|
+
if (same) {
|
|
72
|
+
if (!same.keys.includes(k)) same.keys.push(k);
|
|
73
|
+
if (times > same.best) { same.best = times; same.value = s.value; same.text = sentence(s.field, s.value); }
|
|
74
|
+
same.times += times;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
items.push({ key: k, keys: [k], field: s.field, layer, value: s.value, text: sentence(s.field, s.value), times, best: times });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const it of items) {
|
|
81
|
+
it.contradicts = Array.isArray(it.value) ? [] : items.filter((o) => o !== it && o.field === it.field && !Array.isArray(o.value)).map((o) => o.key);
|
|
82
|
+
}
|
|
83
|
+
const order = Object.keys(layers);
|
|
84
|
+
items.sort((a, b) => order.indexOf(a.layer) - order.indexOf(b.layer) || b.times - a.times || a.text.localeCompare(b.text));
|
|
85
|
+
return items.map(({ value, best, ...rest }) => rest);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sameChoice(field, a, b) {
|
|
89
|
+
try { return T.sameChoice(field, a, b); } catch { return JSON.stringify(a) === JSON.stringify(b); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The state: { forgotten: { key: at }, kept: { key: at } }. Never throws. */
|
|
93
|
+
export function readLearnedState(stateDir) {
|
|
94
|
+
try {
|
|
95
|
+
const j = JSON.parse(readFileSync(join(stateDir, STATE_FILE), 'utf8'));
|
|
96
|
+
return { forgotten: obj(j.forgotten), kept: obj(j.kept) };
|
|
97
|
+
} catch { return { forgotten: {}, kept: {} }; }
|
|
98
|
+
}
|
|
99
|
+
const obj = (x) => (x && typeof x === 'object' && !Array.isArray(x) ? x : {});
|
|
100
|
+
|
|
101
|
+
export function writeLearnedState(stateDir, state) {
|
|
102
|
+
mkdirSync(stateDir, { recursive: true });
|
|
103
|
+
const path = join(stateDir, STATE_FILE);
|
|
104
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
105
|
+
writeFileSync(tmp, JSON.stringify({ forgotten: state.forgotten, kept: state.kept }, null, 2) + '\n', { mode: 0o600 });
|
|
106
|
+
renameSync(tmp, path);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The profile with every forgotten trait taken out of its layers, in place (the loaders'
|
|
111
|
+
* provenance marker lives on the object, so it is kept). Signals the subject said themselves
|
|
112
|
+
* are never touched by a forget — only `observed` ones are listed, so only those can match.
|
|
113
|
+
*/
|
|
114
|
+
export function applyForgotten(vd, state) {
|
|
115
|
+
const forgotten = state?.forgotten || {};
|
|
116
|
+
const layers = vd?.phaedo_fingerprint?.layers;
|
|
117
|
+
if (!layers || !Object.keys(forgotten).length) return vd;
|
|
118
|
+
const next = {};
|
|
119
|
+
for (const [id, l] of Object.entries(layers)) {
|
|
120
|
+
if (!l || !Array.isArray(l.signals)) { next[id] = l; continue; }
|
|
121
|
+
const signals = l.signals.filter((s) => !(s && s.evidence_basis === 'observed' && forgotten[itemKey(s.field, s.value)]));
|
|
122
|
+
next[id] = signals.length === l.signals.length ? l : { ...l, signals };
|
|
123
|
+
}
|
|
124
|
+
vd.phaedo_fingerprint = { ...vd.phaedo_fingerprint, layers: next };
|
|
125
|
+
return vd;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Items not yet kept or removed. */
|
|
129
|
+
const any = (map, it) => it.keys.some((k) => map[k]);
|
|
130
|
+
export const isForgotten = (it, state) => any(state.forgotten, it);
|
|
131
|
+
export const isKept = (it, state) => any(state.kept, it);
|
|
132
|
+
export const unreviewed = (items, state) => items.filter((i) => !isKept(i, state) && !isForgotten(i, state));
|
|
133
|
+
|
|
134
|
+
/** The screen. PURE. */
|
|
135
|
+
export function formatList(items, state) {
|
|
136
|
+
if (!items.length) return 'Phaedo hasn\'t learned anything from how you work yet.';
|
|
137
|
+
const num = new Map(items.map((it, i) => [it.key, i + 1]));
|
|
138
|
+
const reviewedBefore = Object.keys(state.kept).length + Object.keys(state.forgotten).length > 0;
|
|
139
|
+
const lines = [`Here's what Phaedo learned from how you work (${items.length}). Remove anything that isn't right.`, ''];
|
|
140
|
+
for (const [i, it] of items.entries()) {
|
|
141
|
+
const fresh = reviewedBefore && !isKept(it, state) ? ' (new)' : '';
|
|
142
|
+
const clash = it.contradicts.filter((k) => num.has(k)).map((k) => `#${num.get(k)}`);
|
|
143
|
+
lines.push(`${String(i + 1).padStart(3)}. ${shorten(it.text)}${fresh}`);
|
|
144
|
+
if (clash.length) lines.push(` ⚠ disagrees with ${clash.join(', ')}; keep the one that's right`);
|
|
145
|
+
}
|
|
146
|
+
lines.push('', 'Remove: npm run learned -- --remove 3,5 Keep: --keep 1,2 (or --keep all)');
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** "3,5" / "all" → indexes into `items`; unknown numbers reported. PURE. */
|
|
151
|
+
export function pick(arg, items) {
|
|
152
|
+
if (String(arg).trim().toLowerCase() === 'all') return { picked: items.slice(), bad: [] };
|
|
153
|
+
const picked = [], bad = [];
|
|
154
|
+
for (const p of String(arg || '').split(/[\s,]+/).filter(Boolean)) {
|
|
155
|
+
const n = Number(p);
|
|
156
|
+
if (Number.isInteger(n) && n >= 1 && n <= items.length) picked.push(items[n - 1]); else bad.push(p);
|
|
157
|
+
}
|
|
158
|
+
return { picked, bad };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Record a decision. PURE: returns the new state. */
|
|
162
|
+
export function decide(state, items, action, now = new Date().toISOString()) {
|
|
163
|
+
const s = { forgotten: { ...state.forgotten }, kept: { ...state.kept } };
|
|
164
|
+
for (const it of items) for (const k of it.keys || [it.key]) {
|
|
165
|
+
if (action === 'remove') { s.forgotten[k] = now; delete s.kept[k]; }
|
|
166
|
+
if (action === 'keep') { s.kept[k] = now; delete s.forgotten[k]; }
|
|
167
|
+
if (action === 'restore') { delete s.forgotten[k]; s.kept[k] = now; }
|
|
168
|
+
}
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One line for /phaedo:status, or null when nothing is waiting. */
|
|
173
|
+
export function reviewLine(items, state) {
|
|
174
|
+
const n = unreviewed(items, state).length;
|
|
175
|
+
if (!n) return null;
|
|
176
|
+
return `Phaedo learned ${n} thing${n === 1 ? '' : 's'} about how you work that you haven't looked at yet — /phaedo:learned (or npm run learned) to keep or remove ${n === 1 ? 'it' : 'them'}.`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
async function main(argv) {
|
|
182
|
+
const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? (argv[i + 1] ?? '') : null; };
|
|
183
|
+
const { dirname } = await import('path');
|
|
184
|
+
const { fileURLToPath } = await import('url');
|
|
185
|
+
const { loadFingerprintPreferCache } = await import('./fingerprint-source.js');
|
|
186
|
+
const { defaultStateDir } = await import('./cache.js');
|
|
187
|
+
const MCP_DIR = dirname(fileURLToPath(import.meta.url));
|
|
188
|
+
const stateDir = defaultStateDir();
|
|
189
|
+
let vd;
|
|
190
|
+
try { vd = await loadFingerprintPreferCache(MCP_DIR, { unfiltered: true }); } catch {
|
|
191
|
+
process.stdout.write('Phaedo has no profile on this computer yet, so there is nothing to review.\n');
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const all = learnedItems(vd);
|
|
195
|
+
let state = readLearnedState(stateDir);
|
|
196
|
+
const showing = all.filter((i) => !isForgotten(i, state));
|
|
197
|
+
const gone = all.filter((i) => isForgotten(i, state));
|
|
198
|
+
const say = (s) => process.stdout.write(s + '\n');
|
|
199
|
+
const report = (bad) => { if (bad.length) say(`Not on the list: ${bad.join(', ')}.`); };
|
|
200
|
+
|
|
201
|
+
for (const [flag, action, from] of [['--remove', 'remove', showing], ['--keep', 'keep', showing], ['--restore', 'restore', gone]]) {
|
|
202
|
+
const arg = opt(flag);
|
|
203
|
+
if (arg === null) continue;
|
|
204
|
+
const { picked, bad } = pick(arg, from);
|
|
205
|
+
report(bad);
|
|
206
|
+
if (!picked.length) return bad.length ? 1 : 0;
|
|
207
|
+
state = decide(state, picked, action);
|
|
208
|
+
writeLearnedState(stateDir, state);
|
|
209
|
+
const verb = { remove: 'Removed', keep: 'Kept', restore: 'Brought back' }[action];
|
|
210
|
+
for (const it of picked) say(`${verb}: ${shorten(it.text, 100)}`);
|
|
211
|
+
if (action === 'remove') say('Phaedo stops using these on this computer from the next session. To remove them from your phone\'s profile too, use the Phaedo app.');
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
if (argv.includes('--forgotten')) {
|
|
215
|
+
if (!gone.length) { say('You haven\'t removed anything.'); return 0; }
|
|
216
|
+
say('What you removed:');
|
|
217
|
+
gone.forEach((it, i) => say(`${String(i + 1).padStart(3)}. ${shorten(it.text)}`));
|
|
218
|
+
say('\nBring one back: npm run learned -- --restore 2');
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
say(formatList(showing, state));
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (isMainModule(import.meta.url)) {
|
|
226
|
+
main(process.argv.slice(2)).then((c) => process.exit(c), (e) => { process.stderr.write(`✗ ${e.message}\n`); process.exit(1); });
|
|
227
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -50,6 +50,12 @@
|
|
|
50
50
|
"receipts.js",
|
|
51
51
|
"receipts-sync.js",
|
|
52
52
|
"agreement.js",
|
|
53
|
+
"weekly.js",
|
|
54
|
+
"precedent.js",
|
|
55
|
+
"rules-files.js",
|
|
56
|
+
"learned.js",
|
|
57
|
+
"history-import.js",
|
|
58
|
+
"agent-hooks.js",
|
|
53
59
|
"calibration.js",
|
|
54
60
|
"health.js",
|
|
55
61
|
"phone-source.js",
|
|
@@ -88,14 +94,16 @@
|
|
|
88
94
|
"setup": "node install.mjs",
|
|
89
95
|
"setup:pair": "node install.mjs --pair",
|
|
90
96
|
"setup:code-hook": "node install.mjs --code-hook",
|
|
97
|
+
"setup:agent-hooks": "node install.mjs --agent-hooks",
|
|
91
98
|
"setup:capture-model": "node install.mjs --capture-model",
|
|
92
99
|
"unsetup": "node install.mjs --uninstall",
|
|
93
100
|
"pair": "node pair.js",
|
|
94
101
|
"pull": "node pull.js",
|
|
95
|
-
"test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs && node test-plugin.mjs",
|
|
102
|
+
"test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-weekly.mjs && node test-precedent.mjs && node test-rules-files.mjs && node test-agent-hook.mjs && node test-learned.mjs && node test-history-import.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs && node test-plugin.mjs",
|
|
96
103
|
"receipts": "node receipts.js list",
|
|
97
104
|
"agreement": "node agreement.js",
|
|
98
105
|
"ship-readout": "node ship-readout.js",
|
|
106
|
+
"precedent": "node precedent.js",
|
|
99
107
|
"calibration": "node calibration.js",
|
|
100
108
|
"health": "node health.js",
|
|
101
109
|
"validate": "node ../spec/validate-fingerprint.mjs sample-fingerprint.json",
|
|
@@ -113,7 +121,11 @@
|
|
|
113
121
|
"smoke:calibration": "node test-calibration.mjs",
|
|
114
122
|
"smoke:binding": "node test-action-binding.mjs",
|
|
115
123
|
"smoke:autonomy": "node test-autonomy-promotion.mjs",
|
|
116
|
-
"smoke:ap2": "node test-ap2.mjs"
|
|
124
|
+
"smoke:ap2": "node test-ap2.mjs",
|
|
125
|
+
"week": "node weekly.js",
|
|
126
|
+
"rules": "node rules-files.js",
|
|
127
|
+
"learned": "node learned.js",
|
|
128
|
+
"import": "node history-import.js"
|
|
117
129
|
},
|
|
118
130
|
"dependencies": {
|
|
119
131
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
package/precedent.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — situational precedent, Phase 2 (proposal 0009, design doc §9 / G8 B4).
|
|
3
|
+
//
|
|
4
|
+
// A §10 consultation answers dispositionally ("this subject favors caution"). An agent can act
|
|
5
|
+
// on a precedent: "you have faced this shape 6 times; approved 5, edited 1." This module is the
|
|
6
|
+
// derived view that would say so — over the §10.6 receipt store, never a stored artifact.
|
|
7
|
+
//
|
|
8
|
+
// PURE: (receipts, opts) -> report. No I/O, `now` injected — the agreement.js discipline.
|
|
9
|
+
// NOT WIRED into consult: 0009 is a draft, and its rail 4 says precedent informs and never
|
|
10
|
+
// authorizes. Dev-only readout: `npm run precedent`.
|
|
11
|
+
//
|
|
12
|
+
// The SHAPE is the situation key — banded features only, a closed vocabulary per field, so
|
|
13
|
+
// nothing a receipt holds on-device (summary, exact amount, counterparty, ids, previews) can
|
|
14
|
+
// leave through it (0009 rail 1):
|
|
15
|
+
//
|
|
16
|
+
// domain canonical §10.2 term when there is one, else the normalized free-text domain
|
|
17
|
+
// kind merge | deploy | push to main | publish | message | payment | … | other
|
|
18
|
+
// reversible true | false | null
|
|
19
|
+
// magnitude low | medium | high | null (as the agent described it)
|
|
20
|
+
// amount_band none | low | medium | high (against the subject's own edges, G8 B1)
|
|
21
|
+
// size normal | large | null (the diff, when GitHub was read)
|
|
22
|
+
// touched ordinary | sensitive | null (the paths, when GitHub was read)
|
|
23
|
+
// binding shell | connected_app | agent_tool | none (what would carry the action out)
|
|
24
|
+
//
|
|
25
|
+
// Rails enforced here (0009 "Rails proposed for normative status"):
|
|
26
|
+
// 1. banded features only — shapeOf() reads nothing else into the key;
|
|
27
|
+
// 2. minimum support — below MIN_SUPPORT observations the answer is null, never a guess;
|
|
28
|
+
// 3. subject-authored outcomes only — a burst of marks (BATCH_MIN within BATCH_WINDOW_MS)
|
|
29
|
+
// is bookkeeping, not consent, and is excluded (the §10.7 batch gate, per receipt);
|
|
30
|
+
// 5. pre-flight receipts (via phaedo_check_authorization) are counted apart, never mixed in.
|
|
31
|
+
// Rails 4 and 6 are about use, not computation: nothing here produces a signal.
|
|
32
|
+
|
|
33
|
+
import { actionKind } from './ship-readout.js';
|
|
34
|
+
import { canonicalDomain, normalizeDomain } from './lib/domain.js';
|
|
35
|
+
import { DECISIVE } from './agreement.js';
|
|
36
|
+
import { BATCH_WINDOW_MS } from './autonomy-promotion.js';
|
|
37
|
+
import { isMainModule } from './lib/is-main.js';
|
|
38
|
+
|
|
39
|
+
export const SHAPE_REVISION = '2026-10-27.v0';
|
|
40
|
+
export const SHAPE_FIELDS = Object.freeze(['domain', 'kind', 'reversible', 'magnitude', 'amount_band', 'size', 'touched', 'binding']);
|
|
41
|
+
export const DEFAULT_WINDOW_DAYS = 180;
|
|
42
|
+
export const MIN_SUPPORT = 5;
|
|
43
|
+
export const BATCH_MIN = 3; // this many marks inside BATCH_WINDOW_MS = a burst
|
|
44
|
+
export const PREFLIGHT_VIA = 'phaedo_check_authorization';
|
|
45
|
+
|
|
46
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
47
|
+
const OUTCOMES = new Set(['approved', 'rejected', 'modified']);
|
|
48
|
+
const MAGNITUDES = new Set(['low', 'medium', 'high']);
|
|
49
|
+
|
|
50
|
+
const driver = (r, cue) => (Array.isArray(r?.drivers) ? r.drivers.find((d) => d && d.cue === cue) : null)?.value ?? null;
|
|
51
|
+
|
|
52
|
+
function domainOf(raw) {
|
|
53
|
+
if (typeof raw !== 'string' || !raw.trim()) return '(none)';
|
|
54
|
+
return canonicalDomain(raw) || normalizeDomain(raw) || '(none)';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function amountBandOf(r) {
|
|
58
|
+
const a = r?.request?.action_descriptor?.amount;
|
|
59
|
+
if (typeof a !== 'number' || !Number.isFinite(a) || a <= 0) return 'none';
|
|
60
|
+
// consult-core only writes amountBand when the amount raised the stakes; a positive
|
|
61
|
+
// amount without one sat below the subject's low edge.
|
|
62
|
+
const b = driver(r, 'amountBand');
|
|
63
|
+
return b === 'medium' || b === 'high' ? b : 'low';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bindingOf(r) {
|
|
67
|
+
const t = r?.request?.action_binding?.tool_name;
|
|
68
|
+
if (typeof t !== 'string' || !t) return 'none';
|
|
69
|
+
if (t === 'Bash') return 'shell';
|
|
70
|
+
if (t.startsWith('mcp__')) return 'connected_app';
|
|
71
|
+
return 'agent_tool';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The situation a receipt was about, as banded features. Never throws. */
|
|
75
|
+
export function shapeOf(r) {
|
|
76
|
+
const ad = r?.request?.action_descriptor || {};
|
|
77
|
+
const stakes = driver(r, 'diffStakes'); // normal | large | sensitive | large+sensitive | unknown
|
|
78
|
+
const read = typeof stakes === 'string' && stakes !== 'unknown';
|
|
79
|
+
return {
|
|
80
|
+
domain: domainOf(ad.domain),
|
|
81
|
+
kind: actionKind(ad.summary),
|
|
82
|
+
reversible: typeof ad.reversible === 'boolean' ? ad.reversible : null,
|
|
83
|
+
magnitude: MAGNITUDES.has(ad.magnitude) ? ad.magnitude : null,
|
|
84
|
+
amount_band: amountBandOf(r),
|
|
85
|
+
size: read ? (stakes.includes('large') ? 'large' : 'normal') : null,
|
|
86
|
+
touched: read ? (stakes.includes('sensitive') ? 'sensitive' : 'ordinary') : null,
|
|
87
|
+
binding: bindingOf(r),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A stable string for a shape over `fields` (a coarser key = fewer fields). */
|
|
92
|
+
export function shapeKey(shape, fields = SHAPE_FIELDS) {
|
|
93
|
+
return fields.map((f) => `${f}=${shape?.[f] ?? 'null'}`).join('|');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Only the requested fields of a shape — what may cross the boundary. */
|
|
97
|
+
export function projectShape(shape, fields = SHAPE_FIELDS) {
|
|
98
|
+
return Object.fromEntries(fields.map((f) => [f, shape?.[f] ?? null]));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Receipt ids whose outcome was marked in a burst: BATCH_MIN or more marks inside one
|
|
103
|
+
* windowMs span. Receipts without a mark time are never batch (absence of evidence).
|
|
104
|
+
*/
|
|
105
|
+
export function batchMarkedIds(receipts, { windowMs = BATCH_WINDOW_MS, batchMin = BATCH_MIN } = {}) {
|
|
106
|
+
const marked = (Array.isArray(receipts) ? receipts : [])
|
|
107
|
+
.map((r) => ({ id: r?.receipt_id, t: Date.parse(r?.user_action_at) }))
|
|
108
|
+
.filter((x) => x.id && Number.isFinite(x.t))
|
|
109
|
+
.sort((a, b) => a.t - b.t);
|
|
110
|
+
const out = new Set();
|
|
111
|
+
let i = 0;
|
|
112
|
+
for (let j = 0; j < marked.length; j++) {
|
|
113
|
+
while (marked[j].t - marked[i].t > windowMs) i++;
|
|
114
|
+
if (j - i + 1 >= batchMin) for (let k = i; k <= j; k++) out.add(marked[k].id);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function agrees(signal, action) {
|
|
120
|
+
if (action === 'approved') return signal === 'proceed' || signal === 'proceed_with_note';
|
|
121
|
+
if (action === 'rejected') return signal === 'decline';
|
|
122
|
+
return false; // modified: not accepted as-judged (agreement.js v0, conservative)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The receipts that may count as evidence at all: in the window, subject-marked, not in a burst.
|
|
126
|
+
function evidence(receipts, { now, windowDays, windowMs, batchMin }) {
|
|
127
|
+
const since = now - windowDays * DAY;
|
|
128
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
129
|
+
const batch = batchMarkedIds(list, { windowMs, batchMin });
|
|
130
|
+
const out = { usable: [], excluded_batch: 0 };
|
|
131
|
+
for (const r of list) {
|
|
132
|
+
const t = Date.parse(r?.created_at);
|
|
133
|
+
if (!Number.isFinite(t) || t < since || t > now) continue;
|
|
134
|
+
if (!OUTCOMES.has(r?.user_action)) continue;
|
|
135
|
+
if (batch.has(r.receipt_id)) { out.excluded_batch++; continue; }
|
|
136
|
+
out.usable.push({ r, t });
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function tally(items, now) {
|
|
142
|
+
const user_actions = { approved: 0, rejected: 0, modified: 0 };
|
|
143
|
+
let scored = 0, agreed = 0, lastDivergence = null;
|
|
144
|
+
for (const { r, t } of items) {
|
|
145
|
+
user_actions[r.user_action]++;
|
|
146
|
+
const signal = r?.response?.signal;
|
|
147
|
+
if (!DECISIVE.includes(signal)) continue;
|
|
148
|
+
scored++;
|
|
149
|
+
if (agrees(signal, r.user_action)) agreed++;
|
|
150
|
+
else if (lastDivergence === null || t > lastDivergence) lastDivergence = t;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
observed: items.length,
|
|
154
|
+
user_actions,
|
|
155
|
+
oracle_agreement: scored ? Math.round((agreed / scored) * 100) / 100 : null,
|
|
156
|
+
most_recent_divergence_days_ago: lastDivergence === null ? null : Math.floor((now - lastDivergence) / DAY),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The precedent for one situation, or null below minimum support (rail 2 — never a guess).
|
|
162
|
+
* `shape` may be a receipt's shapeOf() or any object with the SHAPE_FIELDS; `fields` picks
|
|
163
|
+
* how coarse the match is. The result carries banded features and counts only (rail 1).
|
|
164
|
+
*/
|
|
165
|
+
export function precedentFor(receipts, shape, {
|
|
166
|
+
now = Date.now(), windowDays = DEFAULT_WINDOW_DAYS, minSupport = MIN_SUPPORT,
|
|
167
|
+
fields = SHAPE_FIELDS, windowMs = BATCH_WINDOW_MS, batchMin = BATCH_MIN,
|
|
168
|
+
} = {}) {
|
|
169
|
+
const want = shapeKey(shape, fields);
|
|
170
|
+
const { usable } = evidence(receipts, { now, windowDays, windowMs, batchMin });
|
|
171
|
+
const match = usable.filter(({ r }) => shapeKey(shapeOf(r), fields) === want);
|
|
172
|
+
const main = match.filter(({ r }) => r.via !== PREFLIGHT_VIA);
|
|
173
|
+
if (main.length < minSupport) return null;
|
|
174
|
+
const pre = match.filter(({ r }) => r.via === PREFLIGHT_VIA);
|
|
175
|
+
const { observed, user_actions, oracle_agreement, most_recent_divergence_days_ago } = tally(main, now);
|
|
176
|
+
return {
|
|
177
|
+
shape: projectShape(shape, fields),
|
|
178
|
+
shape_revision: SHAPE_REVISION,
|
|
179
|
+
observed,
|
|
180
|
+
user_actions,
|
|
181
|
+
oracle_agreement,
|
|
182
|
+
window_days: windowDays,
|
|
183
|
+
most_recent_divergence_days_ago,
|
|
184
|
+
preflight: { observed: pre.length, user_actions: tally(pre, now).user_actions },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The divergence episodes — the only thing 0009's principle says is worth keeping: receipts
|
|
190
|
+
* where a decisive prediction did not match what the subject did. Banded, newest first.
|
|
191
|
+
*/
|
|
192
|
+
export function divergenceEpisodes(receipts, {
|
|
193
|
+
now = Date.now(), windowDays = DEFAULT_WINDOW_DAYS, fields = SHAPE_FIELDS,
|
|
194
|
+
windowMs = BATCH_WINDOW_MS, batchMin = BATCH_MIN,
|
|
195
|
+
} = {}) {
|
|
196
|
+
const { usable } = evidence(receipts, { now, windowDays, windowMs, batchMin });
|
|
197
|
+
return usable
|
|
198
|
+
.filter(({ r }) => DECISIVE.includes(r?.response?.signal) && !agrees(r.response.signal, r.user_action))
|
|
199
|
+
.sort((a, b) => b.t - a.t)
|
|
200
|
+
.map(({ r, t }) => ({
|
|
201
|
+
shape: projectShape(shapeOf(r), fields),
|
|
202
|
+
predicted: r.response.signal,
|
|
203
|
+
user_action: r.user_action,
|
|
204
|
+
days_ago: Math.floor((now - t) / DAY),
|
|
205
|
+
preflight: r.via === PREFLIGHT_VIA,
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Every shape seen in the window with its support — the readout's table, and the data for
|
|
211
|
+
* choosing N, the window and the coarseness of the key (0009 "Open").
|
|
212
|
+
*/
|
|
213
|
+
export function precedentTable(receipts, {
|
|
214
|
+
now = Date.now(), windowDays = DEFAULT_WINDOW_DAYS, minSupport = MIN_SUPPORT,
|
|
215
|
+
fields = SHAPE_FIELDS, windowMs = BATCH_WINDOW_MS, batchMin = BATCH_MIN,
|
|
216
|
+
} = {}) {
|
|
217
|
+
const { usable, excluded_batch } = evidence(receipts, { now, windowDays, windowMs, batchMin });
|
|
218
|
+
const groups = new Map();
|
|
219
|
+
for (const item of usable) {
|
|
220
|
+
if (item.r.via === PREFLIGHT_VIA) continue;
|
|
221
|
+
const s = shapeOf(item.r);
|
|
222
|
+
const k = shapeKey(s, fields);
|
|
223
|
+
if (!groups.has(k)) groups.set(k, { shape: projectShape(s, fields), items: [] });
|
|
224
|
+
groups.get(k).items.push(item);
|
|
225
|
+
}
|
|
226
|
+
const shapes = [...groups.values()]
|
|
227
|
+
.map(({ shape, items }) => ({ shape, ...tally(items, now), supported: items.length >= minSupport }))
|
|
228
|
+
.sort((a, b) => b.observed - a.observed);
|
|
229
|
+
return {
|
|
230
|
+
window_days: windowDays,
|
|
231
|
+
min_support: minSupport,
|
|
232
|
+
fields: [...fields],
|
|
233
|
+
usable: usable.length,
|
|
234
|
+
excluded_batch,
|
|
235
|
+
shapes,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const show = (shape) => Object.entries(shape).filter(([, v]) => v !== null && v !== 'none' && v !== '(none)').map(([k, v]) => `${k}=${v}`).join(' ');
|
|
240
|
+
|
|
241
|
+
export function formatTable(t) {
|
|
242
|
+
const lines = [
|
|
243
|
+
`Precedent — last ${t.window_days} days, ${t.usable} marked decision(s) usable` +
|
|
244
|
+
(t.excluded_batch ? `, ${t.excluded_batch} left out as marked in a burst` : '') + '.',
|
|
245
|
+
`Key: ${t.fields.join(' × ')}. A shape needs ${t.min_support} to answer.`,
|
|
246
|
+
];
|
|
247
|
+
if (!t.shapes.length) lines.push(' No marked decisions yet — nothing to show.');
|
|
248
|
+
for (const s of t.shapes) {
|
|
249
|
+
const a = s.user_actions;
|
|
250
|
+
const agree = s.oracle_agreement === null ? 'no decisive calls' : `matched ${Math.round(s.oracle_agreement * 100)}%`;
|
|
251
|
+
const div = s.most_recent_divergence_days_ago === null ? '' : `, last miss ${s.most_recent_divergence_days_ago}d ago`;
|
|
252
|
+
lines.push(` ${s.supported ? '✓' : '·'} ${s.observed}× ${show(s.shape)} — yes ${a.approved}, no ${a.rejected}, edited ${a.modified}; ${agree}${div}`);
|
|
253
|
+
}
|
|
254
|
+
return lines.join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (isMainModule(import.meta.url)) {
|
|
258
|
+
const args = process.argv.slice(2);
|
|
259
|
+
const num = (flag, d) => { const i = args.indexOf(flag); const n = i >= 0 ? Number(args[i + 1]) : NaN; return Number.isFinite(n) && n > 0 ? n : d; };
|
|
260
|
+
const fi = args.indexOf('--fields');
|
|
261
|
+
const fields = fi >= 0 ? String(args[fi + 1] || '').split(',').filter((f) => SHAPE_FIELDS.includes(f)) : SHAPE_FIELDS;
|
|
262
|
+
const { readReceipts } = await import('./receipts.js');
|
|
263
|
+
const { defaultStateDir } = await import('./cache.js');
|
|
264
|
+
const receipts = await readReceipts(defaultStateDir()).catch(() => []);
|
|
265
|
+
const t = precedentTable(receipts, { windowDays: num('--days', DEFAULT_WINDOW_DAYS), minSupport: num('--min', MIN_SUPPORT), fields: fields.length ? fields : SHAPE_FIELDS });
|
|
266
|
+
process.stdout.write(formatTable(t) + '\n');
|
|
267
|
+
}
|
package/pull.js
CHANGED
|
@@ -8,9 +8,18 @@
|
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
9
|
import { dirname } from 'path';
|
|
10
10
|
import { loadFingerprint, sourceOf, summarizeStarter } from './fingerprint-source.js';
|
|
11
|
+
import { readReceipts } from './receipts.js';
|
|
12
|
+
import { defaultStateDir } from './cache.js';
|
|
13
|
+
import { weeklyHandled, formatWeekly } from './weekly.js';
|
|
14
|
+
import { learnedItems, readLearnedState, reviewLine } from './learned.js';
|
|
11
15
|
|
|
12
16
|
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
13
17
|
|
|
18
|
+
// Every outcome ends with what Phaedo did for the subject this week — the reason to have it.
|
|
19
|
+
const week = async () => ` ${formatWeekly(weeklyHandled(await readReceipts(defaultStateDir()).catch(() => [])))}\n`;
|
|
20
|
+
// …and, when there is any, what Phaedo learned that the subject hasn't looked at yet.
|
|
21
|
+
const review = (vd) => { const line = reviewLine(learnedItems(vd), readLearnedState(defaultStateDir())); return line ? ` ${line}\n` : ''; };
|
|
22
|
+
|
|
14
23
|
loadFingerprint(BASE_DIR)
|
|
15
24
|
.then(async (vd) => {
|
|
16
25
|
const fp = (vd && vd.phaedo_fingerprint) || {};
|
|
@@ -28,12 +37,14 @@ loadFingerprint(BASE_DIR)
|
|
|
28
37
|
` ${learned} thing(s) learned across ${areas} area(s)${sessions ? ` from ${sessions} session(s)` : ''} — ${stage}\n` +
|
|
29
38
|
` Pair the Phaedo phone app (npm run pair) to replace it with your full profile.\n`
|
|
30
39
|
);
|
|
40
|
+
process.stdout.write(await week() + review(vd));
|
|
31
41
|
process.exit(0);
|
|
32
42
|
}
|
|
33
43
|
if (src.live) {
|
|
34
44
|
process.stdout.write(
|
|
35
45
|
`✓ Read your profile from the phone — fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s).\n`
|
|
36
46
|
);
|
|
47
|
+
process.stdout.write(await week() + review(vd));
|
|
37
48
|
process.exit(0);
|
|
38
49
|
}
|
|
39
50
|
const hours = src.ageMs != null ? Math.round(src.ageMs / 3600000) : null;
|
|
@@ -43,9 +54,11 @@ loadFingerprint(BASE_DIR)
|
|
|
43
54
|
` Anything you changed since then — including a revoked authorization — is NOT reflected.\n` +
|
|
44
55
|
` Open Phaedo on your phone and approve the card, then run this again.\n`
|
|
45
56
|
);
|
|
57
|
+
process.stdout.write(await week() + review(vd));
|
|
46
58
|
process.exit(2); // distinct from 1 (hard failure) — stale, not broken
|
|
47
59
|
})
|
|
48
|
-
.catch((err) => {
|
|
60
|
+
.catch(async (err) => {
|
|
49
61
|
process.stderr.write(`✗ Couldn't read your profile: ${err.message}\n`);
|
|
62
|
+
process.stdout.write(await week());
|
|
50
63
|
process.exit(1);
|
|
51
64
|
});
|
package/receipts-sync.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
30
30
|
import { readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
31
31
|
import { agreementDigest } from './agreement.js';
|
|
32
|
+
import { weeklyHandled, formatWeekly } from './weekly.js';
|
|
32
33
|
|
|
33
34
|
const { Kdf, Envelope } = Phaedo;
|
|
34
35
|
const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
|
|
@@ -95,7 +96,11 @@ export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST
|
|
|
95
96
|
pairKey.fill(0);
|
|
96
97
|
}
|
|
97
98
|
try {
|
|
98
|
-
|
|
99
|
+
// The visible win: what Phaedo handled without interrupting the subject this week. Counts
|
|
100
|
+
// plus the sentence the MCP already shows in /phaedo:status, so both surfaces say the same.
|
|
101
|
+
const w = weeklyHandled(list, { now: Number.isFinite(agreementAt) ? agreementAt : Date.now() });
|
|
102
|
+
const week = { ...w, summary: formatWeekly(w) };
|
|
103
|
+
const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, week, generated_at: generatedAt } };
|
|
99
104
|
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
100
105
|
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'receipts_digest', created_at: now() });
|
|
101
106
|
plaintext.fill(0);
|