Skip to content

Commit 707238b

Browse files
feat(journal): per-write mirror for open-task-consumed metadata keys (#1162) (#1168)
* feat(snapshot): per-write mirror substrate constants + canonical-frame predicate * feat(gate): per-write mirror TaskUpdate leg for open-task targeted keys * feat(gate): per-write mirror TaskCreate leg * test(seam): per-write subprocess integration + drain-recovery * docs(protocols): journal-fallback recovery for drained scope tasks * docs(hooks): correct stale PreToolUse matcher claim * test(commands): re-anchor rePACT dispatch-site pin after Phase 0 fallback insertion * test(seam): adversarial matrix + staleness probe for the per-write mirror TEST-phase depth for the per-write task_metadata_snapshot path, driven through the seam (evaluate_lifecycle + the real hook subprocess), with caps imported from the substrate constants: - cap-edge payloads (per-value cap/cap+1, whole disk-and-delta overlay over the payload cap, 5MB jumbo) — bounded journal lines, marker truncation, key existence preserved - marker-lookalike values: provenance-not-shape end-to-end, incl. the hostile non-int original_bytes variant - hostile task_ids (traversal, control chars, unicode) through the sanitized claim path — marker containment, sanitized event ids - pathological shapes: deep nesting intact, serializer-breaking nesting hermetic with no marker poison, None-riddled deltas, non-dict deltas (incl. a list carrying a targeted-key token — guard-first shape) - exit-0 advisory contract: substrate write failure compensates (unclaim) and preserves advisories; identical advisories across the fire/skip frame boundary - leadSessionId staleness probe: stale-mismatch downgrades to skip (baseline, no marker), stale-equal admit lands in the process's own resolvable journal; drained config skips without crash - completing-TaskUpdate disjointness boundary: targeted delta on a completing write yields exactly the completion event - real-process rows: unchanged-rewrite dedup across process lifetimes, stale-config skip, hostile-id containment, oversized stdin frame * docs(gate): document the intentional team-resolution split at the per-write frame gate is_canonical_journal_frame resolves its team via the identity-matched get_team_name() resolver while the per-write emit legs use the persisted ctx team_name; record at both sites why neither direction may be unified (topology correctness after resume-divergence vs cross-seam marker-namespace consistency) and why divergent outcomes stay bounded. * test(seam): pin teachback_rejection fire-trigger + ruled-key minimum subset Close the registry's silent-deletion channel with two additive rows: a teachback_rejection-triggered fire on an open task with an on-disk teachback_submit sibling asserts the whole-payload overlay mirrors BOTH (the rejection-cycle catch-up that gives the teammate-written class its tmux durability; the delta carries only the rejection, so the row also reds if the key leaves the registry), and a minimum-subset pin keeps the five ruled keys deletion-protected while registry extensions stay one-string source diffs. * chore: bump version to 4.6.3 (PATCH) * feat(snapshot): target the audit verdict pair in the per-write mirror registry audit_summary and audit_summary_authored join PER_WRITE_MIRROR_KEYS: both are consumed by the lead at CODE-phase close while the audited task is still open, so a status-blind drain destroys them while load-bearing. Same teammate-written canonical-frame class as teachback_submit. * test(seam): pin the audit verdict pair in the per-write registry contract Extend the ruled-key minimum-subset pin to seven keys and add an audit_summary fire-trigger row: an open-task write of the audit verdict mirrors the whole overlay immediately. The row uses a lead frame with no authored-verdict sibling on disk so the gate's audit_summary_authored preservation machinery stays untangled from the snapshot-seam assertion; deleting either audit key from the registry now goes red. * refactor(claim-gate): re-point _read_lead_session_id to the shared pact_context copy Delete the inline duplicate; import the SSOT implementation (module-attribute binding preserves the test patch seam). Relocate the gate-specific stale-config blast-radius note to the Step-3 call site, and re-orient the LOGIC-PARITY cross-refs: pact_context is the SSOT, session_registry keeps the one remaining inline copy forced by its self-contained-leaf invariant. * test(parity): update drift-guard prose to the SSOT re-point topology The parity test's docstring still narrated two independent inline copies; task_claim_gate now binds the shared pact_context implementation, leaving session_registry as the one inline copy its leaf invariant forces. Prose only — the behavioral parity contract and every assertion are unchanged.
1 parent fb95921 commit 707238b

20 files changed

Lines changed: 2164 additions & 96 deletions

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"name": "PACT",
1313
"source": "./pact-plugin",
1414
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
15-
"version": "4.6.2",
15+
"version": "4.6.3",
1616
"author": {
1717
"name": "Synaptic-Labs-AI"
1818
},

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ When installed as a plugin, PACT lives in your plugin cache:
605605
│ └── cache/
606606
│ └── pact-plugin/
607607
│ └── PACT/
608-
│ └── 4.6.2/ # Plugin version
608+
│ └── 4.6.3/ # Plugin version
609609
│ ├── agents/
610610
│ ├── commands/
611611
│ ├── skills/

pact-plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "PACT",
3-
"version": "4.6.2",
3+
"version": "4.6.3",
44
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
55
"author": {
66
"name": "Synaptic-Labs-AI",

pact-plugin/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PACT — Orchestration Harness for Claude Code
22

3-
> **Version**: 4.6.2
3+
> **Version**: 4.6.3
44
55
Turn a single Claude Code session into a managed team of specialist AI agents that prepare, design, build, and test your code systematically.
66

pact-plugin/commands/rePACT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ Branch behavior depends on whether rePACT is invoked with a scope contract:
189189
Before starting, verify:
190190
1. **Nesting depth**: Read `TaskGet(taskId).metadata.nesting_depth` — if > 1, reject (max depth exceeded). If absent, treat as 0.
191191
2. **Scope contract**: If this rePACT was dispatched from ATOMIZE, read the scope contract from `TaskGet(taskId).metadata.scope_contract` instead of expecting it inline in the prompt. This ensures scope state survives compaction.
192+
- **Journal fallback (covers items 1–2)**: If `TaskGet(taskId)` cannot resolve (task store drained), recover from the session journal instead: run `python3 "{plugin_root}/hooks/shared/session_journal.py" read --session-dir "{session_dir}" --type task_metadata_snapshot` (prints a JSON array), filter to events whose `task_id` matches this task, take the latest-`ts` event, and read `nesting_depth` / `scope_contract` from its `metadata` (honor `_truncated` / `_dropped_keys` markers if present).
192193
3. **Scope appropriateness**: Is this truly a sub-task of the parent?
193194
4. **Domain determination**: Single-domain or multi-domain?
194195

pact-plugin/hooks/shared/HOOK_STDIN_DISCRIMINATORS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,11 @@ UserPromptSubmit / PostToolUse) but is NOT read on TaskCompleted — that frame
7575
captured for the #917 emit-path, which gates on `team_name` + journal
7676
writability rather than this predicate. On PreToolUse, `is_lead` is read via the
7777
match-all `bootstrap_gate` (matcher `''`, so it fires before every tool —
78-
including `TaskCreate` / `TaskUpdate`) and the `Edit` / `Write` pin gates; there
79-
is no `TaskCreate` / `TaskUpdate`-matched PreToolUse hook (that matcher is
80-
PostToolUse-only — `task_lifecycle_gate`, the row above).
78+
including `TaskCreate` / `TaskUpdate`), the `Edit` / `Write` pin gates, and the
79+
`TaskUpdate`-matched `handoff_ordering_gate` (which gates both of its rules on
80+
`is_lead`). `TaskCreate` has no PreToolUse-matched hook — its only
81+
task-lifecycle observer is the PostToolUse-matched `task_lifecycle_gate` (the
82+
row above).
8183

8284
### UserPromptSubmit has no teammate fire path
8385

pact-plugin/hooks/shared/pact_context.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,94 @@ def classify_session_role(input_data: dict) -> str:
906906
return "unknown"
907907

908908

909+
def _read_lead_session_id(team_name: str, teams_dir: str | None = None) -> str:
910+
"""Read the top-level ``leadSessionId`` from
911+
``~/.claude/teams/{team_name}/config.json``.
912+
913+
LOGIC-PARITY: this is the SSOT copy — ``task_claim_gate`` imports it
914+
directly (its former inline copy is consolidated here; the ``teams_dir``
915+
test override is part of the shared signature). The ONE remaining inline
916+
copy is ``session_registry._read_lead_session_id``, INLINED there by
917+
design: that module's self-contained-leaf invariant forbids ``shared.*``
918+
imports, so it must not be re-pointed here. Keep the two implementations
919+
behaviorally identical — the behavioral-parity drift-guard test compares
920+
them on the same logical inputs.
921+
922+
Fail-safe: returns "" on any of: unsafe team_name, missing/unreadable
923+
file, malformed JSON, non-object top-level, or a missing/non-string key.
924+
Callers treat "" as "topology unresolvable" and take their own fail-safe
925+
branch. Never raises.
926+
927+
CURRENCY DEPENDENCY (shared with both parity copies): the value is only
928+
as current as the platform-maintained team config — a stale
929+
``leadSessionId`` after a session resume can misclassify an in-process
930+
frame as tmux (or vice-versa). Consumers must bound that blast radius to
931+
coordination-only decisions (see ``is_canonical_journal_frame``).
932+
"""
933+
if not is_safe_path_component(team_name):
934+
return ""
935+
if teams_dir:
936+
config_path = Path(teams_dir) / team_name / "config.json"
937+
else:
938+
config_path = get_claude_config_dir() / "teams" / team_name / "config.json"
939+
try:
940+
data = json.loads(config_path.read_text(encoding="utf-8"))
941+
lead_session_id = data.get("leadSessionId")
942+
except (OSError, json.JSONDecodeError, ValueError, AttributeError, TypeError):
943+
return ""
944+
return lead_session_id if isinstance(lead_session_id, str) else ""
945+
946+
947+
def is_canonical_journal_frame(input_data: dict) -> bool:
948+
"""True iff THIS process's journal writes land in the canonical
949+
(lead-session) journal: the lead frame in either teammateMode, or an
950+
in-process teammate frame (session_id == leadSessionId — one process,
951+
one session). False for a tmux teammate frame (distinct session_id)
952+
and on ANY resolution failure of the topology leg (unreadable team
953+
config, missing session_id): skipping defers durability to the
954+
completion-time seams — never worse than the shipped baseline — while
955+
emitting from a misclassified frame could silo the event AND poison
956+
the shared content-hash marker namespace, suppressing a later
957+
canonical emit. The is_lead leg is independent of config readability,
958+
so lead-written keys keep full both-modes coverage even when the
959+
topology leg cannot resolve. Never raises.
960+
"""
961+
try:
962+
if is_lead(input_data):
963+
return True
964+
sid = input_data.get("session_id")
965+
if not (isinstance(sid, str) and sid):
966+
# Missing/non-string session_id: topology unresolvable — skip
967+
# before paying the config read.
968+
return False
969+
# Team resolution goes through this module's own identity-matched
970+
# resolver (fail-closed empty on an unknown team, which routes the
971+
# helper to its "" return and this leg to False).
972+
#
973+
# DELIBERATE SPLIT — do not unify with the callers' team resolution.
974+
# The per-write emit legs (task_lifecycle_gate) resolve their
975+
# team_name ONCE from the persisted ctx SSOT
976+
# (get_pact_context()["team_name"]) and use it for task reads +
977+
# marker namespacing, because the dedup namespace must stay
978+
# internally CONSISTENT across every seam that shares those markers
979+
# (all gate seams use the same persisted value). THIS predicate
980+
# instead answers a session-topology question — "does my session_id
981+
# match the REAL platform team's leadSessionId?" — which requires
982+
# the identity-ALIGNED resolver: after a resume-divergence the
983+
# persisted name can point at a config whose leadSessionId is
984+
# stale/wrong, flipping the topology answer. Re-pointing the emit
985+
# path to the aligned resolver would fragment the marker namespace
986+
# against the pre-existing seams (duplicate events); re-pointing
987+
# this predicate to the persisted value would mis-answer topology
988+
# after divergence. Divergent outcomes are bounded (skip or
989+
# duplicate emit — bias-to-preservation, never a lost canonical
990+
# emit), which is why the split is safe as well as intentional.
991+
lead_session_id = _read_lead_session_id(get_team_name())
992+
return bool(lead_session_id) and sid == lead_session_id
993+
except Exception:
994+
return False
995+
996+
909997
def _iter_members(
910998
team_name: str,
911999
teams_dir: str | None = None,

pact-plugin/hooks/shared/session_registry.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,9 +355,11 @@ def _read_lead_session_id(team: str) -> str:
355355
356356
Used by ``register()``'s in-process self-guard to compute the runtime
357357
structural topology signal ``own session_id == leadSessionId`` (in-process)
358-
vs ``!=`` (tmux). LOGIC-PARITY with ``task_claim_gate._read_lead_session_id``,
359-
INLINED here (not imported) to preserve this module's self-contained-leaf
360-
invariant (no ``shared.*`` imports — see module docstring). Reuses the exact
358+
vs ``!=`` (tmux). LOGIC-PARITY with the SSOT copy,
359+
``pact_context._read_lead_session_id`` (which ``task_claim_gate`` also
360+
imports rather than duplicating), INLINED here (not imported) to preserve
361+
this module's self-contained-leaf invariant (no ``shared.*`` imports — see
362+
module docstring). Reuses the exact
361363
``_is_safe_team_segment`` guard + ``_config_root()`` config-read idiom that
362364
``_name_is_team_member`` above already uses, so the read adds no new I/O
363365
machinery.

pact-plugin/hooks/shared/task_metadata_snapshot.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,40 @@
6262
# exclude set stays minimal by design.
6363
SNAPSHOT_EXCLUDE: frozenset[str] = frozenset({"handoff"})
6464

65+
# Keys whose WRITE (TaskCreate/TaskUpdate delta) triggers an immediate
66+
# whole-payload mirror — the open-task-consumed class that a status-blind
67+
# whole-store drain destroys while load-bearing (mid-arc readers consume
68+
# these via TaskGet while the task is still open). The fire predicate is
69+
# targeted-key-in-delta; the payload is always the full overlay (never a
70+
# projection), so cross-seam content-hash dedup stays coherent with the
71+
# completion-time seams. Disjoint from SNAPSHOT_EXCLUDE by test-pinned
72+
# invariant. NOT here: "variety" (already per-write-mirrored at its write
73+
# point by the dispatch_variety emit); "intentional_wait" (rides along in
74+
# the overlay; a wait-only write is not load-bearing alone).
75+
#
76+
# The audit pair is targeted for the same reasons as teachback_submit:
77+
# audit_summary (the auditor's live verdict) and audit_summary_authored
78+
# (the overwrite-protection mirror of the authored verdict) are consumed
79+
# by the lead at CODE-phase close while the audited task is still OPEN,
80+
# so a status-blind whole-store drain destroys them exactly while
81+
# load-bearing (a prior drain lost live audit verdicts mid-arc). Both are
82+
# teammate-written-class keys (the auditor writes from a teammate frame),
83+
# so they carry the same canonical-frame tmux asymmetry as
84+
# teachback_submit: in-process frames mirror per-write; a tmux teammate
85+
# frame defers to the completion seams. audit_summary_authored rides the
86+
# overlay whenever audit_summary fires (the gate's own writeback lands
87+
# before the leg's disk read), but stays targeted in its own right so a
88+
# direct write of the mirror alone is durability-covered too.
89+
PER_WRITE_MIRROR_KEYS: frozenset[str] = frozenset({
90+
"scope_contract",
91+
"nesting_depth",
92+
"worktree_path",
93+
"teachback_submit",
94+
"teachback_rejection",
95+
"audit_summary",
96+
"audit_summary_authored",
97+
})
98+
6599
# Size caps on the canonical serialization (see _canonical_bytes). Empirics
66100
# over the full journal/task-file population found the largest real value at
67101
# ~10 KB and no journal event ever ≥ 32 KB, so both caps are anomaly paths:

pact-plugin/hooks/task_claim_gate.py

Lines changed: 15 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@
9292
import json
9393
import os
9494
import sys
95-
from pathlib import Path
9695

9796
_SUPPRESS_OUTPUT = json.dumps({"suppressOutput": True})
9897

@@ -111,6 +110,12 @@
111110
# catch keeps the exit code clean (0) and the output well-formed.
112111
try:
113112
import shared.pact_context as pact_context
113+
# SSOT delegation: the shared _read_lead_session_id implementation lives
114+
# in shared.pact_context (session_registry keeps the one remaining inline
115+
# copy, forced by its self-contained-leaf invariant). The from-import
116+
# binds the name as THIS module's attribute, preserving the test seam
117+
# that monkeypatches task_claim_gate._read_lead_session_id.
118+
from shared.pact_context import _read_lead_session_id
114119
from shared.session_registry import resolve as registry_resolve
115120
from shared.task_utils import (
116121
iter_team_task_jsons,
@@ -200,44 +205,6 @@ def _stdin_team(stdin: dict) -> str:
200205
return team if isinstance(team, str) else ""
201206

202207

203-
def _read_lead_session_id(team_name: str, teams_dir: str | None = None) -> str:
204-
"""Read the top-level ``leadSessionId`` from
205-
``~/.claude/teams/{team_name}/config.json``.
206-
207-
Mirrors the guarded-read shape of ``pact_context._iter_members``
208-
(try/except → default) — net-new read; no hook reads ``leadSessionId``
209-
today. Returns "" on any of: unsafe team_name, missing/unreadable file,
210-
malformed JSON, non-object top-level, or a missing/non-string key. An empty
211-
return routes the caller to the fail-safe in-process/NO-OP default. Never
212-
raises.
213-
214-
CURRENCY DEPENDENCY (the in-process/tmux topology compare rests on this):
215-
in-process safety assumes ``config.leadSessionId`` is CURRENT. A STALE value
216-
— e.g. after a session resume where the team config retains a prior session's
217-
id — could make the caller's ``session_id == leadSessionId`` compare
218-
MISCLASSIFY an in-process frame as tmux (or vice-versa). Blast radius is
219-
BOUNDED and benign: the discriminator is coordination-only (all frames are the
220-
same OS user; no privilege boundary), and the worst misclassification outcome
221-
is a benign ``pending → in_progress`` auto-flip of the teammate's OWN
222-
owned-unblocked-pending task (in-process misread as tmux) — the very action
223-
the teammate was supposed to take — or a missed nudge (tmux misread as
224-
in-process). The ``owner == confident_name`` conjunction still bounds it to
225-
the resolved teammate's task; no wrong-teammate flip, no escalation.
226-
"""
227-
if not is_safe_path_component(team_name):
228-
return ""
229-
if teams_dir:
230-
config_path = Path(teams_dir) / team_name / "config.json"
231-
else:
232-
config_path = get_claude_config_dir() / "teams" / team_name / "config.json"
233-
try:
234-
data = json.loads(config_path.read_text(encoding="utf-8"))
235-
lead_session_id = data.get("leadSessionId")
236-
except (OSError, json.JSONDecodeError, ValueError, AttributeError, TypeError):
237-
return ""
238-
return lead_session_id if isinstance(lead_session_id, str) else ""
239-
240-
241208
# ─── claim-candidate predicates ──────────────────────────────────────────────
242209

243210

@@ -453,6 +420,15 @@ def _evaluate(stdin: dict) -> str | None:
453420
return None # cannot locate config → fail-safe NO-OP
454421

455422
# ── Step 3: topology discriminator (the final D3 signal — a config read) ──
423+
# CURRENCY (this gate's blast-radius bound; the generic currency note lives
424+
# on the shared helper): the compare below assumes config.leadSessionId is
425+
# CURRENT. A stale value (e.g. after a session resume) can misclassify an
426+
# in-process frame as tmux or vice-versa — bounded and benign HERE: the
427+
# worst outcome is a benign pending→in_progress auto-flip of the teammate's
428+
# OWN owned-unblocked-pending task (the very action it was supposed to
429+
# take) or a missed nudge. The owner == confident_name conjunction bounds
430+
# it to the resolved teammate's task; no wrong-teammate flip, no
431+
# escalation.
456432
lead_session_id = _read_lead_session_id(team_name)
457433
if not lead_session_id:
458434
# ASYMMETRY (design note): an UNKNOWN topology (config unreadable / no

0 commit comments

Comments
 (0)