phaedo-mcp 0.2.2 → 0.3.1
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/NOTICE +17 -0
- package/README.md +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +184 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
package/fingerprint-source.js
CHANGED
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
// Phase 1b (phone): if a pairing record is configured (PHAEDO_PAIRING or
|
|
6
6
|
// mcp/pairing.json), authorize → pull /v1/profile → unwrap the §7.2 envelope
|
|
7
7
|
// from the paired phone (phone-source.js). Spec-faithful; no plaintext at rest.
|
|
8
|
-
// Phase 1a (local): otherwise read a local vault-payload JSON
|
|
9
|
-
//
|
|
8
|
+
// Phase 1a (local): otherwise read a local vault-payload JSON. With neither, there is
|
|
9
|
+
// no profile — tools say so (NO_PROFILE_MESSAGE); the shipped sample is opt-in only.
|
|
10
10
|
|
|
11
11
|
import { readFile } from 'fs/promises';
|
|
12
|
-
import { resolve } from 'path';
|
|
12
|
+
import { resolve, dirname } from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
13
14
|
import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
|
|
14
15
|
import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
|
|
16
|
+
import { foldLocalCapture, isLocalCaptureOwner } from './capture-local.js';
|
|
17
|
+
import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
|
|
18
|
+
import { PhaedoError } from './errors.js';
|
|
15
19
|
|
|
16
20
|
// §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
|
|
17
21
|
// A stored fingerprint from a producer that predates identity stamping (or whose
|
|
@@ -38,20 +42,101 @@ function normalizeIdentity(vd) {
|
|
|
38
42
|
return { ...vd, phaedo_fingerprint: { ...fp, ...patch } };
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
|
|
46
|
+
// §5 CANONICAL ON READ — the step-3 read path, applied ONCE at the boundary.
|
|
47
|
+
//
|
|
48
|
+
// The agent's authored-content readers (ap2.js buildIntentMandatePayload, autonomy-promotion.js,
|
|
49
|
+
// consult-core.js delegation authoring) all take a FINGERPRINT, not a vault. Threading the §5
|
|
50
|
+
// configuration through every one of them would mean changing four signatures and every caller,
|
|
51
|
+
// and would leave four places that must each remember to check §5.4.
|
|
52
|
+
//
|
|
53
|
+
// Folding here instead makes every downstream reader correct by construction, and means step 3
|
|
54
|
+
// (deleting fp.standing_rules) changes nothing about what any of them see. One fold, one §5.4
|
|
55
|
+
// check, one place to be wrong.
|
|
56
|
+
//
|
|
57
|
+
// §5.4 matters more on this path than anywhere else: these entries become AUTHORIZATIONS — what
|
|
58
|
+
// the agent may do without asking — so a configuration belonging to another identity, or to a
|
|
59
|
+
// fingerprint since replaced by a restore or a cross-device merge, must not govern. Falls back
|
|
60
|
+
// to the A-home rather than throwing; an agent that cannot resolve policy must still run the
|
|
61
|
+
// safe path.
|
|
62
|
+
export function foldConfigHome(vd) {
|
|
63
|
+
if (!vd || typeof vd !== 'object') return vd;
|
|
64
|
+
const fp = vd.phaedo_fingerprint;
|
|
65
|
+
const cfg = vd.phaedo_user_configuration;
|
|
66
|
+
if (!fp || typeof fp !== 'object' || !cfg || typeof cfg !== 'object') return vd;
|
|
67
|
+
if (!Capabilities || !AuthoredSource) return vd; // vendored module missing → leave as-is
|
|
68
|
+
if (!AuthoredSource.configGoverns(cfg, fp)) return vd; // §5.4 — not this subject's
|
|
69
|
+
if (!Array.isArray(cfg.standing_instructions)) return vd; // not a statement about rules
|
|
70
|
+
const next = { ...fp };
|
|
71
|
+
// An EMPTY standing_instructions is a statement ("no rules"), not an absence — leaving the
|
|
72
|
+
// A-home in place there would resurrect rules the subject deleted (#247's lesson).
|
|
73
|
+
if (cfg.standing_instructions.length) next.standing_rules = cfg.standing_instructions;
|
|
74
|
+
else delete next.standing_rules;
|
|
75
|
+
return { ...vd, phaedo_fingerprint: next };
|
|
76
|
+
}
|
|
77
|
+
|
|
41
78
|
// Resolution order for the local fixture (1a):
|
|
42
|
-
// 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path)
|
|
79
|
+
// 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path, or `sample`)
|
|
43
80
|
// 2. ./fingerprint.json next to the server
|
|
44
|
-
//
|
|
81
|
+
// — nothing else. There is deliberately NO silent fallback to the shipped sample.
|
|
82
|
+
//
|
|
83
|
+
// NO PROFILE IS AN ANSWER, NOT A DEMO. Until 2026-10-20 an unpaired install with no file fell
|
|
84
|
+
// back to sample-fingerprint.json — so a stranger who ran `npx phaedo-mcp` had a made-up
|
|
85
|
+
// person's style injected into their assistant, with nothing anywhere saying so (the response
|
|
86
|
+
// carried `fingerprint_id: sample-0000…`, which no human reads). The sample is still one word
|
|
87
|
+
// away for anyone who wants to try it — PHAEDO_FINGERPRINT=sample — but it has to be asked for.
|
|
88
|
+
const SAMPLE_KEYWORD = 'sample';
|
|
89
|
+
// The sample ships beside this module, whatever base dir the caller serves from.
|
|
90
|
+
const SAMPLE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'sample-fingerprint.json');
|
|
45
91
|
function resolveSourcePath(baseDir) {
|
|
46
|
-
|
|
92
|
+
const env = process.env.PHAEDO_FINGERPRINT;
|
|
93
|
+
if (env === SAMPLE_KEYWORD) return SAMPLE_PATH;
|
|
94
|
+
if (env) return resolve(process.cwd(), env);
|
|
47
95
|
return resolve(baseDir, 'fingerprint.json');
|
|
48
96
|
}
|
|
49
97
|
|
|
98
|
+
// Read by the assistant (a tool error) and, through it, by the person — so plain words, and
|
|
99
|
+
// it says what did NOT happen as well as what to do.
|
|
100
|
+
export const NO_PROFILE_MESSAGE =
|
|
101
|
+
'No Phaedo profile is set up on this computer, so there is nothing to load and Phaedo has ' +
|
|
102
|
+
'not changed how the assistant responds. A profile comes from the Phaedo phone app, which is ' +
|
|
103
|
+
'not open to the public yet (https://phaedo.so to hear when it is). To try Phaedo with a made-up example profile ' +
|
|
104
|
+
'instead, set PHAEDO_FINGERPRINT=sample in this server\'s settings.';
|
|
105
|
+
|
|
50
106
|
// Returns the vault payload:
|
|
51
107
|
// { phaedo_fingerprint, phaedo_behavioral_signals, phaedo_linguistic_profile,
|
|
52
108
|
// phaedo_session_metrics }
|
|
53
109
|
// Throws on unreadable/invalid source — the tool layer maps that to
|
|
54
110
|
// decryption_failed (the §9.5 "couldn't obtain plaintext fingerprint" case).
|
|
111
|
+
// PROVENANCE. A cache fallback used to be indistinguishable from a live phone read — same
|
|
112
|
+
// object, same shape, no marker anywhere — so an agent answered "yes, you're pre-approved"
|
|
113
|
+
// from a 22-hour-old snapshot with total confidence, and `pull.js` printed "Read your
|
|
114
|
+
// profile from the phone" either way. There was no signal to notice. Callers that make
|
|
115
|
+
// AUTHORITY decisions must be able to tell the difference.
|
|
116
|
+
//
|
|
117
|
+
// Carried on a SYMBOL deliberately: it never serializes, never reaches a schema, never rides
|
|
118
|
+
// a sync, and cannot be mistaken for spec'd fingerprint content. It is a fact about THIS
|
|
119
|
+
// read, not about the artifact.
|
|
120
|
+
export const SOURCE = Symbol('phaedo.fingerprint.source');
|
|
121
|
+
|
|
122
|
+
function stamp(vd, cached) {
|
|
123
|
+
if (!vd || typeof vd !== 'object') return vd;
|
|
124
|
+
const cachedAt = Number(cached?.cachedAt) || null; // readCache returns { vault, cachedAt }
|
|
125
|
+
try {
|
|
126
|
+
Object.defineProperty(vd, SOURCE, {
|
|
127
|
+
value: { live: false, cachedAt, ageMs: cachedAt ? Date.now() - cachedAt : null },
|
|
128
|
+
enumerable: false, configurable: true, writable: true,
|
|
129
|
+
});
|
|
130
|
+
} catch { /* frozen object — provenance is best-effort, never fatal */ }
|
|
131
|
+
return vd;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Provenance of a loaded fingerprint. Absent marker → a live read. */
|
|
135
|
+
export function sourceOf(vd) {
|
|
136
|
+
const s = vd && vd[SOURCE];
|
|
137
|
+
return s || { live: true, cachedAt: null, ageMs: 0 };
|
|
138
|
+
}
|
|
139
|
+
|
|
55
140
|
export async function loadFingerprint(baseDir) {
|
|
56
141
|
// Explicit local source wins over phone pairing: if PHAEDO_FINGERPRINT names a
|
|
57
142
|
// file, honor it and skip the phone entirely. Pointing at a concrete fingerprint
|
|
@@ -69,7 +154,7 @@ export async function loadFingerprint(baseDir) {
|
|
|
69
154
|
try {
|
|
70
155
|
const vd = await loadFingerprintFromPhone(rec);
|
|
71
156
|
await writeCache(stateDir, vd);
|
|
72
|
-
return normalizeIdentity(vd);
|
|
157
|
+
return foldConfigHome(normalizeIdentity(vd));
|
|
73
158
|
} catch (err) {
|
|
74
159
|
// A 4xx means the phone REACHED us and rejected this client (revoked /
|
|
75
160
|
// invalid credentials / failed handshake). Revocation must be effective:
|
|
@@ -81,7 +166,7 @@ export async function loadFingerprint(baseDir) {
|
|
|
81
166
|
throw err;
|
|
82
167
|
}
|
|
83
168
|
const cached = await readCache(stateDir);
|
|
84
|
-
if (cached) return normalizeIdentity(cached.vault); // phone unreachable → encrypted cache (may be stale)
|
|
169
|
+
if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached); // phone unreachable → encrypted cache (may be stale)
|
|
85
170
|
throw err; // no phone + no cache → cannot serve
|
|
86
171
|
}
|
|
87
172
|
}
|
|
@@ -90,15 +175,29 @@ export async function loadFingerprint(baseDir) {
|
|
|
90
175
|
let raw;
|
|
91
176
|
try {
|
|
92
177
|
raw = await readFile(path, 'utf8');
|
|
93
|
-
} catch {
|
|
94
|
-
//
|
|
95
|
-
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// Only the default location may be absent quietly. A path the user NAMED that doesn't
|
|
180
|
+
// read is their mistake to see, not "no profile".
|
|
181
|
+
if (!process.env.PHAEDO_FINGERPRINT && err?.code === 'ENOENT') {
|
|
182
|
+
throw new PhaedoError('decryption_failed', NO_PROFILE_MESSAGE);
|
|
183
|
+
}
|
|
184
|
+
throw new PhaedoError('decryption_failed', `Could not read the Phaedo profile at ${path} (${err?.code || err?.message || err}).`);
|
|
96
185
|
}
|
|
97
186
|
const vd = JSON.parse(raw);
|
|
98
187
|
if (!vd || typeof vd !== 'object' || !vd.phaedo_fingerprint) {
|
|
99
188
|
throw new Error(`Fingerprint source at ${path} is missing phaedo_fingerprint.`);
|
|
100
189
|
}
|
|
101
|
-
|
|
190
|
+
// mcp-local capture tier (desktop-capture-deposit.md §4 tier 3): unpaired,
|
|
191
|
+
// phone-less serving is the one configuration where NOBODY else drains what the
|
|
192
|
+
// capture hook accumulated on this machine — fold its local state into the
|
|
193
|
+
// served fingerprint at read time (capture-local.js; read-time overlay, no
|
|
194
|
+
// persistence, idempotent). Owner-gated: a paired setup never reaches this
|
|
195
|
+
// branch, and an explicit PHAEDO_FINGERPRINT ("use THIS file") stays verbatim —
|
|
196
|
+
// it is the hermetic-test escape hatch.
|
|
197
|
+
if (!process.env.PHAEDO_FINGERPRINT && await isLocalCaptureOwner(hasPairingRecord(baseDir))) {
|
|
198
|
+
return foldConfigHome(normalizeIdentity(await foldLocalCapture(vd)));
|
|
199
|
+
}
|
|
200
|
+
return foldConfigHome(normalizeIdentity(vd));
|
|
102
201
|
}
|
|
103
202
|
|
|
104
203
|
// Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull.
|
|
@@ -129,7 +228,7 @@ export async function loadFingerprint(baseDir) {
|
|
|
129
228
|
export async function loadFingerprintPreferCache(baseDir) {
|
|
130
229
|
if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
|
|
131
230
|
const cached = await readCache(defaultStateDir());
|
|
132
|
-
if (cached) return normalizeIdentity(cached.vault);
|
|
231
|
+
if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
|
|
133
232
|
}
|
|
134
233
|
return loadFingerprint(baseDir);
|
|
135
234
|
}
|
package/health.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Projection health report (P2) — `npm run health`, run during dogfooding.
|
|
3
|
+
//
|
|
4
|
+
// Loads the LIVE decrypted fingerprint (phone pairing / at-rest cache / local
|
|
5
|
+
// file, via the normal source resolution) and runs the §4.6/§4.7 + projection-
|
|
6
|
+
// hygiene checks over it, printing a MEASURED report: conformance + summary-
|
|
7
|
+
// hygiene errors (Proposal P0) and §4.7 unresolved dimensions (Proposal 0006).
|
|
8
|
+
// This is the "projection cleanliness" number for the end-of-window eval
|
|
9
|
+
// (docs/roadmap/dogfooding-eval-2026-06.md). Read-only — no network writes.
|
|
10
|
+
//
|
|
11
|
+
// Exit: 0 = clean · 1 = hygiene errors · 2 = could not load a fingerprint.
|
|
12
|
+
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { loadFingerprint } from './fingerprint-source.js';
|
|
16
|
+
import { validateFingerprint, resolveLayer } from '../spec/validate-fingerprint.mjs';
|
|
17
|
+
|
|
18
|
+
const baseDir = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
let vd;
|
|
21
|
+
try { vd = await loadFingerprint(baseDir); }
|
|
22
|
+
catch (e) { console.error(`Could not load a fingerprint: ${e.message}`); process.exit(2); }
|
|
23
|
+
|
|
24
|
+
const fp = vd && vd.phaedo_fingerprint;
|
|
25
|
+
if (!fp) { console.log('No fingerprint yet (empty vault) — nothing to check.'); process.exit(0); }
|
|
26
|
+
|
|
27
|
+
const layers = fp.layers || {};
|
|
28
|
+
const errs = validateFingerprint(fp); // structural + §4.6 + summary hygiene (P0)
|
|
29
|
+
|
|
30
|
+
// §4.7 (Proposal 0006): count dimensions and flag the ones a thin/conflicting
|
|
31
|
+
// evidence set leaves unresolved (which a consumer must treat conservatively).
|
|
32
|
+
let dimCount = 0;
|
|
33
|
+
const unresolved = [];
|
|
34
|
+
for (const [lid, L] of Object.entries(layers)) {
|
|
35
|
+
const res = resolveLayer(L.signals || []);
|
|
36
|
+
for (const [field, r] of Object.entries(res)) {
|
|
37
|
+
if (!r) continue;
|
|
38
|
+
dimCount++;
|
|
39
|
+
if (r.status === 'unresolved') unresolved.push(`${lid}.${field}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const sigCount = Object.values(layers).reduce((n, L) => n + ((L.signals || []).length), 0);
|
|
43
|
+
|
|
44
|
+
console.log(`\nPhaedo projection health — fingerprint ${fp.fingerprint_id || '(no id)'}`);
|
|
45
|
+
console.log(` layers ${Object.keys(layers).length} · signals ${sigCount} · resolved dimensions ${dimCount}`);
|
|
46
|
+
console.log(` conformance + projection-hygiene errors: ${errs.length}`);
|
|
47
|
+
for (const e of errs) console.log(` ✗ ${e}`);
|
|
48
|
+
console.log(` §4.7 unresolved dimensions (conflicting + thin evidence): ${unresolved.length}`);
|
|
49
|
+
for (const u of unresolved) console.log(` ⚠ ${u}`);
|
|
50
|
+
|
|
51
|
+
const clean = errs.length === 0;
|
|
52
|
+
console.log(`\n${clean ? '✓ projection clean' : `✗ ${errs.length} hygiene issue(s)`}` +
|
|
53
|
+
`${unresolved.length ? ` · ${unresolved.length} unresolved dimension(s)` : ''}\n`);
|
|
54
|
+
process.exit(clean ? 0 : 1);
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — Claude Code ship hook (PreToolUse, PermissionRequest, PostToolUse,
|
|
3
|
+
// PostToolUseFailure on Bash).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. The precedent track is starved: no consultation has been written since
|
|
6
|
+
// 2026-07-28, so agreement, calibration and override learning have nothing to read. The
|
|
7
|
+
// decisions this subject actually delegates, every week, are the ones a coding agent takes
|
|
8
|
+
// for them — merging a PR, deploying the relay, cutting a phone build. This hook consults
|
|
9
|
+
// Phaedo before those run (ship-actions.js names them) and writes a §10.6 receipt, so the
|
|
10
|
+
// receipts accumulate in normal use instead of waiting for an agent to remember a tool.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT MAY DO — ONE RULE. The hook only ever ADDS friction; it never removes it. It
|
|
13
|
+
// never returns `allow`, so it cannot pre-approve anything Claude Code would otherwise
|
|
14
|
+
// have asked about. On a permissive answer it returns no decision at all (Claude Code's
|
|
15
|
+
// own permission flow applies unchanged); on a cautious one it returns `ask`, which puts
|
|
16
|
+
// the action in front of the subject. That is why it reads the at-rest profile cache and
|
|
17
|
+
// skips the server's stale-authority guard: the guard exists to stop a stale read from
|
|
18
|
+
// EXPANDING autonomy, and nothing here can.
|
|
19
|
+
//
|
|
20
|
+
// WHERE OUTCOMES COME FROM — SUBJECT-AUTHORED ONLY (§10.6 honesty rail). The one thing the
|
|
21
|
+
// hook records itself is a yes the subject gave on a prompt, and it records that only on
|
|
22
|
+
// EVIDENCE that the prompt was shown: Claude Code fires PermissionRequest when it is about
|
|
23
|
+
// to show one. So, per call:
|
|
24
|
+
// - prompt shown, then the command ran → the subject said yes → `approved`. Whatever the
|
|
25
|
+
// consult answered, and in any permission mode — a yes on a `proceed` is the scoreable
|
|
26
|
+
// case agreement needs.
|
|
27
|
+
// - prompt shown, then declined → Claude Code fires NO hook for a manual denial
|
|
28
|
+
// (documented), so the agent is told the receipt id up front and records `rejected`.
|
|
29
|
+
// - no prompt (bypass mode, an allow rule) → nothing recorded. After the run the agent is
|
|
30
|
+
// told so, and records the subject's reaction only if they give one. Silence is never
|
|
31
|
+
// approval.
|
|
32
|
+
// This replaces a guess: whether a hook's `ask` still prompts under bypassPermissions is
|
|
33
|
+
// not documented, and no longer needs to be.
|
|
34
|
+
//
|
|
35
|
+
// FAIL-SAFE: any error, timeout or unrecognised input exits 0 with no output, so a broken
|
|
36
|
+
// Phaedo never blocks or slows a merge beyond the budget. Wire it with
|
|
37
|
+
// `npm run setup:code-hook` (install.mjs adds all four events).
|
|
38
|
+
|
|
39
|
+
import { fileURLToPath } from 'url';
|
|
40
|
+
import { dirname, resolve, join } from 'path';
|
|
41
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
|
42
|
+
import { createHash } from 'crypto';
|
|
43
|
+
import { execFile } from 'child_process';
|
|
44
|
+
import { classifyShipCommand } from '../ship-actions.js';
|
|
45
|
+
import { isMainModule } from '../lib/is-main.js';
|
|
46
|
+
|
|
47
|
+
const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
|
+
|
|
49
|
+
// Never hold a command up for long. A consult with a model judge configured can take its
|
|
50
|
+
// own timeout (PHAEDO_CONSULT_TIMEOUT_MS, default 15s); past this the hook says nothing.
|
|
51
|
+
export const BUDGET_MS = 12000;
|
|
52
|
+
|
|
53
|
+
// Signals that hand the action to the subject. `insufficient_signal` is not one: an
|
|
54
|
+
// abstain is Phaedo saying it does not know, which is no reason to interrupt anyone.
|
|
55
|
+
export const ASKING_SIGNALS = new Set(['escalate', 'clarify', 'decline']);
|
|
56
|
+
|
|
57
|
+
// Permission modes in which a PermissionRequest is not evidence that a PERSON answered:
|
|
58
|
+
// auto mode's classifier and dontAsk decide without one.
|
|
59
|
+
const NO_HUMAN_MODES = new Set(['auto', 'dontAsk']);
|
|
60
|
+
|
|
61
|
+
// The profile is read from the at-rest cache, which only a live read refreshes. Past this
|
|
62
|
+
// age the agent is told once a day, so the subject hears about it in plain words.
|
|
63
|
+
export const STALE_PROFILE_DAYS = 14;
|
|
64
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
65
|
+
const PENDING_TTL_MS = DAY_MS;
|
|
66
|
+
|
|
67
|
+
// The caller label on the receipt. `via` stays `phaedo_consult` — the hook runs a full
|
|
68
|
+
// consultation through the same path, and the receipt schema's `via` enum is closed — so
|
|
69
|
+
// the free-text, self-asserted `agent_id` is where "this came from the ship hook" lives.
|
|
70
|
+
export const AGENT_ID = 'claude-code/ship-hook';
|
|
71
|
+
|
|
72
|
+
export const commandHash = (cmd) => createHash('sha256').update(String(cmd ?? '')).digest('hex');
|
|
73
|
+
|
|
74
|
+
// ── CI state for a merge (precedent track Phase 1b, B6) ───────────────────────────────
|
|
75
|
+
// Merging a PR whose checks are failing, or have not finished, is a bigger act than merging a
|
|
76
|
+
// green one — and nothing the agent declares says so. CAUTION-ONLY, by measurement: on the
|
|
77
|
+
// dev Mac's profile a normal merge answers `proceed` with or without `evidence_provided`, and
|
|
78
|
+
// on an admin merge green evidence only SOFTENS the answer (clarify → proceed_with_note). So
|
|
79
|
+
// green is RECORDED and changes nothing; failing or unfinished raises the merge to `high`.
|
|
80
|
+
// The state comes from GitHub, read by the hook itself — never from the agent.
|
|
81
|
+
export const CI_LOOKUP_MS = 4000;
|
|
82
|
+
|
|
83
|
+
/** statusCheckRollup → 'red' | 'pending' | 'green' | 'none' (no checks configured). */
|
|
84
|
+
export function ciStateFromRollup(rollup) {
|
|
85
|
+
const list = Array.isArray(rollup) ? rollup : [];
|
|
86
|
+
if (!list.length) return 'none';
|
|
87
|
+
const FAIL = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'STALE']);
|
|
88
|
+
const OK = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
89
|
+
let pending = false;
|
|
90
|
+
for (const c of list) {
|
|
91
|
+
// CheckRun: status + conclusion. StatusContext: state.
|
|
92
|
+
const v = String(c?.conclusion || c?.state || '').toUpperCase();
|
|
93
|
+
if (FAIL.has(v)) return 'red';
|
|
94
|
+
const done = c?.__typename === 'StatusContext' ? v && v !== 'PENDING' && v !== 'EXPECTED' : String(c?.status || '').toUpperCase() === 'COMPLETED';
|
|
95
|
+
if (!done || !OK.has(v)) pending = true;
|
|
96
|
+
}
|
|
97
|
+
return pending ? 'pending' : 'green';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// How each state changes the consult, and the words the subject reads on the prompt.
|
|
101
|
+
export const CI_RAISES = new Set(['red', 'pending']);
|
|
102
|
+
const CI_WORDS = { red: 'its checks are failing', pending: "its checks haven't finished" };
|
|
103
|
+
|
|
104
|
+
// ── What a merge changes (precedent track Phase 1b, B3) ───────────────────────────────
|
|
105
|
+
// The classifier calls every merge `medium` — a 2-line typo fix and a 2,000-line rewrite of
|
|
106
|
+
// the rule engine alike. Two facts from the PR raise it to `high`; nothing lowers it (a
|
|
107
|
+
// lowered magnitude could let a §10.5 grant capped at `low` fire — the widening direction).
|
|
108
|
+
//
|
|
109
|
+
// LARGE: more than LARGE_LINES changed lines or LARGE_FILES files, markdown excluded. Set from
|
|
110
|
+
// the dev repo's last 60 merged PRs (code-only p50 135 lines / 3 files, p90 848 / 14): about
|
|
111
|
+
// 1 PR in 12 crosses either line.
|
|
112
|
+
export const LARGE_LINES = 1000;
|
|
113
|
+
export const LARGE_FILES = 20;
|
|
114
|
+
// SENSITIVE: a changed path that matters more than its size. Generic rules for any repo (CI
|
|
115
|
+
// and deploy config, migrations, auth / crypto / secrets / security / permissions by name —
|
|
116
|
+
// test files excluded from the name rule), plus the repo's own list in `.phaedo-sensitive`
|
|
117
|
+
// (one path prefix or `*` glob per line, # comments). The list is read from the BASE branch,
|
|
118
|
+
// so a PR cannot shrink it for itself, and a PR that edits the list is sensitive by definition.
|
|
119
|
+
export const SENSITIVE_FILE = '.phaedo-sensitive';
|
|
120
|
+
const GENERIC_SENSITIVE = [
|
|
121
|
+
/^\.github\/workflows\//, /(^|\/)Dockerfile$/, /(^|\/)fly\.toml$/, /\.tf$/, /(^|\/)migrations?\//,
|
|
122
|
+
];
|
|
123
|
+
const SENSITIVE_NAME = /(^|[\/_.-])(auth|crypto|secrets?|security|permissions?)([\/_.-]|$)/i;
|
|
124
|
+
const TEST_PATH = /(^|\/)(tests?|__tests__)\/|(^|\/)test[-_]|[._-]test\.|\.spec\./i;
|
|
125
|
+
|
|
126
|
+
/** Parse `.phaedo-sensitive` text → patterns. */
|
|
127
|
+
export function parseSensitiveList(text) {
|
|
128
|
+
return String(text || '').split(/\r?\n/).map((l) => l.replace(/#.*$/, '').trim()).filter(Boolean);
|
|
129
|
+
}
|
|
130
|
+
function patternMatches(pattern, path) {
|
|
131
|
+
if (!pattern.includes('*')) return path === pattern || path.startsWith(pattern);
|
|
132
|
+
const re = new RegExp('^' + pattern.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*'));
|
|
133
|
+
return re.test(path);
|
|
134
|
+
}
|
|
135
|
+
export function isSensitivePath(path, repoPatterns = []) {
|
|
136
|
+
const p = String(path || '');
|
|
137
|
+
if (!p) return false;
|
|
138
|
+
if (p === SENSITIVE_FILE) return true;
|
|
139
|
+
if (GENERIC_SENSITIVE.some((re) => re.test(p))) return true;
|
|
140
|
+
if (!TEST_PATH.test(p) && SENSITIVE_NAME.test(p)) return true;
|
|
141
|
+
return repoPatterns.some((pat) => patternMatches(pat, p));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** (PR facts, repo patterns) → { large, sensitive, lines, files } — PURE. */
|
|
145
|
+
export function diffStakes(facts, repoPatterns = []) {
|
|
146
|
+
const all = Array.isArray(facts?.files) ? facts.files : [];
|
|
147
|
+
const code = all.filter((f) => !/\.md$/i.test(String(f?.path || '')));
|
|
148
|
+
const lines = code.reduce((n, f) => n + (Number(f?.additions) || 0) + (Number(f?.deletions) || 0), 0);
|
|
149
|
+
// gh lists at most 100 files; past that, the count it reports is the floor of the truth.
|
|
150
|
+
const truncated = Number(facts?.changedFiles) > all.length;
|
|
151
|
+
const files = truncated ? Number(facts.changedFiles) : code.length;
|
|
152
|
+
const large = lines > LARGE_LINES || files > LARGE_FILES || truncated;
|
|
153
|
+
const sensitive = all.some((f) => isSensitivePath(f?.path, repoPatterns));
|
|
154
|
+
return { large, sensitive, lines, files };
|
|
155
|
+
}
|
|
156
|
+
export const stakesValue = (st) => (!st ? 'unknown' : st.large && st.sensitive ? 'large+sensitive' : st.large ? 'large' : st.sensitive ? 'sensitive' : 'normal');
|
|
157
|
+
|
|
158
|
+
function run(cmd, args, cwd, timeoutMs) {
|
|
159
|
+
return new Promise((res) => {
|
|
160
|
+
try {
|
|
161
|
+
execFile(cmd, args, { cwd: cwd || undefined, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
|
|
162
|
+
(err, stdout) => res(err ? null : String(stdout)));
|
|
163
|
+
} catch { res(null); }
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One read-only GitHub lookup for everything the hook needs about a merge, via the gh CLI the
|
|
169
|
+
* agent itself uses: { ci, stakes } — ci 'unknown' and stakes null on any failure.
|
|
170
|
+
*/
|
|
171
|
+
export async function lookupPrFacts(ship, cwd, {
|
|
172
|
+
gh = process.env.PHAEDO_SHIP_GH || 'gh', git = process.env.PHAEDO_SHIP_GIT || 'git', timeoutMs = CI_LOOKUP_MS,
|
|
173
|
+
} = {}) {
|
|
174
|
+
const args = ['pr', 'view', ...(ship.pr ? [ship.pr] : []), ...(ship.repo ? ['--repo', ship.repo] : []),
|
|
175
|
+
'--json', 'statusCheckRollup,files,changedFiles,baseRefName'];
|
|
176
|
+
const out = await run(gh, args, cwd, timeoutMs);
|
|
177
|
+
let j = null;
|
|
178
|
+
try { j = out ? JSON.parse(out) : null; } catch { j = null; }
|
|
179
|
+
if (!j || typeof j !== 'object') return { ci: 'unknown', stakes: null };
|
|
180
|
+
// The repo's own list, from the base branch. Only when the merge targets THIS checkout
|
|
181
|
+
// (no --repo); a list we cannot read is an empty list — the generic rules still apply.
|
|
182
|
+
let patterns = [];
|
|
183
|
+
if (!ship.repo && typeof j.baseRefName === 'string' && /^[\w./-]+$/.test(j.baseRefName)) {
|
|
184
|
+
const text = await run(git, ['show', `origin/${j.baseRefName}:${SENSITIVE_FILE}`], cwd, 2000);
|
|
185
|
+
patterns = parseSensitiveList(text);
|
|
186
|
+
}
|
|
187
|
+
return { ci: ciStateFromRollup(j.statusCheckRollup), stakes: diffStakes(j, patterns) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Back-compat: the CI state alone. */
|
|
191
|
+
export async function lookupCiState(ship, cwd, opts) { return (await lookupPrFacts(ship, cwd, opts)).ci; }
|
|
192
|
+
|
|
193
|
+
// The words the subject reads, joined into one line.
|
|
194
|
+
export function raiseWords(ci, stakes) {
|
|
195
|
+
const w = [];
|
|
196
|
+
if (CI_RAISES.has(ci)) w.push(CI_WORDS[ci]);
|
|
197
|
+
if (stakes?.large) w.push(`it's a large change (${stakes.lines.toLocaleString('en-US')} lines across ${stakes.files} files)`);
|
|
198
|
+
if (stakes?.sensitive) w.push('it touches sensitive files');
|
|
199
|
+
return w.join('; ');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Hook state: tool_use_id → its consult, so later events can find it. ───────────────
|
|
203
|
+
// Ids, a command DIGEST and flags only — never the command. Entries pruned after a day.
|
|
204
|
+
export function readState(path) {
|
|
205
|
+
try {
|
|
206
|
+
const j = JSON.parse(readFileSync(path, 'utf8'));
|
|
207
|
+
if (j && typeof j === 'object' && j.pending && typeof j.pending === 'object') return j;
|
|
208
|
+
} catch { /* missing or corrupt → fresh */ }
|
|
209
|
+
return { pending: {} };
|
|
210
|
+
}
|
|
211
|
+
export function writeState(path, state, now) {
|
|
212
|
+
const pending = {};
|
|
213
|
+
for (const [k, v] of Object.entries(state.pending || {})) if (now - Date.parse(v?.at) < PENDING_TTL_MS) pending[k] = v;
|
|
214
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
215
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
216
|
+
writeFileSync(tmp, JSON.stringify({ ...state, pending }), { mode: 0o600 });
|
|
217
|
+
renameSync(tmp, path);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// What the subject reads on the prompt. Plain words, no numbers, no protocol terms.
|
|
221
|
+
export function promptReason(signal, summary, why) {
|
|
222
|
+
const what = why ? `${summary} — ${why}` : summary;
|
|
223
|
+
if (signal === 'decline') return `Phaedo thinks you'd say no to this one: ${what}. Go ahead anyway?`;
|
|
224
|
+
if (signal === 'clarify') return `Phaedo isn't sure how you'd want this handled: ${what}. Go ahead?`;
|
|
225
|
+
return `Phaedo thinks you'd want to OK this yourself: ${what}.`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// What the agent reads before the command. It carries the receipt id — the one handle
|
|
229
|
+
// that attributes an outcome to THIS decision rather than whatever receipt is newest.
|
|
230
|
+
export function preContext({ summary, signal, receiptId, asked, hint }) {
|
|
231
|
+
const head = `Phaedo was consulted before this command (${summary}) and answered "${signal}"` +
|
|
232
|
+
(hint ? ` — ${hint}` : '') + `. receipt_id: ${receiptId}.`;
|
|
233
|
+
if (asked) {
|
|
234
|
+
return `${head} Phaedo flagged it for the user, so they may be asked to confirm. If they decline, ` +
|
|
235
|
+
`call phaedo_record_outcome with this receipt_id and "rejected" (or "modified" if they want it ` +
|
|
236
|
+
`done differently). A yes on the prompt is recorded automatically.`;
|
|
237
|
+
}
|
|
238
|
+
return `${head} If the user reacts to this specific action when you report it — says it was right, ` +
|
|
239
|
+
`objects, or wants it changed — call phaedo_record_outcome with this receipt_id and "approved", ` +
|
|
240
|
+
`"rejected" or "modified". Record only what they actually said about it; silence is not approval, ` +
|
|
241
|
+
`and do not record anything because you proceeded.`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// After the command, when Phaedo had flagged it and no prompt was shown.
|
|
245
|
+
export function unpromptedContext(receiptId) {
|
|
246
|
+
return `No confirmation prompt was shown for this command (receipt_id: ${receiptId}), though Phaedo ` +
|
|
247
|
+
`had flagged it for the user. Tell them in one line that it ran and Phaedo had flagged it, and ` +
|
|
248
|
+
`record their reaction with phaedo_record_outcome and this receipt_id if they give one. Silence ` +
|
|
249
|
+
`is not approval.`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function staleContext(days, mcpDir) {
|
|
253
|
+
return `Also: Phaedo's copy of the user's profile is ${days} days old (their phone has not been ` +
|
|
254
|
+
`reached since). Mention once, in one line, that they can refresh it by running ` +
|
|
255
|
+
`\`cd ${mcpDir} && npm run pull\` and approving on their phone.`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* One hook invocation. Pure apart from injected deps, so the whole flow is testable
|
|
260
|
+
* without a vault, a receipt store or Claude Code.
|
|
261
|
+
*
|
|
262
|
+
* deps: { consult(req) → { response, drivers, source? }, writeReceipt({request,response,drivers}) → id,
|
|
263
|
+
* markOutcome(id, action), statePath, now, mcpDir? }
|
|
264
|
+
* Returns the JSON object to print, or null for no output.
|
|
265
|
+
*/
|
|
266
|
+
export async function runShipHook(input, deps) {
|
|
267
|
+
if (!input || input.tool_name !== 'Bash') return null;
|
|
268
|
+
const now = deps.now ?? Date.now();
|
|
269
|
+
const ev = input.hook_event_name;
|
|
270
|
+
|
|
271
|
+
if (ev === 'PreToolUse') {
|
|
272
|
+
const ship = classifyShipCommand(input.tool_input?.command);
|
|
273
|
+
if (!ship) return null;
|
|
274
|
+
// B6 + B3: one GitHub lookup per merge — check state, size, paths. Each can only raise.
|
|
275
|
+
let ci = null, stakes = null;
|
|
276
|
+
if (ship.class === 'merge_pr') {
|
|
277
|
+
if (deps.prFacts) ({ ci, stakes } = await deps.prFacts(ship, input.cwd));
|
|
278
|
+
else if (deps.ciState) ci = await deps.ciState(ship, input.cwd);
|
|
279
|
+
}
|
|
280
|
+
const why = raiseWords(ci, stakes);
|
|
281
|
+
const raised = why !== '';
|
|
282
|
+
const request = {
|
|
283
|
+
consultation_type: 'action_approval',
|
|
284
|
+
agent_id: AGENT_ID,
|
|
285
|
+
action_descriptor: raised ? { ...ship.action_descriptor, magnitude: 'high' } : ship.action_descriptor,
|
|
286
|
+
action_binding: ship.action_binding,
|
|
287
|
+
};
|
|
288
|
+
const { response, drivers, source } = await deps.consult(request);
|
|
289
|
+
const signal = String(response?.signal || '');
|
|
290
|
+
// Provenance: what the hook saw, beside what drove the answer. Local receipt only.
|
|
291
|
+
const extra = [];
|
|
292
|
+
if (ci) extra.push({ cue: 'ciState', value: ci, confidence: null, basis: ci === 'unknown' ? null : 'observed' });
|
|
293
|
+
if (ci && deps.prFacts) extra.push({ cue: 'diffStakes', value: stakesValue(stakes), confidence: null, basis: stakes ? 'observed' : null });
|
|
294
|
+
const allDrivers = extra.length ? [...(drivers || []), ...extra] : drivers;
|
|
295
|
+
const receiptId = await deps.writeReceipt({ request, response, drivers: allDrivers });
|
|
296
|
+
if (!receiptId) return null; // no receipt = nothing to learn from; stay out of the way
|
|
297
|
+
const asked = ASKING_SIGNALS.has(signal);
|
|
298
|
+
|
|
299
|
+
const state = readState(deps.statePath);
|
|
300
|
+
if (input.tool_use_id) {
|
|
301
|
+
state.pending[input.tool_use_id] = {
|
|
302
|
+
receipt_id: receiptId, cmd_hash: commandHash(input.tool_input?.command),
|
|
303
|
+
asked, prompt_shown: false, at: new Date(now).toISOString(),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
let context = preContext({
|
|
307
|
+
summary: ship.summary, signal, receiptId, asked,
|
|
308
|
+
hint: typeof response?.rationale_hint === 'string' ? response.rationale_hint : '',
|
|
309
|
+
});
|
|
310
|
+
if (raised) context += ` GitHub shows ${why}, so Phaedo treated this merge as high-stakes.`;
|
|
311
|
+
const ageDays = source && source.live === false && Number.isFinite(source.ageMs) ? Math.floor(source.ageMs / DAY_MS) : 0;
|
|
312
|
+
if (ageDays >= STALE_PROFILE_DAYS && !(now - Date.parse(state.stale_noted_at) < DAY_MS)) {
|
|
313
|
+
context += ' ' + staleContext(ageDays, deps.mcpDir || MCP_DIR);
|
|
314
|
+
state.stale_noted_at = new Date(now).toISOString();
|
|
315
|
+
}
|
|
316
|
+
writeState(deps.statePath, state, now);
|
|
317
|
+
return {
|
|
318
|
+
hookSpecificOutput: {
|
|
319
|
+
hookEventName: 'PreToolUse',
|
|
320
|
+
...(asked ? { permissionDecision: 'ask', permissionDecisionReason: promptReason(signal, ship.summary, why) } : {}),
|
|
321
|
+
additionalContext: context,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (ev === 'PermissionRequest') {
|
|
327
|
+
// Evidence that a prompt is about to be shown for this command. PermissionRequest has
|
|
328
|
+
// no tool_use_id (documented), so match the newest pending consult on the same command.
|
|
329
|
+
if (NO_HUMAN_MODES.has(input.permission_mode)) return null;
|
|
330
|
+
const h = commandHash(input.tool_input?.command);
|
|
331
|
+
const state = readState(deps.statePath);
|
|
332
|
+
const hit = Object.values(state.pending)
|
|
333
|
+
.filter((e) => e.cmd_hash === h && !e.prompt_shown)
|
|
334
|
+
.sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0];
|
|
335
|
+
if (!hit) return null;
|
|
336
|
+
hit.prompt_shown = true;
|
|
337
|
+
writeState(deps.statePath, state, now);
|
|
338
|
+
return null; // never decide here — the subject answers the prompt
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (ev === 'PostToolUse' || ev === 'PostToolUseFailure') {
|
|
342
|
+
if (!input.tool_use_id) return null;
|
|
343
|
+
const state = readState(deps.statePath);
|
|
344
|
+
const entry = state.pending[input.tool_use_id];
|
|
345
|
+
if (!entry) return null;
|
|
346
|
+
delete state.pending[input.tool_use_id];
|
|
347
|
+
writeState(deps.statePath, state, now);
|
|
348
|
+
// An interrupted command is the subject changing their mind mid-run, which is not a
|
|
349
|
+
// clean yes — record nothing.
|
|
350
|
+
if (ev === 'PostToolUseFailure' && input.is_interrupt) return null;
|
|
351
|
+
if (entry.prompt_shown) {
|
|
352
|
+
// The command started after a prompt, so the subject said yes. A failed command is
|
|
353
|
+
// still an approved decision.
|
|
354
|
+
await deps.markOutcome(entry.receipt_id, 'approved');
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
if (!entry.asked) return null; // permissive + unprompted: the pre-context already said how
|
|
358
|
+
return { hookSpecificOutput: { hookEventName: ev, additionalContext: unpromptedContext(entry.receipt_id) } };
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Real deps: the same consult path as the MCP server (consult-runtime.js). ──────────
|
|
364
|
+
async function realDeps() {
|
|
365
|
+
const { loadConsultVd, buildConsultOpts } = await import('../consult-runtime.js');
|
|
366
|
+
const { resolveConsultation, consultDrivers, withRevision } = await import('../consult.js');
|
|
367
|
+
const { buildReceipt, appendReceipt, receiptsCapFromEnv, markOutcome } = await import('../receipts.js');
|
|
368
|
+
const { sourceOf } = await import('../fingerprint-source.js');
|
|
369
|
+
const { defaultStateDir } = await import('../cache.js');
|
|
370
|
+
const stateDir = defaultStateDir();
|
|
371
|
+
const quiet = () => {};
|
|
372
|
+
const opts = buildConsultOpts(MCP_DIR, process.env, { warn: quiet });
|
|
373
|
+
return {
|
|
374
|
+
statePath: join(stateDir, 'ship-hook.json'),
|
|
375
|
+
mcpDir: MCP_DIR,
|
|
376
|
+
prFacts: (ship, cwd) => lookupPrFacts(ship, cwd),
|
|
377
|
+
async consult(request) {
|
|
378
|
+
const vd = await loadConsultVd(MCP_DIR, { preferCache: true, warn: quiet });
|
|
379
|
+
const response = await resolveConsultation(vd, request, opts);
|
|
380
|
+
let drivers = [];
|
|
381
|
+
try { drivers = withRevision(consultDrivers(vd, request, opts), response); } catch { /* provenance is non-critical */ }
|
|
382
|
+
return { response, drivers, source: sourceOf(vd) };
|
|
383
|
+
},
|
|
384
|
+
async writeReceipt({ request, response, drivers }) {
|
|
385
|
+
const r = buildReceipt({ via: 'phaedo_consult', agentId: AGENT_ID, clientId: opts.clientId, request, response, drivers });
|
|
386
|
+
await appendReceipt(stateDir, r, { cap: receiptsCapFromEnv(process.env) });
|
|
387
|
+
return r.receipt_id;
|
|
388
|
+
},
|
|
389
|
+
async markOutcome(id, action) { await markOutcome(stateDir, id, action); },
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function main() {
|
|
394
|
+
let raw = '';
|
|
395
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
396
|
+
const input = JSON.parse(raw);
|
|
397
|
+
// Cheap exit before importing the vault stack: most Bash calls are not ship actions.
|
|
398
|
+
if (input?.tool_name !== 'Bash') return;
|
|
399
|
+
if (input.hook_event_name === 'PreToolUse' && !classifyShipCommand(input.tool_input?.command)) return;
|
|
400
|
+
const out = await runShipHook(input, await realDeps());
|
|
401
|
+
if (out) process.stdout.write(JSON.stringify(out));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (isMainModule(import.meta.url)) {
|
|
405
|
+
const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
|
|
406
|
+
Promise.race([main().catch(() => {}), timeout])
|
|
407
|
+
.then(() => process.exit(0))
|
|
408
|
+
.catch(() => process.exit(0)); // a hook must never break the session
|
|
409
|
+
}
|