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
package/lib/domain.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// §10.2 `action_descriptor.domain` — normalization and the published vocabulary.
|
|
2
|
+
//
|
|
3
|
+
// WHY ANY OF THIS EXISTS. `domain` is a bare `{"type":"string"}` the AGENT picks, and
|
|
4
|
+
// everything downstream keys on it verbatim: §10.5 rule matching, the §10.6 receipt,
|
|
5
|
+
// the delegation tally, §10.7's per-class approval counts. Observed live on the dev
|
|
6
|
+
// machine: `financial` ×16, `finance` ×26, `finance.crypto_trading` ×1 — one concept,
|
|
7
|
+
// three keys, and 7 approvals split 3/4 so neither class reached §10.7's
|
|
8
|
+
// `MIN_APPROVALS = 5`. Analysis: `docs/roadmap/domain-identity-2026-07-22.md`.
|
|
9
|
+
//
|
|
10
|
+
// TWO FUNCTIONS, DELIBERATELY DISTINCT. Conflating them is how an alias table starts
|
|
11
|
+
// granting authority nobody authored.
|
|
12
|
+
//
|
|
13
|
+
// normalizeDomain() lowercase + trim + collapse whitespace. Pure presentation;
|
|
14
|
+
// `"Finance"` and `" finance"` cannot mean different things, so
|
|
15
|
+
// merging them can lose no distinction anyone intended.
|
|
16
|
+
//
|
|
17
|
+
// canonicalDomain() normalizeDomain, then map a published ALIAS onto its canonical
|
|
18
|
+
// term (`financial` → `finance`). This is a SEMANTIC claim, and
|
|
19
|
+
// it is only safe where a wrong merge cannot expand authority.
|
|
20
|
+
//
|
|
21
|
+
// THE SAFETY PROPERTY, stated once and enforced at every call site: a vocabulary alias
|
|
22
|
+
// may widen a GUARDRAIL's reach, never a GRANT's. `canonicalDomain` is therefore used
|
|
23
|
+
// for evidence classification and for matching RESTRICTIVE rules; relaxing rules
|
|
24
|
+
// (`autoproceed`, `bias_bold`) keep matching on `normalizeDomain` alone, exactly as
|
|
25
|
+
// before. So adopting this vocabulary cannot hand any client more autonomy than it
|
|
26
|
+
// already had — it can only make an existing guardrail cover more. If the table below
|
|
27
|
+
// is wrong, the failure is "an agent asks when it needn't", never the reverse.
|
|
28
|
+
//
|
|
29
|
+
// ALIASES ARE SYNONYMS, NEVER GENERALIZATIONS. `financial` → `finance` is a synonym.
|
|
30
|
+
// `crypto_trading` → `finance` would be a child→parent generalization, and it is
|
|
31
|
+
// forbidden: a grant authored for the general class would silently cover a narrower,
|
|
32
|
+
// riskier one the subject never demonstrated. Same defect that made dotted-suffix
|
|
33
|
+
// stripping unsafe (v0.1.55), which is why `investing` is its own term here rather
|
|
34
|
+
// than a `finance` alias, and why `crypto_trading` is absent entirely — an
|
|
35
|
+
// unrecognized domain still works, it just does not merge.
|
|
36
|
+
//
|
|
37
|
+
// PUBLISHED, not private. The vocabulary below is a copy of `spec/domains.json` so the
|
|
38
|
+
// npm package (whose `files` whitelist excludes `spec/`) is self-contained;
|
|
39
|
+
// `mcp/test-domain.mjs` asserts the two cannot drift. A private synonym table would
|
|
40
|
+
// put decision semantics somewhere the subject cannot read and a third-party
|
|
41
|
+
// implementer cannot reproduce.
|
|
42
|
+
|
|
43
|
+
/** Canonical terms → their aliases. Mirror of spec/domains.json `domains`. */
|
|
44
|
+
const VOCABULARY = {
|
|
45
|
+
finance: ['financial', 'finances', 'money', 'billing', 'invoicing', 'expenses', 'accounting'],
|
|
46
|
+
investing: ['investment', 'investments', 'trading', 'brokerage', 'portfolio'],
|
|
47
|
+
legal: ['contracts', 'compliance', 'regulatory'],
|
|
48
|
+
health: ['medical', 'healthcare', 'wellness', 'clinical'],
|
|
49
|
+
scheduling: ['calendar', 'meetings', 'appointments', 'availability', 'booking'],
|
|
50
|
+
communication: ['email', 'outbound email', 'messaging', 'messages', 'comms', 'correspondence'],
|
|
51
|
+
writing: ['drafting', 'editing', 'content', 'copywriting', 'documentation'],
|
|
52
|
+
research: ['analysis', 'reading', 'summarization', 'search'],
|
|
53
|
+
shopping: ['purchasing', 'purchase', 'procurement', 'orders', 'commerce', 'ecommerce'],
|
|
54
|
+
travel: ['trips', 'flights', 'lodging', 'itinerary', 'transportation'],
|
|
55
|
+
engineering: ['code', 'coding', 'software', 'development', 'dev', 'devops', 'infrastructure', 'deployment'],
|
|
56
|
+
data: ['files', 'records', 'storage', 'database', 'export'],
|
|
57
|
+
security: ['access', 'permissions', 'credentials', 'authentication', 'authorization'],
|
|
58
|
+
people: ['hr', 'human resources', 'hiring', 'recruiting', 'personnel', 'staffing'],
|
|
59
|
+
social: ['social media', 'posting', 'publishing', 'community'],
|
|
60
|
+
support: ['customer support', 'customer service', 'helpdesk', 'tickets'],
|
|
61
|
+
admin: ['administration', 'settings', 'accounts', 'subscriptions', 'operations', 'ops'],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** alias → canonical, plus every canonical term mapping to itself. */
|
|
65
|
+
const ALIAS_TO_CANONICAL = (() => {
|
|
66
|
+
const m = new Map();
|
|
67
|
+
for (const [term, aliases] of Object.entries(VOCABULARY)) {
|
|
68
|
+
m.set(term, term);
|
|
69
|
+
for (const a of aliases) m.set(a, term);
|
|
70
|
+
}
|
|
71
|
+
return m;
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
export const VOCABULARY_TERMS = Object.freeze(Object.keys(VOCABULARY).sort());
|
|
75
|
+
export const VOCABULARY_MAP = VOCABULARY;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Presentation-only normalization. Returns '' for anything unusable, which every
|
|
79
|
+
* caller already treats as "no domain".
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeDomain(raw) {
|
|
82
|
+
if (typeof raw !== 'string') return '';
|
|
83
|
+
return raw.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalize, then resolve a published alias to its canonical term. An unrecognized
|
|
88
|
+
* domain is returned normalized but otherwise untouched — free text stays legal and
|
|
89
|
+
* behaves exactly as it does today. Never throws.
|
|
90
|
+
*/
|
|
91
|
+
export function canonicalDomain(raw) {
|
|
92
|
+
const n = normalizeDomain(raw);
|
|
93
|
+
if (!n) return '';
|
|
94
|
+
return ALIAS_TO_CANONICAL.get(n) || n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True iff this domain resolves to a published vocabulary term. */
|
|
98
|
+
export function isVocabularyTerm(raw) {
|
|
99
|
+
const n = normalizeDomain(raw);
|
|
100
|
+
return !!n && ALIAS_TO_CANONICAL.has(n);
|
|
101
|
+
}
|
package/lib/is-main.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// "Was this module executed directly?" — symlink-safe.
|
|
2
|
+
//
|
|
3
|
+
// The obvious ESM idiom
|
|
4
|
+
//
|
|
5
|
+
// import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
6
|
+
//
|
|
7
|
+
// is WRONG whenever any component of the path is a symlink. Node resolves
|
|
8
|
+
// `import.meta.url` through symlinks (it is the module's real URL), while
|
|
9
|
+
// `resolve(process.argv[1])` normalizes `.`/`..` but does NOT follow links — so
|
|
10
|
+
// the two disagree and the check silently returns false.
|
|
11
|
+
//
|
|
12
|
+
// That is not a hypothetical. `os.tmpdir()` on macOS is `/var/folders/…`, a
|
|
13
|
+
// symlink to `/private/var/folders/…`, which made the SessionEnd capture hook a
|
|
14
|
+
// silent no-op for every macOS spawn from a temp path (caught by
|
|
15
|
+
// test-vendored-capture.mjs, which spawns the hook exactly as Claude Code does).
|
|
16
|
+
// The same trap is live in production for any install reached through a link — a
|
|
17
|
+
// symlinked home directory, a linked global npm root, `/tmp` on macOS.
|
|
18
|
+
//
|
|
19
|
+
// A capture hook that quietly does nothing is the worst failure this codebase
|
|
20
|
+
// has: it exits 0, prints nothing, and loses the session. So compare REAL paths,
|
|
21
|
+
// and fall back to the plain URL comparison only if realpath throws (a deleted or
|
|
22
|
+
// unreadable entry point — in which case "not main" is the safe answer anyway).
|
|
23
|
+
|
|
24
|
+
import { realpathSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
|
+
|
|
28
|
+
// (import.meta.url of the caller, [argv]) -> boolean
|
|
29
|
+
export function isMainModule(moduleUrl, argv = process.argv) {
|
|
30
|
+
const entry = argv && argv[1];
|
|
31
|
+
if (!entry || !moduleUrl) return false;
|
|
32
|
+
const self = fileURLToPath(moduleUrl);
|
|
33
|
+
const target = resolve(entry);
|
|
34
|
+
if (self === target) return true; // fast path, no IO
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(self) === realpathSync(target);
|
|
37
|
+
} catch {
|
|
38
|
+
return pathToFileURL(target).href === moduleUrl; // best effort
|
|
39
|
+
}
|
|
40
|
+
}
|
package/lib/phaedo-crypto.js
CHANGED
|
@@ -21,7 +21,7 @@ const ROOT = resolve(HERE, '..', '..'); // repo root — dev
|
|
|
21
21
|
|
|
22
22
|
// Prefer the bundled copies (self-contained package) over the repo root (dev).
|
|
23
23
|
// Load order matters: protocol → envelope → kdf → identity.
|
|
24
|
-
for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js']) {
|
|
24
|
+
for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js', 'capabilities.js', 'authored-source.js']) {
|
|
25
25
|
const vf = resolve(VENDOR, f);
|
|
26
26
|
require(existsSync(vf) ? vf : resolve(ROOT, f));
|
|
27
27
|
}
|
|
@@ -31,6 +31,22 @@ if (!Phaedo?.Envelope || !Phaedo?.Kdf || !Phaedo?.Identity || !Phaedo?.Protocol)
|
|
|
31
31
|
throw new Error('Phaedo crypto modules did not load (Protocol/Envelope/Kdf/Identity).');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// The §5 step-3 upgrade gate's vocabulary. The agent READS authored content, so it has to be
|
|
35
|
+
// able to say what it can do — otherwise the gate stays shut on its silence forever, which is
|
|
36
|
+
// correct but useless. Loaded here rather than imported directly so the published package picks
|
|
37
|
+
// up the vendored copy exactly like the crypto modules do.
|
|
38
|
+
export const Capabilities = globalThis.PhaedoCapabilities;
|
|
39
|
+
if (!Capabilities?.advertise) {
|
|
40
|
+
throw new Error('Phaedo capabilities module did not load.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The step-3 read path's §5.4 rule. Used at the vd boundary so every authored-content reader
|
|
44
|
+
// downstream is §5-canonical without threading the configuration through its signature.
|
|
45
|
+
export const AuthoredSource = globalThis.PhaedoAuthoredSource;
|
|
46
|
+
if (!AuthoredSource?.configGoverns) {
|
|
47
|
+
throw new Error('Phaedo authored-source module did not load.');
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
// base64url <-> Uint8Array (Node-native), matching the extension's wire encoding.
|
|
35
51
|
export const b64uEnc = (u8) => Buffer.from(u8).toString('base64url');
|
|
36
52
|
export const b64uDec = (s) => new Uint8Array(Buffer.from(s, 'base64url'));
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"description": "Phaedo MCP server
|
|
7
|
+
"description": "Phaedo MCP server \u2014 inject your cognitive fingerprint into any MCP client, and consult your decision pattern at agent decision points (docs/protocol/mcp-binding.md).",
|
|
8
8
|
"mcpName": "io.github.phaedo-labs/phaedo-mcp",
|
|
9
9
|
"homepage": "https://phaedo.so",
|
|
10
10
|
"repository": {
|
|
@@ -32,16 +32,30 @@
|
|
|
32
32
|
"consult.js",
|
|
33
33
|
"consult-core.js",
|
|
34
34
|
"consult-policy.js",
|
|
35
|
+
"action-binding.js",
|
|
36
|
+
"autonomy-promotion.js",
|
|
37
|
+
"ap2.js",
|
|
35
38
|
"receipts.js",
|
|
39
|
+
"receipts-sync.js",
|
|
36
40
|
"agreement.js",
|
|
37
41
|
"calibration.js",
|
|
42
|
+
"health.js",
|
|
38
43
|
"phone-source.js",
|
|
44
|
+
"delegation-sync.js",
|
|
45
|
+
"escalation.js",
|
|
46
|
+
"signing.js",
|
|
47
|
+
"signing-request.js",
|
|
48
|
+
"signing-audit.js",
|
|
49
|
+
"capture-sync.js",
|
|
50
|
+
"capture-local.js",
|
|
51
|
+
"capture-a11y.js",
|
|
39
52
|
"cache.js",
|
|
40
53
|
"pairing.js",
|
|
41
54
|
"pair.js",
|
|
42
55
|
"pull.js",
|
|
43
56
|
"install.mjs",
|
|
44
57
|
"build-package.mjs",
|
|
58
|
+
"vendored-files.mjs",
|
|
45
59
|
"vendor/",
|
|
46
60
|
"lib/",
|
|
47
61
|
"hooks/",
|
|
@@ -50,7 +64,9 @@
|
|
|
50
64
|
"sample-fingerprint.json",
|
|
51
65
|
"pairing.sample.json",
|
|
52
66
|
"consult-policies.sample.json",
|
|
53
|
-
"
|
|
67
|
+
"escalation-policy.sample.json",
|
|
68
|
+
"README.md",
|
|
69
|
+
"NOTICE"
|
|
54
70
|
],
|
|
55
71
|
"scripts": {
|
|
56
72
|
"start": "node server.js",
|
|
@@ -60,10 +76,11 @@
|
|
|
60
76
|
"setup": "node install.mjs",
|
|
61
77
|
"setup:pair": "node install.mjs --pair",
|
|
62
78
|
"setup:code-hook": "node install.mjs --code-hook",
|
|
79
|
+
"setup:capture-model": "node install.mjs --capture-model",
|
|
63
80
|
"unsetup": "node install.mjs --uninstall",
|
|
64
81
|
"pair": "node pair.js",
|
|
65
82
|
"pull": "node pull.js",
|
|
66
|
-
"test": "node test-smoke.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-delegation-sync.mjs && node test-delegation-relay.mjs && node test-escalation.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs",
|
|
83
|
+
"test": "node test-smoke.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-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-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",
|
|
67
84
|
"receipts": "node receipts.js list",
|
|
68
85
|
"agreement": "node agreement.js",
|
|
69
86
|
"calibration": "node calibration.js",
|
|
@@ -80,7 +97,10 @@
|
|
|
80
97
|
"smoke:escalation": "node test-escalation.mjs",
|
|
81
98
|
"smoke:receipts": "node test-receipts.mjs",
|
|
82
99
|
"smoke:agreement": "node test-agreement.mjs",
|
|
83
|
-
"smoke:calibration": "node test-calibration.mjs"
|
|
100
|
+
"smoke:calibration": "node test-calibration.mjs",
|
|
101
|
+
"smoke:binding": "node test-action-binding.mjs",
|
|
102
|
+
"smoke:autonomy": "node test-autonomy-promotion.mjs",
|
|
103
|
+
"smoke:ap2": "node test-ap2.mjs"
|
|
84
104
|
},
|
|
85
105
|
"dependencies": {
|
|
86
106
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
package/phone-source.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
// MCP Phase 1b — real key boundary
|
|
1
|
+
// MCP Phase 1b — real key boundary.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// The pairing record may come from EITHER provenance: `pair.js`, which pairs this
|
|
4
|
+
// server as its own SAS-approved, revocable client (mcp-revocable-approval-plan.md,
|
|
5
|
+
// DONE + device-verified), or the older "Option B" export of the extension's
|
|
6
|
+
// `phaedo_local_pairing`. This path works under both — the phone's drains try each
|
|
7
|
+
// verified client's key — but the extension-facing `agent_to_ext` lane does NOT;
|
|
8
|
+
// see delegation-sync.js and docs/roadmap/paired-client-identity-2026-07-22.md.
|
|
9
|
+
//
|
|
10
|
+
// Given a pairing record holding `pair_key`, this runs the spec-faithful
|
|
11
|
+
// authorize → pull → decrypt flow — a
|
|
5
12
|
// direct port of popup.js reAuthorize (:745) + silentSync (:883):
|
|
6
13
|
//
|
|
7
14
|
// authorize: fresh ephemeral X25519, HMAC-bound to pair_key, POST /v1/authorize,
|
|
@@ -16,15 +23,34 @@
|
|
|
16
23
|
// after the one-time pairing.
|
|
17
24
|
|
|
18
25
|
import { readFile } from 'fs/promises';
|
|
19
|
-
import { existsSync } from 'fs';
|
|
26
|
+
import { existsSync, readFileSync } from 'fs';
|
|
20
27
|
import { resolve } from 'path';
|
|
21
|
-
import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
|
|
28
|
+
import { Phaedo, b64uEnc, b64uDec, Capabilities } from './lib/phaedo-crypto.js';
|
|
22
29
|
|
|
23
30
|
const { Identity, Kdf, Envelope } = Phaedo;
|
|
24
31
|
const EXPIRY_SKEW_MS = 30 * 1000; // re-auth this early to avoid edge-of-expiry races
|
|
25
|
-
const TIMEOUT_MS = 5000; // fail fast when the phone is unreachable → cache fallback
|
|
26
32
|
|
|
27
|
-
//
|
|
33
|
+
// TWO timeouts, because the two requests wait on completely different things.
|
|
34
|
+
//
|
|
35
|
+
// `/v1/profile` waits on a MACHINE: the phone either answers or it doesn't, so failing fast
|
|
36
|
+
// is right — an unreachable phone should fall back to the cache quickly.
|
|
37
|
+
//
|
|
38
|
+
// `/v1/authorize` waits on a PERSON: they must notice their phone, read the card, and pass
|
|
39
|
+
// a biometric gate. A single 5s budget covered both, which made every authorize a race the
|
|
40
|
+
// subject usually lost — the client aborted, the card vanished mid-tap, and an approval
|
|
41
|
+
// completed seconds later landed on a request nobody was listening for. Worse, the abort is
|
|
42
|
+
// a TRANSIENT failure, so it took the silent cache fallback: the agent then answered
|
|
43
|
+
// authorization questions from a stale fingerprint while sounding certain. Observed on
|
|
44
|
+
// device 2026-07-25 — the cache had not been refreshed for ~22 hours and a REVOKED payment
|
|
45
|
+
// grant was still being honoured, because no live pull had ever completed.
|
|
46
|
+
//
|
|
47
|
+
// Face ID on the first try can fit in 5s. A passcode fallback cannot, and neither can a
|
|
48
|
+
// phone that is face-down on a table. Two minutes is the human budget; the phone's own card
|
|
49
|
+
// expiry remains the real bound, this just stops the client giving up first.
|
|
50
|
+
const TIMEOUT_MS = 5000; // reachability: /v1/profile
|
|
51
|
+
const APPROVE_TIMEOUT_MS = 120000; // human-in-the-loop: /v1/authorize
|
|
52
|
+
|
|
53
|
+
// ── Pairing record (from `pair.js`, or the legacy extension export — see header)
|
|
28
54
|
// { endpoint, client_id, client_secret, pair_key (b64url 32B), vault_pub, ... }
|
|
29
55
|
export async function loadPairingRecord(baseDir) {
|
|
30
56
|
const path = process.env.PHAEDO_PAIRING
|
|
@@ -55,6 +81,30 @@ export function hasPairingRecord(baseDir) {
|
|
|
55
81
|
return existsSync(resolve(baseDir, 'pairing.json'));
|
|
56
82
|
}
|
|
57
83
|
|
|
84
|
+
// This server's OWN paired-client identifier — the identity §10.5 `agents` scoping
|
|
85
|
+
// matches on (`docs/roadmap/paired-client-identity-2026-07-22.md`).
|
|
86
|
+
//
|
|
87
|
+
// Read SYNCHRONOUSLY at startup, beside the policy file, because it is process-wide
|
|
88
|
+
// configuration rather than per-call state: one MCP server process serves exactly one
|
|
89
|
+
// paired client, so this cannot change between calls without a restart. That is also
|
|
90
|
+
// what makes the whole design work — the server never has to ask a caller who it is.
|
|
91
|
+
//
|
|
92
|
+
// Fail-safe by construction: any failure (no pairing, unreadable, malformed, missing
|
|
93
|
+
// or non-string `client_id`) yields null, and null makes every `agents`-scoped
|
|
94
|
+
// RELAXING rule inert. A broken read can therefore only ever withhold authority, never
|
|
95
|
+
// grant it. Never throws.
|
|
96
|
+
export function readServingClientId(baseDir) {
|
|
97
|
+
const path = process.env.PHAEDO_PAIRING
|
|
98
|
+
? resolve(process.cwd(), process.env.PHAEDO_PAIRING)
|
|
99
|
+
: resolve(baseDir, 'pairing.json');
|
|
100
|
+
try {
|
|
101
|
+
const id = JSON.parse(readFileSync(path, 'utf8'))?.client_id;
|
|
102
|
+
return typeof id === 'string' && id.trim() ? id.trim() : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
58
108
|
// In-memory session cache, keyed by endpoint. Holds session_key (never persisted).
|
|
59
109
|
const _sessions = new Map();
|
|
60
110
|
|
|
@@ -74,8 +124,14 @@ async function authorize(rec, deps) {
|
|
|
74
124
|
scopes: ['profile:full'],
|
|
75
125
|
eph_ext_pub: b64uEnc(eph.publicKey),
|
|
76
126
|
eph_mac: b64uEnc(extMac),
|
|
127
|
+
// What this agent can do, for the §5 step-3 upgrade gate. The agent reads authored
|
|
128
|
+
// content but has no channel of its own to the extension, so the phone records this and
|
|
129
|
+
// couriers it on. Rides the authorize body because that is the one request the agent
|
|
130
|
+
// makes that the phone authenticates AND that recurs — sessions expire, so the record
|
|
131
|
+
// refreshes while the agent is in use and goes stale when it is not, which is the intent.
|
|
132
|
+
capabilities: Capabilities.advertise('agent'),
|
|
77
133
|
}),
|
|
78
|
-
signal: AbortSignal.timeout(
|
|
134
|
+
signal: AbortSignal.timeout(APPROVE_TIMEOUT_MS), // waits on a human, not a machine
|
|
79
135
|
});
|
|
80
136
|
if (!res.ok) throw new PhoneError(`authorize failed (${res.status})`, res.status);
|
|
81
137
|
const body = await res.json();
|
|
@@ -100,12 +156,24 @@ async function getSession(rec, deps) {
|
|
|
100
156
|
}
|
|
101
157
|
|
|
102
158
|
// Map the §9-profile inner plaintext to the vault payload shape the projection
|
|
103
|
-
// expects. Profile inner is { data: <fingerprint>, metrics:
|
|
104
|
-
// sessions}, scopes } (mobile localServer handleProfile + popup
|
|
105
|
-
|
|
159
|
+
// expects. Profile inner is { data: <fingerprint>, user_configuration?, metrics:
|
|
160
|
+
// {signals, ling, sessions}, scopes } (mobile localServer handleProfile + popup
|
|
161
|
+
// restoreMetrics). The optional §5 sibling (config home migration, part 3) maps
|
|
162
|
+
// to phaedo_user_configuration, activating the canonical authored read
|
|
163
|
+
// (consult-policy loadPolicies / context-block renderContextBlock); absent —
|
|
164
|
+
// pre-part-3 phone — consumers fall back to the fingerprint A-home unchanged.
|
|
165
|
+
export function toVaultPayload(inner) {
|
|
106
166
|
const m = inner.metrics || {};
|
|
107
167
|
return {
|
|
108
168
|
phaedo_fingerprint: inner.data,
|
|
169
|
+
...(inner.user_configuration && typeof inner.user_configuration === 'object'
|
|
170
|
+
? { phaedo_user_configuration: inner.user_configuration } : {}),
|
|
171
|
+
// §8.3 signing public key — a sibling of the fingerprint, carried through so a
|
|
172
|
+
// consumer can VERIFY a signature the phone produced (e.g. an AP2 mandate) against a
|
|
173
|
+
// key learned over the trusted pairing channel, not one asserted in the signed object
|
|
174
|
+
// itself. A plain non-empty string or nothing; never trusted from inside `data`.
|
|
175
|
+
...(typeof inner.signing_public_key === 'string' && inner.signing_public_key
|
|
176
|
+
? { phaedo_signing_public_key: inner.signing_public_key } : {}),
|
|
109
177
|
phaedo_behavioral_signals: m.signals || {},
|
|
110
178
|
phaedo_linguistic_profile: m.ling || {},
|
|
111
179
|
phaedo_session_metrics: m.sessions || {},
|
package/projection.js
CHANGED
|
@@ -87,7 +87,10 @@ export function buildInjectionResponse(vd, request = {}, now = Date.now()) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
const { vd: selectedVd, included } = selectLayers(vd, request.requested_layers);
|
|
90
|
-
|
|
90
|
+
// §5.2 v0.2: the §9.2 session_context (domain / task_hint) drives deterministic
|
|
91
|
+
// activation of domain/task-scoped standing instructions; absent, scoped rules
|
|
92
|
+
// render with a conditional prefix instead.
|
|
93
|
+
const projection = PhaedoContext.renderContextBlock(selectedVd, { sessionContext: request.session_context || null });
|
|
91
94
|
if (!projection) {
|
|
92
95
|
throw new PhaedoError('decryption_failed', 'Fingerprint produced no projection (empty or missing layers).');
|
|
93
96
|
}
|
package/pull.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
9
|
import { dirname } from 'path';
|
|
10
|
-
import { loadFingerprint } from './fingerprint-source.js';
|
|
10
|
+
import { loadFingerprint, sourceOf } from './fingerprint-source.js';
|
|
11
11
|
|
|
12
12
|
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
|
|
@@ -15,10 +15,26 @@ loadFingerprint(BASE_DIR)
|
|
|
15
15
|
.then((vd) => {
|
|
16
16
|
const fp = (vd && vd.phaedo_fingerprint) || {};
|
|
17
17
|
const layers = fp.layers ? Object.keys(fp.layers).length : 0;
|
|
18
|
+
const src = sourceOf(vd);
|
|
19
|
+
// This line used to say "from the phone" unconditionally — including when the pull had
|
|
20
|
+
// failed and the at-rest cache was served instead. That is precisely how a 22-hour-old
|
|
21
|
+
// profile went unnoticed while the agent answered authorization questions from it. The
|
|
22
|
+
// whole point of this command is to verify the pairing works, so it has to distinguish
|
|
23
|
+
// "your phone answered" from "your phone didn't, here's an old copy".
|
|
24
|
+
if (src.live) {
|
|
25
|
+
process.stdout.write(
|
|
26
|
+
`✓ Read your profile from the phone — fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s).\n`
|
|
27
|
+
);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
const hours = src.ageMs != null ? Math.round(src.ageMs / 3600000) : null;
|
|
18
31
|
process.stdout.write(
|
|
19
|
-
|
|
32
|
+
`⚠ Could NOT reach your phone — served a CACHED copy${hours != null ? ` about ${hours}h old` : ''} ` +
|
|
33
|
+
`(fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s)).\n` +
|
|
34
|
+
` Anything you changed since then — including a revoked authorization — is NOT reflected.\n` +
|
|
35
|
+
` Open Phaedo on your phone and approve the card, then run this again.\n`
|
|
20
36
|
);
|
|
21
|
-
process.exit(
|
|
37
|
+
process.exit(2); // distinct from 1 (hard failure) — stale, not broken
|
|
22
38
|
})
|
|
23
39
|
.catch((err) => {
|
|
24
40
|
process.stderr.write(`✗ Couldn't read your profile: ${err.message}\n`);
|
package/receipts-sync.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// §10.6 receipts audit channel — MCP ↔ phone transport (G1+G2).
|
|
2
|
+
//
|
|
3
|
+
// Closes the act-as-me learning loop: today receipts written by `phaedo_consult` /
|
|
4
|
+
// `phaedo_escalate` sit at `user_action: null` until the subject runs `node receipts.js
|
|
5
|
+
// mark` — a dev CLI only. This module puts the audit on the subject's PHONE so they can
|
|
6
|
+
// mark outcomes in the flow, which then feeds delegation learning (consult-core.js
|
|
7
|
+
// `deriveDelegationSignals`).
|
|
8
|
+
//
|
|
9
|
+
// Two directions over the existing fp-mailbox transport (relay/mailbox.js):
|
|
10
|
+
//
|
|
11
|
+
// agent_to_phone — MCP deposits a digest of the latest UNMARKED receipts (a single
|
|
12
|
+
// payload with at most N entries, last-write-wins idempotent: the
|
|
13
|
+
// phone always sees the latest snapshot). §10.4-safe: only the
|
|
14
|
+
// fields the phone needs to render — receipt_id, created_at, via,
|
|
15
|
+
// agent_id, action_descriptor, response.signal — never `drivers`
|
|
16
|
+
// (local provenance) and never layer content.
|
|
17
|
+
// phone_to_agent — phone deposits a BATCH of `{ receipt_id, outcome }`; this module
|
|
18
|
+
// drains and calls `markOutcome` per mark. Idempotent on the MCP
|
|
19
|
+
// side (re-marking the same outcome is a no-op), so re-draining is
|
|
20
|
+
// safe and the phone need not track per-mark ACKs — the next
|
|
21
|
+
// receipts digest reflects applied marks (`user_action` set).
|
|
22
|
+
//
|
|
23
|
+
// Crypto: fp_sync_key derived from this server's pair_key. Drained by the PHONE, whose
|
|
24
|
+
// multi-client loop tries each verified client's key — so independent pairing is fine
|
|
25
|
+
// here (unlike agent_to_ext, see delegation-sync.js). The relay only ever sees
|
|
26
|
+
// ciphertext. Best-effort and isolated:
|
|
27
|
+
// a deposit/drain failure must NEVER break the tool that triggered it.
|
|
28
|
+
|
|
29
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
30
|
+
import { readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
31
|
+
import { agreementDigest } from './agreement.js';
|
|
32
|
+
|
|
33
|
+
const { Kdf, Envelope } = Phaedo;
|
|
34
|
+
const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
|
|
35
|
+
const DIGEST_CAP = 50; // phone shows recent N — enough for the latest sittings, bounded payload
|
|
36
|
+
|
|
37
|
+
export function relayBaseFromEnv(env = process.env) {
|
|
38
|
+
return env.PHAEDO_RELAY || DEFAULT_RELAY;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Strip a receipt down to the §10.4-safe fields the phone renders. NEVER includes
|
|
42
|
+
// `drivers` (local provenance cues that would expose Decision&Risk reasoning to the
|
|
43
|
+
// subject device — fine on this device, but the audit UI doesn't need them and not
|
|
44
|
+
// shipping them keeps the digest small + the boundary tight).
|
|
45
|
+
function digestFields(r) {
|
|
46
|
+
if (!r || typeof r !== 'object') return null;
|
|
47
|
+
return {
|
|
48
|
+
receipt_id: r.receipt_id,
|
|
49
|
+
created_at: r.created_at,
|
|
50
|
+
via: r.via,
|
|
51
|
+
agent_id: r.agent_id || null,
|
|
52
|
+
// The paired client that actually SERVED the call, beside the vendor string it
|
|
53
|
+
// claimed. §10.4-safe (an opaque id the subject's own phone issued, not persona
|
|
54
|
+
// content) and the phone is the one surface that can render it usefully: it holds
|
|
55
|
+
// the client registry, so it can show the friendly NAME where the receipt holds
|
|
56
|
+
// only the id. Without it the Activity screen can only report what the agent said
|
|
57
|
+
// it was — which is precisely the claim §10.5 stopped trusting.
|
|
58
|
+
client_id: r.client_id || null,
|
|
59
|
+
request: {
|
|
60
|
+
consultation_type: r?.request?.consultation_type || null,
|
|
61
|
+
action_descriptor: {
|
|
62
|
+
domain: r?.request?.action_descriptor?.domain ?? null,
|
|
63
|
+
reversible: r?.request?.action_descriptor?.reversible ?? null,
|
|
64
|
+
magnitude: r?.request?.action_descriptor?.magnitude ?? null,
|
|
65
|
+
amount: r?.request?.action_descriptor?.amount ?? null,
|
|
66
|
+
summary: r?.request?.action_descriptor?.summary ?? null,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
response: {
|
|
70
|
+
signal: r?.response?.signal ?? null,
|
|
71
|
+
deference_level: r?.response?.deference_level ?? null,
|
|
72
|
+
},
|
|
73
|
+
user_action: r.user_action ?? null, // already-marked entries stay visible (phone shows status)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pure-crypto: take the local receipts list, build a §10.4-safe digest of the most
|
|
78
|
+
// recent `cap` entries, and seal it under fp_sync_key. Returns `{ envelope }` — the
|
|
79
|
+
// same shape `agent_to_ext` (delegation-sync) uses, so the relay's mailbox accepts it.
|
|
80
|
+
export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString() } = {}) {
|
|
81
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
82
|
+
// Most-recent N (receipts are append-only, ordered by creation in the store).
|
|
83
|
+
const head = list.slice(-cap).map(digestFields).filter(Boolean);
|
|
84
|
+
// Agreement rides the SAME digest, but is computed over the WHOLE store, not the
|
|
85
|
+
// capped head — the rate is a 30-day property of the subject's history, and slicing
|
|
86
|
+
// it to the last 50 entries would silently redefine the metric.
|
|
87
|
+
const generatedAt = now();
|
|
88
|
+
const agreementAt = Date.parse(generatedAt);
|
|
89
|
+
const agreement = agreementDigest(list, { now: Number.isFinite(agreementAt) ? agreementAt : Date.now() });
|
|
90
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
91
|
+
let fpSyncKey;
|
|
92
|
+
try {
|
|
93
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
94
|
+
} finally {
|
|
95
|
+
pairKey.fill(0);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, generated_at: generatedAt } };
|
|
99
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
100
|
+
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'receipts_digest', created_at: now() });
|
|
101
|
+
plaintext.fill(0);
|
|
102
|
+
return { envelope };
|
|
103
|
+
} finally {
|
|
104
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Decrypt a phone-deposited marks payload. Returns `{ marks: [{receipt_id, outcome}, ...] }`
|
|
109
|
+
// or null on a malformed/wrong-key envelope. Pure (no I/O) so it's testable.
|
|
110
|
+
export async function parseMarksDeposit(pairKeyB64u, envelope) {
|
|
111
|
+
if (!envelope) return null;
|
|
112
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
113
|
+
let fpSyncKey;
|
|
114
|
+
try { fpSyncKey = await Kdf.deriveFpSyncKey(pairKey); }
|
|
115
|
+
finally { pairKey.fill(0); }
|
|
116
|
+
try {
|
|
117
|
+
const plaintext = await Envelope.unwrap(envelope, fpSyncKey);
|
|
118
|
+
let inner;
|
|
119
|
+
try { inner = JSON.parse(new TextDecoder().decode(plaintext)); }
|
|
120
|
+
finally { if (plaintext.fill) plaintext.fill(0); }
|
|
121
|
+
if (!inner || inner.kind !== 'receipt_marks' || !inner.data) return null;
|
|
122
|
+
const arr = Array.isArray(inner.data.marks) ? inner.data.marks : [];
|
|
123
|
+
return {
|
|
124
|
+
marks: arr
|
|
125
|
+
.map((m) => ({
|
|
126
|
+
receipt_id: typeof m?.receipt_id === 'string' ? m.receipt_id : null,
|
|
127
|
+
outcome: typeof m?.outcome === 'string' ? m.outcome : null,
|
|
128
|
+
}))
|
|
129
|
+
.filter((m) => m.receipt_id && USER_ACTIONS.includes(m.outcome)),
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
} finally {
|
|
134
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Deposit a receipts digest to the relay's agent_to_phone slot. Best-effort; mirrors
|
|
139
|
+
// `depositDelegationSuggestions` exactly (same auth, same timeout, same isolation —
|
|
140
|
+
// a failure NEVER throws out into the consult/escalate path that called us).
|
|
141
|
+
export async function depositReceiptsDigest(pairing, receipts, deps = {}) {
|
|
142
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
143
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
144
|
+
if (!list.length) return { deposited: false, reason: 'no-receipts' };
|
|
145
|
+
if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
|
|
146
|
+
if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
|
|
147
|
+
try {
|
|
148
|
+
const body = await buildReceiptsDeposit(pairing.pair_key, list, deps);
|
|
149
|
+
const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/agent_to_phone`;
|
|
150
|
+
const authHeader = pairing.relay_device_secret
|
|
151
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
152
|
+
: {};
|
|
153
|
+
const res = await fetchFn(url, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'Content-Type': 'application/json', ...authHeader },
|
|
156
|
+
body: JSON.stringify(body),
|
|
157
|
+
signal: AbortSignal.timeout(deps.timeoutMs || 8000),
|
|
158
|
+
});
|
|
159
|
+
return { deposited: !!(res && res.ok), status: res && res.status, count: list.length };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { deposited: false, reason: String(e && e.message || e) };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Drain phone-deposited outcome marks from the phone_to_agent slot and apply each via
|
|
166
|
+
// markOutcome (idempotent). Returns `{ applied, skipped }`. Best-effort; a transport
|
|
167
|
+
// failure returns `{ applied: 0, skipped: 0, reason }` instead of throwing.
|
|
168
|
+
export async function drainAndApplyMarks(pairing, stateDir, deps = {}) {
|
|
169
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
170
|
+
if (!pairing || !pairing.pair_key) return { applied: 0, skipped: 0, reason: 'unpaired' };
|
|
171
|
+
if (!pairing.relay_device_id) return { applied: 0, skipped: 0, reason: 'no-relay-device-id' };
|
|
172
|
+
try {
|
|
173
|
+
const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/phone_to_agent`;
|
|
174
|
+
const authHeader = pairing.relay_device_secret
|
|
175
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
176
|
+
: {};
|
|
177
|
+
const res = await fetchFn(url, {
|
|
178
|
+
method: 'GET',
|
|
179
|
+
headers: authHeader,
|
|
180
|
+
signal: AbortSignal.timeout(deps.timeoutMs || 8000),
|
|
181
|
+
});
|
|
182
|
+
if (!res || !res.ok) return { applied: 0, skipped: 0, status: res && res.status };
|
|
183
|
+
const body = await res.json().catch(() => null);
|
|
184
|
+
const parsed = await parseMarksDeposit(pairing.pair_key, body && body.envelope);
|
|
185
|
+
if (!parsed) return { applied: 0, skipped: 0, reason: 'no-marks' };
|
|
186
|
+
let applied = 0, skipped = 0;
|
|
187
|
+
for (const m of parsed.marks) {
|
|
188
|
+
try {
|
|
189
|
+
const r = await markOutcome(stateDir, m.receipt_id, m.outcome);
|
|
190
|
+
if (r.matched) applied++; else skipped++;
|
|
191
|
+
} catch { skipped++; }
|
|
192
|
+
}
|
|
193
|
+
return { applied, skipped, total: parsed.marks.length };
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { applied: 0, skipped: 0, reason: String(e && e.message || e) };
|
|
196
|
+
}
|
|
197
|
+
}
|