@passioncode-ai/passioncode 0.2.42 → 0.2.43

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 (27) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  9. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -3
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +82 -11
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +2 -1
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +181 -5
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  20. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +15 -10
  21. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +4 -1
  22. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +38 -9
  23. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
  25. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -1
  26. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +358 -14
  27. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -23,6 +23,7 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
23
23
  LOCK_STALE_S,
24
24
  NO_SESSION_MARK,
25
25
  NUDGES_FILE,
26
+ REPOSITORIES_MARK,
26
27
  SESSION_ID,
27
28
  VERSION,
28
29
  WORKER_DEADLINE_S,
@@ -60,23 +61,30 @@ FACT_KEYS = ("head", "filesChanged", "linesAdded", "linesRemoved")
60
61
  HEAD = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
61
62
 
62
63
 
63
- def git_facts(cwd: Path, budget_s: Optional[float] = None) -> Dict[str, Optional[int]]:
64
+ def _git(cwd: Any, args: Tuple[str, ...], until: Optional[float]) -> Optional[str]:
65
+ """One git call in `cwd`, its stdout or None (failed, or not answered before `until`, a time.monotonic())."""
66
+ timeout = GIT_TIMEOUT_S if until is None else min(GIT_TIMEOUT_S, until - time.monotonic())
67
+ if timeout <= 0:
68
+ return None
69
+ try:
70
+ done = subprocess.run(["git", "--no-optional-locks", "-C", str(cwd)] + list(args),
71
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
72
+ timeout=timeout, check=False)
73
+ except (OSError, subprocess.SubprocessError):
74
+ return None
75
+ return done.stdout.decode("utf-8", "replace") if done.returncode == 0 else None
76
+
77
+
78
+ def git_facts(cwd: Path, budget_s: Optional[float] = None, until: Optional[float] = None) -> Dict[str, Optional[int]]:
64
79
  """HEAD, files that differ from it (untracked included) and lines added/removed. No path, no text. With
65
- `budget_s`, every git call shares that total; a fact git could not give in time is None."""
80
+ `budget_s`, every git call shares that total; with `until` (a time.monotonic()), they share it with the caller's
81
+ other git calls (FAA-140). A fact git could not give in time is None."""
66
82
  facts: Dict[str, Any] = {"head": None, "filesChanged": None, "linesAdded": None, "linesRemoved": None}
67
- until = None if budget_s is None else time.monotonic() + budget_s
83
+ if until is None and budget_s is not None:
84
+ until = time.monotonic() + budget_s
68
85
 
69
86
  def git(*args: str) -> Optional[str]:
70
- timeout = GIT_TIMEOUT_S if until is None else min(GIT_TIMEOUT_S, until - time.monotonic())
71
- if timeout <= 0:
72
- return None
73
- try:
74
- done = subprocess.run(["git", "--no-optional-locks", "-C", str(cwd)] + list(args),
75
- stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
76
- timeout=timeout, check=False)
77
- except (OSError, subprocess.SubprocessError):
78
- return None
79
- return done.stdout.decode("utf-8", "replace") if done.returncode == 0 else None
87
+ return _git(cwd, args, until)
80
88
 
81
89
  head = (git("rev-parse", "--verify", "-q", "HEAD") or "").strip()
82
90
  facts["head"] = head if HEAD.fullmatch(head) else None
@@ -108,6 +116,276 @@ def _count_status(z: str) -> int:
108
116
  return count
109
117
 
110
118
 
119
+ #: FAA-140 (D2): the Stop hook reads at most this much of the transcript, from its end.
120
+ TRANSCRIPT_TAIL_BYTES = 512 * 1024
121
+
122
+
123
+ #: FAA-140 (seam reading 1): the Bash command text one turn may split, in characters; a long unterminated quote costs
124
+ #: shlex its whole length, so this caps the Stop hook's time on it.
125
+ BASH_BYTES = 65536
126
+
127
+
128
+ #: FAA-140 (REQ-1): at most this many other repositories are named for one turn.
129
+ MAX_REPOSITORIES = 10
130
+
131
+
132
+ #: Distinct candidate paths one turn may offer; past it the rest of the turn is not looked at.
133
+ MAX_CANDIDATES = 64
134
+
135
+
136
+ #: The tools whose path argument is a file the turn changed, and the argument that names it.
137
+ CHANGING_TOOLS = {"Edit": "file_path", "Write": "file_path", "MultiEdit": "file_path", "NotebookEdit": "notebook_path"}
138
+
139
+
140
+ _HOST_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*")
141
+ _PATH_SEGMENT = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]{0,99}|[.][A-Za-z0-9_][A-Za-z0-9._-]{0,98}")
142
+ _URL = re.compile(r"(?i)(?:https?|ssh|git|git\+ssh|ssh\+git)://([^/]*)/(.*)")
143
+ _SCP = re.compile(r"(?:[^@/:\s]+@)?([^@/:\s]+):(?!/)(.+)")
144
+
145
+
146
+ def _repository(host: str, path: str) -> Optional[Dict[str, str]]:
147
+ """{host, owner, name} from a host and a remote path, or None when either is not a plain one."""
148
+ host = host.lower()
149
+ if len(host) > 253 or not _HOST_NAME.fullmatch(host):
150
+ return None
151
+ path = path.strip("/")
152
+ if path.lower().endswith(".git"):
153
+ path = path[:-4]
154
+ segments = path.split("/")
155
+ if len(segments) < 2 or not all(_PATH_SEGMENT.fullmatch(s) for s in segments):
156
+ return None
157
+ return {"host": host, "owner": "/".join(segments[:-1]), "name": segments[-1]}
158
+
159
+
160
+ def parse_origin(url: str) -> Optional[Dict[str, str]]:
161
+ """A remote URL as {host, owner, name} (FAA-140, D1): https and http, `ssh://`, `git://` and the scp form
162
+ `git@host:owner/name.git`. Credentials, a port, `.git` and a trailing slash are dropped; a nested group stays in
163
+ `owner` (`group/sub`). None for a local path, a `file://` remote or anything that does not parse."""
164
+ url = (url or "").strip()
165
+ if not url or len(url) > 2048 or any(c.isspace() for c in url) or "\\" in url:
166
+ return None
167
+ match = _URL.fullmatch(url)
168
+ if match:
169
+ authority, path = match.groups()
170
+ host, _, port = authority.rpartition("@")[2].partition(":")
171
+ if port and not port.isdigit():
172
+ return None
173
+ return _repository(host, path)
174
+ match = _SCP.fullmatch(url)
175
+ if match and "://" not in url:
176
+ return _repository(*match.groups())
177
+ return None
178
+
179
+
180
+ def bash_paths(command: str, cwd: Path) -> List[str]:
181
+ """The folders a Bash command names as `cd <absolute path>` or `git -C <path>` (absolute, or relative to `cwd`),
182
+ in order. Nothing else of the command is kept; a command that does not split is [] (FAA-140)."""
183
+ import shlex # only a Stop hook with a transcript needs it
184
+ try:
185
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
186
+ lexer.whitespace_split = True
187
+ tokens = list(lexer)
188
+ except ValueError:
189
+ return []
190
+ found: List[str] = []
191
+ separators = set("();&|<>")
192
+ start = True # a command word may stand here
193
+ index = 0
194
+ while index < len(tokens):
195
+ token = tokens[index]
196
+ if token and set(token) <= separators:
197
+ start, index = True, index + 1
198
+ continue
199
+ if start and token == "cd" and index + 1 < len(tokens) and os.path.isabs(tokens[index + 1]):
200
+ found.append(os.path.normpath(tokens[index + 1]))
201
+ elif start and os.path.basename(token) == "git":
202
+ look = index + 1
203
+ while look < len(tokens) and tokens[look].startswith("-") and not set(tokens[look]) <= separators:
204
+ if tokens[look] == "-C" and look + 1 < len(tokens) and not set(tokens[look + 1]) <= separators:
205
+ found.append(os.path.normpath(os.path.join(str(cwd), tokens[look + 1])))
206
+ look += 1
207
+ elif tokens[look] == "-c":
208
+ look += 1
209
+ look += 1
210
+ start = False
211
+ index += 1
212
+ return found
213
+
214
+
215
+ def _prompt(entry: Dict[str, Any]) -> bool:
216
+ """Whether a transcript entry is a prompt the person typed: a user entry that is no tool result, no meta entry
217
+ and no subagent's."""
218
+ if entry.get("type") != "user" or entry.get("isMeta") or entry.get("isSidechain") or "toolUseResult" in entry:
219
+ return False
220
+ message = entry.get("message")
221
+ content = message.get("content") if isinstance(message, dict) else None
222
+ if isinstance(content, str):
223
+ return True
224
+ return isinstance(content, list) and bool(content) and not any(
225
+ isinstance(block, dict) and block.get("type") == "tool_result" for block in content)
226
+
227
+
228
+ def _transcript_tail(transcript: str) -> List[bytes]:
229
+ """The whole lines of the transcript's last TRANSCRIPT_TAIL_BYTES; [] when it is not a regular file."""
230
+ import stat
231
+ flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
232
+ fd = os.open(transcript, flags)
233
+ try:
234
+ info = os.fstat(fd)
235
+ if not stat.S_ISREG(info.st_mode):
236
+ return []
237
+ offset = max(0, info.st_size - TRANSCRIPT_TAIL_BYTES)
238
+ os.lseek(fd, offset, os.SEEK_SET)
239
+ chunks, left = [], info.st_size - offset
240
+ while left > 0:
241
+ chunk = os.read(fd, min(left, 65536))
242
+ if not chunk:
243
+ break
244
+ chunks.append(chunk)
245
+ left -= len(chunk)
246
+ finally:
247
+ os.close(fd)
248
+ data = b"".join(chunks)
249
+ lines = data.split(b"\n")
250
+ return lines[1:] if offset > 0 else lines # the first line of a tail is cut
251
+
252
+
253
+ def turn_paths(transcript: Any, cwd: Path, until: Optional[float] = None) -> List[str]:
254
+ """The paths the current turn's tool calls name (FAA-140, D2): Edit, Write, MultiEdit and NotebookEdit's file, and
255
+ the folders of `cd` and `git -C` in Bash, in order, without repeats. Only the tail of the transcript is read, from
256
+ its end back to the last prompt the person typed; of an entry only a tool call's name and its path argument are
257
+ used, never a message's text. [] when the transcript is missing or unreadable. Bash commands are split only when
258
+ they hold `cd` or `git`, at most BASH_BYTES of them a turn, and none once `until` (a time.monotonic()) has passed."""
259
+ if not isinstance(transcript, str) or not transcript or "\0" in transcript:
260
+ return []
261
+ try:
262
+ lines = _transcript_tail(transcript)
263
+ except OSError:
264
+ return []
265
+ calls: List[Dict[str, Any]] = []
266
+ for raw in reversed(lines):
267
+ if not raw.strip():
268
+ continue
269
+ try:
270
+ entry = json.loads(raw.decode("utf-8"))
271
+ except ValueError:
272
+ continue
273
+ if not isinstance(entry, dict):
274
+ continue
275
+ if _prompt(entry):
276
+ break
277
+ message = entry.get("message")
278
+ content = message.get("content") if isinstance(message, dict) else None
279
+ if entry.get("type") != "assistant" or entry.get("isSidechain") or not isinstance(content, list):
280
+ continue
281
+ for block in reversed(content):
282
+ if isinstance(block, dict) and block.get("type") == "tool_use" and isinstance(block.get("input"), dict):
283
+ calls.append(block)
284
+ found: List[str] = []
285
+ left = BASH_BYTES
286
+ for block in reversed(calls):
287
+ if until is not None and time.monotonic() >= until:
288
+ break
289
+ arguments, name = block["input"], block.get("name")
290
+ if name in CHANGING_TOOLS:
291
+ value = arguments.get(CHANGING_TOOLS[name])
292
+ named = [os.path.normpath(os.path.join(str(cwd), value))] if isinstance(value, str) and value else []
293
+ elif name == "Bash" and isinstance(arguments.get("command"), str) and left > 0 \
294
+ and ("cd" in arguments["command"] or "git" in arguments["command"]):
295
+ command = arguments["command"][:left]
296
+ left -= len(command)
297
+ named = bash_paths(command, cwd)
298
+ else:
299
+ continue
300
+ for path in named:
301
+ if path not in found and "\0" not in path and len(path) <= 4096:
302
+ found.append(path)
303
+ if len(found) >= MAX_CANDIDATES:
304
+ return found
305
+ return found
306
+
307
+
308
+ def _existing_folder(path: str) -> Optional[str]:
309
+ """The path when it is a folder, else the nearest existing folder above it (a file's own folder)."""
310
+ current = path
311
+ while not os.path.isdir(current):
312
+ parent = os.path.dirname(current)
313
+ if parent == current:
314
+ return None
315
+ current = parent
316
+ return current
317
+
318
+
319
+ def _nearest_checkout(folder: str) -> Optional[str]:
320
+ """The nearest folder at or above `folder` with a `.git` entry; None outside a checkout. No subprocess."""
321
+ current = folder
322
+ while True:
323
+ if os.path.lexists(os.path.join(current, ".git")):
324
+ return current
325
+ parent = os.path.dirname(current)
326
+ if parent == current:
327
+ return None
328
+ current = parent
329
+
330
+
331
+ def turn_repositories(transcript: Any, cwd: Path, until: float) -> List[Dict[str, str]]:
332
+ """The other Git repositories the current turn changed, as {host, owner, name} (FAA-140, REQ-1): each path of
333
+ turn_paths resolved with one `git rev-parse --show-toplevel` and its repository's `origin` with one `git config`,
334
+ every call before `until` (the Stop hook's facts budget). The start folder's repository (any checkout of it), a
335
+ repository without an `origin` that parses, and a second checkout of one repository are not named; at most MAX_REPOSITORIES. A path
336
+ outside every checkout costs no git call. Never raises: what was found when the budget ran out is the answer."""
337
+ found: List[Dict[str, str]] = []
338
+ try:
339
+ paths = turn_paths(transcript, cwd, until)
340
+ if not paths or time.monotonic() >= until:
341
+ return found
342
+ start = (_git(cwd, ("rev-parse", "--show-toplevel"), until) or "").strip() or None
343
+ checkouts, tops, keys = set(), set(), set()
344
+ own = parse_origin(_git(start, ("config", "--get", "remote.origin.url"), until) or "") if start else None
345
+ if own: # another checkout of the start folder's repository (a worktree, a clone) is that repository
346
+ keys.add(tuple(own[k].lower() for k in ("host", "owner", "name")))
347
+ for path in paths:
348
+ if len(found) >= MAX_REPOSITORIES or time.monotonic() >= until:
349
+ break
350
+ folder = _existing_folder(path)
351
+ checkout = _nearest_checkout(folder) if folder else None
352
+ if checkout is None or checkout in checkouts:
353
+ continue
354
+ checkouts.add(checkout)
355
+ if start and os.path.realpath(checkout) == start:
356
+ continue
357
+ top = (_git(folder, ("rev-parse", "--show-toplevel"), until) or "").strip()
358
+ if not top or top == start or top in tops:
359
+ continue
360
+ tops.add(top)
361
+ repository = parse_origin(_git(top, ("config", "--get", "remote.origin.url"), until) or "")
362
+ key = tuple(repository[k].lower() for k in ("host", "owner", "name")) if repository else None
363
+ if key and key not in keys:
364
+ keys.add(key)
365
+ found.append(repository)
366
+ except Exception: # noqa: BLE001 - a turn without its repositories is still a turn
367
+ pass
368
+ return found
369
+
370
+
371
+ def stored_repositories(entry: Dict[str, Any]) -> List[Dict[str, str]]:
372
+ """The repositories a spool entry carries, checked again at delivery: each exactly {host, owner, name} as
373
+ parse_origin makes them, at most MAX_REPOSITORIES; anything else is left out."""
374
+ value = entry.get("repositories") if isinstance(entry, dict) else None
375
+ out: List[Dict[str, str]] = []
376
+ for item in value if isinstance(value, list) else []:
377
+ if not isinstance(item, dict) or sorted(item) != ["host", "name", "owner"]:
378
+ continue
379
+ if not all(isinstance(item[k], str) for k in item):
380
+ continue
381
+ if _repository(item["host"], "%s/%s" % (item["owner"], item["name"])) != item:
382
+ continue
383
+ out.append(dict(item))
384
+ if len(out) >= MAX_REPOSITORIES:
385
+ break
386
+ return out
387
+
388
+
111
389
  def stored_facts(entry: Dict[str, Any]) -> Dict[str, Any]:
112
390
  """The facts a spool entry carries, as the hook measured them when its turn ended (FAA-68): a head of 40 or 64
113
391
  hex digits and non-negative counts, each else None. Nothing is measured at delivery."""
@@ -432,7 +710,8 @@ def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: f
432
710
  answer = _answer_said(result)
433
711
  except JsonRpcError as exc:
434
712
  answer = exc.said()
435
- note("session=%s event=%s tool=%s answer=%s" % (session_id[:8], event, tool, answer))
713
+ counted = " repositories=%d" % len(arguments["repositories"]) if "repositories" in arguments else ""
714
+ note("session=%s event=%s tool=%s answer=%s%s" % (session_id[:8], event, tool, answer, counted))
436
715
  return result
437
716
 
438
717
  try:
@@ -457,6 +736,15 @@ def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: f
457
736
  client.notify("notifications/initialized")
458
737
  names = _wait_for_session_tools(client, deadline)
459
738
  _write_private(directory / "rejoined", "")
739
+ takes = _takes_repositories(client)
740
+ mark = directory.parent.parent / REPOSITORIES_MARK # state_root: the Stop hook reads a transcript only with it
741
+ try: # shared by every session's worker: a race with another one never ends this delivery
742
+ if takes and not mark.exists():
743
+ _write_private(mark, "")
744
+ elif not takes and mark.exists():
745
+ mark.unlink()
746
+ except OSError as exc:
747
+ note("session=%s outcome=degraded reason=%r" % (session_id[:8], "the repositories mark: %s" % type(exc).__name__))
460
748
  while entries:
461
749
  for path in entries:
462
750
  entry = _read_entry(path)
@@ -484,6 +772,14 @@ def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: f
484
772
  observedAt=entry.get("observedAt"), idempotencyKey=key)
485
773
  if event == "end":
486
774
  arguments["reason"] = reason
775
+ repositories = stored_repositories(entry) if event == "turn" else []
776
+ fitted = [r for r in repositories if _fits(r, _repository_rules(client))][:takes] if takes else []
777
+ if fitted:
778
+ arguments["repositories"] = fitted # FAA-140 (REQ-2): only where the tool takes it
779
+ elif repositories and not (directory / "repositories.degraded").exists():
780
+ why = "repositories its schema refuses were left out" if takes else "does not take repositories"
781
+ note("session=%s event=turn outcome=degraded reason=%r" % (session_id[:8], "%s %s" % (OBSERVE_TOOL, why)))
782
+ (directory / "repositories.degraded").touch()
487
783
  answered = call(event, OBSERVE_TOOL, arguments)
488
784
  if event == "turn" and not answered.get("isError"):
489
785
  _keep_nudges(directory, _answer_json(answered).get("nudges"), note, session_id, nudges_file)
@@ -586,6 +882,54 @@ def _input_fields(client: McpStdio, tool: str) -> List[str]:
586
882
  return []
587
883
 
588
884
 
885
+ def _fits(item: Dict[str, str], props: Dict[str, Any]) -> bool:
886
+ """Whether one {host, owner, name} meets what the schema says of each field (type, length, pattern, enum): an item
887
+ that does not is left out, so Fabric's check never refuses the whole turn for it (seam reading 4)."""
888
+ for key, value in item.items():
889
+ rule = props.get(key)
890
+ if not isinstance(rule, dict):
891
+ continue
892
+ try:
893
+ if rule.get("type", "string") != "string" or len(value) > rule.get("maxLength", len(value)) \
894
+ or len(value) < rule.get("minLength", 0) or ("enum" in rule and value not in rule["enum"]) \
895
+ or ("pattern" in rule and not re.search(rule["pattern"], value)):
896
+ return False
897
+ except (TypeError, re.error):
898
+ return False
899
+ return True
900
+
901
+
902
+ def _repository_rules(client: McpStdio) -> Dict[str, Any]:
903
+ """The item properties fabric_session_observe declares for `repositories`; {} when there are none."""
904
+ for listed in client.tools:
905
+ if listed.get("name") == OBSERVE_TOOL:
906
+ schema = listed.get("inputSchema")
907
+ field = (schema.get("properties") or {}).get("repositories") if isinstance(schema, dict) else None
908
+ items = field.get("items") if isinstance(field, dict) else None
909
+ props = items.get("properties") if isinstance(items, dict) else None
910
+ return props if isinstance(props, dict) else {}
911
+ return {}
912
+
913
+
914
+ def _takes_repositories(client: McpStdio) -> int:
915
+ """How many repositories fabric_session_observe takes a turn: 0 unless it declares `repositories` as an array of
916
+ objects with host, owner and name that requires nothing else, then at most its maxItems (FAA-140, REQ-2). A field
917
+ of another shape would fail Fabric's check and lose the turn's other facts with it."""
918
+ for listed in client.tools:
919
+ if listed.get("name") == OBSERVE_TOOL:
920
+ schema = listed.get("inputSchema")
921
+ field = (schema.get("properties") or {}).get("repositories") if isinstance(schema, dict) else None
922
+ items = field.get("items") if isinstance(field, dict) and field.get("type") == "array" else None
923
+ props = items.get("properties") if isinstance(items, dict) else None
924
+ required = items.get("required", []) if isinstance(items, dict) else None
925
+ if not (isinstance(props, dict) and {"host", "owner", "name"} <= set(props) and isinstance(required, list)
926
+ and set(required) <= {"host", "owner", "name"}):
927
+ return 0
928
+ most = field.get("maxItems")
929
+ return max(0, min(MAX_REPOSITORIES, most)) if isinstance(most, int) and not isinstance(most, bool) else MAX_REPOSITORIES
930
+ return 0
931
+
932
+
589
933
  def _wait_for_session_tools(client: McpStdio, deadline: float) -> List[str]:
590
934
  """fabric-mcp lists only fabric_whoami until it holds a session; then it says its tools changed."""
591
935
  while True:
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "passioncode",
4
4
  "displayName": "PassionCode.ai",
5
- "version": "0.2.42",
5
+ "version": "0.2.43",
6
6
  "description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check at session start that keeps the PassionCode.ai skill set current.",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",