diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 36c4f6f6..390d771b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" }, diff --git a/README.md b/README.md index 9100025f..44929b7b 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/pact-plugin/.claude-plugin/plugin.json b/pact-plugin/.claude-plugin/plugin.json index 56b1b6b1..7d8aafd9 100644 --- a/pact-plugin/.claude-plugin/plugin.json +++ b/pact-plugin/.claude-plugin/plugin.json @@ -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", diff --git a/pact-plugin/README.md b/pact-plugin/README.md index 509d3a64..4faa3984 100644 --- a/pact-plugin/README.md +++ b/pact-plugin/README.md @@ -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. diff --git a/pact-plugin/hooks/bootstrap_marker_writer.py b/pact-plugin/hooks/bootstrap_marker_writer.py index 35b9e207..35d973a6 100644 --- a/pact-plugin/hooks/bootstrap_marker_writer.py +++ b/pact-plugin/hooks/bootstrap_marker_writer.py @@ -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) @@ -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-`` 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. @@ -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): diff --git a/pact-plugin/hooks/session_init.py b/pact-plugin/hooks/session_init.py index 67435e90..b87232dd 100755 --- a/pact-plugin/hooks/session_init.py +++ b/pact-plugin/hooks/session_init.py @@ -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, @@ -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 @@ -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 diff --git a/pact-plugin/hooks/shared/hook_infra_classifier.py b/pact-plugin/hooks/shared/hook_infra_classifier.py index 0a2ea6a7..3497be84 100644 --- a/pact-plugin/hooks/shared/hook_infra_classifier.py +++ b/pact-plugin/hooks/shared/hook_infra_classifier.py @@ -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", diff --git a/pact-plugin/hooks/shared/pact_context.py b/pact-plugin/hooks/shared/pact_context.py index 5d25ddc9..8b4914d1 100644 --- a/pact-plugin/hooks/shared/pact_context.py +++ b/pact-plugin/hooks/shared/pact_context.py @@ -51,6 +51,21 @@ # is a fresh Python process (new module state = clean cache). _cache: dict | None = None +# Per-process cache for the IDENTITY-MATCHED team name (get_team_name's +# detect-and-align result). Populated lazily on the first get_team_name() +# call and reused for the life of the process. +# +# COLD-START TRAP — this MUST stay a plain in-memory module global and MUST +# NEVER be persisted to disk or an env var. The platform team dir is born +# ~38s AFTER SessionStart, so a SessionStart-time resolution MISSES the real +# dir and resolves to the persisted-context default. If that early (wrong) +# value were memoized to disk, a later process would read it back BEFORE +# re-probing the now-present dir and re-introduce the bootstrap deadlock this +# whole change fixes. The born-and-die-per-process lifecycle IS the safety: +# each fresh hook process re-probes the live filesystem. None is the +# "not-yet-resolved" sentinel; "" is a legitimate resolved-empty value. +_aligned_cache: str | None = None + # Default context dict returned on any error _EMPTY_CONTEXT = { "team_name": "", @@ -82,28 +97,29 @@ def reset_for_tests() -> None: """Reset this module's mutable session-context state to its import-time default. Public test-isolation hook. - ``pact_context`` memoizes the resolved session context in two - module-level globals — ``_cache`` (the parsed context dict) and - ``_context_path`` (the resolved context-file path). Both are populated - lazily on first read and persist for the life of the process. That is - correct in production (one session per process) but leaks across tests, - which reuse a single process: a test that populates the cache with a - session bleeds it into every later test. The pytest autouse fixture in - ``tests/conftest.py`` calls this before AND after every test to guarantee - cross-test isolation. + ``pact_context`` memoizes the resolved session context in three + module-level globals — ``_cache`` (the parsed context dict), + ``_context_path`` (the resolved context-file path), and ``_aligned_cache`` + (the identity-matched team name). All are populated lazily on first read + and persist for the life of the process. That is correct in production + (one session per process) but leaks across tests, which reuse a single + process: a test that populates the cache with a session bleeds it into + every later test. The pytest autouse fixture in ``tests/conftest.py`` + calls this before AND after every test to guarantee cross-test isolation. Co-located with the state it resets ON PURPOSE: a future rename of - ``_cache`` / ``_context_path`` must update THIS function in the same - module, instead of silently turning an external direct-assignment reset - into a no-op. Pure, no args, idempotent. Resets ONLY the mutable - cache/path globals — ``_EMPTY_CONTEXT`` and the ``TOKEN_*`` / config - constants are immutable defaults and are not touched. ADDITIVE: production - caching behavior and all existing callers are unchanged; this is invoked - only by tests. + ``_cache`` / ``_context_path`` / ``_aligned_cache`` must update THIS + function in the same module, instead of silently turning an external + direct-assignment reset into a no-op. Pure, no args, idempotent. Resets + ONLY the mutable cache/path globals — ``_EMPTY_CONTEXT`` and the + ``TOKEN_*`` / config constants are immutable defaults and are not touched. + ADDITIVE: production caching behavior and all existing callers are + unchanged; this is invoked only by tests. """ - global _cache, _context_path + global _cache, _context_path, _aligned_cache _cache = None _context_path = None + _aligned_cache = None def _build_session_path(slug: str, session_id: str) -> Path: @@ -191,7 +207,7 @@ def init(input_data: dict) -> None: Args: input_data: Parsed stdin JSON from the hook """ - global _context_path, _cache + global _context_path, _cache, _aligned_cache # Skip if already initialized (test fixtures pre-set _context_path) if _context_path is not None: @@ -221,8 +237,13 @@ def init(input_data: dict) -> None: _context_path = ( _build_session_path(slug, session_id) / "pact-session-context.json" ) - # Clear cache so subsequent reads use the new path + # Clear caches so subsequent reads use the new path. _aligned_cache is + # DERIVED from the context (#989), so it must be invalidated whenever + # the context path changes — otherwise a get_team_name() called before + # init() (which resolves to "" with no path) would poison the cache and + # be returned stale after init(). _cache = None + _aligned_cache = None # else: leave _context_path as None — readers return _EMPTY_CONTEXT @@ -288,9 +309,185 @@ def get_pact_context() -> dict: return _cache +def _resolve_aligned_team_name( + session_id: str, + teams_dir: str | None = None, + default: str | None = None, +) -> str: + """Resolve the REAL platform team name for ``session_id`` by IDENTITY MATCH. + + Detect-and-align (#989). ``session_init`` persists the COMPUTED team name + (``generate_team_name`` -> ``session-``) at SessionStart, but in + divergent launch contexts (Desktop child / print / rename-skip) the + platform names the real team dir with the FULL session UUID instead. This + resolver finds the dir that ACTUALLY belongs to this session so + ``get_team_name`` returns the namespace the tasks really live under. + + IDENTITY-MATCH predicate (launcher-agnostic + collision-proof): a team dir + is THIS session's team iff ``teams//config.json`` exists and its + ``leadSessionId`` field equals ``session_id``. This matches whether the + platform named the dir ``session-`` (2.1.178+ CLI) or the full UUID + (Desktop 2.1.177 child) — there is deliberately NO dir-name-prefix + shortcut; full-UUID, ``session-``, and ``pact-`` are all + first-class. A stale/foreign dir from another session carries a DIFFERENT + ``leadSessionId`` and is rejected, so an ``id8`` collision cannot + mis-resolve. + + FAIL-SAFE DEFAULT: on no identity match (the team dir is half-formed — + ``inboxes/`` present but ``config.json`` not yet written — or simply + absent at a cold-start probe, or anything raises), return ``default``. + ``default`` falls back to the PERSISTED CONTEXT team_name + (``get_pact_context()['team_name']``) when not supplied — that is exactly + today's behavior, so a no-match is zero-regression. Detection can only + UPGRADE (resolve a fresher identity-matched dir); it never degrades below + the persisted value. ``session_init`` threads the freshly-computed name as + an explicit ``default`` at its call site so a first-ever cold SessionStart + (empty persisted context) resolves to the computed name, NOT "". + + PURE / FS-read-only / NEVER raises. The whole scan is wrapped in a TRUE + ``except Exception`` (NOT the typed-tuple ``_iter_members`` precedent at + the ``members[]`` reader). The genuine raise sources the bare except must + catch are: + * ``get_claude_config_dir()`` -> ``Path.home()`` can raise + ``RuntimeError`` when HOME is unresolvable (the ``teams_dir is None`` + branch composes the teams root via home). + * ``Path(teams_dir)`` raises ``TypeError`` when ``teams_dir`` is a + non-``None`` non-str (e.g. an int) — a path cannot be composed from it. + * the per-entry ``config.json`` read (``read_text`` / ``json.loads`` / + ``is_dir``) can raise ``OSError`` / ``json.JSONDecodeError`` / + ``ValueError`` — but those are caught by the INNER typed + ``except`` (skip the bad sibling, keep scanning), so they normally do + NOT reach the outer except; the outer except is the backstop for an + unexpected error in the loop scaffolding itself. + 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``. + + 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 + ``teams/``, doing a ``stat``/``is_dir`` plus a small-JSON ``read_text`` + + ``json.loads`` per entry. Acceptable: the directory holds a handful of + entries in practice (worst case observed ~0.45ms over ~21 dirs), the + no-match path is hit only in the cold-start window (the real team dir is + born ~38s after SessionStart), and each fresh hook process pays it at most + once because ``get_team_name`` memoizes the result via ``_aligned_cache``. + + Args: + session_id: The current session id to identity-match against + ``config.json['leadSessionId']``. Empty -> no match -> default. + teams_dir: Override the teams directory (for testing). Defaults to + ``/teams``. + default: Fail-safe return on no match / error. Defaults to the + persisted-context team_name when None. + + Returns: + The identity-matched team dir name, else ``default`` (or the + persisted-context team_name when ``default`` is None). + """ + try: + # Resolve the default first (inside the try): get_pact_context() is a + # safe read, but compute the fallback before the scan so every exit + # path — match, no-match, raise — returns a defined value. + fallback = default if default is not None else get_pact_context().get( + "team_name", "" + ) + if not session_id: + return fallback + if teams_dir is not None: + teams_root = Path(teams_dir) + else: + teams_root = get_claude_config_dir() / "teams" + # Sorted iteration -> deterministic resolution if (pathologically) + # two dirs claimed the same leadSessionId. + for entry in sorted(teams_root.iterdir()): + try: + if not entry.is_dir(): + continue + config_path = entry / "config.json" + data = json.loads(config_path.read_text(encoding="utf-8")) + if data.get("leadSessionId") != session_id: + continue + # Path-safety the matched dir name BEFORE returning it — a + # tampered config could name a path-unsafe dir. On failure, + # skip this entry and keep scanning (do not abort the search). + name = entry.name + if not is_safe_path_component(name): + continue + return name + except (OSError, json.JSONDecodeError, ValueError, TypeError, AttributeError): + # This dir is unreadable / malformed — skip it, keep scanning + # the rest. A single bad sibling must not abort detection. + continue + 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. + if default is not None: + return default + try: + return get_pact_context().get("team_name", "") + except Exception: + return "" + + def get_team_name() -> str: - """Convenience: return team_name from context, lowercased. Empty string on error.""" - return get_pact_context().get("team_name", "").lower() + """Convenience: return the identity-matched team name, lowercased. + + Detect-and-align (#989): resolves the REAL platform team for this session + via ``_resolve_aligned_team_name`` (identity match on + ``config.json['leadSessionId']``) when the persisted SSOT team_name is + NON-EMPTY, using the persisted value as the fail-safe default. Stays a + PURE READ — never writes. + + EMPTY-SSOT SHORT-CIRCUIT — DELIBERATE SECURITY FAIL-CLOSED GATE (do not + remove). When the persisted context team_name is EMPTY, return "" WITHOUT + running identity-match. An empty SSOT is the existing "team unknown → + refuse" signal: every downstream consumer (the dispatch gate, etc.) + DENYs fail-closed on an empty team_name rather than guessing a path + segment. Identity-match must NOT recover a team from an EMPTY SSOT — that + would over-reach the fail-closed guard pinned by + test_empty_ssot_team_fails_closed_both_modes. #989's real targets + (resume-revert, Desktop full-UUID divergence) all have a NON-EMPTY but + WRONG persisted value, so the gate still aligns them; only the + empty/"unknown" case is short-circuited. The tmux leg already + fails-closed (no identity match), but the empty-SSOT case must fail-closed + in BOTH topologies — including the in-process leg where a real dir would + otherwise identity-match. + + Per-process cached in ``_aligned_cache`` (born-and-die per process; see + the module-global's cold-start-trap comment). The ``.lower()`` + normalization is preserved exactly as before — applied HERE, on the + resolver's return, AFTER the resolver has path-safety-checked the raw + matched-dir name. Empty string on error. + """ + global _aligned_cache + if _aligned_cache is not None: + 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. + 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). + resolved = _resolve_aligned_team_name(get_session_id(), default=ctx_team) + _aligned_cache = resolved.lower() + return _aligned_cache def get_session_id() -> str: @@ -733,7 +930,7 @@ def build_context_cache( Returns: ``(target, context)`` on success, or ``None`` if the path is uncomputable. """ - global _context_path, _cache + global _context_path, _cache, _aligned_cache context = { "team_name": team_name, @@ -763,8 +960,14 @@ def build_context_cache( # Populate the in-process cache UNCONDITIONALLY (Option A). The cache is the # process's own working truth; disk persistence is an independent side-effect. + # Invalidate _aligned_cache (#989): it is DERIVED from the context team_name, + # so a context (re)write must drop the memoized aligned value — the next + # get_team_name() then re-resolves against the freshly-written context (whose + # team_name is already the aligned name on the session_init / write-back + # paths). Keeps the aligned cache coherent with _cache. _context_path = target _cache = context + _aligned_cache = None return target, context @@ -915,11 +1118,21 @@ def heal_context_if_missing(input_data: dict) -> bool: (fabricated team name) and create an unreapable session dir; mirrors session_init's session_id_was_missing persist gate - Heal = write_context(generate_team_name(input_data), str(raw_id), - CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_ROOT) — the existing - build+cache+persist seam: atomic write (mkstemp+rename), 0o600, and - build_context_cache resets _cache so THIS process's subsequent - get_team_name()/get_session_dir() calls see the healed values. + Heal = write_context(_resolve_aligned_team_name(str(raw_id), + default=generate_team_name(input_data)), str(raw_id), CLAUDE_PROJECT_DIR, + CLAUDE_PLUGIN_ROOT) — the existing build+cache+persist seam: atomic write + (mkstemp+rename), 0o600, and build_context_cache resets _cache so THIS + process's subsequent get_team_name()/get_session_dir() calls see the + healed values. + + Detect-and-align on the crash-recovery path (#989): this is a 3rd + context-writer, so it MUST persist the IDENTITY-MATCHED name (not the + raw computed name) — otherwise a heal could re-write the wrong + ``session-`` name into the context, undoing the alignment. The + computed name is threaded as the resolver's ``default`` so a cold heal + (real team dir not yet present) still writes a valid computed name + rather than "". When the real dir IS present (the gate-time heal), the + resolver returns the aligned name -> the context converges in one prompt. Content parity with session_init: session_id is persisted as str(raw_id) — RAW, exactly as session_init's main() persists it @@ -954,8 +1167,16 @@ def heal_context_if_missing(input_data: dict) -> bool: raw_id = input_data.get("session_id") if _is_unknown_or_missing_session(raw_id): return False + # Detect-and-align (#989): persist the IDENTITY-MATCHED team name on + # the heal path. Thread the computed name as the resolver default so a + # cold heal (real dir not yet present) still writes a valid computed + # name, while a gate-time heal (real dir present) writes the aligned + # name and converges the context in one prompt. + aligned_team = _resolve_aligned_team_name( + str(raw_id), default=generate_team_name(input_data) + ) write_context( - generate_team_name(input_data), + aligned_team, str(raw_id), os.environ.get("CLAUDE_PROJECT_DIR", ""), os.environ.get("CLAUDE_PLUGIN_ROOT", ""), diff --git a/pact-plugin/tests/test_team_name_detect_align.py b/pact-plugin/tests/test_team_name_detect_align.py new file mode 100644 index 00000000..26871109 --- /dev/null +++ b/pact-plugin/tests/test_team_name_detect_align.py @@ -0,0 +1,1016 @@ +"""Comprehensive detect-and-align matrix for get_team_name() / #989. + +session_init persists the COMPUTED team name (``generate_team_name`` -> +``session-``) at SessionStart, but in divergent launch contexts (Desktop +child / print / rename-skip) the platform names the real team dir with the FULL +36-char session UUID instead. ``_resolve_aligned_team_name`` finds the dir that +ACTUALLY belongs to this session by IDENTITY MATCH (``config.json['leadSessionId'] +== session_id``) so ``get_team_name`` returns the namespace the tasks really live +under. ``get_team_name`` is a PURE READER that short-circuits to "" on an EMPTY +persisted SSOT (the deliberate Option-B fail-closed security gate) and only runs +identity-match on a NON-EMPTY SSOT. + +This file owns the #989 unit/integration matrix: + + * _resolve_aligned_team_name — identity-match, full-UUID dirs, fail-safe + totality (never-raises), is_safe_path_component skip, half-formed window. + * get_team_name — Option-B empty-SSOT fail-closed short-circuit, resume-revert + upgrade, per-process _aligned_cache memoization + reset_for_tests isolation. + * heal_context_if_missing — crash-recovery: context-ABSENT + real-dir-present + -> writes the ALIGNED name (converges in one prompt). + * session_init convergence — persists the ALIGNED name to kill oscillation. + * bootstrap_marker_writer write-back — two-file context-first ordering + + CLAUDE.md-absent skip (no create in a worktree). + * FULL-UUID caller sweep — the ~15 get_team_name callers accept a 36-char + return with no fixed-width / "session-[a-f0-9]{8}" regex narrowing. + +The DUAL-MODE PERMANENT CONTRACT: every behavioral test that depends on the +running frame's topology covers BOTH in-process (session_id == leadSessionId) +AND tmux (session_id != leadSessionId). The both-modes DISPATCH-GATE legs live +in test_team_name_resolution_both_modes.py (the standing merge gate); this file +adds the both-modes RESOLVER legs. + +Non-vacuity: every behavioral assertion seeds a matchable store so the assertion +BITES. The resolver-level non-vacuity is proven structurally — the identity match +returns the FULL-UUID dir that a name-prefix resolver could never produce, and the +empty-SSOT short-circuit returns "" even when a matchable dir IS present (so the +'' is attributable to the guard, not to a missing dir). +""" + +import json +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "hooks")) + + +# ── Two real-shaped session ids + the divergent dir-naming schemes ──────────── +# The LEAD's id keys the team. A tmux teammate runs under a DISTINCT id so the +# two topologies are structurally different (== vs != leadSessionId). +LEAD_SID = "0001639f-a74f-41c4-bd0b-93d9d206e7f7" +TMUX_SID = "ffff8888-bbbb-4ccc-9ddd-eeeeeeeeeeee" + +# The three FIRST-CLASS dir-naming schemes the resolver must handle launcher- +# agnostically (there is deliberately NO dir-name-prefix shortcut): +FULL_UUID_DIR = LEAD_SID # Desktop 2.1.177 child: bare 36-char UUID +SESSION_ID8_DIR = "session-0001639f" # 2.1.178+ CLI: session- +PACT_ID8_DIR = "pact-0001639f" # legacy PACT-minted (still first-class) + + +# ── seeding helpers ─────────────────────────────────────────────────────────── + + +def _seed_team_dir(teams_root, dir_name, *, lead_session_id, with_config=True, + with_inboxes=False): + """Create teams// optionally with config.json (carrying + leadSessionId) and/or an inboxes/ subdir (the half-formed-window signal).""" + team_dir = teams_root / dir_name + team_dir.mkdir(parents=True, exist_ok=True) + if with_config: + (team_dir / "config.json").write_text( + json.dumps({"name": dir_name, "leadSessionId": lead_session_id, + "members": []}), + encoding="utf-8", + ) + if with_inboxes: + (team_dir / "inboxes").mkdir(exist_ok=True) + return team_dir + + +@pytest.fixture +def ctx(monkeypatch, tmp_path): + """Fresh pact_context module state for each test: home -> tmp_path, caches + cleared. Returns (module, teams_root). teams_root is the real + /.claude/teams so the resolver's default teams_dir resolves to it.""" + import shared.pact_context as ctx_module + monkeypatch.setattr(Path, "home", lambda: tmp_path) + ctx_module.reset_for_tests() + teams_root = tmp_path / ".claude" / "teams" + teams_root.mkdir(parents=True, exist_ok=True) + yield ctx_module, teams_root + ctx_module.reset_for_tests() + + +def _write_context_file(monkeypatch, ctx_module, tmp_path, *, team_name, + session_id): + """Persist the pact-session-context.json the reader treats as the SSOT and + point the module's _context_path at it. Clears caches.""" + ctx_path = tmp_path / "pact-session-context.json" + ctx_path.write_text( + json.dumps({ + "team_name": team_name, + "session_id": session_id, + "project_dir": str(tmp_path / "project"), + "plugin_root": str(tmp_path / "plugin"), + "started_at": "2026-01-01T00:00:00Z", + }), + encoding="utf-8", + ) + monkeypatch.setattr(ctx_module, "_context_path", ctx_path) + monkeypatch.setattr(ctx_module, "_cache", None) + monkeypatch.setattr(ctx_module, "_aligned_cache", None) + return ctx_path + + +# ══════════════════════════════════════════════════════════════════════════════ +# 1. _resolve_aligned_team_name — IDENTITY MATCH (the core predicate) +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestResolverIdentityMatch: + """The identity-match predicate: config.json['leadSessionId'] == session_id.""" + + def test_full_uuid_dir_identity_matches(self, ctx): + """SCENARIO 2 — FULL-UUID team-dir fixture (the live divergent case): a + dir named with the bare 36-char UUID is resolved by identity match. NO + dir-name-prefix assumption — the resolver finds it purely via leadSessionId.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + # The resolver lands on the full-UUID dir — a name-prefix resolver + # ('session-' / 'pact-') could NEVER produce this (non-vacuity). + assert resolved == FULL_UUID_DIR + + def test_session_id8_dir_identity_matches(self, ctx): + """The 2.1.178+ CLI shape resolves identically — launcher-agnostic.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + assert resolved == SESSION_ID8_DIR + + def test_pact_id8_dir_identity_matches(self, ctx): + """The legacy PACT-minted shape is still first-class via identity match.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, PACT_ID8_DIR, lead_session_id=LEAD_SID) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + assert resolved == PACT_ID8_DIR + + def test_foreign_leadsessionid_is_rejected_collision_proof(self, ctx): + """COLLISION-PROOF: a dir whose config.leadSessionId belongs to ANOTHER + session is NOT matched, even if its id8 prefix collides. Falls back.""" + ctx_module, teams_root = ctx + # A stale/foreign dir keyed on a DIFFERENT lead session id. + _seed_team_dir(teams_root, SESSION_ID8_DIR, lead_session_id=TMUX_SID) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + # No dir matches LEAD_SID -> the fail-safe default, NOT the foreign dir. + assert resolved == "fallback" + + def test_no_config_json_dir_is_rejected(self, ctx): + """A PACT-artifact dir carrying only file-edits.json (no config.json) — + e.g. a stale session dir — is cleanly rejected by identity match.""" + ctx_module, teams_root = ctx + stale = teams_root / "session-deadbeef" + stale.mkdir(parents=True) + (stale / "file-edits.json").write_text("{}", encoding="utf-8") + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + assert resolved == "fallback" + + def test_correct_dir_selected_among_distractors(self, ctx): + """Non-vacuity: with several sibling dirs present (foreign + half-formed + + the real one), the resolver selects ONLY the identity-matched real one.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, "session-aaaaaaaa", lead_session_id=TMUX_SID) + _seed_team_dir(teams_root, "session-bbbbbbbb", lead_session_id="other-sid") + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + assert resolved == FULL_UUID_DIR + + +# ══════════════════════════════════════════════════════════════════════════════ +# 7. HALF-FORMED window — dir + inboxes/ present but config.json absent +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestHalfFormedWindow: + def test_half_formed_dir_no_config_returns_default(self, ctx): + """SCENARIO 7 — the team dir exists and inboxes/ is present but config.json + has NOT yet landed (the ~38s birth window / 2.1.177-Desktop half-formed + team). No leadSessionId to match -> return the persisted default, NOT a + wrong dir. Self-heals on the next per-process probe once config lands.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, SESSION_ID8_DIR, lead_session_id=None, + with_config=False, with_inboxes=True) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="session-0001639f" + ) + assert resolved == "session-0001639f" + + def test_half_formed_then_config_lands_resolves(self, ctx): + """Once config.json lands (a later per-process probe), the same inputs now + identity-match -> the resolver UPGRADES from default to the real dir.""" + ctx_module, teams_root = ctx + team_dir = _seed_team_dir(teams_root, SESSION_ID8_DIR, lead_session_id=None, + with_config=False, with_inboxes=True) + # config.json lands. + (team_dir / "config.json").write_text( + json.dumps({"name": SESSION_ID8_DIR, "leadSessionId": LEAD_SID}), + encoding="utf-8", + ) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="placeholder" + ) + assert resolved == SESSION_ID8_DIR + + +# ══════════════════════════════════════════════════════════════════════════════ +# 8. FAIL-SAFE TOTALITY — _resolve_aligned_team_name NEVER raises +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestFailSafeTotality: + """The resolver NEVER raises. Two distinct mechanisms guarantee this: + + * A poisoned (path-unsafe) raw session_id — NUL byte or '/' — is NOT a + raise source at all: session_id is only STRING-COMPARED against each + config.json's leadSessionId, never composed into a Path, so a poisoned + id simply never matches -> NO-MATCH -> default (the two + ``..._poisoned_session_id_no_match...`` tests below). + * The genuine raise sources — a non-str teams_dir (TypeError), a + HOME-unresolvable home (RuntimeError), and a per-entry config.json + that is-a-dir/unreadable (OSError/JSONDecodeError) — ARE caught (by the + outer bare `except Exception` for the first two, by the inner typed + except for the per-entry read). NO exception escapes.""" + + def test_nul_poisoned_session_id_no_match_returns_default(self, ctx): + """A NUL byte in the raw session_id does NOT raise — session_id is only + string-compared to leadSessionId, never composed into a Path. So the + poisoned id cannot match the seeded (different-leadSessionId) dir -> + NO-MATCH -> default. NON-VACUITY: a real matchable dir is seeded under a + DIFFERENT leadSessionId, so the default is returned because the + string-compare ran and missed, not because the store is empty.""" + ctx_module, teams_root = ctx + # Seed a real, well-formed dir whose leadSessionId is NOT the poisoned id. + _seed_team_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) + resolved = ctx_module._resolve_aligned_team_name( + "bad\x00id", teams_dir=str(teams_root), default="safe-default" + ) + # The poisoned id never equals LEAD_SID -> no match -> default. The + # well-formed dir IS scanned (the assertion bites: a resolver that + # path-composed the session_id and raised would ALSO return the default, + # so we additionally pin that the SAME dir DOES match its OWN id below.) + assert resolved == "safe-default" + # Counter-proof the dir is genuinely matchable (so the no-match above is + # attributable to the poisoned id, not an unscannable store): + assert ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="safe-default" + ) == SESSION_ID8_DIR + + def test_slash_poisoned_session_id_no_match_returns_default(self, ctx): + """A '/' (traversal) in the raw session_id must not traverse OR raise: it + is string-compared, never pathed, so it never matches -> default. + NON-VACUITY: same as above — a real matchable dir under a DIFFERENT + leadSessionId is seeded, so the default is the string-compare missing.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) + resolved = ctx_module._resolve_aligned_team_name( + "../../etc", teams_dir=str(teams_root), default="safe-default" + ) + assert resolved == "safe-default" + assert ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="safe-default" + ) == SESSION_ID8_DIR + + def test_non_str_teams_dir_does_not_raise(self, ctx): + """teams_dir of a non-str type (e.g. an int) raises TypeError when + composed via Path() — caught by the bare except -> default.""" + ctx_module, _teams_root = ctx + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=12345, default="safe-default" # type: ignore[arg-type] + ) + assert resolved == "safe-default" + + def test_home_unresolvable_does_not_raise(self, ctx, monkeypatch): + """get_claude_config_dir() -> Path.home() can raise RuntimeError when HOME + is unresolvable; with teams_dir=None the resolver composes via home, so a + RuntimeError there must be caught -> default.""" + ctx_module, _teams_root = ctx + + def _boom(): + raise RuntimeError("no home") + + monkeypatch.setattr(Path, "home", _boom) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=None, default="safe-default" + ) + assert resolved == "safe-default" + + def test_config_json_is_a_directory_skips_entry(self, ctx): + """A sibling whose config.json is itself a DIRECTORY (read_text raises + IsADirectoryError/OSError) must be skipped, not abort the scan — the real + dir later in the sorted order is still found.""" + ctx_module, teams_root = ctx + # Sorts BEFORE the real dir ('a...' < 'session-...' < full-uuid '0...'). + # Use a name that sorts first to prove the scan CONTINUES past the bad one. + bad = teams_root / "aaaa-corrupt" + bad.mkdir(parents=True) + (bad / "config.json").mkdir() # config.json is a DIR -> read_text raises + _seed_team_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + # The bad sibling did not abort the scan; the real dir is still resolved. + assert resolved == SESSION_ID8_DIR + + def test_unreadable_config_json_skips_entry(self, ctx): + """A sibling with malformed (non-JSON) config.json is skipped; the real + dir is still resolved.""" + ctx_module, teams_root = ctx + bad = teams_root / "aaaa-malformed" + bad.mkdir(parents=True) + (bad / "config.json").write_text("{not json", encoding="utf-8") + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + assert resolved == FULL_UUID_DIR + + +# ══════════════════════════════════════════════════════════════════════════════ +# 9. is_safe_path_component — a matched dir with a path-unsafe name is SKIPPED +# 10. empty session_id -> no match -> default +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestPathSafetyAndEmptyId: + def test_path_unsafe_matched_dir_name_is_skipped(self, ctx, monkeypatch): + """SCENARIO 9 — a dir whose config.leadSessionId MATCHES but whose own + NAME is path-unsafe (e.g. contains a '.' traversal char that + is_safe_path_component rejects) must be SKIPPED, not returned. A tampered + config could name a path-unsafe dir; the resolver path-safety-checks the + raw matched name BEFORE returning it.""" + ctx_module, teams_root = ctx + # is_safe_path_component allows only [A-Za-z0-9_-]+, so a '.' is unsafe. + # We can't easily create a '..'-named dir, so simulate a matched-but-unsafe + # name by monkeypatching is_safe_path_component to reject the matched name. + import shared.pact_context as pc + _seed_team_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) + + real_guard = pc.is_safe_path_component + monkeypatch.setattr( + pc, "is_safe_path_component", + lambda name: False if name == SESSION_ID8_DIR else real_guard(name), + ) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + # Matched dir name is path-unsafe -> skipped -> fall back. + assert resolved == "fallback" + + def test_path_unsafe_name_dir_on_disk_skipped(self, ctx): + """A real on-disk dir whose NAME contains a '.' (path-unsafe per + is_safe_path_component) but which identity-matches is skipped.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, "team.with.dots", lead_session_id=LEAD_SID) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + assert resolved == "fallback" + + def test_empty_session_id_returns_default(self, ctx): + """SCENARIO 10 — an empty session_id can never identity-match (no value to + compare) -> returns the default without scanning.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, SESSION_ID8_DIR, lead_session_id="") + resolved = ctx_module._resolve_aligned_team_name( + "", teams_dir=str(teams_root), default="fallback" + ) + assert resolved == "fallback" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 4. OPTION B — EMPTY-SSOT FAIL-CLOSED (the security gate) — get_team_name +# 3. RESUME-REVERT / divergence UPGRADE — get_team_name +# 11. reset_for_tests clears _aligned_cache (cross-test isolation) +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestGetTeamNameOptionB: + """get_team_name reads the persisted SSOT FIRST; EMPTY -> '' WITHOUT + identity-match (fail-closed); NON-EMPTY -> identity-match upgrade.""" + + def test_empty_ssot_fails_closed_even_with_matching_dir(self, ctx, monkeypatch, + tmp_path): + """SCENARIO 4 — the Option-B security gate. EMPTY persisted SSOT + + a real identity-matching dir present -> get_team_name returns '' (does NOT + recover via identity-match). NON-VACUITY: a matchable dir IS seeded, so the + '' is attributable to the fail-closed short-circuit, not a missing dir.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name="", session_id=LEAD_SID) + assert ctx_module.get_team_name() == "" + + def test_empty_ssot_short_circuit_memoizes_empty(self, ctx, monkeypatch, + tmp_path): + """The empty-SSOT short-circuit MEMOIZES '' in _aligned_cache (None is the + 'unresolved' sentinel; '' is a legitimate resolved-empty). A second + get_team_name() must serve the cached '' WITHOUT re-reading the context. + NON-VACUITY: after the first call we neuter get_pact_context to RAISE on + any further call — if the second get_team_name() did NOT use the cache it + would re-read and blow up; serving '' proves the memoization is real.""" + import shared.pact_context as pc + ctx_module, _teams_root = ctx + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name="", session_id=LEAD_SID) + # First call: empty SSOT -> short-circuit -> '' cached. + assert ctx_module.get_team_name() == "" + assert ctx_module._aligned_cache == "" # '' memoized, not None + + # Neuter the context reader: a cache MISS would now re-read and raise. + def _boom(): + raise AssertionError("get_pact_context re-read despite cached ''") + + monkeypatch.setattr(pc, "get_pact_context", _boom) + # Second call served from the cached '' -> no re-read, no raise. + assert ctx_module.get_team_name() == "" + + def test_nonempty_wrong_ssot_upgrades_to_full_uuid(self, ctx, monkeypatch, + tmp_path): + """SCENARIO 3 — RESUME-REVERT / divergence: a NON-EMPTY but WRONG SSOT + (the computed session-) + a real full-UUID dir -> get_team_name + UPGRADES to the real full-UUID team via identity match.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + # Persisted SSOT is the WRONG computed short name. + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + # Upgrades to the real on-disk full-UUID dir (lowercased). + assert ctx_module.get_team_name() == FULL_UUID_DIR.lower() + + def test_nonempty_ssot_no_match_noops_to_persisted(self, ctx, monkeypatch, + tmp_path): + """NON-EMPTY SSOT but no identity-matching dir (cold-start / unborn) -> + get_team_name no-ops back to the persisted value (zero regression).""" + ctx_module, _teams_root = ctx # teams_root empty -> no match + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + assert ctx_module.get_team_name() == SESSION_ID8_DIR.lower() + + def test_aligned_cache_memoizes_per_process(self, ctx, monkeypatch, tmp_path): + """get_team_name memoizes in _aligned_cache: a second call returns the + cached value even after the on-disk dir is removed (born-and-die / process).""" + ctx_module, teams_root = ctx + team_dir = _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + first = ctx_module.get_team_name() + assert first == FULL_UUID_DIR.lower() + # Remove the dir; a non-memoized resolver would now fall back. + import shutil + shutil.rmtree(team_dir) + second = ctx_module.get_team_name() + assert second == first # served from _aligned_cache + + def test_reset_for_tests_clears_aligned_cache(self, ctx, monkeypatch, tmp_path): + """SCENARIO 11 — reset_for_tests() clears _aligned_cache: after reset, a + fresh resolution is performed (no cross-test bleed).""" + ctx_module, teams_root = ctx + team_dir = _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + assert ctx_module.get_team_name() == FULL_UUID_DIR.lower() + assert ctx_module._aligned_cache is not None + + ctx_module.reset_for_tests() + assert ctx_module._aligned_cache is None # the bleed-prevention assertion + + # After reset + a fresh context with no matchable dir, the cache does NOT + # bleed the prior full-UUID value. + import shutil + shutil.rmtree(team_dir) + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + assert ctx_module.get_team_name() == SESSION_ID8_DIR.lower() + + +# ══════════════════════════════════════════════════════════════════════════════ +# 1./3. BOTH-MODES resolver legs (the DUAL-MODE PERMANENT CONTRACT, resolver side) +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestBothModesResolver: + """The resolver keys identity match on config.leadSessionId == the LEAD's id. + The result is INDEPENDENT of the running frame's own session_id (in-process + frame_sid == LEAD_SID; tmux frame_sid != LEAD_SID) — because get_team_name + threads get_session_id() (the persisted id, which is the LEAD's), NOT the + acting frame's. We pin BOTH topologies as a standing both-modes contract.""" + + @pytest.mark.parametrize("frame_sid,mode", [ + (LEAD_SID, "in-process"), + (TMUX_SID, "tmux"), + ]) + def test_get_team_name_resolves_lead_team_both_modes(self, ctx, monkeypatch, + tmp_path, frame_sid, mode): + """Both modes: the persisted SSOT session_id is the LEAD's id (that is what + session_init wrote), so get_team_name identity-matches the LEAD's full-UUID + dir in BOTH topologies. The running frame's own sid is irrelevant — the + resolver never recomputes from it.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + # The persisted context session_id is ALWAYS the LEAD's (that is the SSOT + # session_init persists); 'mode' models which process is reading, but the + # SSOT id does not change. get_session_id() returns LEAD_SID either way. + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + assert ctx_module.get_team_name() == FULL_UUID_DIR.lower(), ( + f"{mode}: identity match must resolve the LEAD's team" + ) + + def test_resolver_uses_lead_sid_not_teammate_sid(self, ctx): + """A teammate's OWN (tmux) session id does NOT identity-match the LEAD's + team (no dir carries leadSessionId == TMUX_SID) -> fail-safe default. This + is why resolution must thread the persisted (lead) id, never the frame's.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + # Resolving with the TEAMMATE's own sid finds nothing -> default. + resolved = ctx_module._resolve_aligned_team_name( + TMUX_SID, teams_dir=str(teams_root), default="default-for-teammate" + ) + assert resolved == "default-for-teammate" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 6. CRASH-RECOVERY / heal — context-ABSENT + real-dir-present -> ALIGNED name +# 5. COLD-START — first-ever SessionStart persists the COMPUTED name, not "" +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestHealCrashRecovery: + """heal_context_if_missing is the 3rd context-writer. Option B relies on heal + for ABSENT-context recovery (the reader fail-closes on empty SSOT and does NOT + recover). We PROVE heal converges to the aligned name in one prompt.""" + + def _lead_frame(self, session_id): + # is_lead reads agent_type; a lead frame carries the lead agent_type. + # Reuse the canonical lead-frame fixture shape used elsewhere. + from fixtures.role_frames import lead_frame_qualified + return lead_frame_qualified(session_id=session_id) + + def _prime_absent_context(self, ctx_module, monkeypatch, tmp_path, session_id): + """Point _context_path at an ABSENT file under a real session-scoped dir + so heal's init()-derived path + exists() checks operate on tmp_path.""" + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path / "project")) + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin")) + # init() derives the path from the frame; call it then ensure absent. + ctx_module.init({"session_id": session_id, + "cwd": str(tmp_path / "project")}) + if ctx_module._context_path is not None and ctx_module._context_path.exists(): + ctx_module._context_path.unlink() + + def test_heal_writes_aligned_name_when_real_dir_present(self, ctx, monkeypatch, + tmp_path): + """SCENARIO 6 — context ABSENT + the real full-UUID team dir present -> heal + persists the IDENTITY-MATCHED (aligned) full-UUID name, NOT the computed + session-. Converges the context in one prompt.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + frame = self._lead_frame(LEAD_SID) + self._prime_absent_context(ctx_module, monkeypatch, tmp_path, LEAD_SID) + + healed = ctx_module.heal_context_if_missing(frame) + assert healed is True + persisted = json.loads( + ctx_module._context_path.read_text(encoding="utf-8") + ) + # Heal wrote the ALIGNED full-UUID name (identity-matched), not the + # computed session- default. + assert persisted["team_name"] == FULL_UUID_DIR + + def test_heal_writes_computed_default_when_dir_absent_coldstart(self, ctx, + monkeypatch, + tmp_path): + """SCENARIO 5/6 cold-start — context ABSENT + NO real team dir (the ~38s + unborn window) -> heal persists the COMPUTED name (generate_team_name's + session-), NOT '' (the resolver's default is threaded as the computed + name on the writer path).""" + ctx_module, _teams_root = ctx # teams_root empty -> no identity match + frame = self._lead_frame(LEAD_SID) + self._prime_absent_context(ctx_module, monkeypatch, tmp_path, LEAD_SID) + + healed = ctx_module.heal_context_if_missing(frame) + assert healed is True + persisted = json.loads( + ctx_module._context_path.read_text(encoding="utf-8") + ) + # Cold-start: computed session-, never "". + assert persisted["team_name"] == "session-0001639f" + assert persisted["team_name"] != "" + + +class TestColdStartSessionInit: + """SCENARIO 5 — first-ever SessionStart (no prior context + no team dir): + session_init persists the COMPUTED name, NOT ''. The session_init convergence + call (_resolve_aligned_team_name(session_id, default=team_name)) returns the + computed default when the dir is unborn.""" + + def test_cold_start_session_init_persists_computed_not_empty(self, ctx): + """At a cold SessionStart the team dir is unborn -> the convergence resolver + returns the computed default (NOT ''). Modeled at the resolver boundary the + way session_init.py:1048 calls it: default = the freshly-computed name.""" + ctx_module, _teams_root = ctx # no team dir seeded + computed = "session-0001639f" + # session_init: team_name = _resolve_aligned_team_name(sid, default=computed) + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(_teams_root), default=computed + ) + assert resolved == computed + assert resolved != "" + + def test_cold_start_then_born_dir_converges_to_aligned(self, ctx): + """Once the dir is born, the same convergence call UPGRADES from the + computed default to the aligned full-UUID dir — both writers then agree.""" + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + computed = "session-0001639f" + resolved = ctx_module._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default=computed + ) + assert resolved == FULL_UUID_DIR # converged to the aligned name + + +# ══════════════════════════════════════════════════════════════════════════════ +# 13. TWO-FILE write-back — context-first ordering + CLAUDE.md-absent skip +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestWriteBackTwoFileConsistency: + """bootstrap_marker_writer._write_back_aligned_team_name reconciles the + persisted record to the aligned name. Two files: the context file (load- + bearing, written FIRST) and the CLAUDE.md '- Team:' line (cosmetic, exists()- + guarded). We pin (a) CLAUDE.md-ABSENT -> SKIP CLAUDE.md (no create) + context + still written, and (b) the write ORDER is context-first (structural).""" + + def test_claude_md_absent_skips_no_create_context_still_written( + self, monkeypatch, tmp_path + ): + """SCENARIO 13a — CLAUDE.md absent (gitignored worktree): the write-back + must NOT create CLAUDE.md, but MUST still write the context file.""" + import shared.pact_context as pc + import bootstrap_marker_writer as bmw + + calls = {"write_context": 0, "update_session_info": 0} + + monkeypatch.setattr(pc, "get_team_name", lambda: FULL_UUID_DIR.lower()) + monkeypatch.setattr( + pc, "get_pact_context", + lambda: {"team_name": SESSION_ID8_DIR}, # differs -> divergence + ) + monkeypatch.setattr(pc, "get_session_id", lambda: LEAD_SID) + monkeypatch.setattr(pc, "get_session_dir", lambda: str(tmp_path / "sess")) + monkeypatch.setattr(pc, "get_plugin_root", lambda: str(tmp_path / "plugin")) + monkeypatch.setattr(pc, "get_project_dir", lambda: str(tmp_path / "project")) + + def _fake_write_context(*a, **k): + calls["write_context"] += 1 + + def _fake_update_session_info(*a, **k): + calls["update_session_info"] += 1 + + monkeypatch.setattr(pc, "write_context", _fake_write_context) + monkeypatch.setattr(bmw, "update_session_info", _fake_update_session_info) + + # CLAUDE.md is ABSENT: resolve_project_claude_md_path points at a + # non-existent file under tmp_path. + absent_md = tmp_path / "project" / "CLAUDE.md" + monkeypatch.setattr( + bmw, "resolve_project_claude_md_path", + lambda project_dir: (absent_md, "test"), + ) + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path / "project")) + + bmw._write_back_aligned_team_name() + + # Context written; CLAUDE.md NOT touched; file NOT created. + assert calls["write_context"] == 1 + assert calls["update_session_info"] == 0 + assert not absent_md.exists() + + def test_write_order_is_context_first(self, monkeypatch, tmp_path): + """SCENARIO 13b — the load-bearing context write precedes the cosmetic + CLAUDE.md update. Record the call ORDER and assert context-first (so a + crash between them leaves the load-bearing record correct).""" + import shared.pact_context as pc + import bootstrap_marker_writer as bmw + + order = [] + + monkeypatch.setattr(pc, "get_team_name", lambda: FULL_UUID_DIR.lower()) + monkeypatch.setattr( + pc, "get_pact_context", lambda: {"team_name": SESSION_ID8_DIR} + ) + monkeypatch.setattr(pc, "get_session_id", lambda: LEAD_SID) + monkeypatch.setattr(pc, "get_session_dir", lambda: str(tmp_path / "sess")) + monkeypatch.setattr(pc, "get_plugin_root", lambda: str(tmp_path / "plugin")) + monkeypatch.setattr(pc, "get_project_dir", lambda: str(tmp_path / "project")) + + monkeypatch.setattr( + pc, "write_context", lambda *a, **k: order.append("context") + ) + monkeypatch.setattr( + bmw, "update_session_info", lambda *a, **k: order.append("claude_md") + ) + # CLAUDE.md PRESENT so update_session_info is reached. + present_md = tmp_path / "project" / "CLAUDE.md" + present_md.parent.mkdir(parents=True, exist_ok=True) + present_md.write_text("# CLAUDE.md\n", encoding="utf-8") + monkeypatch.setattr( + bmw, "resolve_project_claude_md_path", + lambda project_dir: (present_md, "test"), + ) + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path / "project")) + + bmw._write_back_aligned_team_name() + + assert order == ["context", "claude_md"], ( + "context file (load-bearing) must be written BEFORE the CLAUDE.md line" + ) + + def test_write_back_noop_when_aligned_equals_persisted(self, monkeypatch, + tmp_path): + """The normal no-divergence CLI case: aligned == persisted -> clean no-op + (no writes at all). Proves the write-back only fires on a real divergence.""" + import shared.pact_context as pc + import bootstrap_marker_writer as bmw + + calls = {"write_context": 0} + monkeypatch.setattr(pc, "get_team_name", lambda: SESSION_ID8_DIR) + monkeypatch.setattr( + pc, "get_pact_context", lambda: {"team_name": SESSION_ID8_DIR} + ) + monkeypatch.setattr( + pc, "write_context", + lambda *a, **k: calls.__setitem__("write_context", + calls["write_context"] + 1), + ) + bmw._write_back_aligned_team_name() + assert calls["write_context"] == 0 + + def test_write_back_inert_on_empty_aligned(self, monkeypatch): + """Option-B interaction: when get_team_name() returns '' (empty SSOT + fail-closed), the write-back's `if not aligned: return` fires -> NO write. + The reader's write-back is intentionally inert on an empty SSOT.""" + import shared.pact_context as pc + import bootstrap_marker_writer as bmw + + calls = {"write_context": 0} + monkeypatch.setattr(pc, "get_team_name", lambda: "") + monkeypatch.setattr( + pc, "write_context", + lambda *a, **k: calls.__setitem__("write_context", + calls["write_context"] + 1), + ) + bmw._write_back_aligned_team_name() + assert calls["write_context"] == 0 + + +# ══════════════════════════════════════════════════════════════════════════════ +# 12. FULL-UUID CALLER SWEEP — the ~15 get_team_name callers accept a 36-char UUID +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestFullUuidCallerSweep: + """The detect-align fix makes get_team_name() return a 36-char full-UUID in + divergent contexts. Every caller consumes that return as a path segment; NONE + may narrow it to a fixed width or a 'session-[a-f0-9]{8}' regex. This sweep + exercises each caller's ACTUAL keying with a full-UUID team name and asserts + it addresses the right store (not the resolver boundary alone). + + REGRESSION-FINDER: if any caller truncates/rejects a 36-char name, the + relevant test FAILS -> a RED finding routed back to devops, NOT papered over. + """ + + def _seed_tasks_store(self, tmp_path, team_name, tasks): + tasks_dir = tmp_path / ".claude" / "tasks" / team_name + tasks_dir.mkdir(parents=True, exist_ok=True) + for i, t in enumerate(tasks): + (tasks_dir / f"{i}.json").write_text(json.dumps(t), encoding="utf-8") + return tasks_dir + + def test_is_safe_path_component_accepts_full_uuid(self): + """The shared path-safety allowlist [A-Za-z0-9_-]+ accepts a 36-char UUID + (hex + hyphens). This is the gate EVERY path-keying caller routes through, + so accepting the full UUID here is the linchpin of the whole sweep.""" + from shared.session_state import is_safe_path_component + assert is_safe_path_component(FULL_UUID_DIR) is True + assert is_safe_path_component(SESSION_ID8_DIR) is True + assert is_safe_path_component(PACT_ID8_DIR) is True + + def test_task_utils_get_task_list_keys_full_uuid_dir(self, monkeypatch, tmp_path): + """task_utils.get_task_list: a full-UUID team_name keys + tasks//*.json correctly (no width assumption).""" + import shared.task_utils as tu + monkeypatch.setattr(tu, "get_team_name", lambda: FULL_UUID_DIR) + tasks_base = tmp_path / ".claude" / "tasks" + self._seed_tasks_store(tmp_path, FULL_UUID_DIR, + [{"id": "1", "owner": "x", "status": "pending"}]) + result = tu.get_task_list(tasks_base_dir=str(tasks_base)) + assert result is not None and len(result) == 1 + + def test_iter_team_task_jsons_accepts_full_uuid(self, tmp_path): + """iter_team_task_jsons (the SSOT per-team reader several callers route + through) yields from tasks// with no narrowing.""" + from shared.task_utils import iter_team_task_jsons + tasks_base = tmp_path / ".claude" / "tasks" + self._seed_tasks_store(tmp_path, FULL_UUID_DIR, + [{"id": "1"}, {"id": "2"}]) + got = list(iter_team_task_jsons(FULL_UUID_DIR, + tasks_base_dir=str(tasks_base))) + assert len(got) == 2 + + def test_cleanup_old_teams_skip_protects_full_uuid_dir(self, tmp_path): + """session_end.cleanup_old_teams: the current-team skip protects a + full-UUID dir from reaping (exact-match, case-insensitive). A full-UUID + current_team_name does not break the skip.""" + from session_end import cleanup_old_teams + teams_base = tmp_path / "teams" + live = teams_base / FULL_UUID_DIR + live.mkdir(parents=True) + (live / "config.json").write_text("{}", encoding="utf-8") + # Age the live dir well past TTL so ONLY the skip protects it. + old = 1 + os.utime(live, (old, old)) + os.utime(live / "config.json", (old, old)) + reaped, _ = cleanup_old_teams( + current_team_name=FULL_UUID_DIR, teams_base_dir=str(teams_base), + max_age_days=30, + ) + # The live full-UUID dir is NOT a '^pact-' candidate at all, AND it is the + # skip target — so it survives. (reaped counts only matched candidates.) + assert live.exists() + assert reaped == 0 + + def test_assemble_tasks_skip_set_keeps_full_uuid_team(self): + """session_end._assemble_tasks_skip_set: a full-UUID team_name survives the + is_safe_path_component allowlist into the skip-set (so the live tasks dir is + protected from the task reaper). NON-NARROWING is the load-bearing property + — a '^pact-' narrowing here would DROP the live dir -> DATA LOSS.""" + from session_end import _assemble_tasks_skip_set + skip = _assemble_tasks_skip_set( + team_name=FULL_UUID_DIR, task_list_id="", session_id=LEAD_SID, + ) + assert FULL_UUID_DIR in skip + + def test_cleanup_old_tasks_skip_protects_full_uuid_tasks_dir(self, tmp_path): + """session_end.cleanup_old_tasks: a full-UUID name in the skip-set protects + tasks// from reaping even when aged past TTL — the end-to-end + data-loss guard for the live divergent team.""" + from session_end import cleanup_old_tasks + tasks_base = tmp_path / "tasks" + live = tasks_base / FULL_UUID_DIR + live.mkdir(parents=True) + (live / "0.json").write_text("{}", encoding="utf-8") + old = 1 + os.utime(live / "0.json", (old, old)) + os.utime(live, (old, old)) + reaped, _ = cleanup_old_tasks( + skip_names={FULL_UUID_DIR}, tasks_base_dir=str(tasks_base), + max_age_days=30, + ) + assert live.exists() + assert reaped == 0 + + def test_session_state_accepts_full_uuid_team(self, monkeypatch, tmp_path): + """session_state.build_session_state: a full-UUID team_name is carried into + the state's team_names list (no width assumption on the display/aggregation + path).""" + import shared.session_state as ss + # _default_state lists the team_name verbatim. + state = ss._default_state(FULL_UUID_DIR) + assert state["team_names"] == [FULL_UUID_DIR] + + # (peer-context sweep test below) + def test_get_peer_context_keys_full_uuid_team(self, tmp_path): + """peer_inject routes get_team_name() into get_peer_context(team_name=...), + which keys teams//config.json. A full-UUID team_name addresses + the right config (no narrowing). Pass teams_dir explicitly (the override + seam) so the test does not depend on CLAUDE_CONFIG_DIR resolution.""" + from shared.peer_context import get_peer_context + teams_base = tmp_path / "teams" + team_dir = teams_base / FULL_UUID_DIR + team_dir.mkdir(parents=True) + (team_dir / "config.json").write_text( + json.dumps({ + "name": FULL_UUID_DIR, + "members": [ + {"name": "architect", "agentType": "pact-architect"}, + {"name": "tester", "agentType": "pact-test-engineer"}, + ], + }), + encoding="utf-8", + ) + # A peer of a DIFFERENT type should be discoverable -> non-empty context. + context = get_peer_context( + agent_type="pact-test-engineer", team_name=FULL_UUID_DIR, + agent_name="tester", teams_dir=str(teams_base), + ) + # The full-UUID team dir was found and read (architect peer surfaced). + # NON-VACUITY: a narrowing caller would return None (config not found). + assert context is not None + assert "architect" in context + + +# ══════════════════════════════════════════════════════════════════════════════ +# NON-VACUITY — committed standing proofs that the assertions BITE +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestNonVacuity: + """The fix ADDS net-new symbols (``_resolve_aligned_team_name``, + ``_aligned_cache``, the empty-SSOT short-circuit), so a source-revert removes + the symbol -> collection ImportError rather than a clean fail (the net-new- + symbol pattern). Instead we prove non-vacuity IN-PROCESS by NEUTERING the + behavior under test (monkeypatch) and asserting the result FLIPS — paired + intact+neutered assertions in one test, a standing CI guard that re-runs every + build. If the production behavior these tests pin is ever removed, the + 'intact' half here flips RED.""" + + def test_upgrade_is_attributable_to_identity_match(self, ctx, monkeypatch, + tmp_path): + """INTACT: a non-empty wrong SSOT + a real full-UUID dir UPGRADES to the + full-UUID via identity match. NEUTERED: with the identity-match resolver + stubbed to return its default (the pre-fix 'just return persisted' + behavior), get_team_name returns the PERSISTED session- instead. The + two outcomes are mutually exclusive -> the upgrade is attributable to the + identity-match, not to anything incidental in the fixture.""" + import shared.pact_context as pc + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + + # INTACT — identity-match upgrades. + assert ctx_module.get_team_name() == FULL_UUID_DIR.lower() + + # NEUTER the resolver to the pre-fix behavior (return the default), reset + # the per-process cache, and re-read. + monkeypatch.setattr( + pc, "_resolve_aligned_team_name", + lambda session_id, teams_dir=None, default=None: default or "", + ) + ctx_module.reset_for_tests() + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + # NEUTERED — no upgrade; the persisted value is returned. FLIP proven. + assert ctx_module.get_team_name() == SESSION_ID8_DIR.lower() + + def test_failclosed_is_attributable_to_short_circuit(self, ctx, monkeypatch, + tmp_path): + """INTACT: empty SSOT + a real matchable dir -> get_team_name returns '' + (Option-B fail-closed short-circuit). NEUTERED: if the empty-SSOT short- + circuit were absent (modeled by removing the guard via a stubbed + get_team_name that runs identity-match unconditionally), the SAME seeded + dir WOULD identity-match to the full-UUID. The seeded matchable dir is what + makes the '' attributable to the guard, not to a missing store.""" + import shared.pact_context as pc + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name="", session_id=LEAD_SID) + + # INTACT — empty SSOT short-circuits to '' DESPITE the matchable dir. + assert ctx_module.get_team_name() == "" + + # Counter-model: WITHOUT the short-circuit (run identity-match on the same + # empty-SSOT inputs), the seeded dir DOES match -> a non-'' result. This + # proves the matchable dir is present (so the INTACT '' is the guard, not a + # missing dir). + bypass = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="" + ) + assert bypass == FULL_UUID_DIR # identity-match WOULD have recovered it + + def test_aligned_cache_neuter_shows_memoization_is_real(self, ctx, monkeypatch, + tmp_path): + """The _aligned_cache memoization is real: with the cache populated, a + second get_team_name() does NOT re-invoke the resolver (proven by stubbing + the resolver to raise on a second call — it is never reached).""" + import shared.pact_context as pc + ctx_module, teams_root = ctx + _seed_team_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + _write_context_file(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + + first = ctx_module.get_team_name() + assert first == FULL_UUID_DIR.lower() + + # After the first resolution, the resolver MUST NOT be called again. + def _boom(*a, **k): + raise AssertionError("resolver re-invoked despite _aligned_cache") + + monkeypatch.setattr(pc, "_resolve_aligned_team_name", _boom) + assert ctx_module.get_team_name() == first # served from cache, no re-call diff --git a/pact-plugin/tests/test_team_name_resolution_both_modes.py b/pact-plugin/tests/test_team_name_resolution_both_modes.py index ca184571..d0174054 100644 --- a/pact-plugin/tests/test_team_name_resolution_both_modes.py +++ b/pact-plugin/tests/test_team_name_resolution_both_modes.py @@ -326,3 +326,71 @@ def test_empty_ssot_team_fails_closed_both_modes(tmp_path, monkeypatch, capsys): assert code == 2, f"empty SSOT must fail-closed (frame_sid={frame_sid})" reason = out["hookSpecificOutput"]["permissionDecisionReason"] assert "session team_name is unavailable" in reason + + +# ─── 3. DETECT-AND-ALIGN end-to-end through the gate (#989), both modes ──────── + + +# The divergent platform dir is named with the FULL session UUID (Desktop +# 2.1.177 child / rename-skip), NOT the computed `session-`. +LEAD_FULL_UUID_DIR = LEAD_SID + + +def test_divergent_full_uuid_resolves_through_gate_both_modes( + tmp_path, monkeypatch, capsys +): + """DETECT-AND-ALIGN end-to-end: the persisted SSOT is the WRONG computed + `session-`, but the platform provisioned the real team dir under the + FULL 36-char UUID. The gate's get_team_name() identity-matches + (config.json['leadSessionId'] == LEAD_SID) and UPGRADES to the full-UUID + store, so the member/task reads land on the REAL dir and the gate ALLOWs — + in BOTH topologies. This is the divergent-resolution complement to the + no-op CLI legs above; it exercises the #989 identity-match through the real + dispatch path, not just the resolver unit. + + NON-VACUITY: tasks are seeded ONLY under the full-UUID dir; the wrong + `session-` store does NOT exist, so an ALLOW is only possible if the + gate resolved the full-UUID dir via identity match (a gate that used the + persisted `session-` would MISS the store and DENY). + + BOTH-MODES NOTE: the resolver keys identity-match on the PERSISTED context + session_id (always the LEAD's — session_init persists it), NOT the acting + frame's session_id. So the persisted session_id is LEAD_SID in BOTH legs; + the topology axis is the acting dispatch FRAME's own session_id (the spawn + frame), which here is a fixed synthetic id independent of the lead. The + upgrade is therefore mode-INDEPENDENT by construction — we assert it holds + under BOTH a spawn frame whose id == the lead (in-process) and != the lead + (tmux), confirming the resolver never recomputes from the acting frame.""" + import shared.pact_context as ctx_module + + for frame_sid, mode in ((LEAD_SID, "in-process"), (TMUX_SID, "tmux")): + plugin_root = tmp_path / "plugin" + _seed_plugin(plugin_root) + # Persist the WRONG computed short name as the SSOT, with the LEAD's + # session id (session_init always persists the lead's id). + _write_context( + monkeypatch, tmp_path, plugin_root, + team_name=LEAD_TEAM, session_id=LEAD_SID, + ) + # Reset the per-process identity-match cache between legs (same-process + # test harness — the cache would otherwise bleed the first leg's result). + monkeypatch.setattr(ctx_module, "_aligned_cache", None) + # Seed the REAL store under the FULL-UUID dir, keyed on the LEAD's + # session id so identity-match finds it. The wrong `session-` store + # is deliberately ABSENT. + _seed_team_store( + tmp_path, team_name=LEAD_FULL_UUID_DIR, lead_session_id=LEAD_SID, + members=(), tasks=((_NAME, "pending"),), + ) + # Model the topology via the acting dispatch FRAME's own session_id. + spawn = _make_spawn(team_name_arg="wrong-team") + spawn["session_id"] = frame_sid + code, out = _run_dispatch(spawn, capsys) + assert code == 0, ( + f"detect-align must resolve the full-UUID store ({mode})" + ) + assert out == _SUPPRESS_EXPECTED + # Load-bearing: the wrong `session-` store does NOT exist — only the + # identity-matched full-UUID dir does. + assert not (tmp_path / ".claude" / "tasks" / LEAD_TEAM).exists() + assert (tmp_path / ".claude" / "tasks" / LEAD_FULL_UUID_DIR).exists()