phaedo-mcp 0.2.2 → 0.3.0
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 +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- 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 +50 -6
- package/consult-policy.js +427 -33
- package/consult.js +8 -1
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- 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 +25 -5
- package/phone-source.js +79 -11
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +33 -2
- package/server.js +305 -15
- package/server.json +3 -3
- 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 +153 -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 +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendored-files.mjs +82 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Where authored content comes FROM — the step-3 read path.
|
|
2
|
+
//
|
|
3
|
+
// Authored standing rules live in two places during the migration: the fingerprint A-home
|
|
4
|
+
// (`fp.standing_rules`, Proposal 0007) and the §5 User Configuration (`standing_instructions`,
|
|
5
|
+
// derived from the A-home on every write). The sequencing decision is that the §5 copy is
|
|
6
|
+
// AUTHORITATIVE ON READ where both exist, so that step 3 can later delete the A-home and change
|
|
7
|
+
// nothing about what readers see. Every reader routing through here is what makes that true.
|
|
8
|
+
//
|
|
9
|
+
// WHY A RESOLVER AND NOT `cfg.standing_instructions || fp.standing_rules`:
|
|
10
|
+
//
|
|
11
|
+
// 1. §5.4. A configuration that does not name the fingerprint it arrived with is not this
|
|
12
|
+
// subject's — stale, mis-routed, or hostile. Letting it govern would let one identity's
|
|
13
|
+
// authored rules apply to another. It must fall back, not be trusted.
|
|
14
|
+
// 2. ABSENT and EMPTY are different. No §5 document at all means "this surface has not been
|
|
15
|
+
// migrated, use the A-home". A §5 document with an empty list means "the subject has no
|
|
16
|
+
// rules", and falling back there would RESURRECT rules the subject deleted — the same
|
|
17
|
+
// failure as the union-vs-tombstone bug in §10.5 revocation (#247).
|
|
18
|
+
// 3. The caller needs to know WHICH home answered. A reader that cannot report its provenance
|
|
19
|
+
// cannot be verified on device, and "it works" would be indistinguishable from "it silently
|
|
20
|
+
// read the old home the whole time".
|
|
21
|
+
//
|
|
22
|
+
// Mirrored by mobile/src/lib/authoredSource.ts; kept honest by
|
|
23
|
+
// scripts/test_mobile_authored_source_parity.mjs, which RUNS both over the same inputs.
|
|
24
|
+
|
|
25
|
+
(function () {
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* WHICH HOME IS AUTHORITATIVE for authored rules — the step-3 cutover switch.
|
|
30
|
+
*
|
|
31
|
+
* It lives HERE, with the resolver, because "where does authored content live" is one question
|
|
32
|
+
* and both the reader and the writer must answer it identically. authored-write.js re-exports
|
|
33
|
+
* it; user-config.js reads it to decide whether a re-derive may overwrite standing_instructions.
|
|
34
|
+
* This module imports nothing, so every consumer can read it without an import cycle.
|
|
35
|
+
*
|
|
36
|
+
* 'fingerprint' — today. Rules live on the A-home; §5 is DERIVED from it on every write.
|
|
37
|
+
* 'config' — after step 3. §5 is the source; the A-home is a mirror, and the derive must
|
|
38
|
+
* stop overwriting §5 or a routine fingerprint write erases authored rules.
|
|
39
|
+
*
|
|
40
|
+
* Flipping this IS the cutover. It ships only once the upgrade gate reads green against real
|
|
41
|
+
* device reports (owner decision 2026-07-27: release check, not runtime interlock).
|
|
42
|
+
*
|
|
43
|
+
* FLIPPED to 'config' on 2026-07-28 and REVERTED the same day, before any device carried it.
|
|
44
|
+
* The revert is the mechanism working: one constant, so a bad answer costs one line.
|
|
45
|
+
*
|
|
46
|
+
* A post-flip review found SIX confirmed paths that lose authored content, every one of them
|
|
47
|
+
* benign while the A-home is authoritative and live the moment it is not:
|
|
48
|
+
* 1. the phone's chokepoint replaces §5 wholesale from the mirror, so any authored save
|
|
49
|
+
* destroys a §5-only rule (mobile/src/lib/authoredWrite.ts)
|
|
50
|
+
* 2. background.js never loads THIS module, so the vault write finalizer throws on every
|
|
51
|
+
* service-worker write and §5 is never maintained there
|
|
52
|
+
* 3. the live /v1/profile sync path carries no §5 at all (popup.js silentSync)
|
|
53
|
+
* 4. popup.js _actOnOverride authors a rule outside the write chokepoint
|
|
54
|
+
* 5. the SW capture re-deposit strips the §5 envelope off the wire, so authored rules and
|
|
55
|
+
* the irreplaceable example_corpus never reach the phone
|
|
56
|
+
* 6. a non-governing §5 document is never cleared, and absorbs authored content into a
|
|
57
|
+
* document that can never govern — and is excluded from §8.2 backup at the same moment
|
|
58
|
+
*
|
|
59
|
+
* WHAT MUST BE TRUE BEFORE FLIPPING AGAIN — all six closed, plus:
|
|
60
|
+
* - every authoring site routes through the chokepoint, enumerated BY BEHAVIOUR
|
|
61
|
+
* - every reader routes through resolveStandingInstructions, likewise
|
|
62
|
+
* - the upgrade gate is actually CONSULTED (it currently has zero production call sites)
|
|
63
|
+
* - the phone advertises config_home_v0.2 only once it genuinely satisfies it
|
|
64
|
+
* - a device pass on a build that carries the phone half
|
|
65
|
+
* See docs/state/current-state.md and scripts/test_cutover_readiness.mjs.
|
|
66
|
+
*/
|
|
67
|
+
const AUTHORED_HOME = 'config';
|
|
68
|
+
|
|
69
|
+
// ── The upgrade gate, wired ────────────────────────────────────────────────────────────────
|
|
70
|
+
//
|
|
71
|
+
// `AUTHORED_HOME` ARMS the cutover. It does not perform it. The effective home is `'config'`
|
|
72
|
+
// only when the constant says so AND capabilities.js reports every reader current — so flipping
|
|
73
|
+
// the constant on a fleet where the phone or the agent is behind changes NOTHING until they
|
|
74
|
+
// catch up, instead of retiring the A-home out from under them.
|
|
75
|
+
//
|
|
76
|
+
// This is the wiring the gate was missing. `canRetireAHome` and `retirementBlockers` were
|
|
77
|
+
// correct and well tested and had ZERO production call sites: evidence was collected into
|
|
78
|
+
// `phaedo_peer_capabilities` and never asked. The 2026-07-28 flip shipped without the mechanism
|
|
79
|
+
// designed to authorise it ever being consulted, and six loss paths went with it.
|
|
80
|
+
//
|
|
81
|
+
// WHY A LATCH RATHER THAN A READ. The consumers (user-config.js, authored-write.js) are pure
|
|
82
|
+
// functions over a vault object, and the capability record lives outside the vault on purpose —
|
|
83
|
+
// it is fleet metadata, and the gate must be answerable without unlocking anything. Making
|
|
84
|
+
// those functions async, or handing them a storage handle, would put I/O on the authoring path.
|
|
85
|
+
// So the surface that CAN read storage pushes the record in, and the pure modules read it
|
|
86
|
+
// synchronously.
|
|
87
|
+
//
|
|
88
|
+
// IT FAILS CLOSED IN EVERY DIRECTION. Unset (nobody pushed a record), capabilities.js not
|
|
89
|
+
// loaded, an empty record, a reader that never reported, a reader on an older build, a report
|
|
90
|
+
// too old — all of them resolve to `'fingerprint'`. Silence is not consent; the only path to
|
|
91
|
+
// `'config'` is positive evidence from every reader.
|
|
92
|
+
let _peerCapabilities = null;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Publish what the fleet last reported. Called by whichever surface can read storage
|
|
96
|
+
* (background.js on startup, popup.js after each mailbox drain).
|
|
97
|
+
*/
|
|
98
|
+
function setPeerCapabilities(known) {
|
|
99
|
+
_peerCapabilities = isObj(known) ? known : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What was last published. Exposed so a caller can show the subject the same evidence. */
|
|
103
|
+
function peerCapabilities() { return _peerCapabilities; }
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The home that is actually in force, and why. Returns a REASON so a withheld cutover can be
|
|
107
|
+
* explained rather than merely observed — the same discipline `retirementBlockers` follows.
|
|
108
|
+
*
|
|
109
|
+
* @returns {{ home: string, armed: boolean, blockers: Array, reason: string }}
|
|
110
|
+
*/
|
|
111
|
+
function effectiveAuthoredHomeDetail(now) {
|
|
112
|
+
// Read the PUBLISHED value, not the closure constant. `api.AUTHORED_HOME` is what every
|
|
113
|
+
// consumer sees, so reading anything else would let the module disagree with its own exports
|
|
114
|
+
// — and it is the value the cutover suites set to stage post-cutover conditions, which means
|
|
115
|
+
// they exercise this path rather than a parallel one.
|
|
116
|
+
const armed = (api && api.AUTHORED_HOME) || AUTHORED_HOME;
|
|
117
|
+
if (armed !== 'config') {
|
|
118
|
+
return { home: 'fingerprint', armed: false, blockers: [], reason: 'not_armed' };
|
|
119
|
+
}
|
|
120
|
+
const CAP = (typeof globalThis !== 'undefined' && globalThis.PhaedoCapabilities) || null;
|
|
121
|
+
if (!CAP || typeof CAP.retirementBlockers !== 'function') {
|
|
122
|
+
// The gate module is not present, so the fleet's state is unknowable here. Unknowable is
|
|
123
|
+
// not the same as fine — this is the `[].every(...)` trap the gate itself was built around.
|
|
124
|
+
return { home: 'fingerprint', armed: true, blockers: [], reason: 'gate_unavailable' };
|
|
125
|
+
}
|
|
126
|
+
if (!isObj(_peerCapabilities)) {
|
|
127
|
+
return { home: 'fingerprint', armed: true, blockers: [], reason: 'no_capability_record' };
|
|
128
|
+
}
|
|
129
|
+
// NAME THE ASKER. The extension's peers are the phone and the agent; passing nothing would
|
|
130
|
+
// fall back to checking all three including itself, which never reports and would block
|
|
131
|
+
// forever. See readersFor in capabilities.js.
|
|
132
|
+
const blockers = CAP.retirementBlockers(_peerCapabilities, now, { self: 'extension' });
|
|
133
|
+
if (blockers.length) {
|
|
134
|
+
return { home: 'fingerprint', armed: true, blockers, reason: 'readers_behind' };
|
|
135
|
+
}
|
|
136
|
+
return { home: 'config', armed: true, blockers: [], reason: 'fleet_current' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The home in force. Use `effectiveAuthoredHomeDetail` when you need to explain a fallback. */
|
|
140
|
+
function effectiveAuthoredHome(now) {
|
|
141
|
+
return effectiveAuthoredHomeDetail(now).home;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Where an answer came from. Returned, never inferred by the caller. */
|
|
145
|
+
const SOURCE = {
|
|
146
|
+
CONFIG: 'config', // §5 — the post-cutover home
|
|
147
|
+
FINGERPRINT: 'fingerprint', // the A-home — the fallback step 3 removes
|
|
148
|
+
NONE: 'none', // neither home had anything to say
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
function isObj(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The configuration's live instructions — those not buried by a §5.2 tombstone.
|
|
155
|
+
*
|
|
156
|
+
* Keyed by the FROZEN identity in sync-merge.js. When that module is absent (a reader that
|
|
157
|
+
* loads only the resolver) the tombstones cannot be evaluated, and the honest answer is to
|
|
158
|
+
* return the rules unchanged rather than guess: dropping rules we cannot key would lose
|
|
159
|
+
* authored content, and that is the worse direction here.
|
|
160
|
+
*/
|
|
161
|
+
function withoutRevoked(cfg) {
|
|
162
|
+
const rules = cfg.standing_instructions;
|
|
163
|
+
const tombs = Array.isArray(cfg.revoked_instructions) ? cfg.revoked_instructions : [];
|
|
164
|
+
if (!tombs.length) return rules;
|
|
165
|
+
const SM = (typeof globalThis !== 'undefined' && globalThis.PhaedoSyncMerge) || null;
|
|
166
|
+
if (!SM || typeof SM.instructionKey !== 'function') return rules;
|
|
167
|
+
const dead = new Set(tombs.map((t) => (isObj(t) ? t.key : null)).filter(Boolean));
|
|
168
|
+
if (!dead.size) return rules;
|
|
169
|
+
return rules.filter((r) => !dead.has(SM.instructionKey(r)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Does this configuration belong to this fingerprint? Mirrors config-sync.js `pairingHolds`
|
|
174
|
+
* deliberately rather than importing it: this module is loaded by readers that have no reason
|
|
175
|
+
* to pull in the transport, and the rule is three lines. Parity between the two is asserted in
|
|
176
|
+
* scripts/test_authored_source.mjs so the duplication cannot drift.
|
|
177
|
+
*/
|
|
178
|
+
function configGoverns(cfg, fp) {
|
|
179
|
+
if (!isObj(cfg) || !isObj(fp)) return false;
|
|
180
|
+
if (typeof fp.fingerprint_id !== 'string' || !fp.fingerprint_id) return false;
|
|
181
|
+
if (cfg.fingerprint_id !== fp.fingerprint_id) return false;
|
|
182
|
+
if (typeof fp.subject_id === 'string' && fp.subject_id &&
|
|
183
|
+
typeof cfg.subject_id === 'string' && cfg.subject_id &&
|
|
184
|
+
cfg.subject_id !== fp.subject_id) return false;
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The authoritative standing instructions, and where they came from.
|
|
190
|
+
*
|
|
191
|
+
* §5 wins whenever it is present AND governs — including when it is EMPTY, because an empty
|
|
192
|
+
* authored set is a statement ("I have no rules"), not an absence. Only a missing or
|
|
193
|
+
* non-governing configuration falls through to the A-home.
|
|
194
|
+
*
|
|
195
|
+
* @returns {{ rules: Array, source: string, reason?: string }}
|
|
196
|
+
*/
|
|
197
|
+
function resolveStandingInstructions(fp, cfg) {
|
|
198
|
+
const aHome = isObj(fp) && Array.isArray(fp.standing_rules) ? fp.standing_rules : null;
|
|
199
|
+
|
|
200
|
+
if (isObj(cfg) && Array.isArray(cfg.standing_instructions)) {
|
|
201
|
+
if (configGoverns(cfg, fp)) {
|
|
202
|
+
// §5.2 tombstones are enforced HERE, not only at the merge. A device that has not merged
|
|
203
|
+
// since the revocation still holds the rule in `standing_instructions`; honouring the
|
|
204
|
+
// tombstone at read time is what makes the revocation take effect immediately rather
|
|
205
|
+
// than on the next sync. §10.5 learned this the hard way — ap2.js honoured its
|
|
206
|
+
// tombstones while the consult resolver did not, so one artifact gave two answers.
|
|
207
|
+
return { rules: withoutRevoked(cfg), source: SOURCE.CONFIG };
|
|
208
|
+
}
|
|
209
|
+
// Present but not this subject's. Fall back rather than trust it, and SAY so — a §5.4
|
|
210
|
+
// rejection that looks identical to "no configuration here" is how a mis-paired document
|
|
211
|
+
// would go unnoticed for months.
|
|
212
|
+
//
|
|
213
|
+
// BUT ITS DELETIONS STILL COUNT. The A-home has no revocation concept, and its copy is
|
|
214
|
+
// union-merged, so a rule the subject deleted comes back from any peer that still holds it
|
|
215
|
+
// — and falling back to that copy put the deleted rule into force again (measured
|
|
216
|
+
// 2026-08-12). Honouring the tombstones on a document we otherwise refuse is safe in a way
|
|
217
|
+
// that honouring its rules would not be: a tombstone can only ever REMOVE an instruction,
|
|
218
|
+
// never add one or change what it says. The worst it can do is withhold a rule the subject
|
|
219
|
+
// wanted; the alternative is obeying one they revoked, which is the direction that cannot
|
|
220
|
+
// be noticed from the outside.
|
|
221
|
+
if (aHome) {
|
|
222
|
+
return { rules: withoutRevoked({ standing_instructions: aHome, revoked_instructions: cfg.revoked_instructions }),
|
|
223
|
+
source: SOURCE.FINGERPRINT, reason: 'config_pairing_failed' };
|
|
224
|
+
}
|
|
225
|
+
return { rules: [], source: SOURCE.NONE, reason: 'config_pairing_failed' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (aHome) return { rules: aHome, source: SOURCE.FINGERPRINT, reason: 'no_config' };
|
|
229
|
+
return { rules: [], source: SOURCE.NONE, reason: 'no_config' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* True once this surface would behave identically with the A-home deleted — i.e. §5 answered.
|
|
234
|
+
* This is the evidence a surface needs before it may advertise `config_home_v0.2` to the
|
|
235
|
+
* upgrade gate. Claiming the capability on the strength of "the code is deployed" rather than
|
|
236
|
+
* "the read actually resolved to §5" is exactly how a gate opens on the wrong evidence.
|
|
237
|
+
*/
|
|
238
|
+
function readsFromConfigHome(fp, cfg) {
|
|
239
|
+
return resolveStandingInstructions(fp, cfg).source === SOURCE.CONFIG;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Declared before the functions above can run (they only read it at call time), so
|
|
243
|
+
// `effectiveAuthoredHomeDetail` can consult the published value.
|
|
244
|
+
const api = {
|
|
245
|
+
AUTHORED_HOME, SOURCE, configGoverns, resolveStandingInstructions, readsFromConfigHome,
|
|
246
|
+
setPeerCapabilities, peerCapabilities, effectiveAuthoredHome, effectiveAuthoredHomeDetail,
|
|
247
|
+
};
|
|
248
|
+
if (typeof globalThis !== 'undefined') globalThis.PhaedoAuthoredSource = api;
|
|
249
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
250
|
+
})();
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The §5 step-3 upgrade gate — who is current, and may the A-home be retired yet.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. Step 3 deletes the fingerprint A-home, which is where authored content lives
|
|
4
|
+
// today. A surface that has not upgraded then reads NOTHING where the subject's rules used to be,
|
|
5
|
+
// and the failure is asymmetric: a missing GRANT fails safe (no `autoproceed` match, so the agent
|
|
6
|
+
// asks) but a missing PROHIBITION does not — with no `escalate`/`decline` rule present the
|
|
7
|
+
// inferred signal governs, and that can be "proceed". Retiring the A-home while any reader is
|
|
8
|
+
// behind therefore removes caution specifically, never permission.
|
|
9
|
+
//
|
|
10
|
+
// Sequencing decision 3 (config-home-v02-sequencing-2026-07-26) defines the gate: with one user
|
|
11
|
+
// it is "Randy's phone and extension are both on the new build." This module makes that a
|
|
12
|
+
// runtime fact instead of a release-day memory.
|
|
13
|
+
//
|
|
14
|
+
// THE SHAPE THAT MATTERS: this fails CLOSED, and the way it fails closed is not the obvious one.
|
|
15
|
+
// `[].every(...)` is TRUE in JavaScript, so a naive "do all peers support it?" answers YES when
|
|
16
|
+
// NO PEER HAS EVER REPORTED. That is this codebase's recurring failure exactly — silence read as
|
|
17
|
+
// success. Silence here means "you do not know", and not knowing means do not retire anything.
|
|
18
|
+
//
|
|
19
|
+
// Mirrored by mobile/src/lib/capabilities.ts; kept honest by
|
|
20
|
+
// scripts/test_mobile_capabilities_parity.mjs, which RUNS both over the same inputs.
|
|
21
|
+
|
|
22
|
+
(function () {
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
/** This surface can send and receive the §5 artifact. True on both surfaces since #266/#267. */
|
|
26
|
+
const CONFIG_TRANSPORT = 'config_transport_v0.1';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The capability step 3 requires: this surface SOURCES authored content from §5 and stays
|
|
30
|
+
* correct when the A-home is absent. Deliberately distinct from CONFIG_TRANSPORT — carrying
|
|
31
|
+
* the artifact and reading from it are different claims, and conflating them is how a gate
|
|
32
|
+
* ends up opening on the wrong evidence. Nothing advertises this yet; the step-3 read path
|
|
33
|
+
* is what earns it.
|
|
34
|
+
*/
|
|
35
|
+
//
|
|
36
|
+
// ⚠️ NECESSARY BUT NOT SUFFICIENT FOR STEP 3 — found on device 2026-07-27. Every surface now
|
|
37
|
+
// genuinely READS from §5, and this gate goes green on that basis. But §5 cannot yet CARRY an
|
|
38
|
+
// authored rule between devices: mergeConfig folds only `example_corpus`, so a rule authored on
|
|
39
|
+
// the extension is discarded by the phone's merge. Correct today (standing_instructions is
|
|
40
|
+
// re-derived from the A-home, which is what actually syncs rules) and broken the moment step 3
|
|
41
|
+
// makes §5 the authored home. See scripts/test_config_sync_rules_gap.mjs — the tripwire that
|
|
42
|
+
// must be deleted, not silenced, when the merge learns to carry rules.
|
|
43
|
+
const CONFIG_HOME = 'config_home_v0.2';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What each surface implements — PER SURFACE, not one shared list.
|
|
47
|
+
*
|
|
48
|
+
* This file is loaded by the extension AND vendored into the published MCP, and the two do not
|
|
49
|
+
* become §5-native at the same moment. A single shared list would have made the agent claim
|
|
50
|
+
* CONFIG_HOME the instant the extension earned it, which is precisely the "gate opens on the
|
|
51
|
+
* wrong evidence" failure the gate exists to prevent — and it would have been invisible, since
|
|
52
|
+
* the agent would simply have started reading a home it does not understand.
|
|
53
|
+
*
|
|
54
|
+
* A surface earns CONFIG_HOME only when EVERY one of its authored-content reads resolves
|
|
55
|
+
* through §5 and it stays correct with the A-home absent.
|
|
56
|
+
*/
|
|
57
|
+
const CAPABILITIES_BY_SURFACE = {
|
|
58
|
+
// Both read sites go through §5: the injection path (context-block.js renderContextBlock)
|
|
59
|
+
// and the popup's rule display (_readStandingRules → authored-source.js).
|
|
60
|
+
extension: [CONFIG_TRANSPORT, CONFIG_HOME],
|
|
61
|
+
// The agent's authored-content readers (ap2.js, autonomy-promotion.js, consult-core.js) all
|
|
62
|
+
// take a FINGERPRINT rather than a vault, so §5 is folded in ONCE at the vd boundary
|
|
63
|
+
// (fingerprint-source.js foldConfigHome) and every one of them is §5-canonical by
|
|
64
|
+
// construction. Same boundary carries the §5.4 check.
|
|
65
|
+
agent: [CONFIG_TRANSPORT, CONFIG_HOME],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Back-compat for callers that ask without naming a surface: the conservative intersection. */
|
|
69
|
+
const CAPABILITIES = [CONFIG_TRANSPORT];
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
// WHO MUST BE CURRENT depends on WHO IS ASKING. Retiring the A-home harms any surface that READS
|
|
73
|
+
// authored content, and there are three; the asker is not one of its own peers, because it knows
|
|
74
|
+
// its own capability from CAPABILITIES_BY_SURFACE — it is the code that is running.
|
|
75
|
+
//
|
|
76
|
+
// This used to be a single list, ['phone', 'agent'], which is the EXTENSION's view written as if
|
|
77
|
+
// it were universal. The moment the phone gained a gate of its own that list had it checking
|
|
78
|
+
// ITSELF and the agent while never hearing about the extension — the one peer whose readiness
|
|
79
|
+
// actually matters to it.
|
|
80
|
+
//
|
|
81
|
+
// `self` is REQUIRED in effect: an unrecognised value falls back to checking all three, which
|
|
82
|
+
// can only add blockers. The opposite default — assuming a caller meant the extension — could
|
|
83
|
+
// REMOVE one, and a gate that quietly stops checking a reader is the failure this whole module
|
|
84
|
+
// exists to prevent.
|
|
85
|
+
const ALL_READERS = ['extension', 'phone', 'agent'];
|
|
86
|
+
function readersFor(self) {
|
|
87
|
+
return ALL_READERS.includes(self) ? ALL_READERS.filter((s) => s !== self) : ALL_READERS.slice();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The extension's peer set. Kept as a named export because callers and tests reference it. */
|
|
91
|
+
const READERS = readersFor('extension');
|
|
92
|
+
|
|
93
|
+
// A report older than this is not evidence about the fleet as it stands now — a device can be
|
|
94
|
+
// reinstalled, downgraded, or simply gone. 30 days is long enough that a phone left in a drawer
|
|
95
|
+
// over a holiday does not trip the gate, short enough that a stale record cannot authorise a
|
|
96
|
+
// one-way door months later.
|
|
97
|
+
const MAX_REPORT_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
98
|
+
|
|
99
|
+
function isObj(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The payload a surface stamps into what it deposits. Deliberately a CAPABILITY LIST rather
|
|
103
|
+
* than a version number: "what can you do" survives version drift and forks, "what version are
|
|
104
|
+
* you" needs a comparison table that nobody maintains.
|
|
105
|
+
*/
|
|
106
|
+
function advertise(surface, now) {
|
|
107
|
+
if (typeof surface !== 'string' || !surface) return null;
|
|
108
|
+
// An UNKNOWN surface gets the conservative set, never the most capable one. Guessing upward
|
|
109
|
+
// here would let a surface nobody has audited close the gate's last blocker.
|
|
110
|
+
const caps = CAPABILITIES_BY_SURFACE[surface] || CAPABILITIES;
|
|
111
|
+
return {
|
|
112
|
+
surface,
|
|
113
|
+
capabilities: caps.slice(),
|
|
114
|
+
stamped_at: now || new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Fold an incoming advertisement into the record of what each peer last reported. Keyed by
|
|
120
|
+
* surface, newest-wins — a surface only ever describes itself, so there is no merge conflict
|
|
121
|
+
* to resolve and no union to get wrong.
|
|
122
|
+
*/
|
|
123
|
+
function recordPeer(known, incoming) {
|
|
124
|
+
const base = isObj(known) ? known : {};
|
|
125
|
+
if (!isObj(incoming) || typeof incoming.surface !== 'string' || !incoming.surface) return base;
|
|
126
|
+
const prior = base[incoming.surface];
|
|
127
|
+
const pt = prior ? Date.parse(prior.stamped_at) || 0 : 0;
|
|
128
|
+
const it = Date.parse(incoming.stamped_at) || 0;
|
|
129
|
+
if (it <= pt) return base;
|
|
130
|
+
return {
|
|
131
|
+
...base,
|
|
132
|
+
[incoming.surface]: {
|
|
133
|
+
capabilities: Array.isArray(incoming.capabilities) ? incoming.capabilities.slice() : [],
|
|
134
|
+
stamped_at: incoming.stamped_at,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fold a LIST of advertisements. The phone deposits its own plus any it is couriering for a
|
|
141
|
+
* paired agent, because the agent has no direct channel to the extension — the same courier
|
|
142
|
+
* role the phone already plays for capture snapshots and delegation suggestions.
|
|
143
|
+
*/
|
|
144
|
+
function recordPeers(known, list) {
|
|
145
|
+
if (!Array.isArray(list)) return isObj(known) ? known : {};
|
|
146
|
+
return list.reduce((acc, ad) => recordPeer(acc, ad), isObj(known) ? known : {});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* May the A-home be retired? Returns a REASON, not just a boolean — the caller has to be able
|
|
151
|
+
* to tell the subject why their cutover is being withheld, and "false" alone cannot.
|
|
152
|
+
*
|
|
153
|
+
* Fails closed on: no record at all, a reader that has never reported, a reader that reported
|
|
154
|
+
* without the capability, and a report too old to be evidence.
|
|
155
|
+
*/
|
|
156
|
+
function retirementBlockers(known, now, opts) {
|
|
157
|
+
const at = now ? Date.parse(now) : Date.now();
|
|
158
|
+
const rec = isObj(known) ? known : {};
|
|
159
|
+
const blockers = [];
|
|
160
|
+
for (const surface of readersFor(opts && opts.self)) {
|
|
161
|
+
const entry = rec[surface];
|
|
162
|
+
if (!isObj(entry)) {
|
|
163
|
+
// The empty-record case. This is the branch `[].every(...)` would have answered "fine".
|
|
164
|
+
blockers.push({ surface, reason: 'never_reported' });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const caps = Array.isArray(entry.capabilities) ? entry.capabilities : [];
|
|
168
|
+
if (!caps.includes(CONFIG_HOME)) {
|
|
169
|
+
blockers.push({ surface, reason: 'lacks_capability' });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const stamped = Date.parse(entry.stamped_at) || 0;
|
|
173
|
+
if (!stamped || at - stamped > MAX_REPORT_AGE_MS) {
|
|
174
|
+
blockers.push({ surface, reason: 'report_too_old' });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return blockers;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Convenience over retirementBlockers. Never call this without surfacing the blockers. */
|
|
181
|
+
function canRetireAHome(known, now, opts) {
|
|
182
|
+
return retirementBlockers(known, now, opts).length === 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One line a human can act on. */
|
|
186
|
+
function describeBlockers(blockers) {
|
|
187
|
+
if (!Array.isArray(blockers) || !blockers.length) return 'every reader is current';
|
|
188
|
+
const words = {
|
|
189
|
+
never_reported: 'has never reported in',
|
|
190
|
+
lacks_capability: 'is on an older build',
|
|
191
|
+
report_too_old: 'has not been seen recently enough to count',
|
|
192
|
+
};
|
|
193
|
+
return blockers.map((b) => `${b.surface} ${words[b.reason] || b.reason}`).join('; ');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const api = {
|
|
197
|
+
CONFIG_TRANSPORT, CONFIG_HOME, CAPABILITIES, CAPABILITIES_BY_SURFACE, READERS, ALL_READERS,
|
|
198
|
+
readersFor, MAX_REPORT_AGE_MS,
|
|
199
|
+
advertise, recordPeer, recordPeers, retirementBlockers, canRetireAHome, describeBlockers,
|
|
200
|
+
};
|
|
201
|
+
if (typeof globalThis !== 'undefined') globalThis.PhaedoCapabilities = api;
|
|
202
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
203
|
+
})();
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Phaedo capture — the tier-agnostic spine: exchange_batch → signal delta.
|
|
2
|
+
//
|
|
3
|
+
// Every capture surface (the Claude Code JSONL hook, the Track B a11y helper, a future
|
|
4
|
+
// OCR tier) converges here once it has an exchange_batch (capture/transcript.js or
|
|
5
|
+
// capture/exchanges.js). This module runs the shared tail — deterministic Path-A
|
|
6
|
+
// detectors (+ optional local-model enrichment) → per-session state fold (resume-safe
|
|
7
|
+
// REPLACE) → cumulative delta lift — and returns the source='delta' signals. It does NOT
|
|
8
|
+
// touch the network: building + depositing the envelope is the transport layer's job
|
|
9
|
+
// (mcp/capture-sync.js), which keeps capture/ deposit-free and lets each surface own its
|
|
10
|
+
// pairing resolution. So a new front-end is a batch producer, not a new pipeline.
|
|
11
|
+
//
|
|
12
|
+
// Pure except the injected state file I/O (capture/state.js), exactly like the hook.
|
|
13
|
+
|
|
14
|
+
import { accumulateBatchCounts } from './detectors.js';
|
|
15
|
+
import { captureModelOpts, enrichCountsWithModel } from './model-detect.js';
|
|
16
|
+
import { loadState, saveState, applySession, cumulativeCounts, defaultStatePath } from './state.js';
|
|
17
|
+
import { liftDeltaSignals } from '../extraction/delta-lift.js';
|
|
18
|
+
|
|
19
|
+
// Fold one batch into this surface's cumulative signal delta.
|
|
20
|
+
// batch — an exchange_batch ({ exchanges, user_prompts, session_local_id }).
|
|
21
|
+
// opts:
|
|
22
|
+
// sourceId — the per-machine+surface source_device_id (capture/source-id.js).
|
|
23
|
+
// surface — surface tag echoed into the result (e.g. 'a11y-macos').
|
|
24
|
+
// statePath — durable per-surface state file (default: state.js defaultStatePath).
|
|
25
|
+
// sessionLocalId — STABLE per-conversation id → per-session REPLACE (idempotent re-reads).
|
|
26
|
+
// now — ISO timestamp (fixed for deterministic tests).
|
|
27
|
+
// captureModel — option (b) model opts, or undefined to auto-resolve from env; falsy = off.
|
|
28
|
+
// env — process.env (for captureModelOpts auto-resolution).
|
|
29
|
+
// Returns { signals, counts, modelAdded, sourceId, surface } — never throws on a bad
|
|
30
|
+
// batch (empty → zero signals); state I/O failures are swallowed (counters just restart).
|
|
31
|
+
export async function foldBatchToSignals(batch, opts = {}) {
|
|
32
|
+
const statePath = opts.statePath || defaultStatePath();
|
|
33
|
+
const nowIso = opts.now || new Date().toISOString();
|
|
34
|
+
const sourceId = opts.sourceId || null;
|
|
35
|
+
const sessionLocalId = opts.sessionLocalId || (batch && batch.session_local_id) || null;
|
|
36
|
+
|
|
37
|
+
const exchanges = (batch && batch.exchanges) || [];
|
|
38
|
+
const prompts = (batch && Array.isArray(batch.user_prompts)) ? batch.user_prompts : exchanges;
|
|
39
|
+
if (!prompts.length) return { signals: [], counts: null, modelAdded: 0, sourceId, surface: opts.surface || null };
|
|
40
|
+
|
|
41
|
+
let counts = accumulateBatchCounts(batch);
|
|
42
|
+
|
|
43
|
+
// Option (b): fold local-model classifications of the regex-MISS user messages into
|
|
44
|
+
// this session's counts BEFORE the state apply, so re-reads stay resume-safe and the
|
|
45
|
+
// deposit stays pure delta. Opt-in + fail-safe (any error leaves regex counts intact).
|
|
46
|
+
let modelAdded = 0;
|
|
47
|
+
const modelOpts = opts.captureModel !== undefined ? opts.captureModel : captureModelOpts(opts.env);
|
|
48
|
+
if (modelOpts && modelOpts.model) {
|
|
49
|
+
try {
|
|
50
|
+
const enriched = await enrichCountsWithModel(batch, counts, modelOpts);
|
|
51
|
+
counts = enriched.counts;
|
|
52
|
+
modelAdded = enriched.added;
|
|
53
|
+
} catch { /* model down/slow/garbage → regex-only, unchanged */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let state;
|
|
57
|
+
try {
|
|
58
|
+
state = applySession(loadState(statePath), sourceId, sessionLocalId, counts, { now: nowIso });
|
|
59
|
+
try { saveState(statePath, state); } catch { /* state loss just restarts counters — never fatal */ }
|
|
60
|
+
} catch {
|
|
61
|
+
// A corrupt state file must never break capture: fold against an empty baseline.
|
|
62
|
+
state = applySession(null, sourceId, sessionLocalId, counts, { now: nowIso });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const signals = liftDeltaSignals(cumulativeCounts(state, sourceId).behavioral_signals, nowIso);
|
|
66
|
+
return { signals, counts, modelAdded, sourceId, surface: opts.surface || null };
|
|
67
|
+
}
|