Skip to content

Commit fb95921

Browse files
feat(journal): mirror load-bearing task metadata via task_metadata_snapshot events (#1147) (#1164)
* feat(journal): register task_metadata_snapshot event schema * feat(hooks): add task_metadata_snapshot shared substrate * refactor(marker): namespace param on agent_handoff marker resolvers * feat(hooks): emit metadata snapshots at lead completion + post-completion backstop * feat(hooks): teammate-frame snapshot twin at TaskCompleted * docs(skills): harvest three-tier snapshot fallback + acceptance round-trip * chore(hooks): register task_metadata_snapshot in helper-import closure SSOT * fix(hooks): rebuild markers in snapshot stage-3a to preserve read-only input * fix(hooks): provenance-tracked marker identity in snapshot builder * test: comprehensive snapshot suite — subprocess seams, adversarial matrix, provenance semantics * chore: bump plugin version to 4.6.2 * fix(hooks): filter platform null-delete keys from snapshot overlay at gate seams * test: pin dropped-keys census overrun as the documented residual * docs(tests): record counter-test cardinality in subprocess suite docstring * fix(hooks): sanitize task_id at lead-side handoff emit intake for cross-family parity
1 parent 485046d commit fb95921

21 files changed

Lines changed: 3552 additions & 22 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.1",
15+
"version": "4.6.2",
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.1/ # Plugin version
608+
│ └── 4.6.2/ # 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.1",
3+
"version": "4.6.2",
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.1
3+
> **Version**: 4.6.2
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/hooks/agent_handoff_emitter.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
unclaim,
7676
)
7777
from shared.session_journal import append_event, get_journal_path, make_event
78+
from shared.task_metadata_snapshot import emit_task_metadata_snapshot
7879
from shared.task_utils import read_task_json
7980

8081
# Suppress false "hook error" display in Claude Code UI on bare exit paths.
@@ -200,6 +201,38 @@ def main() -> None:
200201
print(_SUPPRESS_OUTPUT)
201202
sys.exit(0)
202203

204+
# task_metadata_snapshot seam — teammate-frame twin (hermetic; own
205+
# gates in the substrate). Positioned immediately AFTER the
206+
# transition gate and BEFORE the signal-task bypass: the hardened
207+
# exits below (signal gate, handoff-presence gate, writability gate,
208+
# marker gate) would make an after-the-emit call unreachable for
209+
# exactly the tasks whose seam coverage matters here — self-completed
210+
# tasks with sibling metadata but no/malformed handoff, and signal
211+
# tasks (which DO snapshot; the agent_handoff suppression below is
212+
# untouched). Isolation from the handoff path is carried by the
213+
# hermetic try/except, the substrate's read-only payload build and
214+
# never-raise contract, and the SEPARATE marker namespace — not by
215+
# ordering; this block must not touch any variable the handoff path
216+
# consumes below (task_metadata, handoff, occupant, marker state).
217+
# The transition gate above IS the stdin-primary/disk-fallback
218+
# discipline — the snapshot inherits it by position and adds NO disk
219+
# status requirement of its own (the disk write can be mid-flight at
220+
# TaskCompleted fire-time). The metadata read is the already-
221+
# performed read_task_json result, best-effort: an empty read yields
222+
# an empty payload and a clean no-emit. An unresolvable teammate
223+
# frame defers inside the substrate (writability precondition — no
224+
# claim, no write); the lead-frame seams cover it.
225+
try:
226+
emit_task_metadata_snapshot(
227+
team_name,
228+
task_id,
229+
task_subject,
230+
teammate_name,
231+
task_data.get("metadata") or {},
232+
)
233+
except Exception:
234+
pass
235+
203236
# `or {}` handles explicit JSON null in addition to missing key —
204237
# .get("metadata", {}) returns None when the key is present with a
205238
# null value, which would crash the subsequent .get("type") call

pact-plugin/hooks/shared/agent_handoff_marker.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@
5252
# most), while keeping the marker filename short.
5353
_OCCUPANT_HASH_LEN = 16
5454

55+
# Default O_EXCL marker directory name — the agent_handoff event family's
56+
# namespace. Keyword-only `namespace` parameters below default to this so
57+
# every pre-existing caller resolves a byte-identical marker path; a sibling
58+
# event family (task_metadata_snapshot) passes its own module-constant
59+
# namespace through hard-bound wrappers so the two families' dedup markers
60+
# can never suppress each other. Namespace values are module constants,
61+
# never input-derived.
62+
_DEFAULT_MARKER_NAMESPACE = ".agent_handoff_emitted"
63+
5564
# Signal-task types — tasks that MUST NOT emit a phantom agent_handoff event
5665
# (a blocker/algedonic completion is a control signal, not a HANDOFF; emitting
5766
# would pollute read_events("agent_handoff") + mis-route secretary harvest).
@@ -123,24 +132,31 @@ def occupant_hash(teammate_name: str, task_subject: str) -> str:
123132
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:_OCCUPANT_HASH_LEN]
124133

125134

126-
def _marker_dir(team_name: str) -> Path:
135+
def _marker_dir(
136+
team_name: str, *, namespace: str = _DEFAULT_MARKER_NAMESPACE
137+
) -> Path:
127138
"""
128-
Return the per-team marker directory path.
139+
Return the per-team marker directory path for one event-family namespace.
129140
130-
Lives under ~/.claude/teams/{team}/.agent_handoff_emitted/ — a sibling
131-
to the team's inboxes/ and config.json. session_end.py's team reaper
132-
removes the whole team directory (shutil.rmtree), so the marker dir is
133-
cleaned up automatically when the team ages out.
141+
Lives under ~/.claude/teams/{team}/{namespace}/ (default
142+
.agent_handoff_emitted) — a sibling to the team's inboxes/ and
143+
config.json. session_end.py's team reaper removes the whole team
144+
directory (shutil.rmtree), so every namespace's marker dir is cleaned
145+
up automatically when the team ages out.
134146
135147
Kept task-scoped (not session-scoped) so fire-once semantics survive
136148
pause/resume: a secretary standing task that spans sessions must emit
137149
its agent_handoff event exactly once across the whole team lifespan.
138150
"""
139-
return get_claude_config_dir() / "teams" / team_name / ".agent_handoff_emitted"
151+
return get_claude_config_dir() / "teams" / team_name / namespace
140152

141153

142154
def _resolve_marker_target(
143-
team_name: str, task_id: str, occupant: str
155+
team_name: str,
156+
task_id: str,
157+
occupant: str,
158+
*,
159+
namespace: str = _DEFAULT_MARKER_NAMESPACE,
144160
) -> "tuple[int | None, str | None]":
145161
"""
146162
Sanitize + validate + pin the marker directory; return the open directory
@@ -195,7 +211,7 @@ def _resolve_marker_target(
195211
):
196212
return None, None
197213

198-
marker_dir = _marker_dir(team_name)
214+
marker_dir = _marker_dir(team_name, namespace=namespace)
199215
# Symlink-containment pre-check: if marker_dir already exists as a
200216
# symlink, refuse to use it (a pre-planted symlink could redirect marker
201217
# creation outside the team directory). Fail-open emit rather than risk
@@ -251,7 +267,13 @@ def _resolve_marker_target(
251267
return dir_fd, f"{task_id}-{occupant}"
252268

253269

254-
def already_emitted(team_name: str, task_id: str, occupant: str) -> bool:
270+
def already_emitted(
271+
team_name: str,
272+
task_id: str,
273+
occupant: str,
274+
*,
275+
namespace: str = _DEFAULT_MARKER_NAMESPACE,
276+
) -> bool:
255277
"""
256278
Test-and-set the per-(team, task_id, occupant) marker.
257279
@@ -285,7 +307,9 @@ def already_emitted(team_name: str, task_id: str, occupant: str) -> bool:
285307
compensating-unclaim closes the OTHER suppression source (a marker this
286308
process claimed but whose journal write then failed) — see unclaim().
287309
"""
288-
dir_fd, filename = _resolve_marker_target(team_name, task_id, occupant)
310+
dir_fd, filename = _resolve_marker_target(
311+
team_name, task_id, occupant, namespace=namespace
312+
)
289313
if dir_fd is None:
290314
# Degenerate key / symlink-guarded / unresolvable → fail-open emit.
291315
return False
@@ -307,7 +331,13 @@ def already_emitted(team_name: str, task_id: str, occupant: str) -> bool:
307331
os.close(dir_fd)
308332

309333

310-
def unclaim(team_name: str, task_id: str, occupant: str) -> None:
334+
def unclaim(
335+
team_name: str,
336+
task_id: str,
337+
occupant: str,
338+
*,
339+
namespace: str = _DEFAULT_MARKER_NAMESPACE,
340+
) -> None:
311341
"""
312342
Compensating rollback (#901, R1) — remove the marker THIS process just
313343
created when the subsequent journal write FAILED (append_event returned
@@ -332,7 +362,9 @@ def unclaim(team_name: str, task_id: str, occupant: str) -> None:
332362
Worst case the marker persists and behavior reverts to today's (a poisoned
333363
marker) — strictly no worse than not having the rollback. Never raises.
334364
"""
335-
dir_fd, filename = _resolve_marker_target(team_name, task_id, occupant)
365+
dir_fd, filename = _resolve_marker_target(
366+
team_name, task_id, occupant, namespace=namespace
367+
)
336368
if dir_fd is None:
337369
return
338370
try:

pact-plugin/hooks/shared/hook_infra_classifier.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,11 @@
138138
}),
139139
"agent_handoff_emitter": frozenset({
140140
"agent_handoff_marker", "constants", "pact_context", "paths",
141-
"session_journal", "session_registry", "session_state", "task_utils",
142-
}),
141+
"session_journal", "session_registry", "session_state",
142+
"task_metadata_snapshot", "task_utils",
143+
}), # task_metadata_snapshot reached via the teammate-frame snapshot
144+
# seam (emit_task_metadata_snapshot); its own transitive edges
145+
# (agent_handoff_marker, session_journal) were already here.
143146
"session_init": frozenset({
144147
"claude_md_manager", "constants", "dispatch_helpers", "failure_log",
145148
"merge_guard_common", "pact_config", "pact_context", "paths",
@@ -170,9 +173,11 @@
170173
"task_lifecycle_gate": frozenset({
171174
"agent_handoff_marker", "constants", "dispatch_helpers",
172175
"intentional_wait", "pact_context", "paths", "session_journal",
173-
"session_registry", "session_state", "task_utils", "teachback_schema",
174-
"tool_response", "variety_scorer",
175-
}),
176+
"session_registry", "session_state", "task_metadata_snapshot",
177+
"task_utils", "teachback_schema", "tool_response", "variety_scorer",
178+
}), # task_metadata_snapshot reached via the lead-completion +
179+
# post-completion-backstop snapshot seams; its transitive edges
180+
# were already in this closure.
176181
"bootstrap_gate": frozenset({
177182
"constants", "marker_schema", "pact_context",
178183
"paths", "session_journal", "session_registry",

pact-plugin/hooks/shared/session_journal.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@
127127
# "yes"|"no"|"concern" flag. The GC-immune mirror read by wrap-up Q6's
128128
# cargo_cult_signal_rate (the task store goes false-empty after GC).
129129
"teachback_ack": {"task_id": str, "rationale_articulates_this_dispatch": str},
130+
# hooks/shared/task_metadata_snapshot.emit_task_metadata_snapshot writes
131+
# task_metadata_snapshot at completion (seams in task_lifecycle_gate.py +
132+
# agent_handoff_emitter.py) — the GC-immune mirror of non-handoff task
133+
# metadata (the N-key generalization of dispatch_variety/teachback_ack).
134+
# task_id is the source task; metadata is the size-bounded sibling-key
135+
# payload (SNAPSHOT_EXCLUDE'd, truncation-marked — see the substrate module).
136+
"task_metadata_snapshot": {"task_id": str, "metadata": dict},
130137
# commands/orchestrate.md + comPACT.md write phase_transition with
131138
# phase + status (both quoted strings). session_resume._build_journal_resume
132139
# subscripts `p["phase"]` — this schema check is the defensive bulwark
@@ -356,6 +363,28 @@
356363
"teachback_ack": {
357364
"concern": str,
358365
},
366+
# hooks/shared/task_metadata_snapshot.emit_task_metadata_snapshot writes
367+
# task_metadata_snapshot with these optionals. subject is sentinel-
368+
# substituted "(no subject)" when degenerate (never required-invalid,
369+
# #917 validate-before-claim); owner is absent for ownerless (e.g.
370+
# signal) tasks; task_type mirrors metadata.type so readers distinguish
371+
# signal snapshots (signal tasks DO snapshot — the agent_handoff
372+
# suppression's reader-purity basis does not transfer to the durability
373+
# mirror); truncated is present (True) only when size-bounding fired;
374+
# occupant is occupant_hash(owner or "", subject) — the task-id-reuse
375+
# discriminator readers join against the agent_handoff event's
376+
# (agent, task_subject) identity. The required-fields registration above
377+
# ("task_metadata_snapshot": {...}) is what ACTIVATES this optional check
378+
# — _validate_event_schema short-circuits on unknown types and would
379+
# otherwise skip the optional loop (same activation pattern as
380+
# session_end / missed_wake / teachback_ack).
381+
"task_metadata_snapshot": {
382+
"subject": str,
383+
"owner": str,
384+
"task_type": str,
385+
"truncated": bool,
386+
"occupant": str,
387+
},
359388
# commands/peer-review.md writes remediation with an optional task_id —
360389
# the fixer's Task-B task id. The Q5 coverage denominator
361390
# (variety_divergence.count_task_b_dispatch_sites) uses it to dedup a

0 commit comments

Comments
 (0)