Skip to content

Commit c91a6f8

Browse files
Extract harvest mechanics into a tested pact_harvest CLI (#1034)
Extracts the harvest skill's inline-Python journal glue into a tested, importable `pact_harvest` CLI (`resolve-session-dir` + `resolve-artifacts`), replacing brittle inline logic with an exit-code-keyed, off-lead-safe entry point. Folds in the CLI-extraction follow-up originally split out. What landed: - New `hooks/shared/pact_harvest.py` CLI (2 subcommands; sys.path bootstrap; 0/2/1 exit contract; stdout=data / stderr=diagnostics) - `resolve_latest_artifacts` / `_ts_supersedes` pure helper (fail-open ts compare, last-wins tie-break, trailing-Z anchor, naive->UTC coerce) - SKILL.md rewrite (inline-Python -> CLI; JSON-array parse) - Hardening: session-dir containment check, defensive non-str path-element skip, naive/aware timestamp coercion - Comprehensive non-vacuous test coverage (containment, path-type, missing-arg, mixed-ts, naive/aware, B1 drift parity) Reviewed by 3 cross-lane reviewers + 3 fresh blind reviewers (all APPROVE); full suite green (9676 passed / 0 failed / 0 errors) + CI green. PATCH bump 4.4.41 -> 4.4.42. Part of the harvest/artifact-durability arc; supersedes the CLI-extraction split.
1 parent 53e4a65 commit c91a6f8

17 files changed

Lines changed: 2283 additions & 22 deletions

File tree

.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.4.41",
15+
"version": "4.4.42",
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.4.41/ # Plugin version
608+
│ └── 4.4.42/ # 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.4.41",
3+
"version": "4.4.42",
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.4.41
3+
> **Version**: 4.4.42
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/commands/orchestrate.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,19 @@ Agent(
494494
Completed-phase teammates remain as consultants. Do not shutdown during this workflow.
495495

496496
**Before next phase**:
497-
- [ ] Outputs exist in `docs/preparation/`
497+
- [ ] **Artifact exists and is non-empty** — for a PREPARE phase that **ran** (not skipped), the expected per-feature artifact at `docs/preparation/{feature}.md` (and any additional produced files, e.g. `environment-model-{feature}.md`) MUST exist on disk and be non-empty before you complete the phase task or emit below. A missing or empty artifact is a **failure to investigate**, not an absence of work: do **NOT** complete the phase, do **NOT** emit, and INVESTIGATE first (specialist wrote to the wrong location, produced nothing, or used a different slug), then resolve it. A **skipped** PREPARE phase is exempt — it legitimately produces no artifact, so neither the check nor the emit applies.
498+
- [ ] **Emit `artifact_paths`** — before marking the PREPARE phase task completed, glob the worktree's `docs/preparation/` directory and write a path-only `artifact_paths` journal event so the artifact is recoverable after garbage-collection independently of any HANDOFF. Enumerate the full absolute path list yourself (do NOT read paths from a HANDOFF — a recovery pointer must not depend on the thing it recovers). If the glob finds nothing, do NOT emit an empty event (the missing-artifact case above already covers that anomaly).
499+
```bash
500+
set -e
501+
trap 'rc=$?; echo "[JOURNAL WRITE FAILED] orchestrate.md (bash line $LINENO): \"${BASH_COMMAND%%$'\''\n'\''*}\" exit=$rc" >&2; exit $rc' ERR
502+
SJ="{plugin_root}/hooks/shared/session_journal.py"
503+
# Enumerate this feature's PREPARE artifacts (worktree-isolated → this feature only).
504+
# Emit only when ≥1 non-empty path is found; an empty result is the missing-artifact anomaly, not an event.
505+
python3 "$SJ" write --type artifact_paths --session-dir '{session_dir}' --stdin <<'JSON'
506+
{"workflow": "prepare", "feature": "{feature}", "paths": ["{absolute_path_1}", "{absolute_path_2}"], "task_id": "{prepare_task_id}"}
507+
JSON
508+
```
509+
- [ ] **Complete the PREPARE phase task** — ONLY after the artifact check and the `artifact_paths` emit above: `TaskUpdate(prepareTaskId, status="completed")`. This ordering is load-bearing: the emit MUST land before the phase-task completion so the durability pointer is on disk by the time completion is observed. (A skipped PREPARE phase completes earlier with `metadata.skipped=true` and runs neither the check nor the emit.)
498510
- [ ] Specialist HANDOFF received
499511
- [ ] If blocker reported → `/PACT:imPACT`
500512
- [ ] **S4 Checkpoint** (see [pact-s4-checkpoints.md](../protocols/pact-s4-checkpoints.md)): Environment stable? Model aligned? Plan viable? Optionally query secretary for S4 pattern check (variety 7+). See [pact-orchestrator §Memory Management](../agents/pact-orchestrator.md#memory-management).
@@ -589,7 +601,19 @@ Agent(
589601
Completed-phase teammates remain as consultants. Do not shutdown during this workflow.
590602

591603
**Before next phase**:
592-
- [ ] Outputs exist in `docs/architecture/`
604+
- [ ] **Artifact exists and is non-empty** — for an ARCHITECT phase that **ran** (not skipped), the expected per-feature artifact at `docs/architecture/{feature}.md` (and any additional produced files) MUST exist on disk and be non-empty before you complete the phase task or emit below. A missing or empty artifact is a **failure to investigate**, not an absence of work: do **NOT** complete the phase, do **NOT** emit, and INVESTIGATE first (specialist wrote to the wrong location, produced nothing, or used a different slug), then resolve it. A **skipped** ARCHITECT phase is exempt — it legitimately produces no artifact, so neither the check nor the emit applies.
605+
- [ ] **Emit `artifact_paths`** — before marking the ARCHITECT phase task completed, glob the worktree's `docs/architecture/` directory and write a path-only `artifact_paths` journal event so the artifact is recoverable after garbage-collection independently of any HANDOFF. Enumerate the full absolute path list yourself (do NOT read paths from a HANDOFF). If the glob finds nothing, do NOT emit an empty event (the missing-artifact case above covers that anomaly).
606+
```bash
607+
set -e
608+
trap 'rc=$?; echo "[JOURNAL WRITE FAILED] orchestrate.md (bash line $LINENO): \"${BASH_COMMAND%%$'\''\n'\''*}\" exit=$rc" >&2; exit $rc' ERR
609+
SJ="{plugin_root}/hooks/shared/session_journal.py"
610+
# Enumerate this feature's ARCHITECT artifacts (worktree-isolated → this feature only).
611+
# Emit only when ≥1 non-empty path is found; an empty result is the missing-artifact anomaly, not an event.
612+
python3 "$SJ" write --type artifact_paths --session-dir '{session_dir}' --stdin <<'JSON'
613+
{"workflow": "architect", "feature": "{feature}", "paths": ["{absolute_path_1}", "{absolute_path_2}"], "task_id": "{architect_task_id}"}
614+
JSON
615+
```
616+
- [ ] **Complete the ARCHITECT phase task** — ONLY after the artifact check and the `artifact_paths` emit above: `TaskUpdate(architectTaskId, status="completed")`. This ordering is load-bearing: the emit MUST land before the phase-task completion so the durability pointer is on disk by the time completion is observed. (A skipped ARCHITECT phase completes earlier with `metadata.skipped=true` and runs neither the check nor the emit.)
593617
- [ ] Specialist HANDOFF received
594618
- [ ] If blocker reported → `/PACT:imPACT`
595619
- [ ] **S4 Checkpoint**: Environment stable? Model aligned? Plan viable?
@@ -757,6 +781,16 @@ The auditor stores its final signal as `metadata.audit_summary` via `TaskUpdate`
757781
{"sha": "{short_sha}", "message": "{first_line}", "phase": "CODE"}
758782
JSON
759783
```
784+
- [ ] **Emit `artifact_paths` for auditor decision-logs (conditional, capture-if-present)** — if a concurrent auditor produced decision-log artifacts, glob `docs/decision-logs/{feature}-*.md`; when ≥1 is present, write a path-only `artifact_paths` event (`workflow=code-auditor`) so they harvest. Unlike PREPARE/ARCHITECT, this is **capture-if-present, not flag-if-missing**: the auditor is optional, so absence is normal — emit nothing and do NOT flag a missing artifact.
785+
```bash
786+
set -e
787+
trap 'rc=$?; echo "[JOURNAL WRITE FAILED] orchestrate.md (bash line $LINENO): \"${BASH_COMMAND%%$'\''\n'\''*}\" exit=$rc" >&2; exit $rc' ERR
788+
SJ="{plugin_root}/hooks/shared/session_journal.py"
789+
# Best-effort: only when the glob found ≥1 decision-log; if none, skip this emit entirely (auditor is optional).
790+
python3 "$SJ" write --type artifact_paths --session-dir '{session_dir}' --stdin <<'JSON'
791+
{"workflow": "code-auditor", "feature": "{feature}", "paths": ["{absolute_decision_log_path}"]}
792+
JSON
793+
```
760794
- [ ] **Process coder HANDOFFs** (non-blocking):
761795
```
762796
TaskCreate(subject="secretary: harvest pending HANDOFFs",

pact-plugin/commands/peer-review.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,16 @@ See also: [Communication Charter](../protocols/pact-communication-charter.md) fo
279279
---
280280

281281
**After all reviews complete**:
282-
1. Synthesize findings into a unified review summary with consolidated recommendations
282+
1. Synthesize findings into a unified review summary with consolidated recommendations, written to `docs/review/`. After writing it, confirm the review doc exists on disk and is non-empty before continuing.
283+
- **Emit `artifact_paths`**: write a path-only `artifact_paths` journal event pointing at the review doc(s) so the consolidated review is recoverable after garbage-collection independently of any HANDOFF. The review doc is the lead's own product — enumerate its absolute path(s) here, not from any HANDOFF.
284+
```bash
285+
set -e
286+
trap 'rc=$?; echo "[JOURNAL WRITE FAILED] peer-review.md (bash line $LINENO): \"${BASH_COMMAND%%$'\''\n'\''*}\" exit=$rc" >&2; exit $rc' ERR
287+
python3 "{plugin_root}/hooks/shared/session_journal.py" write \
288+
--type artifact_paths --session-dir '{session_dir}' --stdin <<'JSON'
289+
{"workflow": "peer-review", "feature": "{feature}", "paths": ["{absolute_review_doc_path}"]}
290+
JSON
291+
```
283292
2. **Journal events**: Write a `review_finding` event for each synthesized finding:
284293
```bash
285294
set -e

pact-plugin/commands/plan-mode.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ Each consultant dispatch creates **two tasks**, not one:
194194
- **Task A** — TEACHBACK gate. `subject = "{specialist}: TEACHBACK for plan consultation on {feature}"`, owner = consultant. Description: lightweight understanding-confirm of the consultation scope.
195195
- **Task B** — primary consultation. `subject = "{specialist}: plan consultation for {feature}"`, owner = consultant, `blockedBy = [<Task A id>]`.
196196

197-
Both are created BEFORE the `Agent(...)` spawn call. The consultant claims A, submits teachback metadata, idles on `awaiting_lead_completion`. You review and accept via the two-call atomic pair: `SendMessage(to=consultant, ...)` FIRST, then `TaskUpdate(A, status="completed")` — see [Teachback Review](../protocols/pact-completion-authority.md#teachback-review) for the rationale. On accept, the consultant wakes to claim B and produce the consultation HANDOFF.
197+
Both are created BEFORE the `Agent(...)` spawn call. The consultant claims A, submits teachback metadata, idles on `awaiting_lead_completion`. You review and accept via the two-call atomic pair: `SendMessage(to=consultant, ...)` FIRST, then `TaskUpdate(A, status="completed")` — see [Teachback Review](../protocols/pact-completion-authority.md#teachback-review) for the rationale. On accept, the consultant wakes to claim B and produce the consultation HANDOFF. The consultant writes the HANDOFF and signals stage-ready, then idles — **you (the lead) complete Task B**, not the consultant. Lead-side completion is what reliably journals the consultation HANDOFF; a consultant self-completing their own Task B can leave the HANDOFF unrecorded.
198198

199199
```
200200
# A-FIRST ORDERING (required): create Task A (the teachback gate) BEFORE Task B so the gate gets the LOWER id. Creating Task B first — giving the gate the HIGHER id — is WRONG: it inverts the intuitive "lower id = earlier" reading. The blocking wiring below legitimately names both ids because it runs AFTER both tasks exist.
@@ -354,6 +354,27 @@ TaskUpdate(
354354

355355
Save the synthesized plan to `docs/plans/{feature-slug}-plan.md`.
356356

357+
**Plan-doc self-check**: after writing the plan, confirm `docs/plans/{feature-slug}-plan.md` exists on disk and is non-empty before continuing. An empty or missing plan doc is a failure to investigate (the synthesis did not land), not a no-op — resolve it before completing the planning task.
358+
359+
**Emit `artifact_paths`**: write a path-only `artifact_paths` journal event pointing at the plan doc so it (and the consultation work it synthesizes) is recoverable after garbage-collection independently of any HANDOFF. The plan doc is the lead's own product, so the lead knows its absolute path directly — enumerate it here, not from any HANDOFF.
360+
361+
```bash
362+
set -e
363+
trap 'rc=$?; echo "[JOURNAL WRITE FAILED] plan-mode.md (bash line $LINENO): \"${BASH_COMMAND%%$'\''\n'\''*}\" exit=$rc" >&2; exit $rc' ERR
364+
SJ="{plugin_root}/hooks/shared/session_journal.py"
365+
python3 "$SJ" write --type artifact_paths --session-dir '{session_dir}' --stdin <<'JSON'
366+
{"workflow": "plan-mode", "feature": "{feature-slug}", "paths": ["{absolute_plan_doc_path}"]}
367+
JSON
368+
```
369+
370+
**Trigger a harvest** (consult-only durability): plan-mode is a consultation-only flow with no later CODE/peer-review boundary to trigger memory harvest, so the plan doc and the consultation HANDOFFs would otherwise never reach pact-memory. Create a secretary harvest task after writing the plan so the `artifact_paths` event resolves and the consultation HANDOFFs distill into institutional memory:
371+
372+
```
373+
TaskCreate(subject="secretary: harvest plan-mode consultation",
374+
description="Harvest HANDOFFs and artifact_paths for team {team_name}. Follow the Standard Harvest workflow in your pact-handoff-harvest skill, resolving artifact_paths events to read the plan doc and any consultation artifacts off disk. Report summary when done.")
375+
TaskUpdate(taskId, owner="secretary")
376+
```
377+
357378
**Handling existing plans**:
358379

359380
If a plan already exists for this feature slug:

pact-plugin/hooks/shared/pact_context.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,47 @@ def get_session_dir() -> str:
565565
return str(_build_session_path(slug, session_id))
566566

567567

568+
def reconstruct_session_dir(project_dir: str, session_id: str) -> str:
569+
"""Reconstruct the absolute session directory from explicit context-file
570+
fields — the off-lead, frame-independent counterpart to get_session_dir().
571+
572+
An off-lead reader (notably the `pact-secretary` harvest, which runs in a
573+
teammate frame where get_session_dir() false-returns '') resolves the dir by
574+
reading `pact-session-context.json` and passing its `project_dir` +
575+
`session_id` fields here. Both fields are persisted RAW (write_context stores
576+
the caller-supplied values; init() sanitizes only the PATH segment it builds,
577+
not the stored field — see build_context_cache / the write_context contract),
578+
so this helper MUST reproduce the writer's path-sanitization to land on the
579+
SAME on-disk directory init() wrote to:
580+
581+
- session_id: sanitized via _UNSAFE_SLUG_CHARS_RE (mirrors the substitution
582+
init() applies to the id before building the context path).
583+
- slug: derived as Path(project_dir).name and sanitized + traversal-guarded
584+
inside _build_session_path (the single source of truth for the slug leg).
585+
586+
Routing BOTH axes through the writer's derivation (the regex for the id, the
587+
shared path-builder for the slug) makes this SSOT-by-construction: an off-lead
588+
reconstruction cannot drift from the writer, and a future change to either
589+
guard updates both the writer and this reader at once. Without this, a
590+
project basename or session_id containing a non-`[A-Za-z0-9_-]` character
591+
(e.g. a dot or space) would reconstruct a DIFFERENT directory than the one the
592+
journal was written to → the off-lead read silently returns 0 events.
593+
594+
Args:
595+
project_dir: The `project_dir` field from pact-session-context.json.
596+
session_id: The `session_id` field from pact-session-context.json (raw).
597+
598+
Returns:
599+
The absolute session directory path, or '' if either input is falsy
600+
(matching get_session_dir()'s empty-on-unavailable contract).
601+
"""
602+
if not project_dir or not session_id:
603+
return ""
604+
safe_id = _UNSAFE_SLUG_CHARS_RE.sub("_", str(session_id))
605+
slug = Path(project_dir).name
606+
return str(_build_session_path(slug, safe_id))
607+
608+
568609
def get_plugin_root() -> str:
569610
"""Convenience: return plugin_root from context. Falls back to the
570611
CLAUDE_PLUGIN_ROOT env var (exported into every hook process by the

0 commit comments

Comments
 (0)