@passioncode-ai/passioncode 0.2.6 → 0.2.7
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 +8 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +8 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +48 -31
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +40 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +8 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +67 -65
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.7 - 2026-10-15
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **Fabric Agent Adapter is pinned at `v0.34.1`** (it was `v0.34.0`). v0.34.1 makes its profile and route rules
|
|
8
|
+
answer as the contract does on any JSON — strict comparisons, values shown as JavaScript writes them, numbers as
|
|
9
|
+
doubles — and the doctor's model check never raises (FAA-47).
|
|
10
|
+
|
|
3
11
|
## 0.2.6 - 2026-10-15
|
|
4
12
|
|
|
5
13
|
### Changed
|
package/family.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/passioncode",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Set up PassionCode.ai on a computer with one command — the apps from their verified releases, their MCP servers for every coding agent and the PassionCode.ai skills — and check and repair it with the same command or `doctor`.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"passioncode": "bin/passioncode.js"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"name": "fabric-agent-adapter",
|
|
12
12
|
"displayName": "Fabric Agent Adapter",
|
|
13
13
|
"source": "./plugins/fabric-agent-adapter",
|
|
14
|
-
"version": "0.34.
|
|
14
|
+
"version": "0.34.1",
|
|
15
15
|
"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.",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "PassionCode.ai",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"name": "passioncode",
|
|
36
36
|
"displayName": "PassionCode.ai",
|
|
37
37
|
"source": "./plugins/passioncode",
|
|
38
|
-
"version": "0.2.
|
|
38
|
+
"version": "0.2.7",
|
|
39
39
|
"description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
|
|
40
40
|
"author": {
|
|
41
41
|
"name": "PassionCode.ai",
|
package/payload/manifest.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"family": "passioncode",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"release": true,
|
|
5
5
|
"members": [
|
|
6
6
|
{
|
|
7
7
|
"name": "fabric-agent-adapter",
|
|
8
8
|
"displayName": "Fabric Agent Adapter",
|
|
9
9
|
"repo": "passioncode-ai/fabric-agent-adapter",
|
|
10
|
-
"ref": "v0.34.
|
|
11
|
-
"commit": "
|
|
12
|
-
"version": "0.34.
|
|
10
|
+
"ref": "v0.34.1",
|
|
11
|
+
"commit": "7ad837133ce370190c9ef86d742b3d3ac578cf8e",
|
|
12
|
+
"version": "0.34.1",
|
|
13
13
|
"via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
|
|
14
14
|
"skills": [
|
|
15
15
|
"adapting-projects-to-fabric",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"legacyMarketplaces": [
|
|
24
24
|
"fabric-agent-adapter"
|
|
25
25
|
],
|
|
26
|
-
"contentHash": "sha256:
|
|
26
|
+
"contentHash": "sha256:b1415a71840983f6f1368bddd990a01a5eca99b8304917657619c21ae5fc483b",
|
|
27
27
|
"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.",
|
|
28
28
|
"author": {
|
|
29
29
|
"name": "PassionCode.ai",
|
|
@@ -62,9 +62,9 @@
|
|
|
62
62
|
"name": "passioncode",
|
|
63
63
|
"displayName": "PassionCode.ai",
|
|
64
64
|
"repo": "passioncode-ai/passioncode",
|
|
65
|
-
"ref": "v0.2.
|
|
65
|
+
"ref": "v0.2.7",
|
|
66
66
|
"commit": null,
|
|
67
|
-
"version": "0.2.
|
|
67
|
+
"version": "0.2.7",
|
|
68
68
|
"via": "this repository",
|
|
69
69
|
"skills": [
|
|
70
70
|
"setting-up-passioncode",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
],
|
|
73
73
|
"legacyPluginIds": [],
|
|
74
74
|
"legacyMarketplaces": [],
|
|
75
|
-
"contentHash": "sha256:
|
|
75
|
+
"contentHash": "sha256:3d8ae9bfd32f10a5e01aec6a8d29f6e3db89d155dfae15cb744c1fb2050598d9",
|
|
76
76
|
"description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
|
|
77
77
|
"author": {
|
|
78
78
|
"name": "PassionCode.ai",
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
21
|
+
version: "0.34.1"
|
|
22
22
|
contract-version: "0.1.0"
|
|
23
23
|
extension: "fabric-service/0.1"
|
|
24
24
|
extension-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
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:
|
|
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 =
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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 ${
|
|
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) ? `${
|
|
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 =
|
|
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 ${
|
|
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 ${
|
|
310
|
-
if (!sameIdentifier(binding.capability, route.capability)) disagree('capability', `the binding binds ${
|
|
311
|
-
if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${
|
|
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) => [
|
|
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 =
|
|
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'),
|
|
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 ${
|
|
335
|
-
if (!sameIdentifier(event.capability, route.capability)) wrong('/capability', `the event names ${
|
|
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 ${
|
|
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`, `${
|
|
350
|
-
if (candidate && probe.result === 'spawn-failed' && objectAt(candidate, 'session').spawn !== 'allowed') wrong(`/probes/${position}/result`, `${
|
|
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', `${
|
|
369
|
-
if (selected.session === 'spawned' && session.spawn !== 'allowed') wrong('/selected/session', `${
|
|
370
|
-
if (chosen.drive !== undefined && selected.drive !== undefined && selected.drive !== chosen.drive) wrong('/selected/drive', `the route runs ${
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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",
|
|
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
|
|
80
|
-
"""
|
|
81
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
174
|
-
runner = SUBSCRIPTION_RUNNER.get(login)
|
|
175
|
-
if runner
|
|
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
|
|
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
|
|
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
|
-
% (
|
|
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 =
|
|
207
|
-
if
|
|
208
|
-
findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" %
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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,
|
|
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),
|
|
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
|
-
"""
|
|
405
|
-
|
|
406
|
-
|
|
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]) ->
|
|
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))
|
|
502
|
+
if isinstance(index, bool) or not isinstance(index, (int, float)):
|
|
502
503
|
return -1
|
|
503
|
-
|
|
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
|
|
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:
|
|
541
|
+
def expect_probes(count: float) -> None:
|
|
540
542
|
indexes = [_index_of(p) for p in probes]
|
|
541
|
-
expected =
|
|
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(
|
|
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"]
|
|
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.
|
|
8
|
+
version: "0.34.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
|
|
11
11
|
---
|
package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py
CHANGED
|
@@ -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.
|
|
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
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "passioncode",
|
|
4
4
|
"displayName": "PassionCode.ai",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.7",
|
|
6
6
|
"description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check at session start that keeps the PassionCode.ai skill set current.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|