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

Filter by extension

Filter by extension

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

> **Version**: 4.4.40
> **Version**: 4.4.41

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
8 changes: 7 additions & 1 deletion pact-plugin/commands/comPACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Dispatch concurrent specialists for this self-contained task: $ARGUMENTS
Create a simpler Task hierarchy than full orchestrate:

```
1. `TaskCreate`: Feature task "{verb} {feature}" (self-contained task)
1. `TaskCreate`: Feature task "{verb} {feature}" (self-contained task) — stamp metadata.variety.total (see below)
2. `TaskUpdate`: Feature task status = "in_progress"
3. Analyze: How many agents needed?
4. `TaskCreate`: Agent task(s) — direct children of feature
Expand All @@ -29,6 +29,12 @@ Create a simpler Task hierarchy than full orchestrate:

> Steps 8-10 are detailed in the [After Specialist Completes](#after-specialist-completes) section below (includes test verification and commit steps).

> **Feature-task variety stamp (step 1).** Stamp the feature-level variety total on the feature `TaskCreate`, so the wrap-up Orchestration Retrospective can compute feature-vs-dispatch divergence instead of `feature_variety_missing`:
> ```python
> TaskCreate(subject="{verb} {feature}", metadata={"variety": {"total": N}}) # N = feature-level total, 4-16
> ```
> Advisory, not enforced — the feature task has no teachback-gated sibling, so the dispatch-boundary gate cannot apply. The `.total` field is the load-bearing input `compute_variety_divergence` reads (the full D11 4-rationale block is fine too, but `.total` is the minimum). Mirrors what `orchestrate.md` already persists for its feature task.

**Example structure:**
```
[Feature] "Fix 3 backend bugs" (blockedBy: agent1, agent2, agent3)
Expand Down
18 changes: 17 additions & 1 deletion pact-plugin/commands/plan-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,23 @@ A_id = TaskCreate(
"Mission for Task B: the primary-work task assigned to you in your TaskList (the work task, NOT this TEACHBACK gate task), identified by its subject (the '{role}: {mission}' pattern). Claim it after this teachback is accepted."
)
TaskUpdate(A_id, owner="{specialist-name}")
B_id = TaskCreate(subject="{specialist}: plan consultation for {feature}", description="<consultation mission>")
B_id = TaskCreate(
subject="{specialist}: plan consultation for {feature}",
description="<consultation mission>",
metadata={
"variety": {
"novelty": N,
"novelty_rationale": "<1-sentence: why this score for THIS dispatch's novelty>",
"scope": N,
"scope_rationale": "<1-sentence: why this score for THIS dispatch's scope>",
"uncertainty": N,
"uncertainty_rationale": "<1-sentence: why this score for THIS dispatch's uncertainty>",
"risk": N,
"risk_rationale": "<1-sentence: why this score for THIS dispatch's risk>",
"total": N
}
}
)
TaskUpdate(B_id, owner="{specialist-name}", addBlockedBy=[A_id])
TaskUpdate(A_id, addBlocks=[B_id])
```
Expand Down
18 changes: 17 additions & 1 deletion pact-plugin/commands/rePACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,23 @@ A_id = TaskCreate(
"Mission for Task B: the primary-work task assigned to you in your TaskList (the work task, NOT this TEACHBACK gate task), identified by its subject (the '{role}: {mission}' pattern). Claim it after this teachback is accepted."
)
TaskUpdate(A_id, owner="{scope-prefixed-name}")
B_id = TaskCreate(subject="{scope-prefixed-name}: implement {sub-task}", description="<full mission>\n\nFIRST claim this task (TaskUpdate status=in_progress) before any implementation tool-use — it is pre-assigned to you but still pending; you flip it, not the lead.")
B_id = TaskCreate(
subject="{scope-prefixed-name}: implement {sub-task}",
description="<full mission>\n\nFIRST claim this task (TaskUpdate status=in_progress) before any implementation tool-use — it is pre-assigned to you but still pending; you flip it, not the lead.",
metadata={
"variety": {
"novelty": N,
"novelty_rationale": "<1-sentence: why this score for THIS dispatch's novelty>",
"scope": N,
"scope_rationale": "<1-sentence: why this score for THIS dispatch's scope>",
"uncertainty": N,
"uncertainty_rationale": "<1-sentence: why this score for THIS dispatch's uncertainty>",
"risk": N,
"risk_rationale": "<1-sentence: why this score for THIS dispatch's risk>",
"total": N
}
}
)
TaskUpdate(B_id, owner="{scope-prefixed-name}", addBlockedBy=[A_id])
TaskUpdate(A_id, addBlocks=[B_id])
```
Expand Down
5 changes: 4 additions & 1 deletion pact-plugin/hooks/dispatch_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,13 @@ def _emit_load_failure_deny(stage: str, error: BaseException) -> NoReturn:
# heuristic is muted.
# Unknown values fall back to ``"warn"`` so a typo never disables the
# gate's other rules. Default ``"warn"`` preserves Commit 2 behavior.
# The read is normalized with .strip().lower() BEFORE the membership check
# (``"DENY"`` / ``" deny "`` → deny; ``""`` / bogus → warn), parsing
# identically to handoff_ordering_gate.py's PACT_DISPATCH_VARIETY_MODE knob.
_ALLOWED_INLINE_MISSION_MODES = frozenset({"warn", "deny", "shadow"})
INLINE_MISSION_MODE = os.environ.get(
"PACT_DISPATCH_INLINE_MISSION_MODE", "warn",
)
).strip().lower()
if INLINE_MISSION_MODE not in _ALLOWED_INLINE_MISSION_MODES:
INLINE_MISSION_MODE = "warn"

Expand Down
207 changes: 197 additions & 10 deletions pact-plugin/hooks/handoff_ordering_gate.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
#!/usr/bin/env python3
"""
Location: pact-plugin/hooks/handoff_ordering_gate.py
Summary: PreToolUse hook (matcher="TaskUpdate") that WARNS the lead when a
Summary: PreToolUse hook (matcher="TaskUpdate") with TWO independent branches:
(1) #956 completion-ordering nudge — WARNS the lead when a
TaskUpdate(status="completed") lands on a HANDOFF-expecting task whose
metadata.handoff is not yet present on disk — the #956 write-after-
completion ordering mistake. Advisory only (additionalContext); NEVER
metadata.handoff is not yet present on disk. Advisory only; NEVER
denies.
(2) #865 dispatch-variety gate — fires when a terminal dispatch-wiring
TaskUpdate (owner resolves to a pact-specialist agentType AND
addBlockedBy in the SAME tool_input) links
a Task B that carries no resolvable metadata.variety. Deterministic
STRONG-WARN by default; env-gated DENY opt-in via
PACT_DISPATCH_VARIETY_MODE. The deny path is the file's ONLY
fail-CLOSED exception — every other path fails OPEN.
Used by: hooks.json PreToolUse hook (matcher="TaskUpdate")

This is the NUDGE half of the #956 fix (defense-in-depth). The load-bearing
Expand Down Expand Up @@ -47,10 +54,35 @@

# ─── stdlib first (used on the input-side fail-open BEFORE wrapped imports) ─
import json
import os
import sys

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

# ─── #865 dispatch-variety enforcement mode (env-knob) ─────────────────────
# Models dispatch_gate.py's PACT_DISPATCH_INLINE_MISSION_MODE: read once at
# import; an unknown value falls back to "warn" so a typo never silently
# disables (or, worse, silently DENIES on) the gate. Default "warn" is the
# non-negotiable consumer-blast-radius posture (#997): the gate ships
# deterministic-WARN; "deny" is an explicit per-consumer opt-in.
# warn → additionalContext advisory (the existing WARN mechanism) + exit 0
# deny → permissionDecision:"deny" + exit 2 (the ONLY fail-CLOSED path in
# this file). SOURCE-PROVEN honor: the platform's PreToolUse deny
# branch returns before tool.call() with no tool_name carve-out, so
# a TaskUpdate-matcher deny IS honored — empirically un-exercised, so
# warn ships as the default and deny is opt-in.
# shadow → journal-only calibration; no additionalContext, no deny.
# The read is normalized with .strip().lower() BEFORE the membership check so a
# forgiving opt-in: "DENY" / " deny " / "Deny" → deny; "" / bogus / unknown →
# warn (the safe default). Strictly more forgiving — normalization can never
# enable an unintended mode (anything not in the set still falls back to warn).
_ALLOWED_VARIETY_MODES = frozenset({"warn", "deny", "shadow"})
DISPATCH_VARIETY_MODE = os.environ.get(
"PACT_DISPATCH_VARIETY_MODE", "warn"
).strip().lower()
if DISPATCH_VARIETY_MODE not in _ALLOWED_VARIETY_MODES:
DISPATCH_VARIETY_MODE = "warn"

# Cap on the stdin read. Real PreToolUse TaskUpdate frames carry a tool_input
# (taskId + small metadata) and stay well under this; an over-cap frame
# truncates mid-JSON → JSONDecodeError → input-side fail-open. Bounds memory
Expand All @@ -66,8 +98,10 @@
# clean (0) and the output well-formed.
try:
import shared.pact_context as pact_context
from shared.dispatch_helpers import is_pact_specialist_owner
from shared.intentional_wait import is_self_complete_exempt
from shared.task_utils import is_teachback_subject, read_task_json
from shared.teachback_schema import resolve_variety_total
_IMPORTS_OK = True
except BaseException: # noqa: BLE001 — fail-OPEN catch-all (warn gate never denies)
_IMPORTS_OK = False
Expand Down Expand Up @@ -161,6 +195,135 @@ def _evaluate(input_data: dict) -> str | None:
)


def _evaluate_dispatch_variety(input_data: dict) -> str | None:
"""#865: return an actionable advisory string when a terminal
dispatch-wiring TaskUpdate links a Task B that carries no resolvable
metadata.variety, else None. The caller decides warn-vs-deny-vs-shadow
from DISPATCH_VARIETY_MODE; this function only detects the gap.

This is a NEW branch, parallel to and independent of the #956
completion-ordering _evaluate — neither calls the other.

COMPOSITE-SIGNATURE TRIGGER (the FIRST-OBSERVABLE-WRITE / no-misfire
invariant): fire ONLY on the terminal dispatch-wiring write — a single
TaskUpdate whose tool_input carries BOTH:
- an owner that resolves (via team config) to a pact-specialist
agentType — owners are BARE names, so this is a team-config
resolution, NOT an owner.startswith("pact-") prefix check, AND
- addBlockedBy present and non-empty (the teachback-gate link),
in the SAME tool_input. This composite co-occurrence is uniquely the
dispatch-wiring shape (orchestrate/comPACT/plan-mode/rePACT all wire B
via `TaskUpdate(B, owner=..., addBlockedBy=[A])`). No fire at
TaskCreate(B) (owner empty there — wired by this later TaskUpdate) or on
a partial-wiring TaskUpdate (owner-only OR addBlockedBy-only). All other
addBlockedBy uses across the templates (phase/imPACT blocker blocking)
are addBlockedBy-ONLY with no owner in the same call → already excluded.

STRUCTURAL DECISION (not actor-based): the gate READS the linked Task B's
metadata.variety from disk and fires ONLY when there is no resolvable
total (absent / non-dict / untotaled). Firing on the composite signature
alone would warn on every dispatch including correctly-stamped ones; the
read is what makes the decision detection-precise (and the deny safe).
The "present-but-malformed-rationale" case stays a PostToolUse advisory
in task_lifecycle_gate R4 (the surgical split) — this gate keys solely on
resolve_variety_total being None, the missing-stamp concern.
"""
tool_name = input_data.get("tool_name", "")
if tool_name != "TaskUpdate":
return None # matcher already scopes this, but be defensive

# DUAL-MODE: lead frame only (same structural is_lead discriminator the
# #956 branch uses). A teammate frame emits nothing.
if not pact_context.is_lead(input_data):
return None

tool_input = input_data.get("tool_input") or {}
if not isinstance(tool_input, dict):
return None

# COMPOSITE signature — a pact-specialist owner AND addBlockedBy non-empty
# in the SAME tool_input. Either half alone is a non-terminal/partial write.
# Cheap in-memory guards FIRST (owner-present, addBlockedBy, taskId); the
# owner→agentType resolution is a disk read, deferred until after the
# team_name resolve below (cost-order).
owner = tool_input.get("owner")
if not isinstance(owner, str) or not owner.strip():
return None # no owner → TaskCreate(B) / not a wiring write
add_blocked_by = tool_input.get("addBlockedBy")
if not isinstance(add_blocked_by, list) or not add_blocked_by:
return None # partial wiring (owner-only) → not yet terminal

task_id = tool_input.get("taskId", "") or ""
if not task_id:
return None
try:
pact_context.init(input_data)
team_name = pact_context.get_pact_context().get("team_name", "")
except Exception:
team_name = ""
if not team_name:
return None # no team context → cannot resolve owner/Task B → bypass

# CORRECTED PREDICATE (#865 cycle-1): identify a pact-specialist teammate by
# resolving the BARE owner → team-member → agentType (the same resolution
# the carve-out helpers use), NOT by an owner.startswith("pact-") prefix —
# real owners are bare names, so the old prefix check was always False (the
# gate was dead-on-arrival). is_pact_specialist_owner fail-CLOSES to False on
# any unresolvable path → this gate fail-OPENS (return None), never strands.
# SOLO_EXEMPT agents (general-purpose/Explore/Plan) have non-pact agentTypes
# → excluded here naturally; the secretary (pact-secretary) PASSES this check
# and is suppressed by the is_self_complete_exempt carve-out below.
if not is_pact_specialist_owner(owner, team_name):
return None # owner does not resolve to a pact specialist → not a dispatch

task = read_task_json(task_id, team_name)
if not isinstance(task, dict) or not task:
return None # no task data → bypass (fail-open)

# CARVE-OUTS (preserve R4's silence guarantees verbatim; the helpers are
# already imported). The pact-specialist resolution above admits the
# secretary (pact-secretary IS a registered specialist), so the
# is_self_complete_exempt carve-out is LOAD-BEARING here — it suppresses
# the secretary + signal tasks. is_teachback_subject suppresses the Task-A
# teachback gate by subject.
subject = task.get("subject") or ""
if is_self_complete_exempt(task, team_name) or is_teachback_subject(subject):
return None

# STRUCTURAL READ: does the linked Task B carry a resolvable variety total?
# resolve_variety_total is the shared SSOT (also used by the read-time band
# resolver and write-time validator). None ⇒ absent / non-dict / untotaled
# ⇒ the missing-stamp gap this gate enforces. A resolvable total ⇒ silent
# (a present-but-malformed-rationale stamp is R4's PostToolUse concern).
metadata = task.get("metadata")
variety = metadata.get("variety") if isinstance(metadata, dict) else None
if resolve_variety_total(variety, metadata) is not None:
return None # stamp resolves → not a missing-stamp dispatch

return (
f"PACT dispatch-variety gate: Task {task_id} ({subject!r}) is being "
f"wired into a teachback-gated dispatch (owner {owner!r}) without a "
"resolvable metadata.variety. Per-dispatch variety stamping is "
"required so the hook can resolve the reasoning_reconstruction band "
"and the concurrent-auditor trigger. Stamp the D11 4-rationale block "
"(novelty/scope/uncertainty/risk + total 4-16) on this Task B BEFORE "
"wiring it — mirror the block in orchestrate.md / comPACT.md / "
"peer-review.md / plan-mode.md / rePACT.md."
)


def _emit_warn(advisory: str) -> None:
"""WARN output path: additionalContext advisory + exit 0 (never denies).
Shared by the #956 nudge and the dispatch-variety warn mode."""
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": advisory, # advisory — NOT permissionDecision
}
}))
sys.exit(0) # exit 0 — advisory, never deny / exit-2


def main() -> None:
# Input-side fail-open: an unreadable / oversized / malformed stdin frame
# suppresses + exits 0 (never blocks the TaskUpdate).
Expand All @@ -174,6 +337,36 @@ def main() -> None:
print(_SUPPRESS_OUTPUT)
sys.exit(0)

# #865 dispatch-variety branch FIRST: it is the only branch that can DENY
# (deny mode), and a denied wiring write should be blocked before the #956
# completion nudge is even considered. Both branches fail-OPEN on any logic
# error — a gate that bricks legitimate writes is worse than the gap it
# guards. The deny path (deny mode + confirmed missing stamp) is the sole
# deliberate fail-CLOSED exception.
try:
variety_gap = _evaluate_dispatch_variety(input_data)
except Exception:
variety_gap = None # fail-OPEN on any logic error
if variety_gap:
if DISPATCH_VARIETY_MODE == "deny":
# The ONLY fail-CLOSED path in this file. Source-proven honor:
# the platform PreToolUse deny branch returns before tool.call()
# with no tool_name carve-out, so a TaskUpdate-matcher deny IS
# honored (empirically un-exercised → warn is the default).
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": variety_gap,
}
}))
sys.exit(2)
if DISPATCH_VARIETY_MODE == "warn":
_emit_warn(variety_gap)
# shadow → fall through to suppress (journal-only telemetry is the
# PostToolUse R4/journal surface; here shadow simply does not surface).

# #956 completion-ordering nudge: WARN-only, never denies.
try:
advisory = _evaluate(input_data)
except Exception:
Expand All @@ -183,13 +376,7 @@ def main() -> None:
sys.exit(0)

if advisory:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": advisory, # advisory — NOT permissionDecision
}
}))
sys.exit(0) # exit 0 — advisory, never deny / exit-2
_emit_warn(advisory)

print(_SUPPRESS_OUTPUT)
sys.exit(0)
Expand Down
Loading