@passioncode-ai/fabric-agent-adapter 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +14 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +90 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +23 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +26 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +443 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +140 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +32 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +826 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +217 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +7 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
CHANGED
|
@@ -187,7 +187,11 @@ def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
|
|
|
187
187
|
run: Callable[..., Any] = subprocess.run) -> Dict[str, Any]:
|
|
188
188
|
"""runners.md C2: is the catalogued runner installed, which version answers, and is it signed in. Only a bare
|
|
189
189
|
catalogued binary found on PATH is run, only with the catalogued version argv (and `auth` argv), without a shell,
|
|
190
|
-
within its timeout (at most 5 s). A catalogue entry outside these rules is refused and nothing runs.
|
|
190
|
+
within its timeout (at most 5 s). A catalogue entry outside these rules is refused and nothing runs.
|
|
191
|
+
|
|
192
|
+
``probe`` is the contract's closed probe result for a walk (DEC-0026): ``not-installed`` (no catalogued binary on
|
|
193
|
+
PATH), ``not-responding`` (the version probe did not answer, failed or did not match the catalogue's pattern),
|
|
194
|
+
``not-connected`` (it answers, but the ``auth`` probe says signed out), or None when the runner can serve."""
|
|
191
195
|
kind = entry.get("kind")
|
|
192
196
|
binaries = entry.get("binaries")
|
|
193
197
|
if not isinstance(binaries, list) or not binaries or not all(isinstance(b, str) and _BARE_BINARY.match(b) and ".." not in b for b in binaries):
|
|
@@ -201,11 +205,13 @@ def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
|
|
|
201
205
|
if not isinstance(timeout_ms, int) or isinstance(timeout_ms, bool) or not 0 < timeout_ms <= MAX_PROBE_MS:
|
|
202
206
|
raise ProfileError("version.timeoutMs is at most %d" % MAX_PROBE_MS)
|
|
203
207
|
auth_argv = _argv(entry["auth"], "auth") if "auth" in entry else None
|
|
204
|
-
out: Dict[str, Any] = {"kind": kind, "installed": False, "binary": None, "version": None, "signedIn": None,
|
|
208
|
+
out: Dict[str, Any] = {"kind": kind, "installed": False, "binary": None, "version": None, "signedIn": None,
|
|
209
|
+
"probe": "not-installed", "problems": []}
|
|
205
210
|
binary = next((found for found in (shutil.which(name, path=path) for name in binaries) if found), None)
|
|
206
211
|
if binary is None:
|
|
207
212
|
out["problems"].append("%s is not installed: none of %s is on PATH" % (kind, ", ".join(binaries)))
|
|
208
213
|
return out
|
|
214
|
+
out["probe"] = "not-responding"
|
|
209
215
|
out.update(installed=True, binary=binary)
|
|
210
216
|
|
|
211
217
|
def probe(argv: List[str]) -> Optional[Any]:
|
|
@@ -224,12 +230,18 @@ def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
|
|
|
224
230
|
out["version"] = match.group(1)
|
|
225
231
|
else:
|
|
226
232
|
out["problems"].append("%s answered, but its version did not match the catalogue's pattern" % kind)
|
|
227
|
-
if auth_argv is not None
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
+
signed = probe(auth_argv) if auth_argv is not None else None
|
|
234
|
+
if signed is not None:
|
|
235
|
+
out["signedIn"] = signed.returncode == 0
|
|
236
|
+
if not out["signedIn"]:
|
|
237
|
+
out["problems"].append("%s is installed but not signed in" % kind)
|
|
238
|
+
# The walk's order (runners.md): installed, then responding, then connected. The doctor still reports every problem.
|
|
239
|
+
if out["version"] is None or (auth_argv is not None and signed is None):
|
|
240
|
+
out["probe"] = "not-responding"
|
|
241
|
+
elif out["signedIn"] is False:
|
|
242
|
+
out["probe"] = "not-connected"
|
|
243
|
+
else:
|
|
244
|
+
out["probe"] = None
|
|
233
245
|
return out
|
|
234
246
|
|
|
235
247
|
|
|
@@ -275,3 +287,200 @@ def configuration_summary(doc: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
|
275
287
|
first = (resolved.get("models") or [None])[0]
|
|
276
288
|
tiles.append({"label": "Model", "value": " · ".join(x for x in (resolved["modelProfile"], first) if x)})
|
|
277
289
|
return tiles
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --- runner routes (DEC-0026, DEC-0029; FAC-SEM-028…034) ----------------------------------------------------------
|
|
293
|
+
# The contract's routeRules (src/route-rules.ts), ported finding for finding: code, path and message.
|
|
294
|
+
|
|
295
|
+
#: The closed probe results a walk records for a candidate it passed over (runner-route-event.schema.json).
|
|
296
|
+
PROBE_RESULTS = ("not-catalogued", "not-admitted", "not-installed", "not-responding", "not-connected", "no-held-session",
|
|
297
|
+
"quota-unknown", "refused", "spawn-failed")
|
|
298
|
+
_MISSING = object()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _field(value: Dict[str, Any], key: str) -> str:
|
|
302
|
+
"""String(value[key]) as JavaScript writes it: `undefined` for a missing key, `null` for null."""
|
|
303
|
+
found = value.get(key, _MISSING) if isinstance(value, dict) else _MISSING
|
|
304
|
+
if found is _MISSING:
|
|
305
|
+
return "undefined"
|
|
306
|
+
if found is None:
|
|
307
|
+
return "null"
|
|
308
|
+
return _js_string(found)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _object_at(value: Any, key: str) -> Dict[str, Any]:
|
|
312
|
+
found = value.get(key) if isinstance(value, dict) else None
|
|
313
|
+
return found if isinstance(found, dict) else {}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _ref_key(ref: Any) -> str:
|
|
317
|
+
if not isinstance(ref, dict):
|
|
318
|
+
return ""
|
|
319
|
+
return "%s@%s#%s" % (_field(ref, "id"), _field(ref, "revision"), _field(ref, "contentHash"))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _same_ref(a: Any, b: Any) -> bool:
|
|
323
|
+
return _ref_key(a) != "" and _ref_key(a) == _ref_key(b)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _candidates(value: Any) -> List[Dict[str, Any]]:
|
|
327
|
+
return _list(value.get("candidates") if isinstance(value, dict) else None)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _runner_route(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
331
|
+
findings, kinds = [], set()
|
|
332
|
+
for index, candidate in enumerate(_candidates(value)):
|
|
333
|
+
kind = _field(candidate, "runnerKind")
|
|
334
|
+
if kind in kinds:
|
|
335
|
+
findings.append(_finding("FAC-SEM-028", "/candidates/%d/runnerKind" % index, "runner kind %s appears in the route more than once" % kind))
|
|
336
|
+
kinds.add(kind)
|
|
337
|
+
session = _object_at(candidate, "session")
|
|
338
|
+
if session.get("attach") == "never" and session.get("spawn") == "never":
|
|
339
|
+
findings.append(_finding("FAC-SEM-029", "/candidates/%d/session" % index, 'candidate %s can never run: attach and spawn are both "never"' % kind))
|
|
340
|
+
return findings
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _route_bundle(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
344
|
+
route = _object_at(value, "route")
|
|
345
|
+
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)}
|
|
347
|
+
findings = []
|
|
348
|
+
for index, candidate in enumerate(_candidates(route)):
|
|
349
|
+
key = _ref_key(candidate.get("provider"))
|
|
350
|
+
if key not in admitted:
|
|
351
|
+
findings.append(_finding("FAC-SEM-030", "/route/candidates/%d/provider" % index, "candidate %s names provider %s, which is not a revision admitted for %s"
|
|
352
|
+
% (_field(candidate, "runnerKind"), key, _field(route, "capability"))))
|
|
353
|
+
if isinstance(value.get("binding"), dict):
|
|
354
|
+
findings.extend(_binding_agreement(value["binding"], route))
|
|
355
|
+
if isinstance(value.get("catalogue"), list):
|
|
356
|
+
findings.extend(_catalogue_agreement(_list(value["catalogue"]), route))
|
|
357
|
+
return findings
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _binding_agreement(binding: Dict[str, Any], route: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
361
|
+
findings = []
|
|
362
|
+
|
|
363
|
+
def disagree(path: str, message: str) -> None:
|
|
364
|
+
findings.append(_finding("FAC-SEM-032", "/binding/" + path, message))
|
|
365
|
+
|
|
366
|
+
if binding.get("project", _MISSING) != route.get("project", _MISSING):
|
|
367
|
+
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):
|
|
369
|
+
disagree("capability", "the binding binds %s, the route orders %s" % (_field(binding, "capability"), _field(route, "capability")))
|
|
370
|
+
if binding.get("profileKind") != "local-runner":
|
|
371
|
+
disagree("profileKind", "a runner route serves the local-runner profile, not %s" % _field(binding, "profileKind"))
|
|
372
|
+
if not any(_same_ref(c.get("provider"), binding.get("provider")) for c in _candidates(route)):
|
|
373
|
+
disagree("provider", "the binding's provider %s is not a candidate of the route" % _ref_key(binding.get("provider")))
|
|
374
|
+
if "runnerRoute" in binding and not _same_ref(binding["runnerRoute"], route.get("meta")):
|
|
375
|
+
disagree("runnerRoute", "the binding pins %s, not this route %s" % (_ref_key(binding["runnerRoute"]), _ref_key(route.get("meta"))))
|
|
376
|
+
return findings
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _catalogue_agreement(catalogue: List[Dict[str, Any]], route: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
380
|
+
by_kind = {_field(entry, "kind"): entry for entry in catalogue}
|
|
381
|
+
findings = []
|
|
382
|
+
for index, candidate in enumerate(_candidates(route)):
|
|
383
|
+
kind = _field(candidate, "runnerKind")
|
|
384
|
+
entry = by_kind.get(kind)
|
|
385
|
+
if entry is None:
|
|
386
|
+
findings.append(_finding("FAC-SEM-033", "/route/candidates/%d/runnerKind" % index, "runner kind %s has no entry in the host's catalogue" % kind))
|
|
387
|
+
continue
|
|
388
|
+
if "drive" in candidate and _field(candidate, "drive") not in _object_at(entry, "drives"):
|
|
389
|
+
findings.append(_finding("FAC-SEM-033", "/route/candidates/%d/drive" % index, "the catalogue entry for %s offers no drive %s" % (kind, _field(candidate, "drive"))))
|
|
390
|
+
return findings
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _index_of(ref: Dict[str, Any]) -> int:
|
|
394
|
+
index = ref.get("index")
|
|
395
|
+
if isinstance(index, bool) or not isinstance(index, (int, float)) or not float(index).is_integer():
|
|
396
|
+
return -1
|
|
397
|
+
return int(index)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
401
|
+
route = _object_at(value, "route")
|
|
402
|
+
event = _object_at(value, "event")
|
|
403
|
+
candidates = _candidates(route)
|
|
404
|
+
findings: List[Dict[str, str]] = []
|
|
405
|
+
|
|
406
|
+
def wrong(path: str, message: str) -> None:
|
|
407
|
+
findings.append(_finding("FAC-SEM-034", "/event" + path, message))
|
|
408
|
+
|
|
409
|
+
if not _same_ref(event.get("route"), route.get("meta")):
|
|
410
|
+
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):
|
|
412
|
+
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):
|
|
414
|
+
wrong("/capability", "the event names %s, the route orders %s" % (_field(event, "capability"), _field(route, "capability")))
|
|
415
|
+
|
|
416
|
+
def candidate_at(ref: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
|
|
417
|
+
index = _index_of(ref)
|
|
418
|
+
candidate = candidates[index] if 0 <= index < len(candidates) else None
|
|
419
|
+
if (candidate is None or candidate.get("runnerKind", _MISSING) != ref.get("runnerKind", _MISSING)
|
|
420
|
+
or ("provider" in ref and not _same_ref(candidate.get("provider"), ref["provider"]))):
|
|
421
|
+
wrong(path, "candidate %s (%s) is not that position of the route" % (_field(ref, "index"), _field(ref, "runnerKind")))
|
|
422
|
+
return None
|
|
423
|
+
return candidate
|
|
424
|
+
|
|
425
|
+
probes = _list(event.get("probes"))
|
|
426
|
+
for position, probe in enumerate(probes):
|
|
427
|
+
candidate = candidate_at(probe, "/probes/%d" % position)
|
|
428
|
+
if candidate is not None and probe.get("result") == "no-held-session" and _object_at(candidate, "session").get("spawn") != "never":
|
|
429
|
+
wrong("/probes/%d/result" % position, "%s may spawn, so finding no held session does not pass it over" % _field(probe, "runnerKind"))
|
|
430
|
+
if candidate is not None and probe.get("result") == "spawn-failed" and _object_at(candidate, "session").get("spawn") != "allowed":
|
|
431
|
+
wrong("/probes/%d/result" % position, "%s has spawn: never, so it cannot have failed to spawn" % _field(probe, "runnerKind"))
|
|
432
|
+
|
|
433
|
+
def expect_probes(count: int) -> None:
|
|
434
|
+
indexes = [_index_of(p) for p in probes]
|
|
435
|
+
expected = list(range(max(count, 0)))
|
|
436
|
+
if indexes != expected:
|
|
437
|
+
wrong("/probes", "the walk must record candidates %s in order; it records %s"
|
|
438
|
+
% (", ".join(map(str, expected)) or "none", ", ".join(map(str, indexes)) or "none"))
|
|
439
|
+
|
|
440
|
+
if event.get("kind") == "runner-exhausted":
|
|
441
|
+
expect_probes(len(candidates))
|
|
442
|
+
if event.get("answer") == "held" and route.get("exhausted") != "hold":
|
|
443
|
+
wrong("/answer", "the route does not declare exhausted: hold, so an exhausted walk answers capability-unavailable")
|
|
444
|
+
return findings
|
|
445
|
+
|
|
446
|
+
selected = _object_at(event, "selected")
|
|
447
|
+
chosen = candidate_at(selected, "/selected")
|
|
448
|
+
if chosen is not None:
|
|
449
|
+
session = _object_at(chosen, "session")
|
|
450
|
+
if selected.get("session") == "attached" and session.get("attach") != "preferred":
|
|
451
|
+
wrong("/selected/session", "%s has attach: never, so the host cannot attach it" % _field(selected, "runnerKind"))
|
|
452
|
+
if selected.get("session") == "spawned" and session.get("spawn") != "allowed":
|
|
453
|
+
wrong("/selected/session", "%s has spawn: never, so the host cannot spawn it" % _field(selected, "runnerKind"))
|
|
454
|
+
if "drive" in chosen and "drive" in selected and selected["drive"] != chosen["drive"]:
|
|
455
|
+
wrong("/selected/drive", "the route runs %s through %s, not %s" % (_field(selected, "runnerKind"), _field(chosen, "drive"), _field(selected, "drive")))
|
|
456
|
+
if event.get("sticky") is True:
|
|
457
|
+
# A sticky relaunch keeps the conversation's runner and walks nothing above it.
|
|
458
|
+
if route.get("recovery") == "reprobe":
|
|
459
|
+
wrong("/sticky", "a route with recovery: reprobe walks from the top at every launch")
|
|
460
|
+
if "conversation" not in event:
|
|
461
|
+
wrong("/sticky", "only a conversation can keep its runner")
|
|
462
|
+
expect_probes(0)
|
|
463
|
+
else:
|
|
464
|
+
expect_probes(_index_of(selected))
|
|
465
|
+
|
|
466
|
+
if event.get("kind") == "runner-switched":
|
|
467
|
+
source = _object_at(event, "from")
|
|
468
|
+
candidate_at(source, "/from")
|
|
469
|
+
if _index_of(source) == _index_of(selected):
|
|
470
|
+
wrong("/from", "a switch moves to another candidate")
|
|
471
|
+
if event.get("reason") == "preferred-available":
|
|
472
|
+
if route.get("recovery") != "reprobe":
|
|
473
|
+
wrong("/reason", "only a route with recovery: reprobe moves a conversation back to a preferred runner")
|
|
474
|
+
if not _index_of(selected) < _index_of(source):
|
|
475
|
+
wrong("/reason", "preferred-available moves to a higher-preference candidate")
|
|
476
|
+
return findings
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def route_problems(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
480
|
+
"""The contract's routeRules: findings for a `runner-route` (FAC-SEM-028, 029), a `route-bundle`
|
|
481
|
+
`{route, admissions, binding?, catalogue?}` (030, 032, 033) or a `route-event` `{route, event}` (034); None for
|
|
482
|
+
another kind or a value that is not an object."""
|
|
483
|
+
if not isinstance(value, dict):
|
|
484
|
+
return None
|
|
485
|
+
rules = {"runner-route": _runner_route, "route-bundle": _route_bundle, "route-event": _route_event}.get(kind)
|
|
486
|
+
return rules(value) if rules else None
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -1364,7 +1364,12 @@ _MAX_ROW_VALUES = 512
|
|
|
1364
1364
|
|
|
1365
1365
|
|
|
1366
1366
|
def _canonical_check(value: Any, at: str = "") -> None:
|
|
1367
|
-
if value is None or isinstance(value,
|
|
1367
|
+
if value is None or isinstance(value, bool):
|
|
1368
|
+
return
|
|
1369
|
+
if isinstance(value, str):
|
|
1370
|
+
# CT-25 (DEC-0052): Python cannot encode a lone surrogate as UTF-8, so no two languages agree on its bytes.
|
|
1371
|
+
if _SURROGATE.search(value):
|
|
1372
|
+
raise ServiceError("%s: a lone surrogate has no canonical form" % (at or "/"))
|
|
1368
1373
|
return
|
|
1369
1374
|
if isinstance(value, int):
|
|
1370
1375
|
if abs(value) > _SAFE_INTEGER:
|
|
@@ -1380,6 +1385,7 @@ def _canonical_check(value: Any, at: str = "") -> None:
|
|
|
1380
1385
|
for key, item in value.items():
|
|
1381
1386
|
if not isinstance(key, str):
|
|
1382
1387
|
raise ServiceError("%s: object keys are strings" % (at or "/"))
|
|
1388
|
+
_canonical_check(key, "%s (a key)" % (at or "/"))
|
|
1383
1389
|
_canonical_check(item, "%s/%s" % (at, key))
|
|
1384
1390
|
return
|
|
1385
1391
|
raise ServiceError("%s: %s is not a JSON value" % (at or "/", type(value).__name__))
|
|
@@ -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.
|
|
8
|
+
version: "0.21.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "b291bf09e0a9f9fd3c16cf540832cd021203b7d1"
|
|
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 `
|
|
126
|
+
Pin exactly contract `0.1.0` at commit `b291bf09e0a9f9fd3c16cf540832cd021203b7d1` 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
|