@passioncode-ai/passioncode 0.2.1 → 0.2.4

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 (28) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +35 -0
  3. package/family.json +1 -1
  4. package/package.json +1 -1
  5. package/payload/.claude-plugin/marketplace.json +2 -2
  6. package/payload/manifest.json +8 -8
  7. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -5
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +24 -1
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +58 -16
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +102 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +144 -11
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +590 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.schema.json +631 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +140 -10
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +824 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -3
  22. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  23. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  24. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +24 -3
  25. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
  26. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
  27. package/payload/plugins/passioncode/hooks/repo-rules.js +9 -4
  28. package/payload/plugins/passioncode/skills/working-in-passioncode/SKILL.md +9 -0
@@ -60,13 +60,21 @@ REFUSAL_CODES = frozenset(["invalid_arguments", "body_too_large", "not_authorize
60
60
  "unsupported_capability", "unsupported_operation", "not_available", "idempotency_conflict",
61
61
  "idempotency_window_expired", "capacity_exceeded", "digest_conflict", "revision_conflict",
62
62
  "fenced", "lease_expired", "invalid_transition", "reconcile_required", "observation_required",
63
- "cursor_reset_required", "immutable_participants"])
63
+ "cursor_reset_required", "immutable_participants", "held"])
64
64
  #: On these the attempt is no longer ours: stop acting at once (C4).
65
65
  STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
66
66
  REQUEST_STATES = ("queued", "claimed", "accepted", "in_progress", "completed", "failed_known", "cancelled", "expired",
67
67
  "outcome_unknown")
68
68
  EFFECT_STATES = ("not_started", "started", "succeeded_observed", "failed_observed", "unknown")
69
69
  TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
70
+ #: DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
71
+ #: `held` — it settles, reports on or keeps alive started work (com.renew, com.progress, com.complete, com.reconcile),
72
+ #: cancels or reads. The contract's HOLD_SCOPES.
73
+ HOLD_SCOPES = {"com.submit": ("thread",), "com.reply": ("thread",), "com.enroll": ("project", "slot"),
74
+ "com.responder_replace": ("project", "slot"), "com.claim": ("thread", "project", "slot"),
75
+ "com.accept": ("thread", "project", "slot"), "com.effect_begin": ("thread", "project", "slot")}
76
+ #: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
77
+ HOLD_RETRY_MAX_SECONDS = 900.0
70
78
 
71
79
  _OPAQUE = re.compile(r"[A-Za-z0-9_-]{8,128}")
72
80
  _PROJECT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,191}")
@@ -188,7 +196,7 @@ def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
188
196
  if "request" in value:
189
197
  r = value["request"]
190
198
  keys = ("id", "state", "effect", "revision")
191
- if _closed(r, "/request", keys, keys, problems):
199
+ if _closed(r, "/request", keys, keys + ("settledBy",), problems):
192
200
  if not (isinstance(r.get("id"), str) and _OPAQUE.fullmatch(r["id"])):
193
201
  problems.append("/request/id is a board id")
194
202
  if r.get("state") not in REQUEST_STATES:
@@ -197,6 +205,12 @@ def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
197
205
  problems.append("/request/effect is an effect state")
198
206
  if not _is_int(r.get("revision"), 1):
199
207
  problems.append("/request/revision is an integer ≥ 1")
208
+ if "settledBy" in r:
209
+ # DEC-0081: present only when the estate owner settled it; absent, the responder did.
210
+ if r["settledBy"] != "owner":
211
+ problems.append("/request/settledBy is owner")
212
+ if r.get("state") not in ("completed", "failed_known"):
213
+ problems.append("/request/settledBy names a settled request (completed or failed_known)")
200
214
  if "slot" in value:
201
215
  s = value["slot"]
202
216
  keys = ("project", "capability", "generation", "leaseExpiresAt")
@@ -248,6 +262,8 @@ def refusal_problems(value: Any) -> List[str]:
248
262
  problems.append("/error/message is 1 to 300 characters")
249
263
  if not isinstance(e.get("retryable"), bool):
250
264
  problems.append("/error/retryable is a boolean")
265
+ elif e.get("code") == "held" and e["retryable"] is not True:
266
+ problems.append("/error/retryable is true for held: the operator lifts a hold (DEC-0079)")
251
267
  return problems
252
268
 
253
269
 
@@ -312,14 +328,48 @@ def directory_problems(page: Any) -> List[str]:
312
328
  continue
313
329
  for c, entry in enumerate(caps):
314
330
  cat = "%s/capabilities/%d" % (at, c)
315
- if _closed(entry, cat, ("capability", "responder"), ("capability", "responder"), problems):
331
+ if _closed(entry, cat, ("capability", "responder"), ("capability", "responder", "held"), problems):
316
332
  if not (isinstance(entry.get("capability"), str) and _CAPABILITY.fullmatch(entry["capability"])):
317
333
  problems.append("%s/capability is a capability name" % cat)
318
334
  if entry.get("responder") not in ("active", "stale", "absent"):
319
335
  problems.append("%s/responder is active, stale or absent" % cat)
336
+ if "held" in entry and not isinstance(entry["held"], bool):
337
+ problems.append("%s/held is a boolean (DEC-0079)" % cat)
320
338
  return problems + ["%s %s" % (f["instancePath"], f["message"]) for f in directory_findings(page)]
321
339
 
322
340
 
341
+ def hold_refuses(tool: Any, target: Any, holds: Any) -> bool:
342
+ """DEC-0079, the contract's ``holdRefuses``: True when a board refuses ``tool`` on ``target`` with ``held`` under
343
+ ``holds``. ``target`` is what the board resolved (``thread``, ``project``, ``capability``, ``fenced``, ``repeat``);
344
+ ``holds`` is ``{threads, projects, slots: [{project, capability}]}``. Ids compare as written. Malformed input never
345
+ raises: a member of the wrong type counts as absent, and an unknown tool is never refused ``held``."""
346
+ scopes = HOLD_SCOPES.get(tool) if isinstance(tool, str) else None
347
+ if not scopes or not isinstance(target, dict):
348
+ return False
349
+ if target.get("repeat") is True:
350
+ return False
351
+ if tool == "com.reply" and target.get("fenced") is True:
352
+ return False
353
+ holds = holds if isinstance(holds, dict) else {}
354
+
355
+ def text(value: Any) -> Optional[str]:
356
+ return value if isinstance(value, str) else None
357
+
358
+ def strings(value: Any) -> set:
359
+ return {item for item in value if isinstance(item, str)} if isinstance(value, list) else set()
360
+
361
+ thread, project, capability = text(target.get("thread")), text(target.get("project")), text(target.get("capability"))
362
+ if "thread" in scopes and thread is not None and thread in strings(holds.get("threads")):
363
+ return True
364
+ if "project" in scopes and project is not None and project in strings(holds.get("projects")):
365
+ return True
366
+ slots = holds.get("slots")
367
+ if "slot" in scopes and project is not None and capability is not None and isinstance(slots, list):
368
+ return any(isinstance(slot, dict) and slot.get("project") == project and slot.get("capability") == capability
369
+ for slot in slots)
370
+ return False
371
+
372
+
323
373
  def find_project(page: Any, project_id: str, capability: Optional[str] = None) -> Optional[Dict[str, Any]]:
324
374
  """Look a Project up in one `com.directory` page (read by a Project session — a board session is not served the
325
375
  directory). The page is checked first: a page the contract would refuse, FAC-SEM-107 included, raises
@@ -630,6 +680,9 @@ class Responder:
630
680
  self.claim_keys: Dict[str, str] = {}
631
681
  self._said: Dict[str, str] = {}
632
682
  self._rr = 0
683
+ #: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
684
+ self.hold_delay = POLL_SECONDS
685
+ self._on_hold: set = set()
633
686
 
634
687
  # -- events -------------------------------------------------------------------------------------------------
635
688
  def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None, **value: Any) -> None:
@@ -712,6 +765,9 @@ class Responder:
712
765
  try:
713
766
  receipt = self._receipt("com.enroll", self._call("com.enroll", args))
714
767
  except BoardError as exc:
768
+ if exc.code == "held":
769
+ self._pause(capability)
770
+ return
715
771
  if exc.code == "fenced":
716
772
  self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
717
773
  self._emit("board.slot.contested", "warn", "Another responder holds the %s slot on this Project; this service "
@@ -721,6 +777,25 @@ class Responder:
721
777
  raise
722
778
  self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
723
779
  self._said.pop("board.slot.contested", None)
780
+ self._lifted(capability)
781
+
782
+ def _pause(self, capability: str) -> None:
783
+ """The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and
784
+ never act around the hold. Accepted work in progress continues; only new work waits."""
785
+ slot = self.slots.get(capability) or {}
786
+ pauses = slot.get("pauses", 0) + 1 if slot.get("state") == "paused" else 1
787
+ wait = min(HOLD_RETRY_MAX_SECONDS, self.hold_delay * 2 ** (pauses - 1))
788
+ self.slots[capability] = {"state": "paused", "pauses": pauses, "retry_at": self.now() + wait, "renewed_at": self.now()}
789
+ if capability not in self._on_hold: # once per hold, per capability
790
+ self._on_hold.add(capability)
791
+ self._emit("board.slot.held", "info", "The operator holds new work for %s on this Project; the service waits and "
792
+ "asks again later. Work already accepted continues." % capability, capability=capability)
793
+
794
+ def _lifted(self, capability: str) -> None:
795
+ if capability in self._on_hold:
796
+ self._on_hold.discard(capability)
797
+ self._emit("board.slot.released", "info", "The operator's hold on %s was lifted; the service takes new work "
798
+ "again." % capability, capability=capability)
724
799
 
725
800
  # -- restart: reconcile and transfer -------------------------------------------------------------------------
726
801
  def _read_state(self, entry: Dict[str, Any]) -> Optional[str]:
@@ -750,10 +825,17 @@ class Responder:
750
825
  transfer.setdefault(entry["capability"], []).append(entry)
751
826
  else: # in_progress (lost result once a new generation holds the slot) or outcome_unknown
752
827
  reconcile.append(entry)
753
- generations = {r.get("capability"): r.get("generation", 0) for r in status.get("responders") or [] if isinstance(r, dict)}
828
+ responders = [r for r in status.get("responders") or [] if isinstance(r, dict)]
829
+ generations = {r.get("capability"): r.get("generation", 0) for r in responders}
830
+ on_hold = {r.get("capability") for r in responders if r.get("held") is True} # DEC-0079
754
831
  for capability in capabilities:
755
832
  held = transfer.get(capability)
756
- if held:
833
+ if capability in on_hold:
834
+ for entry in held or []:
835
+ entry["step"] = "lapsed" # taken back by a transfer once the hold is lifted
836
+ self.checkpoint.put(entry)
837
+ self._pause(capability)
838
+ elif held:
757
839
  self._replace(capability, held, generations.get(capability, 0))
758
840
  else:
759
841
  self._enroll(capability)
@@ -767,8 +849,18 @@ class Responder:
767
849
  args = {"idempotency": self._idem(self._key("replace")), "capability": capability, "expectedGeneration": generation,
768
850
  "transfer": {"requests": [e["request"] for e in entries][:64],
769
851
  "checkpoint": {"summary": "Resumed by the same service after a restart; no effect had begun."}}}
770
- receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
852
+ try:
853
+ receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
854
+ except BoardError as exc:
855
+ if exc.code != "held":
856
+ raise
857
+ for entry in entries:
858
+ entry["step"] = "lapsed" # nothing moved: taken back by a transfer once the hold is lifted
859
+ self.checkpoint.put(entry)
860
+ self._pause(capability)
861
+ return
771
862
  self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
863
+ self._lifted(capability)
772
864
  moved = {item["request"]: item for item in receipt.get("transferred") or []}
773
865
  for entry in entries:
774
866
  item = moved.get(entry["request"])
@@ -848,6 +940,10 @@ class Responder:
848
940
  self._emit("board.access.needed", "warn", "This service may not answer requests for this Project: the operator "
849
941
  "has to allow it in Fabric (Agents & access). Nothing is claimed until then.", notify=True,
850
942
  episode="access", reason=code)
943
+ elif code == "held":
944
+ self.state = "paused"
945
+ self._emit("board.held", "info", "The operator holds new work on this Project; the responder waits and asks again "
946
+ "with a growing pause.", episode="held", reason=code)
851
947
  elif code in ("no-epoch", "schema-unsupported", "checkpoint", "receipt-shape"):
852
948
  self.state = "down"
853
949
  self._drop_session()
@@ -887,6 +983,10 @@ class Responder:
887
983
  self._renew(work)
888
984
  held_by_work = work.entry["capability"] if work is not None and self.current is not None else None
889
985
  for capability, slot in list(self.slots.items()):
986
+ if slot.get("state") == "paused":
987
+ if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0):
988
+ self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again
989
+ continue
890
990
  if capability != held_by_work and self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
891
991
  self._enroll(capability) # the holder's enroll renews its lease (no event); a lapsed one re-enrolls
892
992
  if self.current is None and not self.stopping:
@@ -937,6 +1037,9 @@ class Responder:
937
1037
  if exc.code == "fenced":
938
1038
  self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
939
1039
  continue
1040
+ if exc.code == "held":
1041
+ self._pause(capability)
1042
+ continue
940
1043
  raise
941
1044
  if "request" not in answer:
942
1045
  continue # nothing claimable; the same key polls again
@@ -992,7 +1095,18 @@ class Responder:
992
1095
  key = entry["keys"].get("accept") or self._key("accept")
993
1096
  entry["keys"]["accept"], entry["step"] = key, "accepting"
994
1097
  self.checkpoint.put(entry)
995
- receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
1098
+ try:
1099
+ receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
1100
+ except BoardError as exc:
1101
+ if exc.code != "held":
1102
+ raise
1103
+ # A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request
1104
+ # waits on the board for the hold to be lifted.
1105
+ self.checkpoint.drop(entry["request"])
1106
+ self._emit("board.request.waiting", "info", "A claimed request was not accepted: the operator holds it. Nothing "
1107
+ "was done; it waits on the board.", request=entry["request"], capability=entry["capability"])
1108
+ self._pause(entry["capability"])
1109
+ return False
996
1110
  if receipt is None:
997
1111
  return False # the claim was lost before it was accepted; nothing was done
998
1112
  entry["revision"], entry["step"] = receipt["request"]["revision"], "accepted"
@@ -1087,14 +1201,30 @@ class Responder:
1087
1201
  if summary:
1088
1202
  args["summary"] = " ".join(str(summary).split())[:SUMMARY_MAX]
1089
1203
  receipt = None
1090
- for attempt in range(3): # a lost answer: the same key again tells whether it was stored
1204
+ lost, delay = 0, self.hold_delay
1205
+ while True: # a lost answer: the same key again tells whether it was stored
1091
1206
  try:
1092
1207
  receipt = self._fenced("com.effect_begin", entry, args)
1093
1208
  break
1094
1209
  except BoardError as exc:
1095
- if exc.code != "unreachable" or attempt == 2:
1210
+ if exc.code == "held":
1211
+ # The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick
1212
+ # renews it; com.renew is never held), and ask again with the same key.
1213
+ entry["effect"], entry["step"] = False, "effect-held"
1214
+ self.checkpoint.put(entry)
1215
+ self._emit("board.request.waiting", "info", "The operator holds this request before its effect: the "
1216
+ "service waits and does nothing outside it until the hold is lifted.", episode=entry["request"],
1217
+ request=entry["request"], capability=entry["capability"])
1218
+ if ctx.stopped.wait(delay):
1219
+ raise Stopped() from None
1220
+ delay = min(HOLD_RETRY_MAX_SECONDS, delay * 2)
1221
+ entry["effect"], entry["step"] = True, "effect-beginning"
1222
+ self.checkpoint.put(entry)
1223
+ continue
1224
+ if exc.code != "unreachable" or lost == 2:
1096
1225
  raise Stopped() from None
1097
- time.sleep(attempt + 1)
1226
+ lost += 1
1227
+ time.sleep(lost)
1098
1228
  if receipt is None:
1099
1229
  raise Stopped()
1100
1230
  entry["revision"], entry["step"] = receipt["request"]["revision"], "in-progress"