Skip to content
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "PACT",
"source": "./pact-plugin",
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
"version": "4.4.38",
"version": "4.4.39",
"author": {
"name": "Synaptic-Labs-AI"
},
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ When installed as a plugin, PACT lives in your plugin cache:
│ └── cache/
│ └── pact-plugin/
│ └── PACT/
│ └── 4.4.38/ # Plugin version
│ └── 4.4.39/ # Plugin version
│ ├── agents/
│ ├── commands/
│ ├── skills/
Expand Down
2 changes: 1 addition & 1 deletion pact-plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "PACT",
"version": "4.4.38",
"version": "4.4.39",
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
"author": {
"name": "Synaptic-Labs-AI",
Expand Down
2 changes: 1 addition & 1 deletion pact-plugin/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PACT — Orchestration Harness for Claude Code

> **Version**: 4.4.38
> **Version**: 4.4.39

Turn a single Claude Code session into a managed team of specialist AI agents that prepare, design, build, and test your code systematically.

Expand Down
53 changes: 44 additions & 9 deletions pact-plugin/hooks/bootstrap_marker_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,24 +200,59 @@ def _suppress_output(event_name: str) -> str:


def _team_has_secretary(team_name: str) -> bool:
"""Return True iff the team config contains a member with
``name == "secretary"``.
"""Return True iff the secretary has joined the team — proven by EITHER the
config.json members[] roster OR (config-less fallback) the secretary's
inbox file.

Pre-condition for marker write. Returns False silently on any I/O
error, malformed JSON, or missing-secretary case — the sibling
bootstrap_prompt_gate owns the user-visible advisory.

Built on the shared ``pact_context._iter_members`` helper, so the
JSON-shape adversarial-input semantics (missing config, malformed
JSON, non-list members, non-dict member entries, missing keys)
match those of the id-keyed
``pact_context._lookup_agent_in_team_config`` consumer
byte-for-byte. The predicate stays distinct: this one filters on
the member ``name`` field; the lookup filters on member ``id``.
PRIMARY (CLI byte-identical): the members[] check via the shared
``pact_context._iter_members`` helper, so the JSON-shape adversarial-input
semantics (missing config, malformed JSON, non-list members, non-dict
member entries, missing keys) match those of the id-keyed
``pact_context._lookup_agent_in_team_config`` consumer byte-for-byte. The
predicate stays distinct: this one filters on the member ``name`` field;
the lookup filters on member ``id``. Tried FIRST, so under CLI (config
present) the inbox arm is never reached.

CONFIG-LESS FALLBACK: under the Desktop / older-CLI / print
substrate the platform creates ``teams/<full-uuid>/`` with ``inboxes/`` but
no ``config.json`` — so members[] is empty and the marker would deadlock.
Accept ``teams/<team_name>/inboxes/secretary.json`` as the witness.

The inbox witnesses the secretary was DISPATCHED, not that it has joined a
members[] roster — and that is the right signal here. PACT's bootstrap is
``TaskCreate -> TaskUpdate(owner) -> Agent(secretary)`` with NO pre-spawn
``SendMessage`` to the secretary, so the platform writes the secretary's
inbox file only on delivery of a message to an already-dispatched
secretary; it cannot predate the spawn within PACT's choreography. And the
gated tools (Edit/Write/Agent) do not hard-require a LIVE secretary at
unblock time — the one liveness contract (memory queries) is enforced by
awaiting a ``SendMessage`` reply, not by this marker. So a dispatch-level
witness is sufficient and correct for the marker precondition.

Fail-safe ``False`` on any error in either arm (the inbox ``is_file`` probe
is wrapped so an unexpected FS error degrades to the existing silent-False
semantic).
"""
for member in pact_context._iter_members(team_name):
if member.get("name") == _SECRETARY_NAME:
return True
# Config-less fallback: the secretary's inbox file is the witness
# when no config.json members[] roster exists. team_name is get_team_name()
# output (path-safe by construction); the read is wrapped so any FS error
# preserves the fail-safe False.
try:
teams_dir = pact_context.get_claude_config_dir() / "teams"
secretary_inbox = (
teams_dir / team_name / "inboxes" / f"{_SECRETARY_NAME}.json"
)
if secretary_inbox.is_file():
return True
except Exception:
return False
return False


Expand Down
7 changes: 7 additions & 0 deletions pact-plugin/hooks/dispatch_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,13 @@ def _team_member_names(team_name: str) -> set[str]:
architect §5 contract intentionally did NOT include this in
dispatch_helpers.py because task_lifecycle_gate has no need for the
member roster.

ACCEPTED CONFIG-LESS GAP: under the config-less Desktop/SDK
substrate there is no ``config.json``, so this returns ``set()`` and the
name-uniqueness rule degrades to "no collision detected". This is an
ACCEPTED degradation, not a deadlock: the rule fails OPEN (permits), so
dispatch is not blocked; only the (advisory) duplicate-name check is
silently inactive in that substrate.
"""
cfg_path = get_claude_config_dir() / "teams" / team_name / "config.json"
try:
Expand Down
74 changes: 60 additions & 14 deletions pact-plugin/hooks/shared/pact_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,15 +362,18 @@ def _resolve_aligned_team_name(
A typed outer tuple would LEAK the RuntimeError/TypeError above and break
never-raises, which is why the outer guard is a bare ``except Exception``.

NOTE — ``session_id`` is NOT a raise source here. It is used ONLY as a
string compared against ``config.json['leadSessionId']`` (and an empty
check); it is NEVER composed into a ``Path``. So a path-unsafe raw
``session_id`` (embedded ``/`` or NUL) does NOT raise in this function —
it simply never equals any stored ``leadSessionId`` -> NO MATCH ->
``default``. The path-safety gate is applied instead to the matched DIR
NAME (``is_safe_path_component`` below), which IS used as a path segment.
The bare-except precedents in this module are ``persist_context`` and
``heal_context_if_missing``.
NOTE — ``session_id`` is NOT an uncaught raise source here. In the
identity-match loop it is used ONLY as a string compared against
``config.json['leadSessionId']`` (and an empty check). In the branch-2
fallthrough below it IS composed into a ``Path`` (``teams_root /
session_id``), but ONLY after ``is_safe_path_component(session_id)`` gates
it as the FIRST conjunct — so a path-unsafe raw ``session_id`` (embedded
``/`` or NUL) is rejected by that gate and falls through to ``default``,
never reaching the composition. The subsequent ``is_dir()`` / ``exists()``
probes raise at most an ``OSError``-family error, which the outer bare
``except`` catches. The path-safety gate also guards the matched DIR NAME
in the loop (``is_safe_path_component`` there). The bare-except precedents
in this module are ``persist_context`` and ``heal_context_if_missing``.

PERF (SessionStart hot-path scan cost): on a MATCH the scan stops at the
first matching dir; on NO MATCH it iterates EVERY team dir under
Expand Down Expand Up @@ -427,15 +430,40 @@ def _resolve_aligned_team_name(
# This dir is unreadable / malformed — skip it, keep scanning
# the rest. A single bad sibling must not abort detection.
continue
# Branch-2: config-less full-UUID divergence (Desktop child / older-CLI
# / print). The identity-match loop above missed because no team dir
# carries a config.json with this leadSessionId — but the platform may
# still have created teams/<session_id>/ (full UUID) with inboxes/ and
# no config.json. Anchor on the harness-invariant session_id directly:
# if a real own-session substrate exists, resolve to it. is_safe_path_
# component(session_id) is the FIRST conjunct (guard-order — short-
# circuit before any FS probe; a path-unsafe session_id never reaches a
# Path composition). The inboxes/ | file-edits.json witness proves the
# platform built a genuine team substrate for this session (not a bare
# dir). Unreachable under new-CLI: the platform names the dir
# session-<id8>, so teams/<full-uuid>/ never exists (steady-state AND
# the ~38s cold-start) -> CLI byte-identical.
if (
is_safe_path_component(session_id)
and (teams_root / session_id).is_dir()
and (
(teams_root / session_id / "inboxes").is_dir()
or (teams_root / session_id / "file-edits.json").exists()
)
):
return session_id
return fallback
except Exception:
# TOTAL fail-safe: home-resolution RuntimeError (get_claude_config_dir
# -> Path.home, the teams_dir=None branch), a non-str teams_dir
# TypeError (Path(teams_dir)), or any other unexpected error -> the
# persisted/computed default. (session_id is NOT a raise source here —
# it is only string-compared to leadSessionId, never composed into a
# Path; see the NOTE in the docstring above.) NEVER raises —
# get_team_name and the heal path depend on this contract.
# persisted/computed default. (In the identity-match loop session_id is
# only string-compared to leadSessionId; the branch-2 fallthrough DOES
# compose teams_root / session_id, but only AFTER is_safe_path_component
# gates it, and the is_dir()/exists() probes raise at most an
# OSError-family error that THIS except catches — so session_id is still
# not an uncaught raise source; see the NOTE in the docstring above.)
# NEVER raises — get_team_name and the heal path depend on this contract.
if default is not None:
return default
try:
Expand Down Expand Up @@ -479,12 +507,30 @@ def get_team_name() -> str:
return _aligned_cache
# Read the persisted SSOT first. An empty value is the fail-closed signal
# (see the security-gate note above) — short-circuit BEFORE identity-match.
#
# LATENT COUPLING (the config-less fix depends on this): the
# _resolve_aligned_team_name BRANCH-2 config-less fallback (session-id-
# anchored teams/<uuid>/ resolution) is reached ONLY through the non-empty
# path below. So branch-2's config-less reachability DEPENDS on the
# persisted team_name being non-empty here. That holds today because
# session_init persists a non-empty computed default (generate_team_name ->
# session-<id8>, threaded as the resolver default at session_init main()).
# If a future change ever persisted an EMPTY team_name, this short-circuit
# would return '' BEFORE branch-2 ran and the config-less Desktop/SDK fix
# would SILENTLY stop firing (the deadlock would return) — with no error,
# because '' is the legitimate fail-closed "team unknown -> refuse" signal.
# Do NOT "fix" that by recovering a team from an empty SSOT here: that would
# break the deliberate fail-closed gate (test_empty_ssot_team_fails_closed_
# both_modes). The correct invariant to preserve is upstream: keep the
# persisted SSOT non-empty for a real session.
ctx_team = get_pact_context().get("team_name", "")
if not ctx_team:
_aligned_cache = ""
return _aligned_cache
# Non-empty SSOT: identity-match can UPGRADE it to the real platform dir
# (or no-op back to ctx_team on a cold-start / no-match).
# (or no-op back to ctx_team on a cold-start / no-match). This is also the
# ONLY path that reaches the branch-2 config-less fallback (see the LATENT
# COUPLING note above).
resolved = _resolve_aligned_team_name(get_session_id(), default=ctx_team)
_aligned_cache = resolved.lower()
return _aligned_cache
Expand Down
Loading