@passioncode-ai/fabric-agent-adapter 0.43.0 → 0.44.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/package.json +1 -1
  3. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  4. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  5. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -2
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -3
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +8 -5
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +9 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +186 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +6 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +2 -2
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +448 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +6 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +234 -19
  27. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  28. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  30. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +4 -3
  31. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +3 -1
@@ -0,0 +1,390 @@
1
+ """fabric-interop/0.1, the rule layer: the extension's names and limits, its errors, the trace context (C3.4), a
2
+ usage object's reading (C3.2) and the job limits and uncertain effects (DEC-0087). Every function answers by a rule
3
+ and touches no file; fabric_interop builds the tools, the envelope, the job store and the MCP server on it and
4
+ re-exports every name here. Part of the service kit (FAA-92, split from fabric_interop; layer L3). Standard library
5
+ only, Python 3.9+."""
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import re
11
+ import secrets
12
+ from typing import Any, Callable, Dict, List, Optional
13
+
14
+ import sys as _sys # noqa: E402
15
+ from pathlib import Path as _Path # noqa: E402
16
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
17
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
18
+
19
+ from fabric_base import ServiceError # noqa: E402
20
+
21
+ PROTOCOL = "fabric-interop/0.1"
22
+ EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
23
+ MCP_REVISION = "2026-07-28"
24
+ # Revisions a client may name in `initialize`. MCP 2026-07-28 needs no handshake, but clients
25
+ # built on earlier revisions (Claude Code 2.1.285 among them) still open with one, and a server
26
+ # that refuses it is marked failed before tools/list is ever called.
27
+ HANDSHAKE_REVISIONS = ("2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28")
28
+ CONTRACT_VERSION = "0.1.0"
29
+ JOB_STATES = ("working", "input_required", "completed", "failed", "cancelled")
30
+ OUTCOMES = ("succeeded", "partial", "failed", "cancelled", "blocked")
31
+ ENVELOPE_REQUIRED = ["id", "contractVersion", "outcome", "done", "proof", "scope", "notVerified", "artifacts",
32
+ "createdAt", "producer", "output", "usage"]
33
+ # The job handle inline, as a job tool's outputSchema carries it (contract interop-job-handle.schema.json).
34
+ JOB_HANDLE_SCHEMA: Dict[str, Any] = {
35
+ "type": "object", "required": ["job"], "additionalProperties": False,
36
+ "properties": {"job": {"type": "object", "required": ["id", "status"], "additionalProperties": False,
37
+ "properties": {"id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"},
38
+ "status": {"const": "working"}}}},
39
+ }
40
+ TERMINAL = ("completed", "failed", "cancelled")
41
+
42
+ _TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})\Z")
43
+ _JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}\Z")
44
+ _KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}\Z")
45
+ _SECRET_WORDS = re.compile(r"pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|"
46
+ r"(access|auth|bearer|refresh|session)[\s_-]?token|^token\Z|credential", re.IGNORECASE)
47
+
48
+
49
+ class InteropError(ServiceError):
50
+ """A rule of fabric-interop/0.1 was violated; the message is one readable sentence."""
51
+
52
+
53
+ class UnknownJob(InteropError):
54
+ def __init__(self, job_id: str):
55
+ self.job_id = job_id
56
+ super().__init__("unknown-job: no job %s here." % job_id)
57
+
58
+
59
+ # --- C3.4 trace context --------------------------------------------------------
60
+
61
+ def parse_traceparent(value: Any) -> Optional[Dict[str, str]]:
62
+ """W3C Trace Context Level 1, lowercase hex; version ff and all-zero ids are invalid."""
63
+ match = _TRACEPARENT.match(value) if isinstance(value, str) else None
64
+ if not match:
65
+ return None
66
+ version, trace_id, span_id, flags = match.groups()
67
+ if version == "ff" or set(trace_id) == {"0"} or set(span_id) == {"0"}:
68
+ return None
69
+ return {"version": version, "trace_id": trace_id, "span_id": span_id, "flags": flags}
70
+
71
+
72
+ def _span_id() -> str:
73
+ while True:
74
+ value = secrets.token_hex(8)
75
+ if set(value) != {"0"}:
76
+ return value
77
+
78
+
79
+ def child_traceparent(parent: Optional[str]) -> str:
80
+ """A new span under `parent`; a missing or broken parent starts a new trace (W3C: restart)."""
81
+ parsed = parse_traceparent(parent)
82
+ trace_id = parsed["trace_id"] if parsed else secrets.token_hex(16)
83
+ flags = parsed["flags"] if parsed else "01"
84
+ return "00-%s-%s-%s" % (trace_id, _span_id(), flags)
85
+
86
+
87
+ def trace_ids(traceparent: Optional[str]) -> Dict[str, str]:
88
+ """The `traceId`/`spanId` pair an event about this span carries (C3.4 c), or {}."""
89
+ parsed = parse_traceparent(traceparent)
90
+ return {"trace_id": parsed["trace_id"], "span_id": parsed["span_id"]} if parsed else {}
91
+
92
+
93
+ def _usage(usage: Any, nulls: bool = False) -> Dict[str, Any]:
94
+ """A usage object as fabric-interop/0.1 reads it, or with `nulls` as 0.2 does: a number nobody measured is null and
95
+ `partial` a boolean (DEC-0087)."""
96
+ if not isinstance(usage, dict):
97
+ raise InteropError("usage must be an object.")
98
+ for key in ("inputTokens", "outputTokens", "wallMs"):
99
+ if key not in usage:
100
+ raise InteropError("usage.%s is required." % key)
101
+ for key, value in usage.items():
102
+ if nulls and key == "partial":
103
+ if not isinstance(value, bool):
104
+ raise InteropError("usage.partial must be a boolean.")
105
+ continue
106
+ if key not in USAGE_NUMBERS:
107
+ raise InteropError("usage.%s is not a usage field." % key)
108
+ if nulls and value is None:
109
+ continue
110
+ numeric = isinstance(value, (int, float)) and not isinstance(value, bool)
111
+ if not numeric or value < 0 or (key != "costUsd" and not isinstance(value, int)):
112
+ raise InteropError("usage.%s must be a non-negative %s." % (key, "number" if key == "costUsd" else "integer"))
113
+ if nulls and usage_findings(usage, ""):
114
+ raise InteropError("usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).")
115
+ return dict(usage)
116
+
117
+
118
+ # --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
119
+ # #region job-limits-kit, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
120
+ # The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json). Every function
121
+ # here answers and never raises on malformed input.
122
+
123
+ QUOTA_EXHAUSTED = "quota-exhausted"
124
+ OUTCOME_UNKNOWN = "outcome-unknown"
125
+ LIMIT_REACHED = "limit-reached"
126
+ INTEROP_NULL_USAGE = "fabric-interop/0.2"
127
+ #: The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087).
128
+ PROTOCOLS_READ = ("fabric-interop/0.1", INTEROP_NULL_USAGE)
129
+ USAGE_NUMBERS = ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd", "wallMs")
130
+ _UTC_INSTANT = re.compile(r"^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z\Z")
131
+ _PAUSE = re.compile(r"^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?\Z")
132
+ _ERROR_CODE = re.compile(r"^[a-z][a-z0-9-]{1,63}\Z")
133
+ _MAX_STATUS_MESSAGE = 500
134
+ _MAX_ERROR_MESSAGE = 2000
135
+ _ABSENT = object()
136
+
137
+
138
+ def _code_points(value: str) -> int:
139
+ """JSON Schema's length: code points. A Python str holds code points, a lone surrogate counting once as in JavaScript."""
140
+ return len(value)
141
+
142
+
143
+ def _own(value: Any, key: str) -> Any:
144
+ return value.get(key, _ABSENT) if isinstance(value, dict) else _ABSENT
145
+
146
+
147
+ from fabric_time import days_from_civil as _days_from_civil # noqa: E402 - the core's (FAA-65)
148
+
149
+
150
+ def utc_time(value: Any) -> Optional[int]:
151
+ """A UTC instant (`interop-common#/$defs/utcInstant`) as milliseconds since the epoch, or None: the pattern and a
152
+ day that exists, as the contract's strictTime reads it."""
153
+ match = _UTC_INSTANT.match(value) if isinstance(value, str) else None
154
+ if not match:
155
+ return None
156
+ year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6])
157
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
158
+ days = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
159
+ if not 1 <= month <= 12 or not 1 <= day <= days[month - 1] or hour > 23 or minute > 59:
160
+ return None
161
+ millis = int((match.group(7) or "").ljust(3, "0"))
162
+ return ((_days_from_civil(year, month, day) * 24 + hour) * 60 + minute) * 60000 + second * 1000 + millis
163
+
164
+
165
+ def quota_data_ok(data: Any) -> bool:
166
+ """Whether `data` is quota-exhausted's: closed, `retryable` a boolean, `resetsAt` a UTC instant, only with retryable true."""
167
+ retryable = _own(data, "retryable")
168
+ if not isinstance(data, dict) or not isinstance(retryable, bool):
169
+ return False
170
+ if any(name not in ("retryable", "resetsAt") for name in data):
171
+ return False
172
+ if "resetsAt" not in data:
173
+ return True
174
+ return retryable and utc_time(data["resetsAt"]) is not None
175
+
176
+
177
+ def _job_of(answer: Any) -> Optional[Dict[str, Any]]:
178
+ job = _own(answer, "job")
179
+ job_id = _own(job, "id")
180
+ if not isinstance(job, dict) or not isinstance(job_id, str) or not job_id or _own(job, "status") not in JOB_STATES:
181
+ return None
182
+ return job
183
+
184
+
185
+ def read_job_pause(answer: Any) -> Dict[str, Any]:
186
+ """Whether a fabric.job.get answer ({job}) is a job a limit paused: working, its statusMessage `quota-exhausted` or
187
+ `quota-exhausted until <UTC instant>`, either optionally followed by `: <text>`. One that starts quota-exhausted and
188
+ breaks the grammar is malformed."""
189
+ job = _job_of(answer)
190
+ message = _own(job, "statusMessage")
191
+ if job is None or job.get("status") != "working" or not isinstance(message, str) or not message.startswith(QUOTA_EXHAUSTED):
192
+ return {"paused": False}
193
+ match = _PAUSE.match(message)
194
+ if not match or _code_points(message) > _MAX_STATUS_MESSAGE:
195
+ return {"paused": False, "malformed": True}
196
+ until = match.group(1)
197
+ if until is None:
198
+ return {"paused": True}
199
+ return {"paused": True, "resetsAt": until} if utc_time(until) is not None else {"paused": False, "malformed": True}
200
+
201
+
202
+ def job_caller_action(answer: Any, now: Any = _ABSENT) -> Dict[str, Any]:
203
+ """What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`, a UTC instant: poll, wait, answer,
204
+ done, cancelled, resend (under the same key), person, reconcile, failed or invalid. Never "submit anew"."""
205
+ at = utc_time(now)
206
+ job = _job_of(answer)
207
+ if at is None or job is None:
208
+ return {"action": "invalid"}
209
+ status = job["status"]
210
+ if status == "working":
211
+ pause = read_job_pause(answer)
212
+ if "malformed" in pause:
213
+ return {"action": "invalid"}
214
+ if not pause["paused"]:
215
+ return {"action": "poll"}
216
+ if "resetsAt" not in pause:
217
+ return {"action": "wait"}
218
+ return {"action": "wait", "notBefore": pause["resetsAt"]} if utc_time(pause["resetsAt"]) > at else {"action": "poll"}
219
+ if status in ("input_required", "completed", "cancelled"):
220
+ return {"action": {"input_required": "answer", "completed": "done", "cancelled": "cancelled"}[status]}
221
+ error = _own(job, "error")
222
+ code = _own(error, "code")
223
+ message = _own(error, "message")
224
+ code_ok = (isinstance(code, str) and bool(_ERROR_CODE.match(code))) or _is_integer(code)
225
+ shape_ok = isinstance(error, dict) and all(name in ("code", "message", "data") for name in error)
226
+ if not shape_ok or not code_ok or not isinstance(message, str) or not message or _code_points(message) > _MAX_ERROR_MESSAGE:
227
+ return {"action": "invalid"}
228
+ if code == OUTCOME_UNKNOWN:
229
+ return {"action": "reconcile"}
230
+ if code == LIMIT_REACHED:
231
+ return {"action": "person"}
232
+ if code != QUOTA_EXHAUSTED:
233
+ return {"action": "failed"}
234
+ data = _own(error, "data")
235
+ if not quota_data_ok(data):
236
+ return {"action": "invalid"}
237
+ if not data["retryable"]:
238
+ return {"action": "person"}
239
+ if "resetsAt" in data and utc_time(data["resetsAt"]) > at:
240
+ return {"action": "resend", "notBefore": data["resetsAt"]}
241
+ return {"action": "resend"}
242
+
243
+
244
+ def _is_integer(value: Any) -> bool:
245
+ """JavaScript's Number.isInteger for a JSON value."""
246
+ if isinstance(value, bool):
247
+ return False
248
+ if isinstance(value, int):
249
+ return True
250
+ return isinstance(value, float) and value == value and value not in (float("inf"), float("-inf")) and value == int(value)
251
+
252
+
253
+ def intent_idempotency_key(intent: Any) -> Optional[str]:
254
+ """The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 from the first 16
255
+ bytes of the SHA-256 of its RFC 8785 form. The same intent gives the same key on every resend and machine; None
256
+ for another shape or an input with no RFC 8785 form."""
257
+ import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
258
+ names = ("agentId", "capability", "input", "request")
259
+ if not isinstance(intent, dict) or any(name not in names for name in intent):
260
+ return None
261
+ agent_id, capability, given = intent.get("agentId"), intent.get("capability"), intent.get("input")
262
+ if not isinstance(agent_id, str) or not agent_id or not isinstance(capability, str) or not capability or not isinstance(given, dict):
263
+ return None
264
+ form = {"agentId": agent_id, "capability": capability, "input": given}
265
+ if "request" in intent:
266
+ request = intent["request"]
267
+ if not isinstance(request, str) or not request or _code_points(request) > 256:
268
+ return None
269
+ form["request"] = request
270
+ try:
271
+ canonical = _fd.jcs(form)
272
+ except (TypeError, ValueError, RecursionError):
273
+ return None
274
+ raw = bytearray(hashlib.sha256(canonical.encode("utf-8")).digest()[:16])
275
+ raw[6] = (raw[6] & 0x0F) | 0x80
276
+ raw[8] = (raw[8] & 0x3F) | 0x80
277
+ hexed = raw.hex()
278
+ return "%s-%s-%s-%s-%s" % (hexed[:8], hexed[8:12], hexed[12:16], hexed[16:20], hexed[20:])
279
+
280
+
281
+ def reads_null_usage(request_meta: Any) -> bool:
282
+ """Whether a provider may write null and partial in usage toward this caller: its _meta.protocols names fabric-interop/0.2."""
283
+ protocols = _own(request_meta, "protocols")
284
+ return isinstance(protocols, list) and INTEROP_NULL_USAGE in protocols
285
+
286
+
287
+ def request_meta(traceparent: Optional[str] = None) -> Dict[str, Any]:
288
+ """A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087)."""
289
+ meta: Dict[str, Any] = {"protocols": list(PROTOCOLS_READ)}
290
+ if parse_traceparent(traceparent):
291
+ meta["traceparent"] = traceparent
292
+ return meta
293
+
294
+
295
+ def usage_findings(usage: Any, at: str) -> List[Dict[str, str]]:
296
+ """FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null."""
297
+ if not isinstance(usage, dict) or usage.get("partial", _ABSENT) is not False:
298
+ return []
299
+ unknown = [name for name in USAGE_NUMBERS if name in usage and usage[name] is None]
300
+ if not unknown:
301
+ return []
302
+ return [{"code": "FAC-SEM-119", "instancePath": "%s/partial" % at,
303
+ "message": "partial is false while %s is null: an unknown number makes the usage partial" % ", ".join(unknown)}]
304
+
305
+
306
+ def usage_toward(usage: Any, request_meta: Any = None) -> Dict[str, Any]:
307
+ """The usage a provider writes toward a caller (the gate): toward a reader of fabric-interop/0.2, null for a number
308
+ nobody measured and partial true beside it; toward any other, the measured lower bound (null as 0, no partial)."""
309
+ if reads_null_usage(request_meta):
310
+ checked = _usage(usage, nulls=True)
311
+ if any(checked.get(name, 0) is None for name in USAGE_NUMBERS) and "partial" not in checked:
312
+ checked["partial"] = True
313
+ return checked
314
+ if not isinstance(usage, dict):
315
+ raise InteropError("usage must be an object.")
316
+ lower = {key: (0 if value is None else value) for key, value in usage.items() if key != "partial"}
317
+ return _usage(lower)
318
+
319
+
320
+ def _job_findings(value: Any) -> List[Dict[str, str]]:
321
+ findings: List[Dict[str, str]] = []
322
+ if "malformed" in read_job_pause(value):
323
+ findings.append({"code": "FAC-SEM-119", "instancePath": "/job/statusMessage",
324
+ "message": "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters"})
325
+ error = _own(_own(value, "job"), "error")
326
+ data = _own(error, "data")
327
+ resets_at = _own(data, "resetsAt")
328
+ if _own(error, "code") == QUOTA_EXHAUSTED and isinstance(resets_at, str) and utc_time(resets_at) is None:
329
+ findings.append({"code": "FAC-SEM-119", "instancePath": "/job/error/data/resetsAt", "message": "resetsAt is a UTC instant on a day that exists"})
330
+ result = _own(_own(value, "job"), "result")
331
+ findings.extend(usage_findings(_own(result, "usage"), "/job/result/usage"))
332
+ return findings
333
+
334
+
335
+ def job_limit_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
336
+ """FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller, {answer, now, action}: the
337
+ act equals job_caller_action's). None for another kind."""
338
+ if kind == "interop-job":
339
+ return _job_findings(value)
340
+ if kind == "usage":
341
+ return usage_findings(value, "")
342
+ if kind == "result":
343
+ return usage_findings(_own(value, "usage"), "/usage")
344
+ if kind == "job-caller":
345
+ expected = job_caller_action(_own(value, "answer"), _own(value, "now"))
346
+ if json.dumps(_own(value, "action"), sort_keys=True, default=str) == json.dumps(expected, sort_keys=True):
347
+ return []
348
+ return [{"code": "FAC-SEM-120", "instancePath": "/action", "message": "the caller answers %s as jobCallerAction does" % json.dumps(expected, separators=(",", ":"))}]
349
+ return None
350
+
351
+
352
+ def pause_message(resets_at: Optional[str] = None, text: Optional[str] = None) -> str:
353
+ """A paused job's statusMessage in the pause grammar; raises InteropError for one a reader would call malformed."""
354
+ message = QUOTA_EXHAUSTED + ("" if resets_at is None else " until %s" % resets_at) + ("" if text is None else ": %s" % text)
355
+ if "malformed" in read_job_pause({"job": {"id": "x", "status": "working", "statusMessage": message}}):
356
+ raise InteropError("A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.")
357
+ return message
358
+
359
+
360
+ def intent_digest(capability: str, arguments: Any) -> str:
361
+ """What a key is kept beside (FAA-77): the SHA-256 of the RFC 8785 form of [capability, input], so a key admits
362
+ only the intent it was first sent with. An input with no RFC 8785 form is refused."""
363
+ import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
364
+ try:
365
+ return "sha256:" + hashlib.sha256(_fd.jcs([capability, arguments]).encode("utf-8")).hexdigest()
366
+ except (TypeError, ValueError, RecursionError):
367
+ raise InteropError("A job's input with an idempotency key needs a JSON form (RFC 8785).") from None
368
+
369
+
370
+ def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None, approval_granted: bool = False) -> str:
371
+ """The provider's answer to a repeated key: "run" (the same intent runs again, once) or "same" (answer with that job).
372
+ A quota-exhausted job runs again once the limit no longer holds: `limit_holds` False when the provider knows it, else
373
+ retryable with its reset passed at `now`; a limit-reached job once its approval is granted; anything else, and
374
+ outcome-unknown above all, never."""
375
+ if not isinstance(job, dict) or job.get("status") != "failed":
376
+ return "same"
377
+ error = _own(job, "error")
378
+ code = _own(error, "code")
379
+ if code == LIMIT_REACHED:
380
+ return "run" if approval_granted is True else "same"
381
+ if code != QUOTA_EXHAUSTED:
382
+ return "same"
383
+ if limit_holds is not None:
384
+ return "run" if limit_holds is False else "same"
385
+ data = _own(error, "data")
386
+ at = utc_time(now)
387
+ if not quota_data_ok(data) or not data["retryable"] or at is None:
388
+ return "same"
389
+ return "run" if "resetsAt" not in data or utc_time(data["resetsAt"]) <= at else "same"
390
+ # #endregion job-limits-kit
@@ -35,10 +35,28 @@ PLUGIN_MANIFEST = ".claude-plugin/plugin.json"
35
35
  SIGNATURE_STATEMENT = "fabric-package-signature/1"
36
36
  # DEC-0051: ES256 joins when Fabric records its decision D2; until then a verifier refuses it as unknown-algorithm.
37
37
  PACKAGE_ALGORITHMS = ("EdDSA",)
38
- SCHEMA_PATH = Path(__file__).resolve().parent / "fabric-package.schema.json"
38
+ from fabric_jsvalue import json_equal as _json_equal # noqa: E402 - the core's (FAA-65)
39
+ from fabric_package_schema import ( # noqa: E402,F401 - the schema reading (FAA-92)
40
+ _JS_DOT,
41
+ JS_WHITESPACE,
42
+ SCHEMA_PATH,
43
+ _SCHEMA_CACHE,
44
+ _schema,
45
+ _type_of,
46
+ _DATE_TIME,
47
+ _URI,
48
+ _MONTH_DAYS,
49
+ _leap,
50
+ _format_ok,
51
+ _PATTERNS,
52
+ _js_pattern,
53
+ _pointer,
54
+ _validate,
55
+ _problems,
56
+ schema_problems,
57
+ )
39
58
  # What pack refuses to list: version-control and cache litter, and files that are credentials by their name. Matched
40
59
  # whole (fullmatch) and with JavaScript's `.`, so a name ending in a newline is judged as fabric-package.mjs judges it.
41
- _JS_DOT = "[^\n\r\u2028\u2029]"
42
60
  JUNK = re.compile(r"\.git|__pycache__|\.DS_Store|%s*\.pyc" % _JS_DOT)
43
61
  SECRET_FILE = re.compile(r"\.env(\.%s+)?|%s*\.(pem|key|p12|pfx|jks|keystore)|id_(rsa|dsa|ecdsa|ed25519)|\.(npmrc|netrc|pypirc)" % (_JS_DOT, _JS_DOT))
44
62
  SECRET_FILE_OK = re.compile(r"\.env\.(example|sample|template)")
@@ -47,9 +65,6 @@ _PKCS8_ED25519 = bytes.fromhex("302e020100300506032b657004220420")
47
65
  #: How deep a document read from disk may nest. Python's recursion limit and Node's stack give out at different depths,
48
66
  #: so both readers refuse past this one, well below either (FAA-78, finding 43); no package document comes near it.
49
67
  MAX_NESTING = 256
50
- #: JavaScript's whitespace, what String.prototype.trim and the regular expression class \s take (ECMA-262 WhiteSpace
51
- #: and LineTerminator), so a key file and a URI are read as fabric-package.mjs reads them.
52
- JS_WHITESPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
53
68
 
54
69
 
55
70
  class PackageError(Exception):
@@ -150,188 +165,6 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
150
165
  return seed, kid
151
166
 
152
167
 
153
- # --- the schema (bundled from the contract at the pin) ------------------------------------------------
154
-
155
- _SCHEMA_CACHE: Dict[str, Any] = {}
156
-
157
-
158
- def _schema() -> Dict[str, Any]:
159
- if "s" not in _SCHEMA_CACHE:
160
- _SCHEMA_CACHE["s"] = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
161
- return _SCHEMA_CACHE["s"]
162
-
163
-
164
- from fabric_jsvalue import json_equal as _json_equal # noqa: E402 - the core's (FAA-65)
165
-
166
-
167
- def _type_of(value: Any) -> str:
168
- if value is None:
169
- return "null"
170
- if isinstance(value, bool):
171
- return "boolean"
172
- if isinstance(value, int) or (isinstance(value, float) and value.is_integer()):
173
- return "integer"
174
- if isinstance(value, float):
175
- return "number"
176
- if isinstance(value, str):
177
- return "string"
178
- if isinstance(value, list):
179
- return "array"
180
- return "object"
181
-
182
-
183
- # ASCII digits only and the whole string (fullmatch): Python's \d takes any Unicode digit and its $ a trailing newline,
184
- # which JavaScript's do not (FAA-78, finding 43).
185
- _DATE_TIME = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))")
186
- _URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^%s]*" % JS_WHITESPACE)
187
- _MONTH_DAYS = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
188
-
189
-
190
- def _leap(year: int) -> bool:
191
- return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
192
-
193
-
194
- def _format_ok(fmt: str, value: Any) -> bool:
195
- if not isinstance(value, str):
196
- return True
197
- if fmt == "date-time":
198
- m = _DATE_TIME.fullmatch(value)
199
- if not m:
200
- return False
201
- year, month, day, hour, minute, second = (int(g) for g in m.groups()[:6])
202
- # The proleptic calendar the contract's ajv-formats checks: year 0000 exists and is a leap year (0000-02-29
203
- # holds). datetime starts at year 1, so the days are counted here, as fabric-package.mjs counts them.
204
- if not 1 <= month <= 12 or not 1 <= day <= (29 if month == 2 and _leap(year) else _MONTH_DAYS[month]):
205
- return False
206
- if hour > 23 or minute > 59 or second > 59:
207
- return False
208
- return m.group(8) in ("Z", "z") or (int(m.group(9)) < 24 and int(m.group(10)) < 60)
209
- if fmt == "uri":
210
- return bool(_URI.fullmatch(value))
211
- return True
212
-
213
-
214
- _PATTERNS: Dict[str, Any] = {}
215
-
216
-
217
- def _js_pattern(source: str) -> Any:
218
- """A schema's ECMAScript pattern (``u`` flag) as Python reads it the same way: ``$`` is the end of the string, not
219
- the end before a final newline; ``.`` stops at JavaScript's line terminators (\\n, \\r, U+2028, U+2029); ``\\d`` and
220
- ``\\w`` are ASCII. Escapes and character classes are copied as written."""
221
- if source not in _PATTERNS:
222
- out: List[str] = []
223
- i, in_class = 0, False
224
- while i < len(source):
225
- ch = source[i]
226
- if ch == "\\":
227
- out.append(source[i:i + 2])
228
- i += 2
229
- continue
230
- if in_class:
231
- in_class = ch != "]"
232
- elif ch == "[":
233
- in_class = True
234
- elif ch == "$":
235
- ch = r"\Z"
236
- elif ch == ".":
237
- ch = _JS_DOT
238
- out.append(ch)
239
- i += 1
240
- _PATTERNS[source] = re.compile("".join(out), re.ASCII)
241
- return _PATTERNS[source]
242
-
243
-
244
- def _pointer(token: Any) -> str:
245
- return str(token).replace("~", "~0").replace("/", "~1")
246
-
247
-
248
- def _validate(schema: Any, value: Any, at: str, out: List[Dict[str, str]]) -> None:
249
- """The JSON Schema 2020-12 keywords the package schema uses, nothing more."""
250
- if schema is True:
251
- return
252
- if schema is False:
253
- out.append({"instancePath": at, "message": "is not allowed here"})
254
- return
255
- if "$ref" in schema:
256
- target = _schema()
257
- for token in schema["$ref"].lstrip("#/").split("/"):
258
- target = target[token.replace("~1", "/").replace("~0", "~")]
259
- _validate(target, value, at, out)
260
- kind = _type_of(value)
261
- if "type" in schema:
262
- allowed = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
263
- if not (kind in allowed or (kind == "integer" and "number" in allowed)):
264
- out.append({"instancePath": at, "message": "must be %s" % " or ".join(allowed)})
265
- return
266
- if "const" in schema and not _json_equal(schema["const"], value):
267
- out.append({"instancePath": at, "message": "must be %s" % json.dumps(schema["const"])})
268
- if "enum" in schema and not any(_json_equal(item, value) for item in schema["enum"]):
269
- out.append({"instancePath": at, "message": "must be one of %s" % ", ".join(json.dumps(i) for i in schema["enum"])})
270
- if kind == "string":
271
- length = len(value) # code points, as JSON Schema counts
272
- if length < schema.get("minLength", 0):
273
- out.append({"instancePath": at, "message": "is shorter than %d" % schema["minLength"]})
274
- if "maxLength" in schema and length > schema["maxLength"]:
275
- out.append({"instancePath": at, "message": "is longer than %d" % schema["maxLength"]})
276
- if "pattern" in schema and not _js_pattern(schema["pattern"]).search(value):
277
- out.append({"instancePath": at, "message": "does not match %s" % schema["pattern"]})
278
- if "format" in schema and not _format_ok(schema["format"], value):
279
- out.append({"instancePath": at, "message": "is not a %s" % schema["format"]})
280
- if kind in ("integer", "number") and "minimum" in schema and value < schema["minimum"]:
281
- out.append({"instancePath": at, "message": "is below %s" % schema["minimum"]})
282
- if kind == "array":
283
- if len(value) < schema.get("minItems", 0):
284
- out.append({"instancePath": at, "message": "has fewer than %d items" % schema["minItems"]})
285
- if "maxItems" in schema and len(value) > schema["maxItems"]:
286
- out.append({"instancePath": at, "message": "has more than %d items" % schema["maxItems"]})
287
- if schema.get("uniqueItems") and any(_json_equal(value[i], value[j]) for i in range(len(value)) for j in range(i)):
288
- out.append({"instancePath": at, "message": "has duplicate items"})
289
- if "items" in schema:
290
- for index, item in enumerate(value):
291
- _validate(schema["items"], item, "%s/%d" % (at, index), out)
292
- if "contains" in schema:
293
- matches = sum(1 for item in value if not _problems(schema["contains"], item))
294
- if matches < schema.get("minContains", 1):
295
- out.append({"instancePath": at, "message": "has fewer than %d matching items" % schema.get("minContains", 1)})
296
- if "maxContains" in schema and matches > schema["maxContains"]:
297
- out.append({"instancePath": at, "message": "has more than %d matching items" % schema["maxContains"]})
298
- if kind == "object":
299
- for name in schema.get("required", []):
300
- if name not in value:
301
- out.append({"instancePath": at, "message": "must have %s" % name})
302
- properties = schema.get("properties", {})
303
- for name, member in value.items():
304
- if name in properties:
305
- _validate(properties[name], member, "%s/%s" % (at, _pointer(name)), out)
306
- elif "additionalProperties" in schema:
307
- if schema["additionalProperties"] is False:
308
- out.append({"instancePath": "%s/%s" % (at, _pointer(name)), "message": "is not a member of this object"})
309
- else:
310
- _validate(schema["additionalProperties"], member, "%s/%s" % (at, _pointer(name)), out)
311
- if "propertyNames" in schema:
312
- _validate(schema["propertyNames"], name, "%s/%s" % (at, _pointer(name)), out)
313
- for sub in schema.get("allOf", []):
314
- _validate(sub, value, at, out)
315
- if "not" in schema and not _problems(schema["not"], value):
316
- out.append({"instancePath": at, "message": "must not match"})
317
- if "if" in schema:
318
- branch = schema.get("then") if not _problems(schema["if"], value) else schema.get("else")
319
- if branch is not None:
320
- _validate(branch, value, at, out)
321
-
322
-
323
- def _problems(schema: Any, value: Any) -> List[Dict[str, str]]:
324
- out: List[Dict[str, str]] = []
325
- _validate(schema, value, "", out)
326
- return out
327
-
328
-
329
- def schema_problems(pkg: Any) -> List[Dict[str, str]]:
330
- """Where the package breaks ``fabric-package.schema.json`` (the contract's, bundled at the pin)."""
331
- return _problems(_schema(), pkg)
332
-
333
-
334
- # --- the contract's rules (FAC-SEM-064…066, FAC-SEM-102) ------------------------------------------------
335
168
 
336
169
  def _objects(value: Any) -> List[Dict[str, Any]]:
337
170
  return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []