Skip to content
Merged
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.0.0",
"version": "4.0.1",
"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 @@ -504,7 +504,7 @@ When installed as a plugin, PACT lives in your plugin cache:
│ └── cache/
│ └── pact-plugin/
│ └── PACT/
│ └── 4.0.0/ # Plugin version
│ └── 4.0.1/ # 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.0.0",
"version": "4.0.1",
"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.0.0
> **Version**: 4.0.1

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
53 changes: 46 additions & 7 deletions pact-plugin/hooks/wake_lifecycle_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,21 +147,60 @@ def _extract_task_id(input_data: dict[str, Any]) -> str | None:
Pull the task_id out of the PostToolUse payload.

PostToolUse stdin shape carries the original tool_input under
"tool_input" and the tool's response under "tool_response". Both
TaskCreate and TaskUpdate accept/return a task with an `id` field.
Defensively probe both paths; return None if neither yields a
string id.
"tool_input" and the tool's response under "tool_response".
TaskCreate's tool_response is nested — the created task is wrapped
under a "task" key (`tool_response.task.id`) — while TaskUpdate's
tool_response is flat (`tool_response.id`). Probe in precedence
order, returning the first match whose value is a string that is
non-empty after `.strip()`:

1. tool_input.taskId
2. tool_input.task_id
3. tool_response.task.id
4. tool_response.task.taskId
5. tool_response.task.task_id
6. tool_response.id
7. tool_response.taskId
8. tool_response.task_id

WHY the nested `tool_response.task.*` probes precede the flat
`tool_response.*` probes: production-typical TaskCreate payloads
are nested (per #612 logging-shim capture from session
pact-56ce3a2a on 2026-05-02). Placing nested probes first means
the production-common case hits the first matching probe; the
flat probes remain as fallback for TaskUpdate and for legacy/test
fixture shapes.

Returned values are guaranteed non-empty after strip — a
whitespace-only id (e.g. `" "`) would propagate to a TaskStop
call with a syntactically-valid-but-semantically-empty id and
fail downstream; rejecting at the source is cheaper. Returns
None if no probe matches.
"""
tool_input = input_data.get("tool_input") or {}
if isinstance(tool_input, dict):
tid = tool_input.get("taskId") or tool_input.get("task_id")
if isinstance(tid, str) and tid:
if isinstance(tid, str) and tid.strip():
return tid

tool_response = input_data.get("tool_response") or {}
if isinstance(tool_response, dict):
tid = tool_response.get("id") or tool_response.get("taskId") or tool_response.get("task_id")
if isinstance(tid, str) and tid:
nested_task = tool_response.get("task") or {}
if isinstance(nested_task, dict):
tid = (
nested_task.get("id")
or nested_task.get("taskId")
or nested_task.get("task_id")
)
if isinstance(tid, str) and tid.strip():
return tid

tid = (
tool_response.get("id")
or tool_response.get("taskId")
or tool_response.get("task_id")
)
if isinstance(tid, str) and tid.strip():
return tid

return None
Expand Down
70 changes: 70 additions & 0 deletions pact-plugin/tests/fixtures/wake_lifecycle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# wake_lifecycle hook stdin fixtures

Captured `PostToolUse` stdin payloads for the
`pact-plugin/hooks/wake_lifecycle_emitter.py` hook. These fixtures fossilize
the **production** shape of `tool_response` for `TaskCreate` and `TaskUpdate`
so tests cannot silently drift away from what the platform actually delivers.

Background: between PR #603 (regression introduction) and #620 (fix), every
test in `test_inbox_wake_lifecycle_emitter.py` used a hand-constructed flat
`tool_response: {"id": "..."}` payload. Production `TaskCreate` `tool_response`
is **nested** (`tool_response.task.id`) per #612's logging-shim capture from
session `pact-56ce3a2a` on 2026-05-02. The hook silently returned `None` on
every TaskCreate while tests stayed green. This directory exists to make that
class of failure structurally impossible going forward.

## Capture-provenance convention (MANDATORY)

Every fixture in this directory MUST be a JSON object with a sibling
top-level `_meta` key documenting where the payload came from:

```json
{
"_meta": {
"capture_session_id": "pact-56ce3a2a",
"capture_date": "2026-05-02",
"capture_method": "logging-shim",
"issue_ref": "#612"
},
"tool_name": "TaskCreate",
"tool_input": { "...": "..." },
"tool_response": { "task": { "id": "...", "...": "..." } }
}
```

`_meta` is a sibling top-level key. It is NOT nested inside `tool_input` or
`tool_response`. Tests read it for diagnostic context and ignore it when
piping the payload through the hook (the hook itself ignores unknown
top-level keys).

### `_meta` fields

| Field | Required | Purpose |
| --------------------- | -------- | -------------------------------------------------------------------------- |
| `capture_session_id` | Yes | PACT session ID where the payload was captured (e.g., `pact-56ce3a2a`). |
| `capture_date` | Yes | ISO-8601 date of capture (e.g., `2026-05-02`). |
| `capture_method` | Yes | How it was captured: `logging-shim`, `manual-stdin-redirect`, or `legacy`. |
| `issue_ref` | Yes | Issue or PR that justifies preserving this fixture (e.g., `#612`). |
| `notes` | No | Free-form notes (e.g., "preserved as regression backstop"). |

### `capture_method` values

- `logging-shim` — payload was captured by an in-hook stdin logger writing
the raw stdin bytes to a side-channel file. Highest fidelity; preferred for
any new fixture covering platform-shape behavior.
- `manual-stdin-redirect` — payload was captured by tee-ing the hook's
stdin into a file during a real PACT session. Equivalent fidelity to
logging-shim; noted separately for traceability.
- `legacy` — payload predates the convention and was hand-constructed.
Permitted ONLY for backward-compat regression backstops (i.e., tests that
intentionally assert behavior on the broken pre-fix shape). Never use
`legacy` for new shape-resilience fixtures.

## Future hooks

This convention applies to **all future hook-stdin fixtures**, not just
wake_lifecycle. When adding fixtures for another hook (e.g., the
`peer_inject` SubagentStart payload referenced by the audit-test addendum
on PR B / #628), create a sibling subdirectory with its own README and
mirror this convention. The provenance-capture discipline IS the structural
defense against the failure class that #620 surfaced.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"_meta": {
"capture_session_id": "n/a",
"capture_date": "pre-2026-05-02",
"capture_method": "legacy",
"issue_ref": "#620",
"notes": "Legacy hand-constructed TaskCreate fixture preserved for regression backstop. This is the FLAT tool_response.id shape that test fixtures used between PR #603 and #620; it does NOT match production. Kept here so a test can assert the function still extracts a task_id from this shape (the fix is additive — flat fallback remains, nested probe is added ahead of it). DO NOT use this shape for new tests; new tests MUST use task_create_production_shape.json."
},
"tool_name": "TaskCreate",
"session_id": "synthetic-legacy",
"cwd": "/tmp/proj",
"tool_input": {
"taskId": "5"
},
"tool_response": {
"id": "5"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"_meta": {
"capture_session_id": "pact-56ce3a2a",
"capture_date": "2026-05-02",
"capture_method": "logging-shim",
"issue_ref": "#612",
"notes": "Captured TaskCreate PostToolUse stdin showing the nested tool_response.task.id production shape. This is the shape every TaskCreate has carried since PR #603, but tests prior to #620 used the legacy flat shape (see task_create_legacy_fixture_shape.json) and the divergence stayed undetected because _extract_task_id silently returned None."
},
"tool_name": "TaskCreate",
"session_id": "pact-56ce3a2a",
"cwd": "/Users/mj/Sites/collab/PACT-prompt",
"tool_input": {
"subject": "First active teammate task",
"description": "Initial work item dispatched to a teammate.",
"activeForm": "Working on first item"
},
"tool_response": {
"task": {
"id": "5",
"subject": "First active teammate task",
"status": "pending",
"owner": "backend-coder",
"blockedBy": [],
"blocks": []
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"_meta": {
"capture_session_id": "pact-56ce3a2a",
"capture_date": "2026-05-02",
"capture_method": "logging-shim",
"issue_ref": "#612",
"notes": "Captured TaskUpdate PostToolUse stdin showing the FLAT tool_response.id shape. TaskUpdate is currently flat in production (unlike TaskCreate which is nested). Fossilized here so a future platform shape change is caught by the parametrized shape-resilience test rather than silently regressing. The hook's flat-shape probe MUST keep working for this payload."
},
"tool_name": "TaskUpdate",
"session_id": "pact-56ce3a2a",
"cwd": "/Users/mj/Sites/collab/PACT-prompt",
"tool_input": {
"taskId": "5",
"status": "completed"
},
"tool_response": {
"id": "5",
"subject": "First active teammate task",
"status": "completed",
"owner": "backend-coder"
}
}
Loading