phaedo-mcp 0.3.7 → 0.3.13
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 +96 -11
- 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 +43 -18
- package/hooks/session-start.mjs +8 -2
- package/install.mjs +23 -1
- 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/weekly.js +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
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);
|
package/rules-files.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — write what Phaedo has learned into other agents' own instruction files.
|
|
3
|
+
//
|
|
4
|
+
// Claude Code gets the profile from the SessionStart hook. Codex, Copilot CLI, Gemini CLI and
|
|
5
|
+
// Antigravity have no such hook, but each reads a user-level instructions file at the start of
|
|
6
|
+
// every session. This writes a short version of the profile there, inside a marked block that
|
|
7
|
+
// Phaedo owns and replaces; everything else in the file is left exactly as it was.
|
|
8
|
+
//
|
|
9
|
+
// npm run rules # every agent found on this computer
|
|
10
|
+
// npm run rules -- --for codex,gemini
|
|
11
|
+
// npm run rules -- --project ~/code/app # Cursor: .cursor/rules/phaedo.mdc, kept out of git
|
|
12
|
+
// npm run rules -- --remove # take the block out again
|
|
13
|
+
// npm run rules -- --dry-run # show what would be written
|
|
14
|
+
//
|
|
15
|
+
// User-level files only, by default: a profile in a repository's AGENTS.md would be committed
|
|
16
|
+
// and shared. Cursor has no user-level rules file, so it takes an explicit project, and the
|
|
17
|
+
// rule file is added to that repository's .git/info/exclude (local, never committed).
|
|
18
|
+
//
|
|
19
|
+
// Opt-in only. A file that already carries the block is how the subject said yes, so the
|
|
20
|
+
// SessionStart hook refreshes those files (refreshExisting) and never adds new ones.
|
|
21
|
+
|
|
22
|
+
import { homedir } from 'os';
|
|
23
|
+
import { join, dirname, resolve } from 'path';
|
|
24
|
+
import { promises as fs } from 'fs';
|
|
25
|
+
import { execFileSync } from 'child_process';
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
import { isMainModule } from './lib/is-main.js';
|
|
28
|
+
|
|
29
|
+
export const BEGIN = '<!-- phaedo:begin — written by Phaedo and refreshed as it learns. Delete this whole block to stop. -->';
|
|
30
|
+
export const END = '<!-- phaedo:end -->';
|
|
31
|
+
export const MAX_CHARS = 6000; // under Antigravity's ~12k per file and Codex's 32 KiB combined
|
|
32
|
+
export const CURSOR_RULE = join('.cursor', 'rules', 'phaedo.mdc');
|
|
33
|
+
|
|
34
|
+
// The sections an agent can act on, in the order they are worth keeping when space runs out.
|
|
35
|
+
const KEEP = ['## Operating rules (user-confirmed)', '## How to respond to this user', '## How this user thinks'];
|
|
36
|
+
|
|
37
|
+
/** The user-level instruction files Phaedo knows, for this home and environment. */
|
|
38
|
+
export function targets({ home = homedir(), env = process.env } = {}) {
|
|
39
|
+
const codexHome = env.CODEX_HOME ? resolve(env.CODEX_HOME) : join(home, '.codex');
|
|
40
|
+
return {
|
|
41
|
+
codex: { label: 'Codex', file: join(codexHome, 'AGENTS.md'), dir: codexHome },
|
|
42
|
+
copilot: { label: 'Copilot CLI', file: join(home, '.copilot', 'copilot-instructions.md'), dir: join(home, '.copilot') },
|
|
43
|
+
gemini: { label: 'Gemini CLI and Antigravity', file: join(home, '.gemini', 'GEMINI.md'), dir: join(home, '.gemini') },
|
|
44
|
+
// Claude Code already gets the full profile from the SessionStart hook; only on request.
|
|
45
|
+
claude: { label: 'Claude Code', file: join(home, '.claude', 'CLAUDE.md'), dir: join(home, '.claude'), onRequest: true },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sections(projection) {
|
|
50
|
+
const body = String(projection || '').replace(/^\s*<phaedo_user_profile>\s*/, '').replace(/\s*<\/phaedo_user_profile>\s*$/, '');
|
|
51
|
+
const parts = body.split(/\n(?=## )/);
|
|
52
|
+
const preamble = parts[0].startsWith('## ') ? '' : parts.shift().trim();
|
|
53
|
+
const byHead = new Map(parts.map((p) => [p.split('\n', 1)[0].trim(), p.trim()]));
|
|
54
|
+
return { preamble, byHead };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The short profile for an instructions file, from the same projection Claude Code gets.
|
|
59
|
+
* PURE. Null when the projection has nothing an agent can act on.
|
|
60
|
+
*/
|
|
61
|
+
export function rulesText(projection, { now = Date.now(), maxChars = MAX_CHARS } = {}) {
|
|
62
|
+
const { preamble, byHead } = sections(projection);
|
|
63
|
+
const kept = KEEP.map((h) => byHead.get(h)).filter(Boolean);
|
|
64
|
+
if (!kept.length) return null;
|
|
65
|
+
const date = new Date(now).toISOString().slice(0, 10);
|
|
66
|
+
const head = [
|
|
67
|
+
'# How I like to work (from Phaedo)',
|
|
68
|
+
'',
|
|
69
|
+
`Learned by Phaedo and refreshed ${date}. ${preamble.replace(/\s+/g, ' ').trim()}`.trim(),
|
|
70
|
+
].join('\n');
|
|
71
|
+
// Shed whole sections from the end first; cut inside one only if the first alone is too long.
|
|
72
|
+
while (kept.length > 1 && [head, ...kept].join('\n\n').length > maxChars) kept.pop();
|
|
73
|
+
let text = [head, ...kept].join('\n\n');
|
|
74
|
+
if (text.length > maxChars) text = text.slice(0, text.lastIndexOf('\n', maxChars)).trimEnd();
|
|
75
|
+
return text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The managed block, markers included. */
|
|
79
|
+
export function block(text) {
|
|
80
|
+
return `${BEGIN}\n${text}\n${END}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findBlock(content) {
|
|
84
|
+
const b = content.indexOf('<!-- phaedo:begin');
|
|
85
|
+
const e = content.indexOf(END);
|
|
86
|
+
if (b < 0 && e < 0) return { state: 'absent' };
|
|
87
|
+
if (b < 0 || e < 0 || e < b || content.indexOf('<!-- phaedo:begin', b + 1) >= 0) return { state: 'broken' };
|
|
88
|
+
return { state: 'present', start: b, end: e + END.length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The file's new content with the block put in (or replaced), everything else untouched.
|
|
93
|
+
* PURE. { content, changed } — or { error } when the markers are damaged, so a hand-edited
|
|
94
|
+
* file is never guessed at.
|
|
95
|
+
*/
|
|
96
|
+
export function upsertBlock(existing, text) {
|
|
97
|
+
const src = existing ?? '';
|
|
98
|
+
const f = findBlock(src);
|
|
99
|
+
if (f.state === 'broken') return { error: 'the Phaedo markers in this file are damaged — fix or delete them by hand' };
|
|
100
|
+
let content;
|
|
101
|
+
if (f.state === 'present') content = src.slice(0, f.start) + block(text) + src.slice(f.end);
|
|
102
|
+
else content = src.trim() ? `${src.replace(/\s*$/, '')}\n\n${block(text)}\n` : `${block(text)}\n`;
|
|
103
|
+
return { content, changed: content !== src };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The file's content with the block taken out. PURE. */
|
|
107
|
+
export function removeBlock(existing) {
|
|
108
|
+
const src = existing ?? '';
|
|
109
|
+
const f = findBlock(src);
|
|
110
|
+
if (f.state === 'broken') return { error: 'the Phaedo markers in this file are damaged — fix or delete them by hand' };
|
|
111
|
+
if (f.state === 'absent') return { content: src, changed: false };
|
|
112
|
+
const before = src.slice(0, f.start).replace(/\s*$/, '');
|
|
113
|
+
const after = src.slice(f.end).replace(/^\s*/, '');
|
|
114
|
+
const content = before && after ? `${before}\n\n${after}` : before ? `${before}\n` : after;
|
|
115
|
+
return { content, changed: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const hasBlock = (content) => findBlock(content ?? '').state === 'present';
|
|
119
|
+
|
|
120
|
+
/** Cursor's project rule: a whole file Phaedo owns, always applied. PURE. */
|
|
121
|
+
export function cursorRule(text) {
|
|
122
|
+
return [
|
|
123
|
+
'---',
|
|
124
|
+
'description: How I like to work, learned by Phaedo',
|
|
125
|
+
'alwaysApply: true',
|
|
126
|
+
'---',
|
|
127
|
+
'',
|
|
128
|
+
text,
|
|
129
|
+
'',
|
|
130
|
+
].join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── I/O ───────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
const read = (file) => fs.readFile(file, 'utf8').catch((e) => (e.code === 'ENOENT' ? null : Promise.reject(e)));
|
|
136
|
+
const exists = (p) => fs.stat(p).then(() => true, () => false);
|
|
137
|
+
|
|
138
|
+
async function writeAtomic(file, content) {
|
|
139
|
+
await fs.mkdir(dirname(file), { recursive: true });
|
|
140
|
+
const tmp = `${file}.phaedo-${process.pid}.tmp`;
|
|
141
|
+
await fs.writeFile(tmp, content, 'utf8');
|
|
142
|
+
await fs.rename(tmp, file);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Put (or refresh) the block in one file. → { status: 'written'|'unchanged'|'error', error? } */
|
|
146
|
+
export async function applyToFile(file, text, { dryRun = false } = {}) {
|
|
147
|
+
const r = upsertBlock(await read(file), text);
|
|
148
|
+
if (r.error) return { status: 'error', error: r.error };
|
|
149
|
+
if (!r.changed) return { status: 'unchanged' };
|
|
150
|
+
if (!dryRun) await writeAtomic(file, r.content);
|
|
151
|
+
return { status: 'written' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function removeFromFile(file, { dryRun = false } = {}) {
|
|
155
|
+
const src = await read(file);
|
|
156
|
+
if (src === null) return { status: 'unchanged' };
|
|
157
|
+
const r = removeBlock(src);
|
|
158
|
+
if (r.error) return { status: 'error', error: r.error };
|
|
159
|
+
if (!r.changed) return { status: 'unchanged' };
|
|
160
|
+
if (!dryRun) await writeAtomic(file, r.content);
|
|
161
|
+
return { status: 'removed' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Which agents to write for: those named, or every agent whose folder exists here. */
|
|
165
|
+
export async function chooseTargets(all, names = null) {
|
|
166
|
+
if (names) return names.filter((n) => all[n]).map((n) => [n, all[n]]);
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const [n, t] of Object.entries(all)) if (!t.onRequest && await exists(t.dir)) out.push([n, t]);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Keep a project's Cursor rule out of git without touching its committed .gitignore.
|
|
173
|
+
function excludeFromGit(projectDir) {
|
|
174
|
+
let excl;
|
|
175
|
+
try {
|
|
176
|
+
excl = execFileSync('git', ['-C', projectDir, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
177
|
+
} catch { return false; }
|
|
178
|
+
return resolve(projectDir, excl);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function applyToCursorProject(projectDir, text, { dryRun = false } = {}) {
|
|
182
|
+
const file = join(projectDir, CURSOR_RULE);
|
|
183
|
+
const content = cursorRule(text);
|
|
184
|
+
const changed = (await read(file)) !== content;
|
|
185
|
+
const excl = excludeFromGit(projectDir);
|
|
186
|
+
const line = '/' + CURSOR_RULE.split('\\').join('/');
|
|
187
|
+
if (!dryRun) {
|
|
188
|
+
if (changed) await writeAtomic(file, content);
|
|
189
|
+
if (excl) {
|
|
190
|
+
const cur = (await read(excl)) || '';
|
|
191
|
+
if (!cur.split('\n').includes(line)) await writeAtomic(excl, `${cur.replace(/\s*$/, '')}${cur.trim() ? '\n' : ''}${line}\n`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { status: changed ? 'written' : 'unchanged', file, keptOutOfGit: Boolean(excl) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Refresh only the files that already carry the block — the subject opted those in. Used by
|
|
199
|
+
* the SessionStart hook; never adds the block anywhere new, never throws.
|
|
200
|
+
*/
|
|
201
|
+
export async function refreshExisting(projection, { home, env, now } = {}) {
|
|
202
|
+
const text = rulesText(projection, { now });
|
|
203
|
+
if (!text) return [];
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const [name, t] of Object.entries(targets({ home, env }))) {
|
|
206
|
+
try {
|
|
207
|
+
const src = await read(t.file);
|
|
208
|
+
if (!hasBlock(src)) continue;
|
|
209
|
+
out.push({ name, ...(await applyToFile(t.file, text)) });
|
|
210
|
+
} catch { /* one unreadable file never stops the rest */ }
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
const tilde = (p) => (p.startsWith(homedir()) ? '~' + p.slice(homedir().length) : p);
|
|
218
|
+
|
|
219
|
+
async function main(argv) {
|
|
220
|
+
const flag = (f) => argv.includes(f);
|
|
221
|
+
const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : null; };
|
|
222
|
+
const dryRun = flag('--dry-run');
|
|
223
|
+
const names = opt('--for') ? opt('--for').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean) : null;
|
|
224
|
+
const project = opt('--project');
|
|
225
|
+
const all = targets();
|
|
226
|
+
if (names) {
|
|
227
|
+
const unknown = names.filter((n) => !all[n] && n !== 'cursor');
|
|
228
|
+
if (unknown.length) { process.stderr.write(`Unknown agent: ${unknown.join(', ')}. Known: ${Object.keys(all).join(', ')}, cursor (with --project).\n`); return 1; }
|
|
229
|
+
}
|
|
230
|
+
const chosen = await chooseTargets(all, names ? names.filter((n) => n !== 'cursor') : null);
|
|
231
|
+
const say = (s) => process.stdout.write(s + '\n');
|
|
232
|
+
|
|
233
|
+
if (flag('--remove')) {
|
|
234
|
+
for (const [, t] of names ? chosen : Object.entries(all)) {
|
|
235
|
+
const r = await removeFromFile(t.file, { dryRun });
|
|
236
|
+
if (r.status === 'removed') say(`✓ ${t.label}: took Phaedo's section out of ${tilde(t.file)}`);
|
|
237
|
+
else if (r.status === 'error') say(`✗ ${t.label}: ${r.error} (${tilde(t.file)})`);
|
|
238
|
+
}
|
|
239
|
+
if (project) {
|
|
240
|
+
const file = join(resolve(project), CURSOR_RULE);
|
|
241
|
+
if (await exists(file)) { if (!dryRun) await fs.rm(file); say(`✓ Cursor: removed ${tilde(file)}`); }
|
|
242
|
+
}
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const MCP_DIR = dirname(fileURLToPath(import.meta.url));
|
|
247
|
+
const { loadFingerprintPreferCache } = await import('./fingerprint-source.js');
|
|
248
|
+
const { buildInjectionResponse } = await import('./projection.js');
|
|
249
|
+
let text = null;
|
|
250
|
+
try {
|
|
251
|
+
const vd = await loadFingerprintPreferCache(MCP_DIR);
|
|
252
|
+
text = rulesText(buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' }).projection);
|
|
253
|
+
} catch { /* falls through to the message below */ }
|
|
254
|
+
if (!text) { say('Phaedo has no profile with rules or preferences yet, so nothing was written.'); return 0; }
|
|
255
|
+
|
|
256
|
+
if (dryRun) say(`Would write this (${text.length} characters):\n\n${text}\n`);
|
|
257
|
+
if (!chosen.length && !project) {
|
|
258
|
+
say('No other AI agents found on this computer (looked for Codex, Copilot CLI, Gemini CLI and Antigravity).');
|
|
259
|
+
say('Name one with --for codex,copilot,gemini,claude, or a Cursor project with --project <folder>.');
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
let failed = false;
|
|
263
|
+
for (const [, t] of chosen) {
|
|
264
|
+
const r = await applyToFile(t.file, text, { dryRun });
|
|
265
|
+
if (r.status === 'error') { failed = true; say(`✗ ${t.label}: ${r.error} (${tilde(t.file)})`); }
|
|
266
|
+
else say(`✓ ${t.label}: ${r.status === 'written' ? (dryRun ? 'would update' : 'updated') : 'already up to date'} — ${tilde(t.file)}`);
|
|
267
|
+
}
|
|
268
|
+
if (project) {
|
|
269
|
+
const r = await applyToCursorProject(resolve(project), text, { dryRun });
|
|
270
|
+
say(`✓ Cursor: ${r.status === 'written' ? (dryRun ? 'would update' : 'updated') : 'already up to date'} — ${tilde(r.file)}${r.keptOutOfGit ? ' (kept out of git)' : ''}`);
|
|
271
|
+
}
|
|
272
|
+
if (!dryRun) say('Phaedo refreshes these whenever a Claude Code session starts with Phaedo on. To take them out: npm run rules -- --remove');
|
|
273
|
+
return failed ? 1 : 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (isMainModule(import.meta.url)) {
|
|
277
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { process.stderr.write(`✗ ${err.message}\n`); process.exit(1); });
|
|
278
|
+
}
|