Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cf5e8ac
feat(#819): Step 0.5 self-correcting teardown in scan-pending-tasks
michael-wojcik May 21, 2026
2017488
docs(#819): charter delta — surface self-correcting trigger + idempot…
michael-wojcik May 21, 2026
d98433a
test(#819): self-teardown structural + runtime + dogfood verification
michael-wojcik May 21, 2026
1bc7a63
test(#819): cross-site ISO format coupling + idempotency + forbidden-…
michael-wojcik May 21, 2026
d2533b8
chore(version): bump plugin to 4.2.13
michael-wojcik May 21, 2026
710ba5c
docs(#819): tighten latency phrasing + extend audit-prose forbidden-t…
michael-wojcik May 21, 2026
5402a51
test(#819): defense-in-depth on Step 0.5 implicit invariants — stderr…
michael-wojcik May 22, 2026
967540d
chore(#819): pin python baseline + prose precision nit
michael-wojcik May 22, 2026
94d86cc
refactor(#821): drop armed_at/disarmed_at writers; consume auto-stamp…
michael-wojcik May 22, 2026
3d4861a
refactor(#821): unify Step 0/0.5 bash extractors on strptime(ts) + ca…
michael-wojcik May 22, 2026
3011d90
refactor(#821): migrate wake_inbox_drain producer-side idempotency to…
michael-wojcik May 22, 2026
4181a65
docs(#821): collapse audit prose + docstrings + charter + runbook to …
michael-wojcik May 22, 2026
81610ed
test(#821): retire ISO-format byte-identity coupling pin — 4-site cha…
michael-wojcik May 22, 2026
92f6c18
docs(#821): correct Q2 test docstring narrative + refresh stale heter…
michael-wojcik May 22, 2026
64e5fea
docs(#820): peer-review remediation cycle 1 — F1+D1+F2+F5+D3+D2
michael-wojcik May 24, 2026
5a21477
fix(#820): suppress stderr on Step 0 malformed-ts extractor (D4)
michael-wojcik May 24, 2026
4f1fe05
fix(#820): extend stderr suppression to Step 0.5 extractors (D4-Step-…
michael-wojcik May 24, 2026
c852b24
test(#820): harden Q5 anchor + format-drift fall-through coverage (F4…
michael-wojcik May 24, 2026
47d3fa9
docs(#820): pin empirical evidence for trailing_whitespace ts case (F…
michael-wojcik May 24, 2026
34acec8
test(#820): upgrade Q5 anchor pin to form-(c) AST Call-node assertion…
michael-wojcik May 24, 2026
00e852f
docs(#820): document span-anchor stability trade-off (parallel to Row 5)
michael-wojcik May 24, 2026
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.2.12",
"version": "4.2.13",
"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 @@ -505,7 +505,7 @@ When installed as a plugin, PACT lives in your plugin cache:
│ └── cache/
│ └── pact-plugin/
│ └── PACT/
│ └── 4.2.12/ # Plugin version
│ └── 4.2.13/ # 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.2.12",
"version": "4.2.13",
"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.2.12
> **Version**: 4.2.13

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
38 changes: 35 additions & 3 deletions pact-plugin/commands/scan-pending-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Cron-fire body — silent read; emit nothing unless a real artifact is on disk f
```bash
SJ="{plugin_root}/hooks/shared/session_journal.py"
SD='{session_dir}'
ARMED_AT=$(python3 "$SJ" read-last --type scan_armed --session-dir "$SD" | python3 -c 'import json,sys; e=json.load(sys.stdin); print(e["armed_at"] if e else "")')
ARMED_AT=$(python3 "$SJ" read-last --type scan_armed --session-dir "$SD" | python3 -c 'import json,sys,datetime; e=json.load(sys.stdin); print(int(datetime.datetime.strptime(e["ts"],"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()) if e else "")' 2>/dev/null)
if [ -n "$ARMED_AT" ]; then
delta=$(( $(date +%s) - ARMED_AT ))
if [ $delta -ge 0 ] && [ $delta -lt 300 ]; then exit 0; fi
Expand All @@ -41,7 +41,39 @@ Cron-fire body — silent read; emit nothing unless a real artifact is on disk f

Fail-open: `read-last` returns literal `null` on missing journal / no events / corrupt JSONL. The `python3 -c` extraction yields empty string in those cases; `[ -n "$ARMED_AT" ]` is false; the gate falls through to Step 1.

Negative-delta guard: `[ $delta -ge 0 ]` forces future-dated `armed_at` (clock skew / adversarial write) to fall through. Without it, negative deltas would always pass `-lt 300` — the gate would become a kill-switch.
Stderr suppression: the `2>/dev/null` redirect on the inner `python3 -c` extractor silences Python tracebacks (TypeError / ValueError / KeyError) that would otherwise surface in the cron-fire LLM-turn output when the journal contains a malformed `ts` (writer-bug, schema-drift, or corruption — e.g., `ts=42`, `ts=null`, `ts=""`, unparseable string). The fail-open contract is preserved unchanged: the extractor's stdout is still empty on failure, `[ -n "$ARMED_AT" ]` is still false, and the gate still falls through to Step 1. Trade-off: stderr-clean cron-fire turns (no traceback noise to the user) vs lost operator diagnostic visibility under journal corruption (a malformed `ts` no longer surfaces as a visible Python traceback; the only signal is the silent fall-through). Acceptable because the fail-open behavior is intentional and the journal-shape contract is pinned at write time by `session_journal.py`'s `_validate_event_schema`. A future editor MUST NOT redirect stdout (`>/dev/null` or `&>/dev/null`) — that would defeat the fail-open guard which depends on the extractor emitting empty-string on failure.

Negative-delta guard: `[ $delta -ge 0 ]` forces a future-dated `scan_armed.ts` (clock skew / adversarial write) to fall through. Without it, negative deltas would always pass `-lt 300` — the gate would become a kill-switch.

0.5. **Self-correcting teardown check**. Read the latest `teardown_request`, `scan_armed`, and `scan_disarmed` event timestamps; if a `teardown_request` event landed AFTER the current arm AND has not yet been processed via `scan_disarmed`, invoke `Skill("PACT:stop-pending-scan")` and return without continuing to Step 1. This is the **self-correcting fallback path** that catches orchestrator non-compliance with the `_TEARDOWN_DIRECTIVE` `additionalContext` channel — the orchestrator persona's scope-boundary clause excludes inbound `additionalContext` from MUST-binding (see memory `a7bcd37f`), so the cron-fire body itself enforces teardown via the trusted in-band channel + journal source-of-truth pair. Bounds compliance latency to ≤1 cron interval (5min nominal, up to ~6min with typical jitter, 15min worst-case) regardless of `additionalContext`-directive handling. Charter cross-reference: [§Cron-Fire Mechanism Teardown trigger sites](../protocols/pact-communication-charter.md#cron-fire-mechanism).

```bash
SJ="{plugin_root}/hooks/shared/session_journal.py"
SD='{session_dir}'
LATEST_TEARDOWN_REQUEST=$(python3 "$SJ" read-last --type teardown_request --session-dir "$SD" | python3 -c 'import json,sys,datetime; e=json.load(sys.stdin); print(int(datetime.datetime.strptime(e["ts"],"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()) if e else "")' 2>/dev/null)
LATEST_SCAN_ARMED=$(python3 "$SJ" read-last --type scan_armed --session-dir "$SD" | python3 -c 'import json,sys,datetime; e=json.load(sys.stdin); print(int(datetime.datetime.strptime(e["ts"],"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()) if e else "")' 2>/dev/null)
LATEST_SCAN_DISARMED=$(python3 "$SJ" read-last --type scan_disarmed --session-dir "$SD" | python3 -c 'import json,sys,datetime; e=json.load(sys.stdin); print(int(datetime.datetime.strptime(e["ts"],"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()) if e else "")' 2>/dev/null)
if [ -n "$LATEST_TEARDOWN_REQUEST" ] && [ -n "$LATEST_SCAN_ARMED" ] && \
[ "$LATEST_TEARDOWN_REQUEST" -gt "$LATEST_SCAN_ARMED" ] && \
{ [ -z "$LATEST_SCAN_DISARMED" ] || \
[ "$LATEST_TEARDOWN_REQUEST" -gt "$LATEST_SCAN_DISARMED" ]; }; then
exit 0
fi
```

When the bash exits 0 here, the scan body's LLM-side action is: invoke `Skill("PACT:stop-pending-scan")` and return without executing Steps 1+. The `stop-pending-scan` body is idempotent (CronList no-op on absent; `scan_disarmed` writes are benign; latest-event semantics dominate). Precedent: `commands/wrap-up.md:98` invokes the same skill from a sibling command. Multiple consecutive cron-fires hitting this branch before `stop-pending-scan` completes EACH write would result in multiple `scan_disarmed` events — benign; the latest dominates.

Fail-open: `read-last` returns literal `null` on missing journal / no events / corrupt JSONL. The `python3 -c` extractors yield empty string in those cases; `[ -n "$VAR" ]` is false; the gate falls through to Step 1.

Stderr suppression: each of the 3 inline `python3 -c` extractors above carries `2>/dev/null` for the same trade-off documented in the Step 0 `## Stderr suppression` paragraph above (stderr-clean cron-fire turns vs lost operator diagnostic visibility under journal corruption; fail-open contract preserved because the guards consume stdout only). A future editor MUST NOT redirect stdout (`>/dev/null` or `&>/dev/null`) on these extractors — that would defeat the fail-open `[ -n "$VAR" ]` guards which depend on the extractors emitting empty-string on failure.

Uniform strptime conversion: `scan_armed.ts`, `scan_disarmed.ts`, and `teardown_request.ts` are all stamped as ISO-8601 UTC strings by `session_journal.make_event` (format literal `"%Y-%m-%dT%H:%M:%SZ"`). All three extractors use `python3 strptime(...).replace(tzinfo=utc).timestamp()` to convert ISO→int-epoch inline, making operands integer-comparable. Direct lexical comparison of `ts` strings would coincidentally match epoch ordering under the canonical fixed-shape format, but breaks silently under format drift (sub-second fractions, mixed TZ suffixes, or any future format relaxation) — strptime conversion is the architecturally correct path and is pinned by `test_python_consumer_parses_ts_via_strptime_not_string_compare`.

A future editor MUST NOT add `set -e`, `set -o pipefail`, or an `ERR` trap to this block — empty-operand `-gt` would abort the cron-fire turn, breaking fail-open and reintroducing the compliance gap Option D exists to close.

A future editor MUST NOT wrap the `python3 -c` extractors in `try/except` to silence parse errors — empty-string-on-failure is the fail-open contract; swallowing exceptions with a default value silently breaks it.

A future editor MUST NOT switch the extractor to `fromisoformat` without verifying Python version baseline (3.11+ required for `Z`-suffix handling); the explicit-format `strptime` is portable to 3.7+. If a future Python baseline bump to 3.11+ makes `fromisoformat` viable, coordinate the switch across the 2 coupled sites (`session_journal.py` `make_event` SSOT + the uniform-strptime extractor pattern in Step 0 / Step 0.5 / `wake_inbox_drain.py:685-694`) AND update `test_python_consumer_parses_ts_via_strptime_not_string_compare`'s canonical literal.

1. `TaskList` — enumerate tasks. Filter to: `owner == any teammate` AND `status == "in_progress"` AND `metadata.intentional_wait.reason == "awaiting_lead_completion"`. (These are the tasks where a teammate has submitted teachback or handoff and is idle awaiting acceptance.)
2. For each candidate, raw-read `~/.claude/tasks/{team_name}/{id}.json` via filesystem read (NOT `TaskGet` — TaskGet does not surface `metadata.teachback_submit` or `metadata.handoff`). Inspect `metadata.teachback_submit` (for teachback gate tasks) and `metadata.handoff` (for primary-work tasks).
Expand All @@ -61,7 +93,7 @@ The five anti-hallucination guardrails are LOAD-BEARING. Each guardrail prevents

### No-Narration

> **No-Narration**: The scan emits NO user-facing prose narrating what it found, considered, skipped, or did. The only outputs are: (a) `SendMessage` to the teammate as part of the acceptance two-call pair, (b) `TaskUpdate(status="completed")`, or (c) nothing. The scan never emits "Scanning… found 0 pending tasks", "Skipping task #N because…", "Race window detected, will retry next fire", or similar status-narrating text.
> **No-Narration**: The scan emits NO user-facing prose narrating what it found, considered, skipped, or did. The only outputs are: (a) `SendMessage` to the teammate as part of the acceptance two-call pair, (b) `TaskUpdate(status="completed")`, (c) `Skill("PACT:stop-pending-scan")` invocation when Step 0.5 self-correcting teardown fires, or (d) nothing. The scan never emits "Scanning… found 0 pending tasks", "Skipping task #N because…", "Race window detected, will retry next fire", or similar status-narrating text.

**Audit**: No-Narration prevents the cron-fire noise failure mode. A 5-minute cron firing 12 times per hour produces 12 LLM turns per hour during active teammate work. If each fire emits a "Scanning…" prose line, the user's transcript fills with 12 useless status lines per hour. Worse, the prose-emit pattern primes the editing LLM to treat the cron fire as a conversation turn requiring response — re-opening the cascade failure mode the scan exists to prevent. An editing LLM tempted to "add a brief status line for observability" is re-introducing the failure mode. Observability happens via `CronList` (cron is registered), `TaskList` (tasks transition status), and journal events (HANDOFF acceptance is journaled) — NOT via per-fire prose.

Expand Down
7 changes: 3 additions & 4 deletions pact-plugin/commands/start-pending-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,12 @@ Single procedure — the command IS the operation. No Arm/Teardown sub-section.
set -e
trap 'rc=$?; echo "[JOURNAL WRITE FAILED] start-pending-scan.md (bash line $LINENO): \"${BASH_COMMAND%%$'\''\n'\''*}\" exit=$rc" >&2; exit $rc' ERR
SJ="{plugin_root}/hooks/shared/session_journal.py"
ARMED_AT=$(date +%s)
python3 "$SJ" write --type scan_armed --session-dir '{session_dir}' --stdin <<JSON
{"armed_at": $ARMED_AT}
python3 "$SJ" write --type scan_armed --session-dir '{session_dir}' --stdin <<'JSON'
{}
JSON
```

Note: `<<JSON` (not `<<'JSON'`) so `$ARMED_AT` expands. `set -e` + ERR trap mirror the canonical orchestrate.md pattern.
Note: the heredoc body is `{}` — the arm time is carried by the auto-stamped `ts` field set by `make_event`, parsed via `strptime` at the consumer side. `<<'JSON'` (quoted delimiter) is safe because there are no shell expansions in the payload. `set -e` + ERR trap mirror the canonical orchestrate.md pattern.

**Audit**: idempotency lives in this command (CronList-presence check), NOT in the directive that invokes it. An editing LLM tempted to add an "if not already armed" guard at the directive site would re-introduce LLM-self-diagnosis as the gate, which is the failure mode the unconditional-emit discipline closes (hook emits unconditionally on the lifecycle transition; the skill body decides whether the work needs doing).

Expand Down
7 changes: 3 additions & 4 deletions pact-plugin/commands/stop-pending-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,12 @@ Single procedure — the command IS the operation.
set -e
trap 'rc=$?; echo "[JOURNAL WRITE FAILED] stop-pending-scan.md (bash line $LINENO): \"${BASH_COMMAND%%$'\''\n'\''*}\" exit=$rc" >&2; exit $rc' ERR
SJ="{plugin_root}/hooks/shared/session_journal.py"
DISARMED_AT=$(date +%s)
python3 "$SJ" write --type scan_disarmed --session-dir '{session_dir}' --stdin <<JSON
{"disarmed_at": $DISARMED_AT}
python3 "$SJ" write --type scan_disarmed --session-dir '{session_dir}' --stdin <<'JSON'
{}
JSON
```

Note: `<<JSON` (not `<<'JSON'`) so `$DISARMED_AT` expands. `set -e` + ERR trap mirror the canonical orchestrate.md pattern and the symmetric write in start-pending-scan.md Step 5.
Note: the heredoc body is `{}` — the teardown time is carried by the auto-stamped `ts` field set by `make_event`, parsed via `strptime` at the consumer side. `<<'JSON'` (quoted delimiter) is safe because there are no shell expansions in the payload. `set -e` + ERR trap mirror the canonical orchestrate.md pattern and the symmetric write in start-pending-scan.md Step 5.

Ordering rationale: the CronList lookup is the only mechanism for locating the cron ID — IDs are platform-assigned and not caller-specifiable. The filter-then-delete sequence is the canonical pattern; reversing it is impossible without an externally-tracked ID. The scan_disarmed write follows the CronDelete so the event reflects actual disarm (the cron-absent post-condition has held since Step 4 completed); writing it before Step 4 would risk surfacing a stale disarm event if the CronDelete subsequently failed.

Expand Down
25 changes: 15 additions & 10 deletions pact-plugin/hooks/shared/session_journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@
# Trust boundary: write path validates events against this dict; read path
# trusts disk content. Loosening this dict without auditing all readers
# will silently break extractors assuming validated shape (e.g., Step 0
# bash in commands/scan-pending-tasks.md assumes scan_armed.armed_at:int).
# bash in commands/scan-pending-tasks.md assumes scan_armed.ts is the
# auto-stamped ISO-8601 string from make_event, parsed via strptime).
_REQUIRED_FIELDS_BY_TYPE: dict[str, dict[str, type]] = {
# hooks/session_init.py writes session_start with team, session_id,
# project_dir, worktree on the valid-stdin path only (under R3, the event
Expand Down Expand Up @@ -184,20 +185,24 @@
"wake_tally_warn": {"team_name": str, "reason": str},
# commands/start-pending-scan.md Step 5 writes scan_armed after the
# CronCreate that arms the pending-scan cron. commands/scan-pending-tasks.md
# Step 0 reads the latest scan_armed event timestamp and skips the
# scan body when elapsed-since-arm < WARMUP_GRACE_SECONDS. The grace
# window is coupled in lockstep to the cron interval (300s grace +
# */5 cron) — first-fire-coverage invariant; see start-pending-scan.md
# §CronCreate Block audit.
"scan_armed": {"armed_at": int},
# Step 0 reads the latest scan_armed event's auto-stamped `ts` (parsed
# via strptime → epoch) and skips the scan body when elapsed-since-arm
# < WARMUP_GRACE_SECONDS. The grace window is coupled in lockstep to
# the cron interval (300s grace + */5 cron) — first-fire-coverage
# invariant; see start-pending-scan.md §CronCreate Block audit. No
# type-specific required fields — `ts` is supplied by the make_event
# auto-stamp path (matches session_end / cleanup_summary precedent).
"scan_armed": {},
# commands/stop-pending-scan.md writes scan_disarmed after the
# CronDelete that tears down the pending-scan cron. Paired writer
# to scan_armed; together they form the event-model lifecycle
# consumed by hooks/wake_inbox_drain.py — the drain hook's
# producer-side idempotency suppresses the redundant Arm directive
# only when scan_armed is strictly more recent than scan_disarmed
# (re-arm dominance under post-Teardown re-arm).
"scan_disarmed": {"disarmed_at": int},
# only when scan_armed.ts is strictly more recent than
# scan_disarmed.ts (re-arm dominance under post-Teardown re-arm).
# No type-specific required fields — `ts` is supplied by the
# make_event auto-stamp path.
"scan_disarmed": {},
# hooks/teardown_request_emitter.py writes teardown_request after
# the lead-driven TaskUpdate(status="completed") drives the team's
# lifecycle-relevant active-task count to 0 (Tier-1 fast path), AND
Expand Down
Loading