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

Filter by extension

Filter by extension

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

> **Version**: 3.20.4
> **Version**: 3.21.0

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

Expand Down
10 changes: 8 additions & 2 deletions pact-plugin/commands/pause.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,13 @@ JSON

The timestamp (`ts`) is set automatically by `make_event()` and serves the same purpose as the previous `paused_at` field.

### 6. Shut Down Teammates
### 6. Tear Down the Lead's Wake Mechanism

Invoke `Skill("PACT:inbox-wake")` and execute the Teardown operation with `agent_name="team-lead"`. This stops the lead's Monitor task (`TaskStop`, ignoring not-found errors) and unlinks `inbox-wake-state-team-lead.json`. Teardown is best-effort — see [Teardown Block](../skills/inbox-wake/SKILL.md#teardown-block) for the exact sequence.

Run BEFORE step 7 (teammate shutdown). Teammates execute their own Teardown as part of approving `shutdown_request` (see `pact-agent-teams` `## Shutdown`). On resume, `session_init.py` re-arms the lead's Monitor at SessionStart; per-teammate Monitors are re-armed at SubagentStart on respawn.

### 7. Shut Down Teammates

Send `shutdown_request` individually to each active teammate **by name** and wait for responses. The secretary must have completed consolidation tasks (steps 1 and 3) before receiving the shutdown request.

Expand All @@ -114,7 +120,7 @@ For each active teammate:

Do NOT delete the team — it will be garbage-collected or reused on resume.

### 7. Report
### 8. Report

```
"Session paused. PR #{N} open at {url}. Resume with `/PACT:peer-review`."
Expand Down
1 change: 1 addition & 0 deletions pact-plugin/commands/wrap-up.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ This is the deep-clean pass. Pass 1 (workflow-level HANDOFF review) is the prima

- **Identify** any temporary files created during the session (e.g., `temp_test.py`, `debug.log`, `foo.txt`, `test_output.json`).
- **Delete** these files to leave the workspace clean.
- **Tear down the lead's wake mechanism**: invoke `Skill("PACT:inbox-wake")` and execute the Teardown operation with `agent_name="team-lead"`. This stops the lead's Monitor task and unlinks the `inbox-wake-state-team-lead.json` sidecar. Teardown is best-effort — tolerate `TaskStop` not-found errors per the skill's [Teardown Block](../skills/inbox-wake/SKILL.md#teardown-block). Run BEFORE step 6 (Worktree Cleanup) so the Monitor is stopped while the worktree state file is still reachable for the unlink.

## 4. Orchestration Retrospective (Second-Order Cybernetics)

Expand Down
33 changes: 28 additions & 5 deletions pact-plugin/hooks/peer_inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@
)


# Wake-arm directive — Tier-0 additionalContext (#591). Emitted per
# SubagentStart fire alongside the bootstrap prelude, peer list, and
# trailing reminders. {agent_name} interpolates via the existing
# safe_name pattern at the call site so the skill watches the correct
# inbox (inboxes/{agent_name}.json) and writes the correct STATE_FILE
# (inbox-wake-state-{agent_name}.json). Idempotent in the skill — the
# directive emits unconditionally; the skill no-ops if a valid
# STATE_FILE is already on disk for this agent. Per architect §15.2,
# Tier-0 hook delivery is the chosen channel (vs Tier-1 skill body)
# because #444's "hook-emitted directives: unconditional > conditional"
# discipline is non-negotiable for spawn-time directives.
_WAKE_ARM_TEMPLATE = (
"\n\nArm wake mechanism: invoke Skill(\"PACT:inbox-wake\") and execute the "
"Arm operation before any tool call. Pass agent_name=\"{agent_name}\" so the "
"skill watches the correct inbox. Arm is idempotent — invoke unconditionally; "
"the skill no-ops if a valid STATE_FILE is already on disk for this agent."
)


def _sanitize_agent_name(agent_name: str) -> str:
"""Strip characters from agent_name that could break out of the
PACT ROLE marker format.
Expand Down Expand Up @@ -167,19 +186,23 @@ def get_peer_context(
)

prelude = _BOOTSTRAP_PRELUDE_TEMPLATE.format(agent_name=safe_name)
wake_arm = _WAKE_ARM_TEMPLATE.format(agent_name=safe_name)
# Output ordering: prelude → peer_context → "\n\n" → plugin banner →
# _TEACHBACK_REMINDER → _COMPLETION_AUTHORITY_NOTE. The plugin banner
# is a single line with no leading/trailing newlines, so an explicit
# "\n\n" separator goes between peer_context and the banner.
# _TEACHBACK_REMINDER and _COMPLETION_AUTHORITY_NOTE each begin with
# "\n\n", preserving visual spacing through the trailing reminders.
# _TEACHBACK_REMINDER → _COMPLETION_AUTHORITY_NOTE → wake_arm. The
# plugin banner is a single line with no leading/trailing newlines, so
# an explicit "\n\n" separator goes between peer_context and the banner.
# _TEACHBACK_REMINDER, _COMPLETION_AUTHORITY_NOTE, and _WAKE_ARM_TEMPLATE
# each begin with "\n\n", preserving visual spacing through the trailing
# reminders. Wake-arm is chain-end (#591) — additive vs the prelude
# template; future audits find it without searching template internals.
return (
prelude
+ peer_context
+ "\n\n"
+ format_plugin_banner()
+ _TEACHBACK_REMINDER
+ _COMPLETION_AUTHORITY_NOTE
+ wake_arm
)


Expand Down
56 changes: 56 additions & 0 deletions pact-plugin/hooks/session_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,52 @@ def get_project_slug() -> str:
return ""


def cleanup_wake_registry(team_name: str) -> None:
"""Best-effort removal of inbox-wake STATE_FILE sidecars for the given team.

Belt-and-suspenders for force-termination edge cases (SIGKILL, crash)
where the primary skill-invocation Teardown path didn't run. Cannot
stop the orphaned Monitors — those are agent-runtime tools unreachable
from this hook context. Sidecar removal lets the next session's Arm
cold-start cleanly instead of seeing a STATE_FILE pointing at a
long-dead Monitor.

Per-agent STATE_FILE: every agent (lead AND every teammate spawned in
the team) owns its own `inbox-wake-state-{agent-name}.json`. This helper
globs the entire family and unlinks each — the lead's
`inbox-wake-state-team-lead.json` and every teammate's
`inbox-wake-state-{teammate-name}.json`.

D1 has no heartbeat sidecar — single STATE_FILE per agent only.

Path-traversal discipline (#492/#543 risk class):
- team_name validated via is_safe_path_component (existing helper).
- resolved path asserted under teams_root via relative_to(teams_root).
- Glob pattern `inbox-wake-state-*.json` is constrained to the validated
team_dir; Path.glob returns paths anchored to team_dir, so symlink-
escape via the glob result is closed by the prior relative_to check.
- Path.unlink wrapped in try/except OSError (missing_ok=True suppresses
FileNotFoundError; other OSError subtypes still raise — caught here
per module-wide fail-open posture).
"""
if not team_name or not is_safe_path_component(team_name):
return # fail-closed on invalid team name
teams_root = (Path.home() / ".claude" / "teams").resolve()
team_dir = (teams_root / team_name).resolve()
try:
team_dir.relative_to(teams_root)
except ValueError:
return # team_dir escaped teams_root (symlink attack defense)
try:
for state_file in team_dir.glob("inbox-wake-state-*.json"):
try:
state_file.unlink(missing_ok=True)
except OSError:
pass # fail-open per module convention
except OSError:
pass # fail-open if glob itself fails (e.g., team_dir vanished)


def check_unpaused_pr(
tasks: list[dict] | None,
project_slug: str,
Expand Down Expand Up @@ -801,6 +847,16 @@ def main():
# Callsite short-circuit on empty team_name is the belt-and-suspenders
# layer around the internal fail-closed guard.
current_team_name = get_team_name()

# Wake-registry cleanup (#591). Belt-and-suspenders for force-
# termination paths. Cannot reach TaskStop from hook context;
# only the registry sidecar is removable here. D1 has no
# heartbeat sidecar — single STATE_FILE per agent only. Glob
# `inbox-wake-state-*.json` to catch lead AND every teammate's
# sidecar in one pass (symmetric per-agent arming, §15.4).
if current_team_name:
cleanup_wake_registry(current_team_name)

teams_r, teams_s = 0, 0
tasks_r, tasks_s = 0, 0
teams_reaper_ran = False
Expand Down
14 changes: 14 additions & 0 deletions pact-plugin/hooks/session_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,20 @@ def main():
# wrapper at the call site.
context_parts.append(format_plugin_banner())

# 4d. Wake-arm directive — Tier-0 additionalContext (#591). Emits
# unconditionally on every SessionStart fire (startup/resume/clear/compact)
# per "Hook-emitted directives: unconditional > conditional" Working Memory
# entry. Arm is idempotent: the skill's Arm operation no-ops if a valid
# STATE_FILE is already on disk, so re-emission is cheap. There is no
# watchdog in D1 — a silently-dead Monitor is undetectable in-session and
# the mechanism degrades to "no wake" until the next SessionStart re-arms.
context_parts.append(
'Arm wake mechanism: invoke Skill("PACT:inbox-wake") and execute the '
'Arm operation before any teammate dispatch. Arm is idempotent — invoke '
'unconditionally on every SessionStart (startup, resume, clear, compact); '
'the skill no-ops if a valid STATE_FILE is already on disk.'
)

# 5. Remind orchestrator to create session-unique PACT team (or reuse on resume)
team_name = generate_team_name(input_data)

Expand Down
12 changes: 12 additions & 0 deletions pact-plugin/protocols/pact-communication-charter.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,18 @@ Before resending an apparently-unacknowledged message, verify the addressee has
- For immediate halt of in-flight teammate work, user-side manual interrupt is required.
- The team-lead's responsibility to "surface immediately" means at the team-lead's next idle, not at arbitrary real-time.

### Wake Mechanism

Inbox-grow events fire a turn on the **addressed agent** (lead or teammate) during poller-gated waits. Both directions of the dispatch graph are covered: teammate→lead replies and lead→teammate dispatches. The wake mechanism is best-effort: when armed, it bounds idle-boundary delivery latency by Monitor's 2-s poll interval. There is no in-session watchdog; a silently-dead Monitor degrades the channel to baseline (the agent's existing idle-poll behavior).

Each agent (lead AND every teammate) arms its own Monitor on its own single-file inbox via `wc -c` byte-grow; the Monitor emits `INBOX_GREW` on stdout to fire a turn on the addressed agent; the agent returns to idle and the platform's `useInboxPoller` delivers the message. Single skill, two invocation sites — see [implementation: Skill("PACT:inbox-wake")](../skills/inbox-wake/SKILL.md) for canonical mechanics.

Lifetime is session-scoped per agent. The lead's Monitor is armed at SessionStart via `session_init.py`; each teammate's Monitor is armed at SubagentStart via `peer_inject.py` (per-spawn). Re-arm is idempotent — the skill no-ops if a valid STATE_FILE is already on disk for the agent. Teardown fires at session-end paths for the lead (`/wrap-up`, `/pause`) and at `shutdown_request` approval for teammates (see `pact-agent-teams` `## Shutdown`).

Wake is **signal**, not content. On `INBOX_GREW`, the addressed agent ends the turn and returns to idle — the platform's idle-delivery is the channel-of-record for content. See the skill body's [§Overview alarm-clock framing](../skills/inbox-wake/SKILL.md#overview) for the principle anchor.

D1 design intentionally has no watchdog. The audit artifact at `docs/architecture/591-inbox-wake-skill-redesign.md` predates the kill-mechanism finding (PREPARE §C) and should not be used as a reference for charter content.

## Part II — Written Output

## Pillar 1 — Plain English
Expand Down
Loading