Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.30",
"version": "4.4.31",
"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.30/ # Plugin version
│ └── 4.4.31/ # 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.30",
"version": "4.4.31",
"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.30
> **Version**: 4.4.31

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
100 changes: 100 additions & 0 deletions pact-plugin/hooks/bootstrap_marker_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,13 @@ def _emit_load_failure_advisory(stage: str, error: BaseException) -> NoReturn:
import shared.pact_context as pact_context
from bootstrap_gate import is_marker_set
from shared import BOOTSTRAP_MARKER_NAME
from shared.claude_md_manager import resolve_project_claude_md_path
from shared.marker_schema import (
MARKER_MAX_BYTES,
MARKER_SCHEMA_VERSION,
expected_marker_signature,
)
from shared.session_resume import update_session_info
except BaseException as _module_load_error: # noqa: BLE001 — fail-closed catch-all
_emit_load_failure_advisory("module imports", _module_load_error)

Expand Down Expand Up @@ -331,6 +333,98 @@ def _write_marker(session_dir: Path, session_id: str, plugin_root: str,
raise


def _write_back_aligned_team_name() -> None:
"""Self-heal the PERSISTED team name to the IDENTITY-MATCHED one (#989).

Per-prompt, lead-gated write-back. ``get_team_name()`` resolves the REAL
platform team via identity match (``config.json['leadSessionId']``);
``get_pact_context()['team_name']`` is what session_init PERSISTED at
SessionStart. In a divergent launch context these DIFFER — session_init
wrote the computed ``session-<id8>`` while the platform named the dir with
the full UUID. This function reconciles the persisted record (and the
human-readable CLAUDE.md ``- Team:`` line) to the aligned value, so the
persisted file stops being stale and the two SessionStart writers converge.

Fires ONLY when the aligned name is non-empty AND differs from the
persisted name (the normal no-divergence CLI case is a clean no-op — they
match, so this returns immediately). Caller has already lead-gated.

NEVER raises — every error is swallowed. The marker write is the load-
bearing action; a write-back failure must not abort it or crash the hook.

CLAUDE.md guard (HARD requirement): the target is resolved via
``resolve_project_claude_md_path`` and ``exists()``-guarded BEFORE calling
``update_session_info``. That function's Case-0 branch CREATES a brand-new
PACT-managed CLAUDE.md when the file is absent — which in a gitignored/
absent worktree would MATERIALIZE a file we must never create. So when the
CLAUDE.md is absent we SKIP the CLAUDE.md write entirely (the context-file
write-back still happens). When present, we pass the FULL correct tuple
(session_id / aligned team_name / session_dir / plugin_root) because
``update_session_info`` rewrites the WHOLE managed session block.
"""
try:
aligned = pact_context.get_team_name()
if not aligned:
return
persisted = pact_context.get_pact_context().get("team_name", "").lower()
if aligned == persisted:
# Clean no-op: persisted record already matches the real team
# (the normal in-scope CLI case, and the steady state after the
# first reconciliation).
return

session_id = pact_context.get_session_id()
session_dir = pact_context.get_session_dir()
plugin_root = pact_context.get_plugin_root()

# Context-file write-back: rewrite the persisted team_name to the
# aligned value (full build+cache+persist seam; atomic, 0o600). This
# ALWAYS runs on a divergence, independent of the CLAUDE.md branch.
#
# ORDERING IS INTENTIONAL — context FIRST, CLAUDE.md SECOND. The context
# file is the SSOT every team-scoped hook reads; the CLAUDE.md '- Team:'
# line is cosmetic (human-readable) and NO reader cross-checks it against
# the context. The two writes are not transactional, but each is a
# whole-file atomic op, so a crash BETWEEN them leaves the load-bearing
# record (the context file) already correct — the worst residual is a
# stale cosmetic CLAUDE.md line, self-healed on the next prompt. So the
# non-atomicity is safe by construction, not an accepted risk.
pact_context.write_context(
aligned, session_id, pact_context.get_project_dir(), plugin_root
)

# CLAUDE.md write-back: exists()-guard BEFORE update_session_info so we
# never trip its Case-0 create-on-absent branch in a worktree. Resolve
# the project CLAUDE.md path the same way update_session_info does.
#
# TOCTOU NOTE (security, mitigated / out-of-scope): there is a window
# between this exists() check and update_session_info's write. To exploit
# it an attacker would need write access to the project dir AS THE SAME OS
# USER running the hook — at which point the box is already compromised
# (they could edit CLAUDE.md, the context file, or the hook itself
# directly). update_session_info itself re-resolves + rewrites the whole
# managed block atomically under its own logic, so the only residual is a
# benign cosmetic write. No cross-user privilege boundary is crossed here,
# so this is out-of-scope by the same-user trust model.
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "")
if not project_dir:
return
target_file, _source = resolve_project_claude_md_path(project_dir)
if not target_file.exists():
# Absent (e.g. gitignored worktree CLAUDE.md): SKIP the CLAUDE.md
# write. The context-file write-back above already happened; the
# human-readable line just stays absent, which is correct here.
return
# Present: rewrite the whole managed session block with the aligned
# team name + the full correct tuple.
update_session_info(session_id, aligned, session_dir, plugin_root)
except Exception as e:
print(
f"bootstrap_marker_writer: team-name write-back failed: {e}",
file=sys.stderr,
)


def _try_write_marker(input_data: dict) -> None:
"""Verify pre-conditions and write marker if all are met.

Expand Down Expand Up @@ -377,6 +471,12 @@ def _try_write_marker(input_data: dict) -> None:
if not pact_context.is_lead(input_data):
return

# Self-heal the persisted team name to the identity-matched one (#989),
# lead-gated like the marker write below. No-op when they already match
# (the normal CLI case). Never raises. Done BEFORE the secretary check so
# the check (and the marker's session_id) read the aligned team.
_write_back_aligned_team_name()

# Pre-condition: team config + secretary member exist on disk.
team_name = pact_context.get_team_name()
if not _team_has_secretary(team_name):
Expand Down
30 changes: 27 additions & 3 deletions pact-plugin/hooks/session_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
from shared.constants import get_compact_summary_path
from shared.pact_context import (
_is_unknown_or_missing_session,
_resolve_aligned_team_name,
build_context_cache,
classify_session_role,
generate_team_name,
Expand Down Expand Up @@ -1025,6 +1026,27 @@ def main():
file=sys.stderr,
)
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")

# Oscillation convergence (#989 detect-and-align). session_init writes
# team_name to BOTH the context cache (below) and CLAUDE.md (step 5b)
# on EVERY SessionStart, including compact/clear re-fires. The
# bootstrap_marker_writer self-heals to the IDENTITY-MATCHED name per
# prompt. If session_init kept writing the raw COMPUTED name while the
# marker writer wrote the aligned name, the two would flip-flop forever
# whenever they differ (the divergent full-UUID launch case). FIX:
# resolve the aligned name HERE and persist THAT, so both writers
# converge on one value. The freshly-computed `team_name` is threaded
# as the resolver's explicit `default` — critical for cold start: at
# SessionStart the real team dir is ~38s unborn, so the identity match
# MISSES and the resolver returns the default; on a first-ever cold
# start the persisted context is empty, so the computed name (not "")
# must be the default. Gated on a valid session_id (the sentinel path
# skips all persistence anyway). Once the dir is born, a later
# SessionStart (or the marker writer) resolves the aligned name and
# both writers agree → the per-prompt write-back becomes a true no-op.
if not session_id_was_missing:
team_name = _resolve_aligned_team_name(session_id, default=team_name)

# Lead-role gate (#877). is_lead is total (never raises) and reads only
# the harness-set agent_type. Computed once and reused for both Class-A
# writes below so the disk-write split and the journal-anchor gate share
Expand Down Expand Up @@ -1159,9 +1181,11 @@ def main():
# the spawn prompt already owns the role and session_init lacks agent_name
# under tmux. This is a CONDITIONAL EMISSION, not a new numbered step.
if frame_role == "teammate": # m3: reuse the role captured at the early seam (was a recompute)
# O1 fix + Finding-1. team_name above is generate_team_name(input_data)
# = pact-{this teammate's OWN session hash}, NOT the lead's team — so
# resolve the lead's team + this teammate's own member name from the
# O1 fix + Finding-1. team_name above is the #989-aligned name
# (identity-matched on this teammate's OWN session_id, defaulting to
# generate_team_name) — for a tmux teammate this is derived from the
# teammate's OWN session, NOT the lead's team — so resolve the lead's
# team + this teammate's own member name from the
# self-registration registry: the teammate wrote {own session_id →
# name@team} at its first action, so a self-lookup by our OWN
# session_id recovers both the @team (the lead's team — the datum a
Expand Down
24 changes: 18 additions & 6 deletions pact-plugin/hooks/shared/hook_infra_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,25 @@
"tool_response", "variety_scorer",
}),
"bootstrap_gate": frozenset({
"constants", "marker_schema", "pact_context", "paths",
"session_journal", "session_registry", "session_state",
}),
"claude_md_manager", "constants", "marker_schema", "pact_context",
"paths", "pin_caps", "session_journal", "session_registry",
"session_resume", "session_state", "staleness",
}), # claude_md_manager / session_resume / staleness / pin_caps reached
# here via bootstrap_gate -> bootstrap_marker_writer -> session_resume
# (update_session_info) + claude_md_manager (resolve_project_claude_md_path),
# and session_resume -> staleness -> pin_caps. Added when #989's
# write-back self-heal pulled session_resume + claude_md_manager into
# bootstrap_marker_writer's imports.
"bootstrap_marker_writer": frozenset({
"constants", "marker_schema", "pact_context", "paths",
"session_journal", "session_registry", "session_state",
}),
"claude_md_manager", "constants", "marker_schema", "pact_context",
"paths", "pin_caps", "session_journal", "session_registry",
"session_resume", "session_state", "staleness",
}), # claude_md_manager / session_resume / staleness / pin_caps reached
# here because #989's write-back self-heal added
# resolve_project_claude_md_path (claude_md_manager) + update_session_info
# (session_resume) imports; session_resume -> staleness -> pin_caps. This
# is the SOURCE edge that also grows bootstrap_gate's closure (which
# imports bootstrap_marker_writer).
"file_tracker": frozenset({
"constants", "pact_context", "paths", "session_journal",
"session_registry", "session_state",
Expand Down
Loading