@passioncode-ai/passioncode 0.2.42 → 0.2.44

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 (37) hide show
  1. package/CHANGELOG.md +18 -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 +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 +8 -4
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +82 -11
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +24 -8
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +22 -9
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +6 -1
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +14 -6
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +23 -13
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +6 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +9 -5
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +16 -5
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +276 -12
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  30. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +20 -13
  31. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +4 -1
  32. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +38 -9
  33. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
  35. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +358 -14
  37. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -20,7 +20,7 @@ need from Fabric. In Claude Code they live in the plugin's `hooks/hooks.json` an
20
20
  | Event | When it acts | What it does |
21
21
  |---|---|---|
22
22
  | SessionStart (`startup`, `clear`; the plugin's matcher, and the script ignores `compact` and `resume` for every host) | Claude Code has an MCP server named `fabric` (local, project or user scope) | Fabric running, in a project that uses Fabric (below): adds the preamble: `fabric_whoami` first; `com.status` and `com.list`, `com.read_ack` only what was read; `fabric_work_list`, then `fabric_work_claim`; `fabric_work_release` at the end; ask another Project only through `com.submit`. Fabric running in a folder with no such sign (a user-scope registration reaches every folder): one line pointing at `fabric_whoami`, which names the project when Fabric serves the folder anyway (FAA-60). Fabric down, in a project that uses Fabric: one line, skip `fabric_whoami` and the board, start Fabric through the lifecycle broker only if the task needs it. Fabric down anywhere else, or not registered: nothing. |
23
- | Stop (each turn) | as for SessionStart, and the running Fabric's `hub.json` lists `"session-hooks/1"` in `features` | measures the turn's facts (HEAD, files changed, lines added and removed: counts and ids only, git sharing 1.5 s), spools them in one entry and starts a detached worker that sends `fabric_session_observe {event: "turn", head, filesChanged, linesAdded, linesRemoved, …}` as stored, keeps the `nudges` its answer carries (Fabric 0.3.14: each `{kind, says, task_id?, request_id?}`, shown on a turn once, at most five; it stays for `fabric_work_list` and `fabric_heartbeat`, so it may come again there) in `nudges.json`, then reads `com.status` and keeps the unread count in the session's spool (`board.json`) |
23
+ | Stop (each turn) | as for SessionStart, and the running Fabric's `hub.json` lists `"session-hooks/1"` in `features` | measures the turn's facts (HEAD, files changed, lines added and removed: counts and ids only, git sharing 1.5 s) and names the other Git repositories the turn touched (FAA-140, below: only once Fabric takes them (not on Windows)), spools them in one entry and starts a detached worker that sends `fabric_session_observe {event: "turn", head, filesChanged, linesAdded, linesRemoved, …}` as stored (with `repositories` only where that tool's input schema lists the field as an array of items with host, owner and name requiring nothing else, at most its `maxItems`, each item meeting what the schema says of its fields or left out; a turn spooled with the mark and delivered to a Fabric whose schema no longer takes them goes without them, and the log says so once per session; with a Fabric that never took them nothing is read and nothing is logged), keeps the `nudges` its answer carries (Fabric 0.3.14: each `{kind, says, task_id?, request_id?}`, shown on a turn once, at most five; it stays for `fabric_work_list` and `fabric_heartbeat`, so it may come again there) in `nudges.json`, then reads `com.status` and keeps the unread count in the session's spool (`board.json`) |
24
24
  | UserPromptSubmit | the session's spool holds a count or nudges | adds ONE line, `N unread on the project board: com.list`, only when the count rose since the last line (a fall or the same count says nothing), then each nudge's `says` once, as Fabric writes it (Fabric 0.3.14 starts each with `Fabric:`; any other line gets the tag `Fabric reminder: `), one line each, at most five lines of at most 400 characters; the nudges file is taken by renaming it away before they are printed (FAA-142). It reads, renames and writes small files in the session's folder and never calls Fabric. |
25
25
  | SessionEnd | as for Stop | spools the end; the worker sends `fabric_work_release {claims: "session", outcome: "let-go", …}` (with `observedAt`, the time the hook fired, where the tool's input schema names it: Fabric 0.3.9 then refuses an end from before the session enrolled), then `fabric_session_observe {event: "end", reason}`, and removes the session's spool. Reason `clear` ends nothing: `/clear` keeps the agent process and its fabric-mcp, so no claim is released and no end observed; turns already spooled still go out (since 0.42.0). Their nudges stay in the old id's folder and are not printed here; Fabric 0.3.14 only shows a nudge on a turn's answer and keeps it for `fabric_work_list` and `fabric_heartbeat`, so the new session gets it there (board `b605a636`; decision of 0.51.2, FAA-142) |
26
26
 
@@ -31,6 +31,29 @@ id, and keeps the retry only while the end is the newest entry. When `fabric-mcp
31
31
  no session holds the id), the worker drops the spool, writes a `no-session` mark in the session's folder and logs it
32
32
  once; for 15 minutes the hooks spool nothing for that id (an end removes the folder), then one rejoin tries again.
33
33
 
34
+ **The other repositories a turn touched** (FAA-140, Fabric CO-367). Never on Windows (FAA-175: a Bash command's
35
+ paths there are not the host's). Elsewhere, only after a worker has seen the running Fabric's
36
+ `fabric_session_observe` declare `repositories` as an array of items with `host`, `owner` and `name` that require
37
+ nothing else does the Stop hook read the
38
+ session's transcript at all: the worker then leaves `observe-takes-repositories` in the hooks' folder (beside
39
+ `hooks.log`) and removes it when the schema stops saying so (after a Fabric downgrade, the turns that end before a worker next
40
+ runs, about a minute, are still read; none of their names is sent); without it nothing below happens. With it, the hook reads the last 512 KB of the
41
+ transcript (the payload's `transcript_path`; earlier turns included) and uses only the entries after the last prompt
42
+ the person typed. Those lines hold message text and tool output too (file contents, command output: a secret the session displayed can be there; it stays in memory and is never spooled, logged or sent); of each entry it uses only a tool call's
43
+ name and path argument: the `file_path` of Edit, Write and MultiEdit, the `notebook_path` of NotebookEdit, and in a
44
+ Bash command `cd <absolute path>` and `git -C <path>` (absolute, or relative to the session's folder), split only in
45
+ commands that hold `cd` or `git`, at most 64 KB of command text a turn. These are the repositories the turn
46
+ *touched*: a `cd` or a read-only `git -C <path> log` names one as much as an edit does. A subagent's entries are not
47
+ read, so a repository only a subagent touched is not named. Each
48
+ path is resolved with one `git rev-parse --show-toplevel` and its repository's `origin` with one `git config --get
49
+ remote.origin.url`, every call inside the same 1.5 s as the turn's facts (the facts first); a path outside every
50
+ checkout costs no git call, and when the budget runs out the hook spools what it found. Each repository is named
51
+ `{host, owner, name}` from its `origin` (https, `ssh://`, `git@host:owner/name`; credentials, a port and `.git`
52
+ dropped; a nested group stays in `owner`, and on a self-hosted ssh remote `owner` is the folder path on that server
53
+ before the repository's name, which may hold a user name). The repository of the session's folder when the turn ends (the Stop payload's `cwd`; any checkout of it: a worktree, a clone), a repository whose `origin`
54
+ is missing or does not parse, and a second checkout of one repository are not named; at most 10. No local path leaves the hook; a
55
+ missing or unreadable transcript names none and is no error.
56
+
34
57
  **A project that uses Fabric** (`project_uses_fabric`), cheapest sign first: `fabric` registered at local or project
35
58
  scope for the folder (user scope is every folder's, so it says nothing about this one); a `fabric-agent.json`,
36
59
  `fabric-contract.lock.json` or `fabric.package.json` file from the folder up to its checkout's root (the folder alone
@@ -104,14 +127,19 @@ only variable names are looked at, never a value (two of them carry the session'
104
127
  ## What they never do
105
128
 
106
129
  - **Block or slow the session.** Each hook has a 5 s ceiling in `hooks.json`; the script reads a few small files and
107
- returns in tens of milliseconds, plus the turn's git facts in Stop and SessionEnd (at most 1.5 s, shared by every
108
- git call). Every call to Fabric happens in a detached worker with a 10 s deadline; at its end the worker closes
130
+ returns in tens of milliseconds, plus the turn's git facts in Stop and SessionEnd and, in Stop once Fabric takes them (not on Windows), the transcript's
131
+ tail and the other repositories' names (the git calls share at most 1.5 s; the tail read is at most 512 KB). Every call to Fabric happens in a detached worker with a 10 s deadline; at its end the worker closes
109
132
  `fabric-mcp`'s stdin and then ends its whole process group, so a wrapper (`npx`, a shell script,
110
133
  `fabric-mcp.cmd`) cannot keep it waiting. Every hook exits 0, on every path.
111
- - **Read a secret.** The hooks never open Fabric's door token or an enrollment credential; `fabric-mcp` holds
112
- those. Nothing secret is in argv (the worker's argv is the script and a spool folder) or in the log.
113
- - **Send or read content.** A turn is the HEAD commit, the number of files that differ from it (untracked included)
114
- and the lines added and removed against it; no path, branch name, commit message or diff text. The board is read
134
+ - **Keep a secret.** The hooks never open Fabric's door token or an enrollment credential; `fabric-mcp` holds
135
+ those. Nothing secret is in argv (the worker's argv is the script and a spool folder) or in the log. Once Fabric
136
+ takes repositories (not on Windows), an `origin` URL that embeds a credential (`https://user:token@host/...`) is read by
137
+ `git config` with it; the Stop hook drops the credential in memory before anything is spooled, logged or sent.
138
+ - **Send content, or use more than it reads for.** A turn is the HEAD commit, the number of files that differ from it
139
+ (untracked included) and the lines added and removed against it, and, once Fabric takes them (not on Windows), the other repositories
140
+ it touched by their `origin` name (`{host, owner, name}`); no path, branch name, commit message or diff text. The
141
+ transcript's tail is read, message text and tool output with it, and only tool names and path arguments are used; a sent turn's log
142
+ line counts its repositories, never names them. The board is read
115
143
  as a count (`com.status`), never a message. Git runs with `--no-optional-locks`.
116
144
  - **Start a second Fabric session.** A `fabric-mcp` started on its own would enroll a new session, and Fabric ends a
117
145
  binding's oldest live session past eight (ADR-0134 amendment 2): the agent's own. The worker and `watch-mail` run
@@ -129,7 +157,8 @@ only variable names are looked at, never a value (two of them carry the session'
129
157
  One line per event that did something, never content: `~/Library/Caches/ai.passioncode.fabric-agent-adapter/hooks.log`
130
158
  (macOS), `$XDG_CACHE_HOME/fabric-agent-adapter/hooks.log` (Linux, default `~/.cache`),
131
159
  `%LOCALAPPDATA%\fabric-agent-adapter\hooks.log` (Windows). Mode 0600, rotated at 1 MB to `hooks.log.1`. The
132
- per-session spool is `sessions/<session id>/` beside it (turn entries capped at 50, `board.json`, `board.shown`, and the nudges not yet printed: `nudges.json` for the prompt
160
+ per-session spool is `sessions/<session id>/` beside it (turn entries capped at 50, each holding the session's folder when
161
+ the turn ended (the Stop payload's `cwd`) and, when the turn touched another repository (only once Fabric takes them (not on Windows)), `repositories` as `{host, owner, name}`; `board.json`, `board.shown`, `repositories.degraded` (the once-a-session note that repositories were left out: the schema no longer takes them, or refused every item), and the nudges not yet printed: `nudges.json` for the prompt
133
162
  hook, `nudges.watch.json` for `watch-mail`, each taken by renaming it away; a
134
163
  folder untouched for 7 days, whose worker failed or whose session ended without SessionEnd, is removed by the next
135
164
  Stop or SessionEnd hook, even while the hooks are inert; an end entry whose release or observation got no answer stays
@@ -151,6 +180,6 @@ launched-session check exact.
151
180
  | D1 | `fabric-mcp` enrolls with the runtime session id | for `--runner claude-code`, `runtimeSessionId` = env `CLAUDE_CODE_SESSION_ID` in `fabric.session.enroll {cwd, runtimeSessionId}` (Claude Code 2.1.296 gives MCP stdio servers and Bash commands that variable, equal to the hooks' `session_id`) |
152
181
  | D2 | rejoin only | `fabric-mcp --runner claude-code --rejoin-only --runtime-session-id <id>` attaches to that runtime session's live enrolled session; never asks, never mints; otherwise only `fabric_whoami`, as not enrolled; Fabric 0.3.9 exits 3 at once when no session holds the id |
153
182
  | D3 | the flag | `hub.json` `"features": ["session-hooks/1"]` once D1, D2, D4 and D5 ship |
154
- | D4 | `fabric_session_observe` | `{event: "turn" or "end", runtime: "claude-code", runtimeSessionId, observedAt, head, filesChanged, linesAdded, linesRemoved, reason (end only), idempotencyKey}` → `session.turn.observed@1`, `session.ended@1`; from Fabric 0.3.14 a turn's answer is `{recorded, seq, nudges?}`, each nudge `{kind, says, task_id?, request_id?}` shown on a turn once, at most five, the same on a repeated turn, and kept for `fabric_work_list` and `fabric_heartbeat` (FAA-142) |
183
+ | D4 | `fabric_session_observe` | `{event: "turn" or "end", runtime: "claude-code", runtimeSessionId, observedAt, head, filesChanged, linesAdded, linesRemoved, reason (end only), repositories (turn only, optional), idempotencyKey}` → `session.turn.observed@1`, `session.ended@1`; `repositories` (FAA-140, proposed to Fabric on `a8bdda7a`, accepted on `d3f9e6fb`) is at most 10 `{host, owner, name}` and is sent only where the tool's input schema lists it with items carrying host, owner and name, so a Fabric without it gets the call it gets today; from Fabric 0.3.14 a turn's answer is `{recorded, seq, nudges?}`, each nudge `{kind, says, task_id?, request_id?}` shown on a turn once, at most five, the same on a repeated turn, and kept for `fabric_work_list` and `fabric_heartbeat` (FAA-142) |
155
184
  | D5 | release a whole session | `fabric_work_release {claims: "session", outcome: "let-go", reason, idempotencyKey}`; `observedAt` too where its input schema names it (Fabric 0.3.9: `stale-end` for an end observed before the session enrolled) |
156
185
  | D6 | a launched-session marker | `FABRIC_LAUNCHED_SESSION=1` in the environment of every session Fabric launches, whatever its adapter; until then, the inference above |
@@ -76,6 +76,8 @@ from fabric_session_env import ( # noqa: F401
76
76
  spawn_worker,
77
77
  _remove_tree,
78
78
  NO_SESSION_MARK,
79
+ REPOSITORIES_MARK,
80
+ repositories_wanted,
79
81
  NO_SESSION_HOLD_S,
80
82
  no_session_held,
81
83
  )
@@ -100,6 +102,15 @@ from fabric_session_mcp import ( # noqa: F401
100
102
  _wait_for_session_tools,
101
103
  NO_SESSION_EXIT,
102
104
  NoSessionError,
105
+ TRANSCRIPT_TAIL_BYTES,
106
+ MAX_REPOSITORIES,
107
+ BASH_BYTES,
108
+ _takes_repositories,
109
+ parse_origin,
110
+ bash_paths,
111
+ turn_paths,
112
+ turn_repositories,
113
+ stored_repositories,
103
114
  )
104
115
  from fabric_session_mail import ( # noqa: F401
105
116
  WATCH_MIN_INTERVAL_S,
@@ -36,6 +36,7 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
36
36
  prune_stale_sessions,
37
37
  read_small,
38
38
  rejoined_recently,
39
+ repositories_wanted,
39
40
  session_dir,
40
41
  spawn_worker,
41
42
  spool,
@@ -44,6 +45,7 @@ from fabric_session_mcp import ( # noqa: E402 - the kit (FAA-65)
44
45
  FACTS_BUDGET_S,
45
46
  deliver,
46
47
  git_facts,
48
+ turn_repositories,
47
49
  )
48
50
  from fabric_session_mail import ( # noqa: E402 - the kit (FAA-65)
49
51
  watch_mail_cli,
@@ -164,7 +166,8 @@ def hook_user_prompt_submit(payload: Dict[str, Any], env: Optional[Dict[str, str
164
166
 
165
167
 
166
168
  def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]] = None, spawn=spawn_worker) -> str:
167
- """Stop (`turn`) and SessionEnd (`end`): spool one entry, with the turn's facts measured now, and start the worker.
169
+ """Stop (`turn`) and SessionEnd (`end`): spool one entry, with the turn's facts measured now (and a turn's other
170
+ repositories, FAA-140), and start the worker.
168
171
  Returns what happened, for the tests. Nothing is spooled and no worker runs unless the running Fabric advertises
169
172
  `session-hooks/1` and `fabric` is registered: hub.json is read first because it is the cheap check that fails for
170
173
  every Fabric before the one that ships the hooks' dependency (`status` says so: "Session hooks: inert"). Stale
@@ -213,10 +216,16 @@ def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]
213
216
  directory.mkdir(parents=True, exist_ok=True, mode=0o700)
214
217
  _write_private(directory / LAUNCHED_MARK, "")
215
218
  return "skipped: launched by Fabric"
216
- # The turn's own facts, measured when it ended (counts and ids only, never a path), not when a worker runs.
219
+ # The turn's own facts, measured when it ended (counts and ids only, never a path), not when a worker runs. The
220
+ # other repositories the turn changed (FAA-140) share the same budget: git_facts first, then what time is left.
221
+ until = time.monotonic() + FACTS_BUDGET_S
217
222
  entry = {"event": event, "runtimeSessionId": session_id, "cwd": str(cwd),
218
223
  "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
219
- "facts": git_facts(cwd, budget_s=FACTS_BUDGET_S)}
224
+ "facts": git_facts(cwd, until=until)}
225
+ if event == "turn" and repositories_wanted(env): # only once Fabric takes them: no transcript read before (FAA-140)
226
+ repositories = turn_repositories(payload.get("transcript_path"), cwd, until)
227
+ if repositories:
228
+ entry["repositories"] = repositories
220
229
  if event == "end" and isinstance(payload.get("reason"), str):
221
230
  entry["reason"] = payload["reason"][:64]
222
231
  spool(directory, entry)
@@ -58,7 +58,7 @@ SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
58
58
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
59
59
 
60
60
 
61
- VERSION = "0.52.0"
61
+ VERSION = "0.54.0"
62
62
 
63
63
 
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
@@ -476,6 +476,10 @@ LAUNCHED_MARK = "launched"
476
476
  #: (Fabric 0.3.9, TR-11). For NO_SESSION_HOLD_S the hooks spool nothing for that id; after it, one rejoin tries again,
477
477
  #: so a Fabric that restarted and enrolled the id again is found (board thread 02bf19c5, ask 4).
478
478
  NO_SESSION_MARK = "no-session"
479
+ #: The file in the hooks' own folder (state_root) that says the running Fabric's fabric_session_observe takes
480
+ #: `repositories` as {host, owner, name}: a worker writes it from the tool's schema and removes it when the schema stops
481
+ #: saying so. Until it exists the Stop hook reads no transcript (FAA-140, seam and product readings 1).
482
+ REPOSITORIES_MARK = "observe-takes-repositories"
479
483
  NO_SESSION_HOLD_S = 15 * 60.0
480
484
 
481
485
 
@@ -527,6 +531,12 @@ def _write_private(path: Path, text: str) -> None:
527
531
  os.replace(str(tmp), str(path))
528
532
 
529
533
 
534
+ def repositories_wanted(env: Optional[Dict[str, str]] = None) -> bool:
535
+ """Whether the Stop hook reads the transcript for the turn's other repositories (FAA-140): only once a worker
536
+ left the mark, and never on Windows, where a Bash command's paths are not this host's (FAA-175)."""
537
+ return sys.platform != "win32" and (state_root(env=env) / REPOSITORIES_MARK).exists()
538
+
539
+
530
540
  def session_dir(session_id: str, env: Optional[Dict[str, str]] = None) -> Path:
531
541
  return state_root(env=env) / "sessions" / session_id
532
542
 
@@ -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.44",
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",