@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.
- package/CHANGELOG.md +9 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +82 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +181 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +15 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +38 -9
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +358 -14
- 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
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|