@passioncode-ai/fabric-agent-adapter 0.34.0 → 0.34.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/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.1 - 2026-10-15
4
+
5
+ A review of 0.33.0 and 0.34.0 found the profile and route rules disagreeing with the contract on JSON they never met in
6
+ its vectors. The kits now answer as the contract's TypeScript does on any input. The contract pin is unchanged
7
+ (`d8146cf`).
8
+
9
+ ### Fixed
10
+
11
+ - **FAC-SEM-059 (`runner-profile`) compared loosely.** Python read a missing member and `null` as the same and `1`
12
+ and `true` as equal: an unknown login with `runnerKind: null`, or an allow-list `true` against a profile `1`, passed.
13
+ Node threw on a login or runner kind whose own `toString` member is data. Both now compare with `===`, show such a
14
+ value as its JSON, and read only the vendor table's own members.
15
+ - **FAC-SEM-060 compared a recorded resolution loosely.** Python's `!=` called `1` and `true` equal; Node's
16
+ `JSON.stringify` comparison called `NaN` and `null` equal. Both now use the contract's `jsonEqual`, where NaN equals
17
+ nothing.
18
+ - **A refusal (`model-classes-empty`) named its policy wrongly on hostile input.** Node threw on an id or revision
19
+ whose own `toString` is data; Python wrote `[object Object]`, read `null` as `undefined`, and did not read a revision
20
+ as `Number()` does (`"0x10"`, `[5]`, `null`). Both now use the contract's `show` and `toNumber`; a revision that is
21
+ not a number is written as null.
22
+ - **Route rules (FAC-SEM-028…034) threw or misread hostile JSON.** Node used `String()` where the contract uses `show`;
23
+ Python wrote `[object Object]`, and compared a probe's runner kind and a drive with `!=` (`1` equal to `true`).
24
+ - **The doctor's `model_usable` raised on a catalogue row whose id is an object** (Python), and threw on a policy id
25
+ whose own `toString` is data (Node).
26
+ - **Python wrote and compared JSON values unlike JavaScript** (found by an independent review of this fix):
27
+ - a lone surrogate (`"\ud800"`, valid JSON) raised `TypeError` wherever a message wrote a value, because
28
+ `_json_stringify` used the RFC 8785 writer that refuses one; it now escapes it as `JSON.stringify` does;
29
+ - members whose keys are array indexes came out in the order written, where JavaScript writes them first and
30
+ ascending, so two equal hostile runner kinds were not a FAC-SEM-028 duplicate;
31
+ - integers past 2**53 compared exactly, where JavaScript compares doubles (`9007199254740993` is
32
+ `9007199254740992`), and integers past the double range raised `OverflowError` where JavaScript reads Infinity;
33
+ - a probe index was written as an exact integer instead of JavaScript's number (`1e+21`).
34
+ These helpers are shared with the estate and identifier kits, whose vectors stay green.
35
+ - **Node looked a rule kind up on inherited members**: `routeProblems("__proto__", …)` threw and `"constructor"`
36
+ answered the value; both now answer `undefined`, as the contract does.
37
+ - **The task board broke the workspace's publication**: rows FAA-28 and FAA-27a split into six cells (an escaped
38
+ `\|`, and a fragment a 0.31.0 edit left behind). Both are fixed, and `test/validate.py` now refuses any board row that
39
+ does not split into the header's columns.
40
+
41
+ ### Evidence
42
+
43
+ - 38 new profile cases and 21 new route cases (`make_profile_vectors.mjs`, `make_route_vectors.mjs`), each answered by
44
+ the contract's own rules; cases JavaScript cannot carry as an object (big integers, key order) keep their JSON text.
45
+ Against 0.34.0's kits the first 44 of them fail 27 Python subtests and two Node tests.
46
+ - The reviewer's side-by-side comparison of the contract, the Node kit and the Python kit on 17 hostile inputs: 17 of
47
+ 17 identical after the fix (Python 1 of 17 before).
48
+ - Unit tests for the doctor and the rule dispatch on hostile input, in both kits; `BoardRowTests` for the board check.
49
+ - Planted defects in the new code, all caught; two were redundant code, now removed.
50
+
3
51
  ## 0.34.0 - 2026-10-15
4
52
 
5
53
  The adapter is pinned at contract `d8146cf`. The profile kits now read what a lane reads (DEC-0083; FAA-45, contract
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.34.0",
3
+ "version": "0.34.1",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.34.0",
6
+ "version": "0.34.1",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.34.0"
8
+ version: "0.34.1"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
11
11
  ---
@@ -163,12 +163,19 @@ def normal_identifier(value: Any) -> Optional[str]:
163
163
  return _urn_form(authority, path, query) if scheme.lower() == "urn" else _generic_form(scheme, authority, path, query, fragment)
164
164
 
165
165
 
166
+ def _double(value: Any) -> float:
167
+ try:
168
+ return float(value)
169
+ except OverflowError:
170
+ return float("inf") if value > 0 else float("-inf")
171
+
172
+
166
173
  def _strict_equal(a: Any, b: Any) -> bool:
167
174
  """JavaScript's === on parsed JSON: numbers by value, booleans only with booleans, objects by identity."""
168
175
  if isinstance(a, bool) or isinstance(b, bool):
169
176
  return isinstance(a, bool) and isinstance(b, bool) and a == b
170
177
  if isinstance(a, (int, float)) and isinstance(b, (int, float)):
171
- return a == b
178
+ return _double(a) == _double(b) # as JavaScript holds them: 2**53 + 1 is 2**53
172
179
  if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
173
180
  return a is b
174
181
  return type(a) is type(b) and a == b
@@ -18,7 +18,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
18
18
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.34.0"
21
+ version: "0.34.1"
22
22
  contract-version: "0.1.0"
23
23
  extension: "fabric-service/0.1"
24
24
  extension-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
@@ -39,7 +39,9 @@ and name the policy; never fall back to a model outside it. No level at all is `
39
39
  (a model catalogued once; a provider never both ordered and ignored, a base slug covering its variants), a
40
40
  `model-profiles` set (an id once), a `runner-profile` (a subscription only in its vendor's runner — Claude in Claude
41
41
  Code, Kimi in Kimi Code, ChatGPT in Codex; a key route's allow-list is its own model profile; a key named by reference,
42
- never by value) and a recorded `profile-resolution` (it equals the order's answer).
42
+ never by value) and a recorded `profile-resolution` (it equals the order's answer). Every rule compares as the contract
43
+ does — `===` (`1` and `true` differ, `null` is not absent) and its `jsonEqual` for a recorded resolution, where NaN
44
+ equals nothing — and never raises on hostile JSON: a value whose own `toString` member is data is shown as its JSON.
43
45
 
44
46
  ## A lane that reads user content
45
47
 
@@ -36,12 +36,25 @@ const show = (value) => {
36
36
  return JSON.stringify(value) ?? typeof value;
37
37
  }
38
38
  };
39
- const jsonEqual = (a, b) => JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
40
- function sortKeys(value) {
41
- if (Array.isArray(value)) return value.map(sortKeys);
42
- if (isObject(value)) return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
43
- return value;
39
+ /** The contract's jsonEqual: arrays by item, objects by own member whatever the order, anything else by ===, so 1 and
40
+ * true differ and NaN equals nothing (a JSON.stringify comparison would call NaN and null equal). */
41
+ function jsonEqual(a, b) {
42
+ if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]));
43
+ if (isObject(a) || isObject(b)) {
44
+ if (!isObject(a) || !isObject(b)) return false;
45
+ const keys = Object.keys(a);
46
+ return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && jsonEqual(a[key], b[key]));
47
+ }
48
+ return a === b;
44
49
  }
50
+ /** The contract's toNumber: Number(value), or NaN where Number() throws (an object whose own toString is data). */
51
+ const toNumber = (value) => {
52
+ try {
53
+ return Number(value);
54
+ } catch {
55
+ return Number.NaN;
56
+ }
57
+ };
45
58
 
46
59
  /** FAC-SEM-060: the request's profile, else the agent's, else the binding's or project's, else the estate default. */
47
60
  export function resolveModelProfile({ catalogue, profiles, choices = {}, policy } = {}) {
@@ -62,7 +75,7 @@ export function resolveModelProfile({ catalogue, profiles, choices = {}, policy
62
75
  });
63
76
  if (within.length) return { from: level, profile: id, models: within };
64
77
  const meta = isObject(policy.meta) ? policy.meta : {};
65
- return { refused: 'model-classes-empty', profile: id, policy: { id: String(meta.id), revision: Number(meta.revision) } };
78
+ return { refused: 'model-classes-empty', profile: id, policy: { id: show(meta.id), revision: toNumber(meta.revision) } };
66
79
  }
67
80
 
68
81
  const finding = (code, instancePath, message) => ({ code, instancePath, message });
@@ -95,12 +108,12 @@ function modelProfiles(value) {
95
108
  function runnerProfile(value) {
96
109
  const findings = [];
97
110
  if (value.route === 'subscription' && isObject(value.subscription)) {
98
- const login = String(value.subscription.login);
111
+ const login = show(value.subscription.login);
99
112
  const runner = Object.hasOwn(SUBSCRIPTION_RUNNER, login) ? SUBSCRIPTION_RUNNER[login] : undefined;
100
- if (runner !== value.runnerKind) findings.push(finding('FAC-SEM-059', '/runnerKind', `a ${login} subscription runs only in ${runner ?? "its vendor's runner"}, not in ${String(value.runnerKind)}; another runner takes the key route`));
113
+ if (runner !== value.runnerKind) findings.push(finding('FAC-SEM-059', '/runnerKind', `a ${login} subscription runs only in ${runner ?? "its vendor's runner"}, not in ${show(value.runnerKind)}; another runner takes the key route`));
101
114
  }
102
115
  if (value.route === 'key' && isObject(value.key)) {
103
- if (value.key.modelAllowList !== value.modelProfile) findings.push(finding('FAC-SEM-059', '/key/modelAllowList', `the key admits the models of ${String(value.key.modelAllowList)}, but the profile runs ${String(value.modelProfile)}`));
116
+ if (value.key.modelAllowList !== value.modelProfile) findings.push(finding('FAC-SEM-059', '/key/modelAllowList', `the key admits the models of ${show(value.key.modelAllowList)}, but the profile runs ${show(value.modelProfile)}`));
104
117
  if (typeof value.key.ref === 'string' && secretValue(value.key.ref)) findings.push(finding('FAC-SEM-059', '/key/ref', 'the key is named by its secret reference, never by its value'));
105
118
  }
106
119
  return findings;
@@ -167,7 +180,9 @@ function openVendorRoutes(value) {
167
180
 
168
181
  /** The contract's profileRules: findings for a model-catalogue, model-profiles, runner-profile or profile-resolution; else undefined. */
169
182
  export function profileProblems(kind, value) {
170
- return { 'model-catalogue': modelCatalogue, 'model-profiles': modelProfiles, 'runner-profile': runnerProfile, 'profile-resolution': (v) => [...profileResolution(v), ...openVendorRoutes(v)] }[kind]?.(value);
183
+ const table = { 'model-catalogue': modelCatalogue, 'model-profiles': modelProfiles, 'runner-profile': runnerProfile, 'profile-resolution': (v) => [...profileResolution(v), ...openVendorRoutes(v)] };
184
+ // Own members only: a kind such as `constructor` or `__proto__` names no rule.
185
+ return typeof kind === 'string' && Object.hasOwn(table, kind) ? table[kind](value) : undefined;
171
186
  }
172
187
 
173
188
  // ---- the doctor's checks ----
@@ -231,15 +246,16 @@ export function runnerCheck(entry, { searchPath = process.env.PATH, run = spawnS
231
246
 
232
247
  /** Problems with running `model` here: not in the catalogue, or a class the project's policy does not allow. */
233
248
  export function modelUsable(model, catalogue, policy) {
249
+ // The last row of an id wins, as the Map the resolver builds; a model that is not a string matches no row.
234
250
  const rows = new Map(list(isObject(catalogue) ? catalogue.models : undefined).map((row) => [row.id, row]));
235
- if (!rows.has(model)) return [`model ${model} is not in the model catalogue`];
251
+ if (typeof model !== 'string' || !rows.has(model)) return [`model ${show(model)} is not in the model catalogue`];
236
252
  if (isObject(policy) && Array.isArray(policy.modelClasses)) {
237
253
  const allowed = new Set(strings(policy.modelClasses));
254
+ const policyId = show((isObject(policy.meta) ? policy.meta : {}).id);
238
255
  const admission = strings(rows.get(model).classes).filter((c) => c !== OPEN_VENDOR);
239
- const policyId = String((isObject(policy.meta) ? policy.meta : {}).id);
240
256
  if (!admission.length) return [`model ${model} has no class but ${OPEN_VENDOR}, which admits nothing, so policy ${policyId} does not allow it`];
241
257
  const outside = admission.filter((c) => !allowed.has(c));
242
- if (outside.length) return [`model ${model} has class ${outside.join(', ')}, which policy ${String((isObject(policy.meta) ? policy.meta : {}).id)} does not allow`];
258
+ if (outside.length) return [`model ${model} has class ${outside.join(', ')}, which policy ${policyId} does not allow`];
243
259
  }
244
260
  return [];
245
261
  }
@@ -277,14 +293,14 @@ export const PROBE_RESULTS = Object.freeze(['not-catalogued', 'not-admitted', 'n
277
293
  const routeCandidates = (value) => (Array.isArray(value?.candidates) ? value.candidates.filter(isObject) : []);
278
294
  const objectAt = (value, key) => (isObject(value?.[key]) ? value[key] : {});
279
295
  // A revision reference compared whole; its id is an identifier, so it enters the key in its normal form (DEC-0076).
280
- const refKey = (ref) => (isObject(ref) ? `${String(normalIdentifier(ref.id) ?? ref.id)}@${String(ref.revision)}#${String(ref.contentHash)}` : '');
296
+ const refKey = (ref) => (isObject(ref) ? `${show(normalIdentifier(ref.id) ?? ref.id)}@${show(ref.revision)}#${show(ref.contentHash)}` : '');
281
297
  const sameRef = (a, b) => refKey(a) !== '' && refKey(a) === refKey(b);
282
298
 
283
299
  function runnerRoute(value) {
284
300
  const findings = [];
285
301
  const kinds = new Set();
286
302
  routeCandidates(value).forEach((candidate, index) => {
287
- const kind = String(candidate.runnerKind);
303
+ const kind = show(candidate.runnerKind);
288
304
  if (kinds.has(kind)) findings.push(finding('FAC-SEM-028', `/candidates/${index}/runnerKind`, `runner kind ${kind} appears in the route more than once`));
289
305
  kinds.add(kind);
290
306
  const session = objectAt(candidate, 'session');
@@ -300,25 +316,25 @@ function routeBundle(value) {
300
316
  const findings = [];
301
317
  routeCandidates(route).forEach((candidate, index) => {
302
318
  const key = refKey(candidate.provider);
303
- if (!admitted.has(key)) findings.push(finding('FAC-SEM-030', `/route/candidates/${index}/provider`, `candidate ${String(candidate.runnerKind)} names provider ${key}, which is not a revision admitted for ${String(route.capability)}`));
319
+ if (!admitted.has(key)) findings.push(finding('FAC-SEM-030', `/route/candidates/${index}/provider`, `candidate ${show(candidate.runnerKind)} names provider ${key}, which is not a revision admitted for ${show(route.capability)}`));
304
320
  });
305
321
  if (isObject(value.binding)) {
306
322
  const binding = value.binding;
307
323
  const disagree = (where, message) => findings.push(finding('FAC-SEM-032', `/binding/${where}`, message));
308
324
  // DEC-0076: project and capability are identifiers, compared by normal form.
309
- if (!sameIdentifier(binding.project, route.project)) disagree('project', `the binding is for ${String(binding.project)}, the route for ${String(route.project)}`);
310
- if (!sameIdentifier(binding.capability, route.capability)) disagree('capability', `the binding binds ${String(binding.capability)}, the route orders ${String(route.capability)}`);
311
- if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${String(binding.profileKind)}`);
325
+ if (!sameIdentifier(binding.project, route.project)) disagree('project', `the binding is for ${show(binding.project)}, the route for ${show(route.project)}`);
326
+ if (!sameIdentifier(binding.capability, route.capability)) disagree('capability', `the binding binds ${show(binding.capability)}, the route orders ${show(route.capability)}`);
327
+ if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${show(binding.profileKind)}`);
312
328
  if (!routeCandidates(route).some((c) => sameRef(c.provider, binding.provider))) disagree('provider', `the binding's provider ${refKey(binding.provider)} is not a candidate of the route`);
313
329
  if (binding.runnerRoute !== undefined && !sameRef(binding.runnerRoute, route.meta)) disagree('runnerRoute', `the binding pins ${refKey(binding.runnerRoute)}, not this route ${refKey(route.meta)}`);
314
330
  }
315
331
  if (Array.isArray(value.catalogue)) {
316
- const byKind = new Map(value.catalogue.filter(isObject).map((entry) => [String(entry.kind), entry]));
332
+ const byKind = new Map(value.catalogue.filter(isObject).map((entry) => [show(entry.kind), entry]));
317
333
  routeCandidates(route).forEach((candidate, index) => {
318
- const kind = String(candidate.runnerKind);
334
+ const kind = show(candidate.runnerKind);
319
335
  const entry = byKind.get(kind);
320
336
  if (!entry) { findings.push(finding('FAC-SEM-033', `/route/candidates/${index}/runnerKind`, `runner kind ${kind} has no entry in the host's catalogue`)); return; }
321
- if (candidate.drive !== undefined && !Object.hasOwn(objectAt(entry, 'drives'), String(candidate.drive))) findings.push(finding('FAC-SEM-033', `/route/candidates/${index}/drive`, `the catalogue entry for ${kind} offers no drive ${String(candidate.drive)}`));
337
+ if (candidate.drive !== undefined && !Object.hasOwn(objectAt(entry, 'drives'), show(candidate.drive))) findings.push(finding('FAC-SEM-033', `/route/candidates/${index}/drive`, `the catalogue entry for ${kind} offers no drive ${show(candidate.drive)}`));
322
338
  });
323
339
  }
324
340
  return findings;
@@ -331,14 +347,14 @@ function routeEvent(value) {
331
347
  const findings = [];
332
348
  const wrong = (where, message) => findings.push(finding('FAC-SEM-034', `/event${where}`, message));
333
349
  if (!sameRef(event.route, route.meta)) wrong('/route', `the event names route ${refKey(event.route)}, not ${refKey(route.meta)}`);
334
- if (!sameIdentifier(event.project, route.project)) wrong('/project', `the event is for ${String(event.project)}, the route for ${String(route.project)}`);
335
- if (!sameIdentifier(event.capability, route.capability)) wrong('/capability', `the event names ${String(event.capability)}, the route orders ${String(route.capability)}`);
350
+ if (!sameIdentifier(event.project, route.project)) wrong('/project', `the event is for ${show(event.project)}, the route for ${show(route.project)}`);
351
+ if (!sameIdentifier(event.capability, route.capability)) wrong('/capability', `the event names ${show(event.capability)}, the route orders ${show(route.capability)}`);
336
352
  const indexOf = (ref) => (typeof ref.index === 'number' && Number.isInteger(ref.index) ? ref.index : -1);
337
353
  const candidateAt = (ref, where) => {
338
354
  const index = indexOf(ref);
339
355
  const candidate = index >= 0 ? list[index] : undefined;
340
356
  if (!candidate || candidate.runnerKind !== ref.runnerKind || (ref.provider !== undefined && !sameRef(candidate.provider, ref.provider))) {
341
- wrong(where, `candidate ${String(ref.index)} (${String(ref.runnerKind)}) is not that position of the route`);
357
+ wrong(where, `candidate ${show(ref.index)} (${show(ref.runnerKind)}) is not that position of the route`);
342
358
  return undefined;
343
359
  }
344
360
  return candidate;
@@ -346,8 +362,8 @@ function routeEvent(value) {
346
362
  const probes = Array.isArray(event.probes) ? event.probes.filter(isObject) : [];
347
363
  probes.forEach((probe, position) => {
348
364
  const candidate = candidateAt(probe, `/probes/${position}`);
349
- if (candidate && probe.result === 'no-held-session' && objectAt(candidate, 'session').spawn !== 'never') wrong(`/probes/${position}/result`, `${String(probe.runnerKind)} may spawn, so finding no held session does not pass it over`);
350
- if (candidate && probe.result === 'spawn-failed' && objectAt(candidate, 'session').spawn !== 'allowed') wrong(`/probes/${position}/result`, `${String(probe.runnerKind)} has spawn: never, so it cannot have failed to spawn`);
365
+ if (candidate && probe.result === 'no-held-session' && objectAt(candidate, 'session').spawn !== 'never') wrong(`/probes/${position}/result`, `${show(probe.runnerKind)} may spawn, so finding no held session does not pass it over`);
366
+ if (candidate && probe.result === 'spawn-failed' && objectAt(candidate, 'session').spawn !== 'allowed') wrong(`/probes/${position}/result`, `${show(probe.runnerKind)} has spawn: never, so it cannot have failed to spawn`);
351
367
  });
352
368
  const expectProbes = (count) => {
353
369
  const indexes = probes.map(indexOf);
@@ -365,9 +381,9 @@ function routeEvent(value) {
365
381
  const chosen = candidateAt(selected, '/selected');
366
382
  if (chosen) {
367
383
  const session = objectAt(chosen, 'session');
368
- if (selected.session === 'attached' && session.attach !== 'preferred') wrong('/selected/session', `${String(selected.runnerKind)} has attach: never, so the host cannot attach it`);
369
- if (selected.session === 'spawned' && session.spawn !== 'allowed') wrong('/selected/session', `${String(selected.runnerKind)} has spawn: never, so the host cannot spawn it`);
370
- if (chosen.drive !== undefined && selected.drive !== undefined && selected.drive !== chosen.drive) wrong('/selected/drive', `the route runs ${String(selected.runnerKind)} through ${String(chosen.drive)}, not ${String(selected.drive)}`);
384
+ if (selected.session === 'attached' && session.attach !== 'preferred') wrong('/selected/session', `${show(selected.runnerKind)} has attach: never, so the host cannot attach it`);
385
+ if (selected.session === 'spawned' && session.spawn !== 'allowed') wrong('/selected/session', `${show(selected.runnerKind)} has spawn: never, so the host cannot spawn it`);
386
+ if (chosen.drive !== undefined && selected.drive !== undefined && selected.drive !== chosen.drive) wrong('/selected/drive', `the route runs ${show(selected.runnerKind)} through ${show(chosen.drive)}, not ${show(selected.drive)}`);
371
387
  if (event.sticky === true) {
372
388
  // A sticky relaunch keeps the conversation's runner and walks nothing above it.
373
389
  if (route.recovery === 'reprobe') wrong('/sticky', 'a route with recovery: reprobe walks from the top at every launch');
@@ -394,5 +410,6 @@ function routeEvent(value) {
394
410
  */
395
411
  export function routeProblems(kind, value) {
396
412
  if (!isObject(value)) return undefined;
397
- return { 'runner-route': runnerRoute, 'route-bundle': routeBundle, 'route-event': routeEvent }[kind]?.(value);
413
+ const table = { 'runner-route': runnerRoute, 'route-bundle': routeBundle, 'route-event': routeEvent };
414
+ return typeof kind === 'string' && Object.hasOwn(table, kind) ? table[kind](value) : undefined;
398
415
  }
@@ -90,6 +90,14 @@ def _number(value: Any) -> bool:
90
90
  return isinstance(value, (int, float)) and not isinstance(value, bool)
91
91
 
92
92
 
93
+ def _double(value: Any) -> float:
94
+ """A JSON number as JavaScript holds it: the nearest double, and an integer beyond the double range ±Infinity."""
95
+ try:
96
+ return float(value)
97
+ except OverflowError:
98
+ return math.inf if value > 0 else -math.inf
99
+
100
+
93
101
  def _same(a: Any, b: Any) -> bool:
94
102
  """JavaScript's === (and a Set's SameValueZero) on parsed JSON: an object equals only itself."""
95
103
  if a is _MISSING or b is _MISSING:
@@ -99,7 +107,7 @@ def _same(a: Any, b: Any) -> bool:
99
107
  if isinstance(a, bool) or isinstance(b, bool):
100
108
  return isinstance(a, bool) and isinstance(b, bool) and a == b
101
109
  if _number(a) and _number(b):
102
- return a == b
110
+ return _double(a) == _double(b) # 2**53 + 1 is 2**53 in JavaScript
103
111
  return type(a) is type(b) and a == b
104
112
 
105
113
 
@@ -137,7 +145,7 @@ def _js_to_number(value: Any) -> float:
137
145
  radix = _JS_RADIX.fullmatch(text)
138
146
  if radix:
139
147
  digits, base = next((radix.group(k), b) for k, b in (("x", 16), ("o", 8), ("b", 2)) if radix.group(k))
140
- return float(int(digits, base))
148
+ return _double(int(digits, base))
141
149
  return math.nan
142
150
 
143
151
 
@@ -173,10 +181,37 @@ def _strict_string(value: Any) -> str:
173
181
  return _js_string(value)
174
182
 
175
183
 
184
+ _INDEX_KEY = re.compile(r"0|[1-9][0-9]*", re.ASCII)
185
+
186
+
187
+ def _js_keys(value: Dict[str, Any]) -> List[str]:
188
+ """An object's own keys in the order JavaScript lists them: array-index keys ("0" … "4294967294", no leading zero)
189
+ ascending, then the others in the order written."""
190
+ index = sorted((k for k in value if _INDEX_KEY.fullmatch(k) and int(k) <= 2 ** 32 - 2), key=int)
191
+ taken = set(index)
192
+ return index + [k for k in value if k not in taken]
193
+
194
+
195
+ def _js_json_string(text: str) -> str:
196
+ """JSON.stringify of a string (well-formed, ES2019): a lone surrogate is escaped as \\uXXXX, never refused."""
197
+ out = ['"']
198
+ for ch in text:
199
+ code = ord(ch)
200
+ if ch in _delegation._ESCAPES:
201
+ out.append(_delegation._ESCAPES[ch])
202
+ elif code < 0x20 or 0xD800 <= code <= 0xDFFF:
203
+ out.append("\\u%04x" % code)
204
+ else:
205
+ out.append(ch)
206
+ out.append('"')
207
+ return "".join(out)
208
+
209
+
176
210
  def _json_stringify(value: Any) -> str:
177
- """JSON.stringify for a parsed JSON value: members in their order, numbers as JavaScript writes them."""
211
+ """JSON.stringify for a parsed JSON value: members in JavaScript's key order, numbers as JavaScript writes them, a
212
+ lone surrogate escaped."""
178
213
  if isinstance(value, dict):
179
- return "{" + ",".join(_delegation._string(k) + ":" + _json_stringify(v) for k, v in value.items()) + "}"
214
+ return "{" + ",".join(_js_json_string(k) + ":" + _json_stringify(value[k]) for k in _js_keys(value)) + "}"
180
215
  if isinstance(value, list):
181
216
  return "[" + ",".join(_json_stringify(v) for v in value) + "]"
182
217
  if value is None:
@@ -186,7 +221,7 @@ def _json_stringify(value: Any) -> str:
186
221
  if _number(value):
187
222
  text = _js_string(value)
188
223
  return "null" if text in ("NaN", "Infinity", "-Infinity") else text
189
- return _delegation._string(value)
224
+ return _js_json_string(value)
190
225
 
191
226
 
192
227
  def _show(value: Any) -> str:
@@ -163,12 +163,19 @@ def normal_identifier(value: Any) -> Optional[str]:
163
163
  return _urn_form(authority, path, query) if scheme.lower() == "urn" else _generic_form(scheme, authority, path, query, fragment)
164
164
 
165
165
 
166
+ def _double(value: Any) -> float:
167
+ try:
168
+ return float(value)
169
+ except OverflowError:
170
+ return float("inf") if value > 0 else float("-inf")
171
+
172
+
166
173
  def _strict_equal(a: Any, b: Any) -> bool:
167
174
  """JavaScript's === on parsed JSON: numbers by value, booleans only with booleans, objects by identity."""
168
175
  if isinstance(a, bool) or isinstance(b, bool):
169
176
  return isinstance(a, bool) and isinstance(b, bool) and a == b
170
177
  if isinstance(a, (int, float)) and isinstance(b, (int, float)):
171
- return a == b
178
+ return _double(a) == _double(b) # as JavaScript holds them: 2**53 + 1 is 2**53
172
179
  if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
173
180
  return a is b
174
181
  return type(a) is type(b) and a == b
@@ -43,6 +43,34 @@ def _list(value: Any) -> List[Dict[str, Any]]:
43
43
  return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
44
44
 
45
45
 
46
+ _MISSING_KEY = _estate._MISSING
47
+
48
+
49
+ def _at(value: Any, key: str) -> Any:
50
+ """value[key] as JavaScript reads a member: absent is distinct from null (``_MISSING_KEY``)."""
51
+ return _estate._get(value, key)
52
+
53
+
54
+ def _json_equal(a: Any, b: Any) -> bool:
55
+ """The contract's jsonEqual on parsed JSON: arrays by item, objects by member whatever the order, anything else by
56
+ ===, so 1 and true differ and NaN equals nothing."""
57
+ if isinstance(a, list) or isinstance(b, list):
58
+ return isinstance(a, list) and isinstance(b, list) and len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
59
+ if isinstance(a, dict) or isinstance(b, dict):
60
+ return (isinstance(a, dict) and isinstance(b, dict) and len(a) == len(b)
61
+ and all(k in b and _json_equal(v, b[k]) for k, v in a.items()))
62
+ return _estate._same(a, b) # NaN != NaN in Python too
63
+
64
+
65
+ def _without_nan(value: Any) -> Any:
66
+ """A resolution as JSON writes it: NaN and an infinity become null."""
67
+ if isinstance(value, dict):
68
+ return {k: _without_nan(v) for k, v in value.items()}
69
+ if isinstance(value, list):
70
+ return [_without_nan(v) for v in value]
71
+ return None if isinstance(value, float) and not math.isfinite(value) else value
72
+
73
+
46
74
  def _indexed(value: Any) -> List[tuple]:
47
75
  """The object items of an array with their index as written, so an instancePath names the item even after a
48
76
  non-object (the contract's ``indexed``)."""
@@ -59,7 +87,7 @@ def _set_key(value: Any) -> Any:
59
87
  if isinstance(value, bool):
60
88
  return ("boolean", value)
61
89
  if isinstance(value, (int, float)):
62
- return ("number", float(value))
90
+ return ("number", _estate._double(value))
63
91
  if isinstance(value, str):
64
92
  return ("string", value)
65
93
  return ("object", id(value))
@@ -76,40 +104,12 @@ def _strings(value: Any) -> List[str]:
76
104
  return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
77
105
 
78
106
 
79
- def _js_string(value: Any) -> str:
80
- """JavaScript's String(value), which the contract's messages and policy ids use."""
81
- if value is None:
82
- return "undefined"
83
- if value is True:
84
- return "true"
85
- if value is False:
86
- return "false"
87
- if isinstance(value, float) and value.is_integer():
88
- return str(int(value))
89
- if isinstance(value, (dict, list)):
90
- return "[object Object]" if isinstance(value, dict) else ",".join(_js_string(v) for v in value)
91
- return str(value)
92
-
93
-
94
- def _js_number(value: Any) -> Optional[float]:
95
- """JavaScript's Number(value); NaN is None (JSON writes it as null)."""
96
- if isinstance(value, bool):
97
- return 1 if value else 0
98
- if isinstance(value, (int, float)):
99
- return value if not (isinstance(value, float) and math.isnan(value)) else None
100
- if isinstance(value, str):
101
- text = value.strip()
102
- if text == "":
103
- return 0
104
- try:
105
- number = float(text)
106
- except ValueError:
107
- return None
108
- return int(number) if number.is_integer() else number
109
- return None
107
+ def resolve_model_profile(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
108
+ """FAC-SEM-060, as JSON writes the contract's answer (a policy revision that is not a number is null)."""
109
+ return _without_nan(_resolve(catalogue=catalogue, profiles=profiles, choices=choices, policy=policy))
110
110
 
111
111
 
112
- def resolve_model_profile(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
112
+ def _resolve(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
113
113
  """FAC-SEM-060: the request's profile, else the agent's, else the binding's or project's, else the estate default.
114
114
  When the project's policy names `modelClasses`, a model stays only if every class the catalogue gives it is
115
115
  allowed; the descriptive class `open-vendor` is not checked (DEC-0083), and a model with no other class does not stay.
@@ -137,7 +137,8 @@ def resolve_model_profile(*, catalogue: Any = None, profiles: Any = None, choice
137
137
  if within:
138
138
  return {"from": level, "profile": profile_id, "models": within}
139
139
  meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
140
- return {"refused": "model-classes-empty", "profile": profile_id, "policy": {"id": _js_string(meta.get("id")), "revision": _js_number(meta.get("revision"))}}
140
+ return {"refused": "model-classes-empty", "profile": profile_id,
141
+ "policy": {"id": _estate._show(_at(meta, "id")), "revision": _estate._js_to_number(_at(meta, "revision"))}}
141
142
 
142
143
 
143
144
  def _finding(code: str, path: str, message: str) -> Dict[str, str]:
@@ -170,16 +171,16 @@ def _model_profiles(value: Dict[str, Any]) -> List[Dict[str, str]]:
170
171
  def _runner_profile(value: Dict[str, Any]) -> List[Dict[str, str]]:
171
172
  findings = []
172
173
  if value.get("route") == "subscription" and isinstance(value.get("subscription"), dict):
173
- login = _js_string(value["subscription"].get("login"))
174
- runner = SUBSCRIPTION_RUNNER.get(login)
175
- if runner != value.get("runnerKind"):
174
+ login = _estate._show(_at(value["subscription"], "login"))
175
+ runner = SUBSCRIPTION_RUNNER.get(login, _MISSING_KEY)
176
+ if not _estate._same(runner, _at(value, "runnerKind")):
176
177
  findings.append(_finding("FAC-SEM-059", "/runnerKind", "a %s subscription runs only in %s, not in %s; another runner takes the key route"
177
- % (login, runner if runner is not None else "its vendor's runner", _js_string(value.get("runnerKind")))))
178
+ % (login, runner if runner is not _MISSING_KEY else "its vendor's runner", _estate._show(_at(value, "runnerKind")))))
178
179
  if value.get("route") == "key" and isinstance(value.get("key"), dict):
179
180
  key = value["key"]
180
- if key.get("modelAllowList") != value.get("modelProfile") or ("modelAllowList" in key) != ("modelProfile" in value):
181
+ if not _estate._same(_at(key, "modelAllowList"), _at(value, "modelProfile")):
181
182
  findings.append(_finding("FAC-SEM-059", "/key/modelAllowList", "the key admits the models of %s, but the profile runs %s"
182
- % (_js_string(key.get("modelAllowList")), _js_string(value.get("modelProfile")))))
183
+ % (_estate._show(_at(key, "modelAllowList")), _estate._show(_at(value, "modelProfile")))))
183
184
  if isinstance(key.get("ref"), str) and _secrets.secret_value(key["ref"]):
184
185
  findings.append(_finding("FAC-SEM-059", "/key/ref", "the key is named by its secret reference, never by its value"))
185
186
  return findings
@@ -203,9 +204,9 @@ def _profile_resolution(value: Dict[str, Any]) -> List[Dict[str, str]]:
203
204
  if isinstance(model, str) and model not in catalogued:
204
205
  findings.append(_finding("FAC-SEM-060", "/profiles/%d/models/%d" % (i, j), "profile %s names %s, which the catalogue does not hold" % (_show_id(profile), model)))
205
206
  choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
206
- expected = resolve_model_profile(catalogue=catalogue, profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
207
- if "resolved" not in value or value["resolved"] != expected:
208
- findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" % _compact(expected)))
207
+ expected = _resolve(catalogue=catalogue, profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
208
+ if not _json_equal(_at(value, "resolved"), expected):
209
+ findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" % _estate._json_stringify(_without_nan(expected))))
209
210
  return findings
210
211
 
211
212
 
@@ -346,18 +347,20 @@ def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
346
347
 
347
348
  def model_usable(model: str, catalogue: Any, policy: Any = None) -> List[str]:
348
349
  """Problems with running `model` here: not in the catalogue, or a class the project's policy does not allow."""
349
- rows = {row.get("id"): row for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)}
350
- if model not in rows:
351
- return ["model %s is not in the model catalogue" % model]
350
+ # The last row of an id wins, as a JavaScript Map built from the rows; only a string id can match a model's name.
351
+ rows = {row["id"]: row for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None) if isinstance(row.get("id"), str)}
352
+ if not isinstance(model, str) or model not in rows:
353
+ return ["model %s is not in the model catalogue" % _estate._show(model)]
352
354
  if isinstance(policy, dict) and isinstance(policy.get("modelClasses"), list):
353
355
  allowed = set(_strings(policy["modelClasses"]))
354
356
  meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
357
+ policy_id = _estate._show(_at(meta, "id"))
355
358
  admission = [c for c in _strings(rows[model].get("classes")) if c != OPEN_VENDOR] # DEC-0083: descriptive
356
359
  if not admission:
357
- return ["model %s has no class but %s, which admits nothing, so policy %s does not allow it" % (model, OPEN_VENDOR, _js_string(meta.get("id")))]
360
+ return ["model %s has no class but %s, which admits nothing, so policy %s does not allow it" % (model, OPEN_VENDOR, policy_id)]
358
361
  outside = [c for c in admission if c not in allowed]
359
362
  if outside:
360
- return ["model %s has class %s, which policy %s does not allow" % (model, ", ".join(outside), _js_string(meta.get("id")))]
363
+ return ["model %s has class %s, which policy %s does not allow" % (model, ", ".join(outside), policy_id)]
361
364
  return []
362
365
 
363
366
 
@@ -401,13 +404,9 @@ _MISSING = object()
401
404
 
402
405
 
403
406
  def _field(value: Dict[str, Any], key: str) -> str:
404
- """String(value[key]) as JavaScript writes it: `undefined` for a missing key, `null` for null."""
405
- found = value.get(key, _MISSING) if isinstance(value, dict) else _MISSING
406
- if found is _MISSING:
407
- return "undefined"
408
- if found is None:
409
- return "null"
410
- return _js_string(found)
407
+ """The contract's show(value[key]): `undefined` for a missing key, `null` for null, and an object whose own
408
+ `toString` is data as its JSON, where String() would throw."""
409
+ return _estate._show(_at(value, key))
411
410
 
412
411
 
413
412
  def _object_at(value: Any, key: str) -> Dict[str, Any]:
@@ -496,11 +495,14 @@ def _catalogue_agreement(catalogue: List[Dict[str, Any]], route: Dict[str, Any])
496
495
  return findings
497
496
 
498
497
 
499
- def _index_of(ref: Dict[str, Any]) -> int:
498
+ def _index_of(ref: Dict[str, Any]) -> float:
499
+ """The contract's indexOf: a JSON number that is an integer, as JavaScript holds it (a double; beyond the double
500
+ range it is Infinity, which is no integer), else -1."""
500
501
  index = ref.get("index")
501
- if isinstance(index, bool) or not isinstance(index, (int, float)) or not float(index).is_integer():
502
+ if isinstance(index, bool) or not isinstance(index, (int, float)):
502
503
  return -1
503
- return int(index)
504
+ number = _estate._double(index)
505
+ return number if math.isfinite(number) and number.is_integer() else -1
504
506
 
505
507
 
506
508
  def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
@@ -521,8 +523,8 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
521
523
 
522
524
  def candidate_at(ref: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
523
525
  index = _index_of(ref)
524
- candidate = candidates[index] if 0 <= index < len(candidates) else None
525
- if (candidate is None or candidate.get("runnerKind", _MISSING) != ref.get("runnerKind", _MISSING)
526
+ candidate = candidates[int(index)] if 0 <= index < len(candidates) else None
527
+ if (candidate is None or not _estate._same(_at(candidate, "runnerKind"), _at(ref, "runnerKind"))
526
528
  or ("provider" in ref and not _same_ref(candidate.get("provider"), ref["provider"]))):
527
529
  wrong(path, "candidate %s (%s) is not that position of the route" % (_field(ref, "index"), _field(ref, "runnerKind")))
528
530
  return None
@@ -536,12 +538,12 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
536
538
  if candidate is not None and probe.get("result") == "spawn-failed" and _object_at(candidate, "session").get("spawn") != "allowed":
537
539
  wrong("/probes/%d/result" % position, "%s has spawn: never, so it cannot have failed to spawn" % _field(probe, "runnerKind"))
538
540
 
539
- def expect_probes(count: int) -> None:
541
+ def expect_probes(count: float) -> None:
540
542
  indexes = [_index_of(p) for p in probes]
541
- expected = list(range(max(count, 0)))
542
- if indexes != expected:
543
+ expected = range(int(max(count, 0))) # Array.from({length: count}): ToLength, built only where it is read
544
+ if len(indexes) != len(expected) or any(index != expected[position] for position, index in enumerate(indexes)):
543
545
  wrong("/probes", "the walk must record candidates %s in order; it records %s"
544
- % (", ".join(map(str, expected)) or "none", ", ".join(map(str, indexes)) or "none"))
546
+ % (", ".join(map(_estate._js_string, expected)) or "none", ", ".join(map(_estate._js_string, indexes)) or "none"))
545
547
 
546
548
  if event.get("kind") == "runner-exhausted":
547
549
  expect_probes(len(candidates))
@@ -557,7 +559,7 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
557
559
  wrong("/selected/session", "%s has attach: never, so the host cannot attach it" % _field(selected, "runnerKind"))
558
560
  if selected.get("session") == "spawned" and session.get("spawn") != "allowed":
559
561
  wrong("/selected/session", "%s has spawn: never, so the host cannot spawn it" % _field(selected, "runnerKind"))
560
- if "drive" in chosen and "drive" in selected and selected["drive"] != chosen["drive"]:
562
+ if "drive" in chosen and "drive" in selected and not _estate._same(selected["drive"], chosen["drive"]):
561
563
  wrong("/selected/drive", "the route runs %s through %s, not %s" % (_field(selected, "runnerKind"), _field(chosen, "drive"), _field(selected, "drive")))
562
564
  if event.get("sticky") is True:
563
565
  # A sticky relaunch keeps the conversation's runner and walks nothing above it.
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.34.0"
8
+ version: "0.34.1"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
11
11
  ---
@@ -19,7 +19,7 @@ compatibility: >-
19
19
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
20
20
  metadata:
21
21
  author: PassionCode.ai
22
- version: "0.34.0"
22
+ version: "0.34.1"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
60
60
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
61
61
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
62
62
  LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
63
- VERSION = "0.34.0"
63
+ VERSION = "0.34.1"
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
65
65
  # A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
66
66
  # what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value