@passioncode-ai/fabric-agent-adapter 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +17 -5
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +182 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +7 -2
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +32 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -4
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +156 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +9 -6
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +128 -3
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +11 -4
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +182 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +11 -6
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +176 -3
  20. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  21. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -19,6 +19,7 @@ import shutil
19
19
  import subprocess
20
20
  from typing import Any, Callable, Dict, List, Optional, Sequence
21
21
 
22
+ import fabric_identifiers as _ids
22
23
  import fabric_secrets as _secrets
23
24
  import fabric_service as _fs
24
25
 
@@ -314,9 +315,12 @@ def _object_at(value: Any, key: str) -> Dict[str, Any]:
314
315
 
315
316
 
316
317
  def _ref_key(ref: Any) -> str:
318
+ """A revision reference compared whole: id, revision and content hash. The id is an identifier, so it enters the key
319
+ in its normal form (DEC-0076)."""
317
320
  if not isinstance(ref, dict):
318
321
  return ""
319
- return "%s@%s#%s" % (_field(ref, "id"), _field(ref, "revision"), _field(ref, "contentHash"))
322
+ normal = _ids.normal_identifier(ref.get("id"))
323
+ return "%s@%s#%s" % (normal if normal is not None else _field(ref, "id"), _field(ref, "revision"), _field(ref, "contentHash"))
320
324
 
321
325
 
322
326
  def _same_ref(a: Any, b: Any) -> bool:
@@ -343,7 +347,7 @@ def _runner_route(value: Dict[str, Any]) -> List[Dict[str, str]]:
343
347
  def _route_bundle(value: Dict[str, Any]) -> List[Dict[str, str]]:
344
348
  route = _object_at(value, "route")
345
349
  admitted = {_ref_key(a.get("provider")) for a in _list(value.get("admissions"))
346
- if a.get("state") == "admitted" and a.get("capability", _MISSING) == route.get("capability", _MISSING)}
350
+ if a.get("state") == "admitted" and _ids.same_identifier(a.get("capability", _MISSING), route.get("capability", _MISSING))}
347
351
  findings = []
348
352
  for index, candidate in enumerate(_candidates(route)):
349
353
  key = _ref_key(candidate.get("provider"))
@@ -363,9 +367,10 @@ def _binding_agreement(binding: Dict[str, Any], route: Dict[str, Any]) -> List[D
363
367
  def disagree(path: str, message: str) -> None:
364
368
  findings.append(_finding("FAC-SEM-032", "/binding/" + path, message))
365
369
 
366
- if binding.get("project", _MISSING) != route.get("project", _MISSING):
370
+ # DEC-0076: project and capability are identifiers, compared by normal form.
371
+ if not _ids.same_identifier(binding.get("project", _MISSING), route.get("project", _MISSING)):
367
372
  disagree("project", "the binding is for %s, the route for %s" % (_field(binding, "project"), _field(route, "project")))
368
- if binding.get("capability", _MISSING) != route.get("capability", _MISSING):
373
+ if not _ids.same_identifier(binding.get("capability", _MISSING), route.get("capability", _MISSING)):
369
374
  disagree("capability", "the binding binds %s, the route orders %s" % (_field(binding, "capability"), _field(route, "capability")))
370
375
  if binding.get("profileKind") != "local-runner":
371
376
  disagree("profileKind", "a runner route serves the local-runner profile, not %s" % _field(binding, "profileKind"))
@@ -408,9 +413,9 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
408
413
 
409
414
  if not _same_ref(event.get("route"), route.get("meta")):
410
415
  wrong("/route", "the event names route %s, not %s" % (_ref_key(event.get("route")), _ref_key(route.get("meta"))))
411
- if event.get("project", _MISSING) != route.get("project", _MISSING):
416
+ if not _ids.same_identifier(event.get("project", _MISSING), route.get("project", _MISSING)):
412
417
  wrong("/project", "the event is for %s, the route for %s" % (_field(event, "project"), _field(route, "project")))
413
- if event.get("capability", _MISSING) != route.get("capability", _MISSING):
418
+ if not _ids.same_identifier(event.get("capability", _MISSING), route.get("capability", _MISSING)):
414
419
  wrong("/capability", "the event names %s, the route orders %s" % (_field(event, "capability"), _field(route, "capability")))
415
420
 
416
421
  def candidate_at(ref: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
@@ -1305,7 +1305,8 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
1305
1305
  surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
1306
1306
  update_available: Optional[str] = None, pid: Optional[int] = None,
1307
1307
  extensions: Optional[Dict[str, Any]] = None,
1308
- configuration: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1308
+ configuration: Optional[Dict[str, Any]] = None,
1309
+ protocols: Optional[Sequence[str]] = None) -> Dict[str, Any]:
1309
1310
  """The unauthenticated identity answer. `degraded` is always present; ready means empty.
1310
1311
  `configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
1311
1312
  `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
@@ -1339,15 +1340,182 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
1339
1340
  if problems:
1340
1341
  raise ServiceError("configuration: %s." % "; ".join(problems))
1341
1342
  doc["configuration"] = configuration
1343
+ if protocols is not None:
1344
+ # DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so
1345
+ # a service lists them only once its hosts are pinned at or after it.
1346
+ problems = protocols_findings(list(protocols))
1347
+ if problems:
1348
+ raise ServiceError("protocols: %s." % "; ".join(f["message"] for f in problems))
1349
+ doc["protocols"] = list(protocols)
1342
1350
  return doc
1343
1351
 
1344
1352
 
1353
+ # --- DEC-0067: protocol revisions; DEC-0070: an event's machine-readable data --------------------------------------
1354
+
1355
+ #: Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`, DEC-0067); a test
1356
+ #: holds it equal to the contract's register at the pin.
1357
+ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
1358
+ "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
1359
+ "fabric-activity": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
1360
+ "fabric-interop": ("0.1",), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
1361
+ "fabric-operator-channel": ("0.1",), "fabric-package": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
1362
+ "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
1363
+ "fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "pipeline": ("0.1",),
1364
+ "revocation-delta": ("1",), "session-handoff": ("1",), "window-lease": ("1",),
1365
+ }
1366
+ MAX_PROTOCOLS = 64
1367
+ _REVISION = re.compile(r"([a-z][a-z0-9.-]{0,63})/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})", re.ASCII)
1368
+
1369
+
1370
+ def parse_revision(value: Any) -> Optional[Tuple[str, str]]:
1371
+ """`<name>/<revision>` split, or None."""
1372
+ match = _REVISION.fullmatch(value) if isinstance(value, str) else None
1373
+ return (match.group(1), match.group(2)) if match else None
1374
+
1375
+
1376
+ def accepted_revisions(name: str, newest: Optional[str] = None) -> List[str]:
1377
+ """The revisions a receiver implementing ``name`` up to ``newest`` accepts: N and, when the register has one, N−1."""
1378
+ known = PROTOCOL_REVISIONS.get(name, ())
1379
+ if not known:
1380
+ return []
1381
+ top = len(known) - 1 if newest is None else (known.index(newest) if newest in known else -1)
1382
+ if top < 0:
1383
+ return []
1384
+ return ["%s/%s" % (name, r) for r in reversed(known[max(0, top - 1):top + 1])]
1385
+
1386
+
1387
+ def negotiate_revision(received: Any, accepted: Sequence[Any]) -> Dict[str, Any]:
1388
+ """A receiver's answer to a document naming ``received`` (DEC-0067, the contract's `negotiateRevision`):
1389
+ ``{"ok": True}``; the `schema-unsupported` refusal naming what was received (at most 80 characters) and the accepted
1390
+ revisions of that protocol (all of them when it names none the receiver reads); or ``{"fault":
1391
+ "no-accepted-revisions"}``, the receiver's own, when it accepts no well-formed revision. Revisions compare exactly."""
1392
+ usable: List[str] = []
1393
+ for entry in accepted if isinstance(accepted, (list, tuple)) else []:
1394
+ if parse_revision(entry) and entry not in usable:
1395
+ usable.append(entry)
1396
+ usable = usable[:MAX_PROTOCOLS]
1397
+ if not usable:
1398
+ return {"fault": "no-accepted-revisions"}
1399
+ if isinstance(received, str) and received in usable:
1400
+ return {"ok": True}
1401
+ parsed = parse_revision(received)
1402
+ same = [e for e in usable if parsed and parse_revision(e)[0] == parsed[0]]
1403
+ return {"error": {"code": "schema-unsupported", "message": "this receiver does not read that protocol revision",
1404
+ "data": {"protocol": received[:80] if isinstance(received, str) else "", "accepted": same or usable}}}
1405
+
1406
+
1407
+ def protocols_findings(protocols: Any, prefix: str = "/protocols") -> List[Dict[str, str]]:
1408
+ """FAC-SEM-099 (DEC-0067): 1 to 64 entries, each `<name>/<revision>` once; at most two revisions of one protocol (an
1409
+ unpublished one refused and not counted), and two of a contract protocol are N and N−1, consecutive in the register."""
1410
+ flag = lambda at, message: {"code": "FAC-SEM-099", "instancePath": at, "message": message} # noqa: E731
1411
+ if not isinstance(protocols, list):
1412
+ return [flag(prefix, "protocols is a list of protocol revisions")]
1413
+ if not 1 <= len(protocols) <= MAX_PROTOCOLS:
1414
+ return [flag(prefix, "protocols lists 1 to %d revisions" % MAX_PROTOCOLS)]
1415
+ findings: List[Dict[str, str]] = []
1416
+ by_name: Dict[str, List[int]] = {}
1417
+ seen: List[str] = []
1418
+ for i, entry in enumerate(protocols):
1419
+ at = "%s/%d" % (prefix, i)
1420
+ parsed = parse_revision(entry)
1421
+ if not parsed:
1422
+ findings.append(flag(at, "a protocol revision is <name>/<revision>, such as fabric-service/0.1"))
1423
+ continue
1424
+ if entry in seen:
1425
+ findings.append(flag(at, "each protocol revision once"))
1426
+ continue
1427
+ seen.append(entry)
1428
+ known = PROTOCOL_REVISIONS.get(parsed[0])
1429
+ if known and parsed[1] not in known:
1430
+ findings.append(flag(at, "%s has no published revision %s" % parsed))
1431
+ continue
1432
+ indices = by_name.setdefault(parsed[0], [])
1433
+ indices.append(known.index(parsed[1]) if known else -1)
1434
+ if len(indices) > 2:
1435
+ findings.append(flag(at, "at most 2 revisions of one protocol: N and N−1"))
1436
+ elif known and len(indices) == 2 and abs(indices[0] - indices[1]) != 1:
1437
+ findings.append(flag(at, "two revisions of one protocol are N and N−1, consecutive in the register"))
1438
+ return findings
1439
+
1440
+
1441
+ _SCHEMA_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s]+", re.ASCII)
1442
+
1443
+
1444
+ def _scalar(value: Any) -> bool:
1445
+ return value is None or isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= 500)
1446
+
1447
+
1448
+ def feed_data_problems(data: Any) -> List[str]:
1449
+ """DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101: no string in `value`, at either
1450
+ level, under a credential's name or of a credential's shape (named by its place, never its value)."""
1451
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector
1452
+ if not isinstance(data, dict) or set(data) != {"schema", "value"}:
1453
+ return ["data is {schema, value}"]
1454
+ problems: List[str] = []
1455
+ if not (isinstance(data["schema"], str) and _SCHEMA_URI.fullmatch(data["schema"])):
1456
+ problems.append("data.schema is the absolute URI of a payload schema the service publishes")
1457
+ value = data["value"]
1458
+ if not isinstance(value, dict) or len(value) > 32:
1459
+ return problems + ["data.value is an object of at most 32 members"]
1460
+ for key, member in value.items():
1461
+ if _scalar(member):
1462
+ continue
1463
+ if isinstance(member, list) and len(member) <= 32 and all(_scalar(m) for m in member):
1464
+ continue
1465
+ if isinstance(member, dict) and len(member) <= 32 and all(_scalar(m) for m in member.values()):
1466
+ continue
1467
+ problems.append("data.value.%s is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members" % key)
1468
+ if problems:
1469
+ return problems
1470
+ for finding in feed_data_findings({"events": [{"data": data}]}):
1471
+ problems.append("FAC-SEM-101: %s %s" % (finding["instancePath"].replace("/events/0", ""), finding["message"]))
1472
+ return problems
1473
+
1474
+
1475
+ def _pointer_token(name: str) -> str:
1476
+ return str(name).replace("~", "~0").replace("/", "~1")
1477
+
1478
+
1479
+ def feed_data_findings(page: Any) -> List[Dict[str, str]]:
1480
+ """FAC-SEM-101 over an events page (the contract's `feedDataFindings`): a list's items and an object's members sit
1481
+ under the outer name too; a number is a count, never a credential."""
1482
+ import fabric_secrets # noqa: E402
1483
+ findings: List[Dict[str, str]] = []
1484
+
1485
+ def check(named: bool, member: Any, at: str) -> None:
1486
+ if not isinstance(member, str):
1487
+ return
1488
+ if named:
1489
+ findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data puts no string under a credential's name"})
1490
+ elif fabric_secrets.secret_value(member) or fabric_secrets.secret_url(member):
1491
+ findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data carries no value shaped like a credential"})
1492
+
1493
+ events = page.get("events") if isinstance(page, dict) else None
1494
+ for i, event in enumerate(events if isinstance(events, list) else []):
1495
+ data = event.get("data") if isinstance(event, dict) else None
1496
+ value = data.get("value") if isinstance(data, dict) else None
1497
+ if not isinstance(value, dict):
1498
+ continue
1499
+ for key, member in value.items():
1500
+ at = "/events/%d/data/value/%s" % (i, _pointer_token(key))
1501
+ named = fabric_secrets.credential_name(key)
1502
+ check(named, member, at)
1503
+ if isinstance(member, list):
1504
+ for j, item in enumerate(member):
1505
+ check(named, item, "%s/%d" % (at, j))
1506
+ elif isinstance(member, dict):
1507
+ for inner, leaf in member.items():
1508
+ check(named or fabric_secrets.credential_name(inner), leaf, "%s/%s" % (at, _pointer_token(inner)))
1509
+ return findings
1510
+
1511
+
1345
1512
  # --- events ------------------------------------------------------------------
1346
1513
 
1347
1514
  def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
1348
1515
  link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
1349
- span_id: Optional[str] = None) -> Dict[str, Any]:
1350
- """One activity event: one sentence a person reads, never a machine id.
1516
+ span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1517
+ """One activity event: one sentence a person reads, never a machine id. `data` (DEC-0070) adds machine-readable
1518
+ fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
1351
1519
 
1352
1520
  An event about traced work carries its trace as a pair, `trace_id` and `span_id`
1353
1521
  (fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
@@ -1373,6 +1541,11 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
1373
1541
  event["notify"] = True
1374
1542
  if trace_id is not None:
1375
1543
  event["traceId"], event["spanId"] = trace_id, span_id
1544
+ if data is not None:
1545
+ problems = feed_data_problems(data)
1546
+ if problems:
1547
+ raise ServiceError("Event data: %s." % "; ".join(problems))
1548
+ event["data"] = {"schema": data["schema"], "value": data["value"]}
1376
1549
  return event
1377
1550
 
1378
1551
 
@@ -5,9 +5,9 @@ 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.24.0"
8
+ version: "0.26.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
10
+ contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -123,7 +123,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
123
123
  The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
124
124
  does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
125
125
 
126
- Pin exactly contract `0.1.0` at commit `fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0` and
126
+ Pin exactly contract `0.1.0` at commit `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b` and
127
127
  read the pinned guide before implementing protocol details. If this skill is installed
128
128
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
129
129
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
@@ -18,7 +18,7 @@ compatibility: >-
18
18
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.24.0"
21
+ version: "0.26.0"
22
22
  ---
23
23
 
24
24
  # 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.24.0"
63
+ VERSION = "0.26.0"
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