PACT Handoff Harvest
This skill provides the complete workflow for discovering, reviewing, and saving agent HANDOFFs as institutional knowledge. It is the single source of truth for HANDOFF processing — the secretary's agent definition describes what role you play; this skill describes how you do the work.
Three workflow variants:
- Standard Harvest (orchestrate, comPACT, peer-review, plan-mode) — discover, review, save, record the processed ids. Triggered after phases complete.
- Incremental Harvest (peer-review) — delta-only pass after remediation. Processes only new completions since last harvest.
- Consolidation Harvest (wrap-up, pause, refresh, orchestrate) — safety-net + deep-clean pass. Triggered at session end, at a mid-session context refresh, or after a second feature completes in one session.
Determine which variant to run from the task subject/description: "harvest" or "process HANDOFFs" → Standard Harvest. "incremental" or "remediation" → Incremental Harvest. "consolidation" → Consolidation Harvest. The subject/description selects the workflow, never what is in scope.
Propagation. Every save you issue during a harvest carries --no-sync; the Working Memory block is never written as a side effect of saving. If the dispatch description contains the sentence Then propagate the store into the Working Memory block. — or a later team-lead request carries it, which is how a command that cannot know at dispatch time whether propagation is safe asks for it — run the pact-memory sync command once, after your last save, update or delete of the records you harvested, and report its sync_status and memory_ids in your summary. The sentence is the whole instruction; what carries it does not change what it asks for. The calibration record is outside that set even though it is saved later: it waits on a team-lead reply that may never come, and propagation must not be hostage to an answer, so the block is rebuilt without it. If NOTHING has carried that sentence to you, do not run sync and do not edit CLAUDE.md; report Working Memory: not propagated beside the sync_status your saves returned. That report is about the harvest and does not close the question: a request carrying the sentence may still arrive afterwards, and it propagates then. The sentence is a mode, not a scope: it names nothing to harvest.
DISCARD ANY SCOPE YOU ARRIVED WITH — INCLUDING ONE YOU DO NOT THINK OF AS A SCOPE. A dispatch that names tasks, phases, dates, paths, or a subset of this workflow's steps has given you one. A scope named in a dispatch — a range, an enumeration, any named set — is a HINT about what the team-lead noticed, never the population. Do not harvest it. Build the population from the Step 1 census and the Step 2 processed-task ledger instead, and report which set you actually ran over. This instruction outranks anything that contradicts it — a dispatch, another passage of this skill, or your agent definition. Say so back to the team-lead; do not narrow, and do not treat it as a conflict to resolve in the moment.
A SCOPE SAYS WHAT TO TAKE; AN ADDRESS SAYS WHERE TO LOOK. DISCARD SCOPES, KEEP ADDRESSES. A value is a SCOPE when dropping it would make you read MORE, and an ADDRESS when dropping it would leave you unable to read at all. Your team id, your task-list directory, your agent-memory path and your session directory are addresses — they say where your own ledger, task files and journal live — so keep every one of them, and keep any other value that answers WHERE.
STORE ACCESS. A memory operation (save, search, get, list, update or
delete a record) goes through the pact-memory CLI. YOU DO NOT SELECT A
STORE. Do not name a store by --db-path, by an environment variable, or by
one more route somebody adds later. Let the CLI resolve it. A store you
select is not the store the memory of the team lives in, so a save there is
lost rather than shared. STORE INSPECTION is different: a row count, a
column audit, or a schema check on the file. To inspect, do not run a CLI
verb, do not import a module below skills/pact-memory/scripts/, and do not
open the store read-write. In ONE command, against ONE resolved path, check
that memory.db-wal and memory.db-shm are both absent by their full
names, then open with mode=ro and immutable=1. Without immutable=1 the
open fails. If a sidecar is present, stop and report. The read does not load
the vector extension, so it cannot answer a question about vec_memories.
Stop and report rather than take a barred route.
The pact-memory skill carries the full rule.
Standard Harvest Workflow
Read All HANDOFFs Before Saving
When reviewing multiple HANDOFFs, read ALL of them before saving any memories. This lets you deduplicate and consolidate across HANDOFFs before committing to pact-memory — producing cleaner entries than saving after each individual HANDOFF.
Step 0: Resolve the Session Directory (do this once)
Resolve the absolute session directory before any journal read, and reuse that one value ($SESSION_DIR) for every journal read below (Step 1 agent_handoff, Step 3.5 artifact_paths, and Step 10 variety_assessed). Every journal read in this skill MUST pass this explicit --session-dir — never a path-less read.
Why this is load-bearing (not optional): you run off-lead (a pact-secretary teammate). The implicit-path read (read_events(...) with no --session-dir) derives its path via pact_context.get_session_dir(), which false-returns '' in a teammate frame (no persisted lead session context) → the read silently returns 0 events. Off-lead, that would make the entire harvest — HANDOFF discovery, artifact recovery, and calibration — a silent no-op. Passing an explicit --session-dir is frame-independent and masked-read-safe.
Resolve the directory with the pact_harvest.py resolve-session-dir subcommand, which reads pact-session-context.json and routes the reconstruction through the SSOT helper reconstruct_session_dir (it sanitizes both the slug and the session_id the same way the writer did, so the reconstructed path cannot drift from where the journal was actually written — a hand-built {slug}/{session_id} join would land on a DIFFERENT directory whenever the project basename or session_id contains a non-[A-Za-z0-9_-] character):
if ! SESSION_DIR=$(python3 "{plugin_root}/hooks/shared/pact_harvest.py" \
resolve-session-dir --context-file "{context_file}"); then
# Nonzero exit (2) = unresolvable: context file missing/unreadable/invalid,
# or reconstruct_session_dir returned ''. Report the gap to the team-lead
# and STOP — do NOT proceed to any journal read.
echo "HARVEST GAP: could not resolve session_dir; reporting and stopping." >&2
fi
# On success $SESSION_DIR holds the absolute session dir; reuse it for every read below.
Key the report-gap-and-stop branch on the subcommand's EXIT CODE, never on parsing stdout for emptiness — a nonzero exit is unambiguous and cannot be defeated by a stray byte. On a nonzero exit, report the gap to the team-lead and stop — do NOT fall back to a path-less read (that silently re-introduces the off-lead false-empty bug). An unresolved session_dir is a reportable gap, not a degrade-to-implicit case.
Step 1: Task Discovery
You have several sources for finding completed agent tasks. Sources 1 and 2 find tasks that emitted a HANDOFF; source 3 is required for coverage:
Session journal (primary, GC-proof): $SESSION_DIR/session-journal.jsonl (the $SESSION_DIR resolved in Step 0) — read agent_handoff events via the existing session_journal.py read subcommand (explicit --session-dir, masked-read-safe):
EVENTS=$(python3 "{plugin_root}/hooks/shared/session_journal.py" read \
--session-dir "$SESSION_DIR" --type agent_handoff)
read prints a JSON ARRAY to stdout ([ {...}, {...} ]), NOT one JSON object per line. So parse the whole stdout once — json.loads(EVENTS) → a list of event dicts — then iterate the list (do not iterate line-by-line). Each event is {"type": "agent_handoff", "agent": "...", "task_id": "...", "task_subject": "...", "handoff": {...}, "ts": "..."} — full HANDOFF content inline, garbage-collection-proof. Deduplicate: extract unique task_ids only.
TaskList (supplementary): Read TaskList for completed tasks owned by agents. Useful as a cross-reference and for catching tasks where the completion hook didn't fire. Note: the platform garbage-collects older task files during long sessions, so TaskList may be incomplete.
Task-file metadata census (required for coverage): list the team's task files under the task-list directory named in your context (Task list: <abs>) and read the metadata key set of every completed task. A task is in scope when it carries ANY agent-authored key — handoff, handoff_amendment, handoff_addendum, teachback_submit, post_completion_verification, audit_summary, audit_summary_authored, or any key not on this list. Enumerate the keys each task carries; never test for one key. A task whose content sits only in a non-handoff key emits no agent_handoff event, so sources 1 and 2 cannot see it — resolving handoff alone finds nothing on that task and reports success. An auditor carries its findings in audit_summary, mirrored to audit_summary_authored, and its task carries no handoff key at all, so a census that resolves handoff reports an auditor as having produced nothing.
Scope the census by the keys a task carries, never by a key family you expect. A census scoped to one key family fails the same way as one scoped to a single key.
If none of these sources have completed agent tasks, report "No pending HANDOFFs to review" and complete — this is normal when HANDOFFs were already processed by an earlier trigger (idempotent).
Step 2: Dedup Check (Processed Tasks)
A dispatch does not narrow this list. The census finds which tasks carry content; only this ledger says where the last pass stopped, so the delta against it is the population — discard any scope the dispatch suggested. Read your processed task list from your team's section of session_processed_tasks.md, in the agent-memory directory the platform gave you — use the path you are given, never one built from your agent type. The file is namespaced by team — locate only your own ## team={your team_id} section with the Step 8 rule's existence decision: the grep -c header count, what each of its outcomes means for whether your section exists, and the awk extract on the 1 branch (Step 8 also carries the file-format contract). The write actions inside those branches — appending, creating a section — belong to the step that writes, not to this one. Do NOT restate that rule here, and do NOT decide the section exists by searching what you read — a ledger past the read cap hides your section while the file is intact, and a pass that concludes "no processed IDs" reprocesses everything. Your processed set is the UNION of every Processed task IDs line in that section. A section carries many; taking the newest line alone under-reports the set and reprocesses work already done. Skip any task IDs already processed — review only the delta against this ledger. A dispatch that says "harvest the delta" means its own named range; that is a different delta, it is not this one, and it does not narrow this one. This enables incremental passes (e.g., after remediation).
Step 3: Read All HANDOFFs
For each discovered task, read the HANDOFF from the two copies and combine them. This is not a fallback chain. Each copy can hold content the other does not.
- Session journal (GC-proof): If the task was discovered through
agent_handoff journal events, the event's handoff field contains the full HANDOFF content inline. The journal carries the HANDOFF as it stood at the LAST EMITTING WRITE. Do not read it as the accepted HANDOFF. A revision that lands before completion reaches the journal. A revision that lands after completion can reach no journal event, so the journal copy can be the superseded one.
- Task file (freshest, and the task-store drain removes it):
task_utils.read_task_json(task_id, team_name), the raw task JSON, which carries owner, subject and metadata. The HANDOFF is metadata.handoff (TaskGet is metadata-blind). ⚠️ THAT READER RETURNS AN EMPTY DICT ON A MISSING FILE, NOT None. It is fail-open by design, so a drained task file, a malformed JSON file and an IO error all give {}. Test the drained case as EMPTINESS (if not task:), and do NOT test task is None, which is False for every one of those states.
- Take the UNION of the two copies, and PREFER THE TASK FILE on a conflict, with ONE narrow exception. Do not choose one copy. Content moves in the two directions between them, so a union is the safe operation and a choice is not. Keep each field that is in one copy alone. When the two hold different content for one field, keep the task-file content. THE EXCEPTION HAS THREE STATES, NOT TWO. When the task-file field is PRESENT AND EMPTY and the journal field holds content, keep the JOURNAL content. When the task-file field is ABSENT, or PRESENT AND NON-EMPTY, keep the task-file preference. A
TaskUpdate metadata merge REPLACES a nested sub-object, so a partial re-write that carries an empty field makes the present-and-empty state, and an unconditional task-file preference then ERASES the journal content in silence. 🔴 DO NOT restate this as a blanket prefer-the-non-empty-side rule. The exception is per-field and applies to the present-and-empty state alone. A blanket rule makes the journal the default winner and the task file is the freshest copy, which INVERTS the precedence this step is built on.
- When the task file is ABSENT, report
handoff divergence check unavailable for task N: task file absent and use the journal copy alone. Do not skip in silence. A drained task file is a reportable gap and not a clean single-source read.
- Report gap: If no source resolves, report the gap to the lead. Record the task_id, the agent name and the timestamp so that the team-lead has context.
SELECTION, because agent_handoff is a multi-event family. Do not take the first match. The first match is the FIRST emit, which is the superseded copy. Use the rule Step 3.1 uses for snapshots:
- Filter the
agent_handoff events to the matching task_id.
- Group the match by the
(agent, task_subject) identity the event carries. Compute that identity with the shared occupant_hash function and not with a local reimplementation. An agent_handoff event carries no occupant field, so compute the identity from the event's own fields. The platform reuses task_ids across arcs, and a reused task_id carries one identity for each arc. When the group count is more than one, process each group as a distinct work unit. Do not pick one group.
- In each group take the latest
ts, and take the last in journal order on an equal ts (journal events stamp ts at second granularity, so a same-second re-emit ties. The later line in journal order is the authoritative one, the same tie-break Step 3.1 uses).
- The task file belongs to at most one group. Union the task-file copy into the group with the identity
occupant_hash(owner, subject) computed from the task record, and report each other group through the report-message rule below.
🔴 THIS STEP IS THE ONE PLACE THE SELECTION RULE IS WRITTEN, AND IT HAS A SECOND IMPLEMENTATION. hooks/shared/session_resume.py applies the same grouping and latest-wins when it renders its Completed Work summary, and it points here rather than repeat the rule. A change to the selection above MUST change that code in the SAME commit. Nothing compares the two.
DERIVE THE DISK-SIDE IDENTITY THE WAY THE EMIT PATHS DERIVE THEIRS. The two emit paths SUBSTITUTE before they hash, and the task record carries the value the substitution replaced. Compute the disk identity from the SUBSTITUTED values. Without the substitution the identity of a substituted task cannot equal one group identity, so the union does not run on the very tasks it protects:
- Subject term. When the record subject is missing, empty, or whitespace-only, substitute the literal
(no subject) before hashing. The two emit paths both apply this.
- Agent term. When the record owner is missing, empty, or whitespace-only, the
TaskCompleted emit path hashes the platform teammate name for that task in place of the owner. The task record does not carry that name, so this one is NOT reproducible from the record. Report such a task through the report-message rule below and do not read it as a drain.
HOW TO CALL THE TWO NAMES THIS STEP USES. The step above names occupant_hash and the pseudocode below names latest_by_ts_then_journal_order. Neither is callable from a name alone, and the instruction to use the shared function rather than a local one is not followable without a route. DO NOT reimplement either. Use these.
occupant_hash, the SHARED identity hash. This is the one runnable copy in this file. Step 3.1 uses the same command and points here.
python3 -c "import sys; sys.path.insert(0, '{plugin_root}/hooks'); from shared.agent_handoff_marker import occupant_hash; print(occupant_hash(sys.argv[1], sys.argv[2]))" "$AGENT" "$TASK_SUBJECT"
🔴 THE AGENT AND THE SUBJECT MUST RIDE AS ARGV. DO NOT INTERPOLATE THEM INTO THE -c STRING. Read sys.argv[1] and sys.argv[2] as written above, and keep the two values as separate shell arguments after the closing quote. The author of the task writes its subject, so a subject folded into the -c body is Python that the subject author chose, executed by you. Nothing about the values makes this safe: the ARGV BOUNDARY is what makes it safe, and rewriting the command is enough to remove it.
latest_by_ts_then_journal_order, defined HERE because it is defined at no site in the repo. It takes the events of ONE group, in journal order, and returns the authoritative one:
def latest_by_ts_then_journal_order(events):
"""Last write wins. `events` MUST arrive in journal order."""
winner = events[0]
for event in events[1:]:
# `>=` and not `>`: an equal ts keeps the LATER journal line.
if event["ts"] >= winner["ts"]:
winner = event
return winner
🔴 THE STRING COMPARE ON ts IS CORRECT BY A CALLER PROPERTY, NOT BY A SCHEMA PROPERTY, AND THE DIFFERENCE IS THE WHOLE OF THE RISK. make_event stamps %Y-%m-%dT%H:%M:%SZ, and neither agent_handoff emit path passes a ts of its own, so each event of this family carries ONE format and a string compare orders them correctly TODAY. make_event HONOURS a caller-supplied ts through setdefault, so the one-format property belongs to the CALLERS and the schema does not enforce it. A second format defeats the compare in silence, because a MIXED SET IS ORDERED BY BYTES AND NOT BY TIME.
🔴 SO IF ANY CALLER EVER PASSES A ts, PARSE THE TWO VALUES BEFORE YOU COMPARE THEM. Do not compare them as strings, and do not reach for a rule about which byte decides. THIS IS THE LOAD-BEARING SENTENCE OF THIS WARNING AND IT IS THE ONE PART THAT HAS NEEDED NO CORRECTION. Three rounds of review moved the text below it: a claim, then a rule replacing the claim, then a precondition replacing the rule. Each was complete across its own list of spellings and each was broken by the next probe. This sentence survived all of them. A sibling resolver in this repo carries the same warning for the same cause.
WHAT FOLLOWS EXPLAINS THE CONSEQUENCE IF THAT PROPERTY IS LOST. IT IS AN EXPLANATION AND NOT A LAW, AND IT CARRIES A PRECONDITION. A string compare walks left to right, so the DATETIME PREFIX decides first, and AT AN EQUAL DATETIME PREFIX THE COMPARE IS DECIDED BY THE FIRST BYTE AFTER THE SECONDS. MEASURED at one prefix, against the Z form at 0x5A: + at 0x2B, - at 0x2D and . at 0x2E each read as OLDER, a lowercase z at 0x7A reads as NEWER, and a bare form with NO suffix reads oldest of all. So 2026-08-19T20:00:00.123456Z sorts BEFORE 2026-08-19T20:00:00Z while naming the LATER instant. THE FRACTION IS THE MOST REACHABLE OF THESE AND IT TAKES NO DELIBERATE CHOICE: a bare datetime.now(timezone.utc).isoformat() emits microseconds by default, where an offset takes a decision.
🔴 THE PRECONDITION: THAT RULE HOLDS WHERE THE DATETIME PREFIX IS FIXED-WIDTH AND THE SECONDS ARE PRESENT. OUTSIDE THAT, THE DECIDING BYTE IS NOT THE ONE THE RULE NAMES. MEASURED, two forms that break it. 2026-08-19 20:00:00+00:00 against the Z form decides at INDEX 10, a SPACE at 0x20 against T at 0x54, and str(datetime.now(timezone.utc)) produces that form. 2026-08-19T20:00:00.123Z against 2026-08-19T20:00:00.123456Z carries . as the byte after the seconds for BOTH, so the rule answers nothing, and the compare decides at INDEX 23 with the LATER instant reading as older. A timespec='minutes' form has no seconds field at all, so the named position does not exist.
THE BOUND ON THAT LIST, AND IT IS THE CAUSE FOR PUTTING THE PARSE INSTRUCTION FIRST: it comes from a probe of the stdlib producers and one hand-built form, so a producer outside those is outside the check. NO RULE ABOUT WHICH BYTE DECIDES CAN BE MADE COMPLETE, because a new spelling can move the deciding byte to a position no rule anticipated.
🔴 THIS RULE MIRRORS CODE, AND NOTHING ENFORCES THE MIRROR. The two substitutions above are implemented in agent_handoff_emitter and in task_lifecycle_gate._emit_lead_side_agent_handoff. One rule thus lives at THREE sites: two emit paths IMPLEMENT it and this step DESCRIBES it. A change to the identity derivation in either emit path MUST change this step in the SAME commit. The coupling is stated because it cannot be removed here, and one rule at several sites with nothing to say the sites are coupled is what let a repaired rule and a stale rule stand together in this same file.
A NON-MATCHING GROUP IS NOT ALWAYS A DRAIN, AND THE TWO CASES MUST NOT SHARE ONE MESSAGE. Report an ABSENT task file as handoff divergence check unavailable for task N: task file absent. When the task file is PRESENT and no group carries its identity, report handoff identity mismatch for task N: task file present, no event group carries its identity. One message for the two cases makes a present file read as a drained one, which is the strongest signal this workflow emits.
A SUBSTITUTION IS NOT THE ONLY CAUSE OF A MISMATCH, WHICH IS WHY THE SECOND MESSAGE IS NEEDED AND NOT MERELY TIDY. The identity terms are the agent and the subject AS THEY STOOD AT EMIT TIME. An edit to either term after an emit leaves the record holding a value that no group carries. The subject term also reaches the TaskCompleted emit path from the platform payload rather than from the task record. So a mismatch on a present task file is an ordinary state, it is not evidence of a drain, and it is not always closable by the substitution rule above. Report it as itself, and process each group from the journal copy alone.
THIS WORKFLOW DOES NOT RE-SWEEP A TASK IT HAS PROCESSED BEFORE. Step 2 skips each task_id in the processed list, the Incremental Harvest Workflow discovers the delta against that same list, and the Consolidation Harvest Workflow safety net reads only tasks that are not in it. A COMPLETED TASK IS NOT A CLOSED TASK. An agent writes content to a task after the pass that recorded that task as processed, and no trigger in this file collects it.
THE RE-SWEEP IS NOT AN OPTIONAL REFINEMENT, AND HERE IS THE FAILURE IT PREVENTS. A sibling key written after completion has no trigger of its own. A journal snapshot carries such a key only when something else fires a snapshot on that task afterwards, and it carries it as a side effect rather than by design. So on a task that keeps receiving other writes, the journal picks the key up, and on a task that goes quiet, the key is on the task file alone and the task-store drain removes it. The quiet task is the one that looks finished, so the loss is silent and this pass reports success while it happens. On a quiescent completed task, this harvest is the carrier and the journal is not. To collect that content, run an incremental pass before the session ends and put that task_id in its population even though the ledger lists it as processed. Do not edit the ledger to achieve this: writes there are append-only, so an id cannot be taken back out, and it does not need to be — the ledger is a dedup record, not a work queue, so nothing reads it to decide what work remains. The cost is that a re-opened task leaves no durable trace, and that trace has no reader.
Pseudocode for the read. It carries all the selection steps, because a task_id reused across arcs resolves to the wrong arc when any one of them is dropped:
def emit_side_subject(subject):
"""Mirror of the sentinel BOTH emit paths apply before they hash.
Change this when either emit path changes, in the SAME commit."""
return subject if (subject and str(subject).strip()) else "(no subject)"
# The values that count as EMPTY for the exception below. Test MEMBERSHIP,
# not truthiness: `not disk_value` is also TRUE for `0` and for `False`, so a
# task-file field that deliberately holds one of those would keep the journal
# value and discard the disk value.
# KEEP `None` IN THIS SET. Drop it and the narrowing itself opens the erasure
# it was written to close: a field explicitly holding null would count as
# CONTENT and overwrite the journal copy with null.
_EMPTY_VALUES = (None, "", [], {})
def union_preferring_task_file(journal_handoff, disk_handoff):
"""Task file wins each conflict, apart from present-and-empty. Three states."""
merged = {**journal_handoff}
for field, disk_value in disk_handoff.items():
# PRESENT AND EMPTY on the task file, with content in the journal:
# keep the journal value. Each other state: the task file wins.
# A field ABSENT from the task file does not reach this loop, so the
# journal value survives by construction.
if disk_value in _EMPTY_VALUES and journal_handoff.get(field):
continue
merged[field] = disk_value
return merged
for task_id in unprocessed:
matches = [e for e in journal_events if e.task_id == task_id]
# STEP 2: group by identity. An agent_handoff event carries no `occupant`
# field, so compute the identity from the event's own (agent, task_subject)
# with the shared function.
groups = {}
for e in matches:
groups.setdefault(occupant_hash(e.agent, e.task_subject), []).append(e)
# EMPTY DICT once the drain removed it, and NOT None. read_task_json is
# fail-open: a missing file, a malformed file and an IO error all give {}.
task = read_task_json(task_id, team_name)
# Hash the SUBSTITUTED subject, because that is what the emit paths
# hashed. The owner substitution (platform teammate name on an empty
# owner) is NOT reproducible from the record, so a task carrying it
# falls through to the identity-mismatch report below.
# `.get` AND NOT A SUBSCRIPT, AT BOTH KEYS. The guard above tests
# EMPTINESS, which covers a DRAINED record and does NOT cover a PRESENT
# BUT PARTIAL one. MEASURED across the task store: 143 of 1421 records
# carry no `owner` key at all, and THREE of those carry
# `metadata.handoff`, so they are in this harvest target population.
# A subscript raises KeyError on the accurate case rule 2 above tells
# you to route to the identity-mismatch report, because an absent owner
# is what the emit path replaces with the platform name.
# `subject` is missing in 0 of 1421 today, and it takes `.get` anyway:
# read_task_json is FAIL-OPEN, so it returns a SHAPE and not a SCHEMA,
# and a subscript asserts a guarantee that reader does not make.
disk_identity = (
occupant_hash(
task.get("owner", ""), emit_side_subject(task.get("subject", ""))
)
if task else None
)
disk_handoff = (task.get("metadata", {}).get("handoff") if task else None) or {}
if not groups:
if not task:
report_gap(task_id)
else:
process(disk_handoff) # task file only, no journal copy to union
continue
# More than one group means the task_id was reused across arcs. Each group
# is a distinct work unit. Do NOT pick one.
for identity, events in groups.items():
# STEP 3: latest ts, and the line that comes after it on an equal ts.
journal_event = latest_by_ts_then_journal_order(events)
handoff = journal_event.handoff # content at the last emitting write
if identity == disk_identity:
# UNION, task file first, apart from present-and-empty.
handoff = union_preferring_task_file(handoff, disk_handoff)
elif not task:
report(f"handoff divergence check unavailable for task "
f"{task_id}: task file absent")
else:
# The task file is PRESENT and belongs to at most one group.
# This is an identity mismatch, NOT a drain. Do not reuse the
# drain wording for it.
report(f"handoff identity mismatch for task {task_id}: task "
f"file present, no event group carries its identity")
process(handoff)
Read all HANDOFFs before proceeding to extraction.
Step 3.1: Resolve Sibling Metadata (snapshot fallback)
A HANDOFF may reference sibling metadata keys on its task (verification records, parked analyses, teachback history, variety rationales). Those siblings die with the task file when the task store drains — but every completed task's non-handoff metadata is also mirrored into the journal as a task_metadata_snapshot event. Resolve sibling keys through this fallback:
Task file (freshest, and it can be drained): task_utils.read_task_json(task_id, team_name).get("metadata", {}), the raw task JSON's metadata object, the same accessor the Step 3 pseudocode uses. That reader returns an EMPTY DICT on a missing file, so a drained task gives {} rather than a raise, and the fallback below is what covers it.
Snapshot fallback (GC-proof): read the mirrored snapshots via the existing subcommand (explicit --session-dir, masked-read-safe):
SNAPSHOTS=$(python3 "{plugin_root}/hooks/shared/session_journal.py" read \
--session-dir "$SESSION_DIR" --type task_metadata_snapshot)
As in Step 1, read prints a JSON ARRAY — parse the whole stdout once, then iterate. Each event carries task_id, metadata (the size-bounded sibling-key payload), subject, occupant, and optionally owner / task_type / truncated. Selection: filter to events with the matching task_id; because the platform reuses task_ids across arcs, when you are resolving siblings FOR an agent_handoff event, additionally filter to events whose occupant equals occupant_hash(agent, task_subject) computed from that handoff event's own fields with the SAME shared function — never a local reimplementation. The runnable command is in Step 3, under HOW TO CALL THE TWO NAMES THIS STEP USES. It is stated once, there, and referenced here. Aggregate (whole-arc) reads apply the arc-scoped --since bound first, exactly as Step 10 does for variety_assessed. Take the latest-ts event within the match, last-wins on an equal ts (journal events stamp ts at second granularity, so a same-second re-emit ties; the later line in journal order is the authoritative one — the same tie-break the artifact-paths supersede uses) — a task may legally carry multiple snapshots (a changed payload after completion re-emits; the latest is the authoritative end-state). A value of shape {"_truncated": true, ...} or a top-level _dropped_keys list means the full value lived only in the task file — note the truncation in your synthesis, don't fake the missing content.
Graceful degrade: neither source resolves → record the gap (task_id, key, timestamp) exactly as Step 3's report-gap tier does; never invent content.
SIBLING KEY NAMES CARRY NO ORDER. A task commonly carries a family of related sibling keys, and their names do not record which one is newest. A key that names itself final can be the earlier of two. Do not sort sibling keys by name, and do not read a name as a position. ONE NAME DOES CARRY A RELATION, and it is not a position: a sibling key that amends, corrects or withdraws HANDOFF content SUPERSEDES what it addresses, whatever its date. Synthesize from the amended state, and never bank a claim an amendment withdrew. Resolve the order of the rest in this priority:
- Use a write-time field the key itself carries, for example
written_at_utc. This is the only self-dating source, and it is present only when the agent that wrote the key chose to record it.
- Take no other date in the payload as the write time. A date in the body commonly dates a different event, for example a read of some other file or a measurement of a document. A wrong timestamp is worse than an absent one, because it is executable and produces a confident wrong order.
- When no write-time field is present, the set is unordered. Read every member of the family, synthesize from all of them, and report the ambiguity. Do not present one member as the end state, and do not drop the members you cannot date.
Step 3.5: Resolve and Read Phase Artifacts (always)
Each phase's HANDOFF is the distilled frame; the phase's disk artifact (e.g. docs/preparation/{feature}.md, docs/architecture/{feature}.md, docs/plans/{slug}-plan.md, docs/review/…) is the fuller substance. The lead writes a path-only artifact_paths journal event pointing at each phase's artifact(s); that event lives in the journal (outside any worktree), so it survives git worktree remove even though the pointed-at file is worktree-ephemeral. Always resolve these events and fold the artifact substance into the same synthesis the HANDOFF drives.
Build the feature set from the journal, the same way Step 1 builds the task population. resolve-artifacts requires a --feature value and nothing in your context carries one. Do not take it from your dispatch: each artifact_paths event names its own feature, so the distinct values across those events ARE the set, and reading them is one command:
FEATURES=$(python3 "{plugin_root}/hooks/shared/session_journal.py" read \
--session-dir "$SESSION_DIR" --type artifact_paths \
| python3 -c "import json,sys; print('\n'.join(sorted({e['feature'] for e in json.load(sys.stdin) if e.get('feature')})))")
No events, or none carrying a feature → the set is empty, there is nothing to resolve, and that is a normal result rather than a gap.
Resolve AND READ, one feature at a time. Both happen INSIDE the loop (masked-read-safe — uses the Step 0 $SESSION_DIR). The resolve-artifacts subcommand reads the artifact_paths events and applies the supersede-by-(workflow, feature)-latest-ts dedup for you:
while IFS= read -r FEATURE; do
[ -n "$FEATURE" ] || continue
ARTIFACTS=$(python3 "{plugin_root}/hooks/shared/pact_harvest.py" resolve-artifacts \
--session-dir "$SESSION_DIR" --feature "$FEATURE")
# stdout is a single-line JSON object {workflow: [abs_path, ...]}, e.g.:
# {"prepare":["/abs/docs/preparation/$FEATURE.md"],"architect":["/abs/docs/architecture/$FEATURE.md"]}
# Empty (no artifacts for this feature) -> {}. Parse with json.loads, iterate keys.
# READ the paths HERE, and keep what you read UNDER THIS FEATURE.
done <<< "$FEATURES"
🔴 $ARTIFACTS IS OVERWRITTEN ON EVERY ITERATION AND NOTHING WARNS YOU. Read its paths before the next iteration begins. After done it holds the LAST feature alone, so a read placed after the loop harvests one feature, discards every other, and reports success.
🔴 DO NOT MERGE THE PER-FEATURE OBJECTS INTO ONE. The object is keyed by workflow ALONE: the feature is consumed by the --feature filter and never appears in the result. Every feature runs the same phases, so two features collide on MOST of their keys, and a merge keeps one path-list per workflow while looking perfectly well-formed. Keep the results separate, keyed by feature.
Read the loop variable, never a word-split. A feature slug can carry a space, and for FEATURE in $FEATURES would split one such slug into two names that resolve to nothing.
Paths are full-absolute; read them while the worktree is live (the worktree-cleanup harvest-before-teardown guard guarantees this ordering at the single teardown chokepoint). If a path no longer resolves (file already gone — the accepted abnormal-teardown edge), skip it, note the gap, and degrade to HANDOFF-only for that artifact.
The subcommand already filters to this feature, groups by workflow, takes the latest-ts event per (workflow, feature), and returns only the resolved set. Each artifact_paths event carries the COMPLETE path-list for its (workflow, feature) (a full enumeration per emit, not a delta), so the latest event is self-sufficient — the supersede never merges across events. Result: one path-list per workflow, FOR THIS ONE FEATURE.
Synthesize ONE entry from BOTH sources together (NOT verbatim, NOT a second entry). For each work unit, produce a SINGLE pact-memory entry synthesized from the HANDOFF and its artifact: the artifact is the fuller substance, the HANDOFF is the distilled frame. A work unit is an agent_handoff group from Step 3, not a feature — one feature carries many work units, so the artifacts a unit draws on are the ones you read under THAT UNIT'S feature. A ~19 KB artifact becomes a richer-but-bounded entry (a few hundred tokens of decisions/lessons informed by the full substance) — do NOT store the raw artifact. Substance flows into the entry's context/decisions; put the artifact's path in an entity notes field (NOT a files field — that field is rejected on save).
Dedup — reuse the existing mechanism; do NOT invent a content-diff. Against existing memory: the Step 6 save-vs-update entity+topic protocol, unchanged — the synthesized HANDOFF+artifact entry enriches an existing entry exactly as a HANDOFF-only entry does. Against the HANDOFF's own content: the only new rule is sequencing — because step 3 synthesizes the HANDOFF and artifact into ONE entry, there is no separate artifact-entry to dedup; the single synthesis IS the dedup. (Idempotency: the existing processed-task ledger of Step 2/Step 8 extends to mark a (workflow, feature) artifact as read, so an incremental or consolidation re-harvest does not re-read and re-distill the same artifact.)
Step 4: Extract Institutional Knowledge
Focus on:
- Architectural decisions with rationale
- Cross-cutting concerns that affect multiple components
- Stakeholder decisions (user-specified constraints or preferences)
- Patterns established that future work should follow
- Integration points between components
- Risks and uncertainties that warrant tracking
Step 5: Capture Organizational State
Alongside institutional
…(truncated)
1---2name: pact-handoff-harvest3description: HANDOFF discovery, review, save, and ledger-record workflow for the PACT secretary. Use when: processing agent HANDOFFs after workflow phases, running session consolidation, or recovering orphaned completed handoffs from prior sessions. Triggers: harvest HANDOFFs, process HANDOFFs, incremental, consolidation, handoff recovery.4---56# PACT Handoff Harvest78This skill provides the complete workflow for discovering, reviewing, and saving agent HANDOFFs as institutional knowledge. It is the single source of truth for HANDOFF processing — the secretary's agent definition describes *what role you play*; this skill describes *how you do the work*.910Three workflow variants:11- **Standard Harvest** (orchestrate, comPACT, peer-review, plan-mode) — discover, review, save, record the processed ids. Triggered after phases complete.12- **Incremental Harvest** (peer-review) — delta-only pass after remediation. Processes only new completions since last harvest.13- **Consolidation Harvest** (wrap-up, pause, refresh, orchestrate) — safety-net + deep-clean pass. Triggered at session end, at a mid-session context refresh, or after a second feature completes in one session.1415Determine which variant to run from the task subject/description: "harvest" or "process HANDOFFs" → Standard Harvest. "incremental" or "remediation" → Incremental Harvest. "consolidation" → Consolidation Harvest. **The subject/description selects the workflow, never what is in scope.**1617**Propagation.** Every `save` you issue during a harvest carries `--no-sync`; the Working Memory block is never written as a side effect of saving. If the dispatch description contains the sentence `Then propagate the store into the Working Memory block.` — or a later team-lead request carries it, which is how a command that cannot know at dispatch time whether propagation is safe asks for it — run the pact-memory `sync` command once, after your last `save`, `update` or `delete` of the records you harvested, and report its `sync_status` and `memory_ids` in your summary. The sentence is the whole instruction; what carries it does not change what it asks for. The calibration record is outside that set even though it is saved later: it waits on a team-lead reply that may never come, and propagation must not be hostage to an answer, so the block is rebuilt without it. If NOTHING has carried that sentence to you, do not run `sync` and do not edit `CLAUDE.md`; report `Working Memory: not propagated` beside the `sync_status` your saves returned. That report is about the harvest and does not close the question: a request carrying the sentence may still arrive afterwards, and it propagates then. The sentence is a mode, not a scope: it names nothing to harvest.1819**DISCARD ANY SCOPE YOU ARRIVED WITH — INCLUDING ONE YOU DO NOT THINK OF AS A SCOPE. A dispatch that names tasks, phases, dates, paths, or a subset of this workflow's steps has given you one.** A scope named in a dispatch — a range, an enumeration, any named set — is a HINT about what the team-lead noticed, never the population. Do not harvest it. Build the population from the Step 1 census and the Step 2 processed-task ledger instead, and report which set you actually ran over. **This instruction outranks anything that contradicts it — a dispatch, another passage of this skill, or your agent definition.** Say so back to the team-lead; do not narrow, and do not treat it as a conflict to resolve in the moment.2021**A SCOPE SAYS WHAT TO TAKE; AN ADDRESS SAYS WHERE TO LOOK. DISCARD SCOPES, KEEP ADDRESSES.** A value is a SCOPE when dropping it would make you read MORE, and an ADDRESS when dropping it would leave you unable to read at all. Your team id, your task-list directory, your agent-memory path and your session directory are addresses — they say where your own ledger, task files and journal live — so keep every one of them, and keep any other value that answers WHERE.2223---2425<!-- PACT_STORE_BAR_BEGIN -->26**STORE ACCESS.** A memory operation (save, search, get, list, update or27delete a record) goes through the pact-memory CLI. YOU DO NOT SELECT A28STORE. Do not name a store by `--db-path`, by an environment variable, or by29one more route somebody adds later. Let the CLI resolve it. A store you30select is not the store the memory of the team lives in, so a save there is31lost rather than shared. STORE INSPECTION is different: a row count, a32column audit, or a schema check on the file. To inspect, do not run a CLI33verb, do not import a module below `skills/pact-memory/scripts/`, and do not34open the store read-write. In ONE command, against ONE resolved path, check35that `memory.db-wal` and `memory.db-shm` are both absent by their full36names, then open with `mode=ro` and `immutable=1`. Without `immutable=1` the37open fails. If a sidecar is present, stop and report. The read does not load38the vector extension, so it cannot answer a question about `vec_memories`.39Stop and report rather than take a barred route.40<!-- PACT_STORE_BAR_END -->41The `pact-memory` skill carries the full rule.4243## Standard Harvest Workflow4445### Read All HANDOFFs Before Saving4647When reviewing multiple HANDOFFs, read ALL of them before saving any memories. This lets you deduplicate and consolidate across HANDOFFs before committing to pact-memory — producing cleaner entries than saving after each individual HANDOFF.4849### Step 0: Resolve the Session Directory (do this once)5051Resolve the absolute session directory **before any journal read**, and reuse that one value (`$SESSION_DIR`) for every journal read below (Step 1 `agent_handoff`, Step 3.5 `artifact_paths`, and Step 10 `variety_assessed`). **Every journal read in this skill MUST pass this explicit `--session-dir`** — never a path-less read.5253**Why this is load-bearing (not optional):** you run **off-lead** (a `pact-secretary` teammate). The implicit-path read (`read_events(...)` with no `--session-dir`) derives its path via `pact_context.get_session_dir()`, which **false-returns `''` in a teammate frame** (no persisted lead session context) → the read silently returns **0 events**. Off-lead, that would make the entire harvest — HANDOFF discovery, artifact recovery, and calibration — a silent no-op. Passing an explicit `--session-dir` is frame-independent and masked-read-safe.5455Resolve the directory with the `pact_harvest.py resolve-session-dir` subcommand, which reads `pact-session-context.json` and routes the reconstruction through the SSOT helper `reconstruct_session_dir` (it sanitizes both the slug and the `session_id` the same way the writer did, so the reconstructed path cannot drift from where the journal was actually written — a hand-built `{slug}/{session_id}` join would land on a DIFFERENT directory whenever the project basename or `session_id` contains a non-`[A-Za-z0-9_-]` character):5657```bash58if ! SESSION_DIR=$(python3 "{plugin_root}/hooks/shared/pact_harvest.py" \59 resolve-session-dir --context-file "{context_file}"); then60 # Nonzero exit (2) = unresolvable: context file missing/unreadable/invalid,61 # or reconstruct_session_dir returned ''. Report the gap to the team-lead62 # and STOP — do NOT proceed to any journal read.63 echo "HARVEST GAP: could not resolve session_dir; reporting and stopping." >&264fi65# On success $SESSION_DIR holds the absolute session dir; reuse it for every read below.66```6768**Key the report-gap-and-stop branch on the subcommand's EXIT CODE**, never on parsing stdout for emptiness — a nonzero exit is unambiguous and cannot be defeated by a stray byte. On a nonzero exit, **report the gap to the team-lead and stop** — do NOT fall back to a path-less read (that silently re-introduces the off-lead false-empty bug). An unresolved `session_dir` is a reportable gap, not a degrade-to-implicit case.6970### Step 1: Task Discovery7172You have several sources for finding completed agent tasks. Sources 1 and 2 find tasks that emitted a HANDOFF; source 3 is required for coverage:73741. **Session journal** (primary, GC-proof): `$SESSION_DIR/session-journal.jsonl` (the `$SESSION_DIR` resolved in Step 0) — read `agent_handoff` events via the existing `session_journal.py read` subcommand (explicit `--session-dir`, masked-read-safe):7576 ```bash77 EVENTS=$(python3 "{plugin_root}/hooks/shared/session_journal.py" read \78 --session-dir "$SESSION_DIR" --type agent_handoff)79 ```8081 `read` prints a **JSON ARRAY** to stdout (`[ {...}, {...} ]`), NOT one JSON object per line. So parse the whole stdout once — `json.loads(EVENTS)` → a list of event dicts — then iterate the list (do **not** iterate line-by-line). Each event is `{"type": "agent_handoff", "agent": "...", "task_id": "...", "task_subject": "...", "handoff": {...}, "ts": "..."}` — full HANDOFF content inline, garbage-collection-proof. **Deduplicate**: extract unique task_ids only.822. **`TaskList`** (supplementary): Read `TaskList` for completed tasks owned by agents. Useful as a cross-reference and for catching tasks where the completion hook didn't fire. Note: the platform garbage-collects older task files during long sessions, so `TaskList` may be incomplete.833. **Task-file metadata census** (required for coverage): list the team's task files under the task-list directory named in your context (`Task list: <abs>`) and read the **metadata key set** of every completed task. A task is in scope when it carries ANY agent-authored key — `handoff`, `handoff_amendment`, `handoff_addendum`, `teachback_submit`, `post_completion_verification`, `audit_summary`, `audit_summary_authored`, or any key not on this list. **Enumerate the keys each task carries; never test for one key.** A task whose content sits only in a non-`handoff` key emits no `agent_handoff` event, so sources 1 and 2 cannot see it — resolving `handoff` alone finds nothing on that task and reports success. An auditor carries its findings in `audit_summary`, mirrored to `audit_summary_authored`, and its task carries no `handoff` key at all, so a census that resolves `handoff` reports an auditor as having produced nothing.8485Scope the census by the keys a task **carries**, never by a key family you expect. A census scoped to one key family fails the same way as one scoped to a single key.8687If none of these sources have completed agent tasks, report "No pending HANDOFFs to review" and complete — this is normal when HANDOFFs were already processed by an earlier trigger (idempotent).8889### Step 2: Dedup Check (Processed Tasks)9091**A dispatch does not narrow this list.** The census finds which tasks carry content; only this ledger says where the last pass stopped, so the delta against it is the population — discard any scope the dispatch suggested. Read your processed task list from your team's section of `session_processed_tasks.md`, in the agent-memory directory the platform gave you — use the path you are given, never one built from your agent type. The file is namespaced by team — locate **only** your own `## team={your team_id}` section with the **Step 8 rule's existence decision**: the `grep -c` header count, what each of its outcomes means for whether your section exists, and the `awk` extract on the `1` branch (Step 8 also carries the file-format contract). **The write actions inside those branches — appending, creating a section — belong to the step that writes, not to this one.** Do NOT restate that rule here, and do NOT decide the section exists by searching what you read — a ledger past the read cap hides your section while the file is intact, and a pass that concludes "no processed IDs" reprocesses everything. **Your processed set is the UNION of every `Processed task IDs` line in that section.** A section carries many; taking the newest line alone under-reports the set and reprocesses work already done. Skip any task IDs already processed — review only the delta **against this ledger**. A dispatch that says "harvest the delta" means its own named range; that is a different delta, it is not this one, and it does not narrow this one. This enables incremental passes (e.g., after remediation).9293### Step 3: Read All HANDOFFs9495For each discovered task, read the HANDOFF from the two copies and combine them. This is **not** a fallback chain. Each copy can hold content the other does not.96971. **Session journal** (GC-proof): If the task was discovered through `agent_handoff` journal events, the event's `handoff` field contains the full HANDOFF content inline. **The journal carries the HANDOFF as it stood at the LAST EMITTING WRITE.** Do not read it as the accepted HANDOFF. A revision that lands before completion reaches the journal. A revision that lands after completion can reach no journal event, so the journal copy can be the superseded one.982. **Task file** (freshest, and the task-store drain removes it): `task_utils.read_task_json(task_id, team_name)`, the raw task JSON, which carries `owner`, `subject` and `metadata`. The HANDOFF is `metadata.handoff` (`TaskGet` is metadata-blind). ⚠️ **THAT READER RETURNS AN EMPTY DICT ON A MISSING FILE, NOT `None`.** It is fail-open by design, so a drained task file, a malformed JSON file and an IO error all give `{}`. Test the drained case as EMPTINESS (`if not task:`), and do NOT test `task is None`, which is False for every one of those states.993. **Take the UNION of the two copies, and PREFER THE TASK FILE on a conflict, with ONE narrow exception.** Do not choose one copy. Content moves in the two directions between them, so a union is the safe operation and a choice is not. Keep each field that is in one copy alone. When the two hold different content for one field, keep the task-file content. **THE EXCEPTION HAS THREE STATES, NOT TWO.** When the task-file field is PRESENT AND EMPTY and the journal field holds content, keep the JOURNAL content. When the task-file field is ABSENT, or PRESENT AND NON-EMPTY, keep the task-file preference. A `TaskUpdate` metadata merge REPLACES a nested sub-object, so a partial re-write that carries an empty field makes the present-and-empty state, and an unconditional task-file preference then ERASES the journal content in silence. 🔴 **DO NOT restate this as a blanket prefer-the-non-empty-side rule.** The exception is per-field and applies to the present-and-empty state alone. A blanket rule makes the journal the default winner and the task file is the freshest copy, which INVERTS the precedence this step is built on.1004. **When the task file is ABSENT, report `handoff divergence check unavailable for task N: task file absent`** and use the journal copy alone. Do not skip in silence. A drained task file is a reportable gap and not a clean single-source read.1015. **Report gap**: If no source resolves, report the gap to the lead. Record the task_id, the agent name and the timestamp so that the team-lead has context.102103**SELECTION, because `agent_handoff` is a multi-event family.** Do not take the first match. The first match is the FIRST emit, which is the superseded copy. Use the rule Step 3.1 uses for snapshots:1041051. Filter the `agent_handoff` events to the matching `task_id`.1062. Group the match by the `(agent, task_subject)` identity the event carries. Compute that identity with the shared `occupant_hash` function and not with a local reimplementation. An `agent_handoff` event carries no `occupant` field, so compute the identity from the event's own fields. The platform reuses task_ids across arcs, and a reused task_id carries one identity for each arc. When the group count is more than one, process each group as a distinct work unit. Do not pick one group.1073. In each group take the **latest `ts`**, and take the **last in journal order on an equal `ts`** (journal events stamp `ts` at second granularity, so a same-second re-emit ties. The later line in journal order is the authoritative one, the same tie-break Step 3.1 uses).1084. The task file belongs to at most one group. Union the task-file copy into the group with the identity `occupant_hash(owner, subject)` computed from the task record, and report each other group through the report-message rule below.109110🔴 **THIS STEP IS THE ONE PLACE THE SELECTION RULE IS WRITTEN, AND IT HAS A SECOND IMPLEMENTATION.** `hooks/shared/session_resume.py` applies the same grouping and latest-wins when it renders its Completed Work summary, and it points here rather than repeat the rule. A change to the selection above MUST change that code in the SAME commit. Nothing compares the two.111112**DERIVE THE DISK-SIDE IDENTITY THE WAY THE EMIT PATHS DERIVE THEIRS.** The two emit paths SUBSTITUTE before they hash, and the task record carries the value the substitution replaced. Compute the disk identity from the SUBSTITUTED values. Without the substitution the identity of a substituted task cannot equal one group identity, so the union does not run on the very tasks it protects:1131141. **Subject term.** When the record subject is missing, empty, or whitespace-only, substitute the literal `(no subject)` before hashing. The two emit paths both apply this.1152. **Agent term.** When the record owner is missing, empty, or whitespace-only, the `TaskCompleted` emit path hashes the platform teammate name for that task in place of the owner. The task record does not carry that name, so this one is NOT reproducible from the record. Report such a task through the report-message rule below and do not read it as a drain.116117**HOW TO CALL THE TWO NAMES THIS STEP USES.** The step above names `occupant_hash` and the pseudocode below names `latest_by_ts_then_journal_order`. Neither is callable from a name alone, and the instruction to use the shared function rather than a local one is not followable without a route. **DO NOT reimplement either. Use these.**1181191. **`occupant_hash`**, the SHARED identity hash. This is the one runnable copy in this file. Step 3.1 uses the same command and points here.120121 ```bash122 python3 -c "import sys; sys.path.insert(0, '{plugin_root}/hooks'); from shared.agent_handoff_marker import occupant_hash; print(occupant_hash(sys.argv[1], sys.argv[2]))" "$AGENT" "$TASK_SUBJECT"123 ```124125 🔴 **THE AGENT AND THE SUBJECT MUST RIDE AS ARGV. DO NOT INTERPOLATE THEM INTO THE `-c` STRING.** Read `sys.argv[1]` and `sys.argv[2]` as written above, and keep the two values as separate shell arguments after the closing quote. The author of the task writes its subject, so a subject folded into the `-c` body is Python that the subject author chose, executed by you. Nothing about the values makes this safe: the ARGV BOUNDARY is what makes it safe, and rewriting the command is enough to remove it.1261272. **`latest_by_ts_then_journal_order`**, defined HERE because it is defined at no site in the repo. It takes the events of ONE group, in journal order, and returns the authoritative one:128129 ```python130 def latest_by_ts_then_journal_order(events):131 """Last write wins. `events` MUST arrive in journal order."""132 winner = events[0]133 for event in events[1:]:134 # `>=` and not `>`: an equal ts keeps the LATER journal line.135 if event["ts"] >= winner["ts"]:136 winner = event137 return winner138 ```139140 🔴 **THE STRING COMPARE ON `ts` IS CORRECT BY A CALLER PROPERTY, NOT BY A SCHEMA PROPERTY, AND THE DIFFERENCE IS THE WHOLE OF THE RISK.** `make_event` stamps `%Y-%m-%dT%H:%M:%SZ`, and neither `agent_handoff` emit path passes a `ts` of its own, so each event of this family carries ONE format and a string compare orders them correctly TODAY. `make_event` HONOURS a caller-supplied `ts` through `setdefault`, so the one-format property belongs to the CALLERS and the schema does not enforce it. A second format defeats the compare in silence, because a MIXED SET IS ORDERED BY BYTES AND NOT BY TIME.141142 🔴 **SO IF ANY CALLER EVER PASSES A `ts`, PARSE THE TWO VALUES BEFORE YOU COMPARE THEM. Do not compare them as strings, and do not reach for a rule about which byte decides.** THIS IS THE LOAD-BEARING SENTENCE OF THIS WARNING AND IT IS THE ONE PART THAT HAS NEEDED NO CORRECTION. Three rounds of review moved the text below it: a claim, then a rule replacing the claim, then a precondition replacing the rule. Each was complete across its own list of spellings and each was broken by the next probe. This sentence survived all of them. A sibling resolver in this repo carries the same warning for the same cause.143144 **WHAT FOLLOWS EXPLAINS THE CONSEQUENCE IF THAT PROPERTY IS LOST. IT IS AN EXPLANATION AND NOT A LAW, AND IT CARRIES A PRECONDITION.** A string compare walks left to right, so the DATETIME PREFIX decides first, and AT AN EQUAL DATETIME PREFIX THE COMPARE IS DECIDED BY THE FIRST BYTE AFTER THE SECONDS. MEASURED at one prefix, against the `Z` form at 0x5A: `+` at 0x2B, `-` at 0x2D and `.` at 0x2E each read as OLDER, a lowercase `z` at 0x7A reads as NEWER, and a bare form with NO suffix reads oldest of all. So `2026-08-19T20:00:00.123456Z` sorts BEFORE `2026-08-19T20:00:00Z` while naming the LATER instant. THE FRACTION IS THE MOST REACHABLE OF THESE AND IT TAKES NO DELIBERATE CHOICE: a bare `datetime.now(timezone.utc).isoformat()` emits microseconds by default, where an offset takes a decision.145146 🔴 **THE PRECONDITION: THAT RULE HOLDS WHERE THE DATETIME PREFIX IS FIXED-WIDTH AND THE SECONDS ARE PRESENT. OUTSIDE THAT, THE DECIDING BYTE IS NOT THE ONE THE RULE NAMES.** MEASURED, two forms that break it. `2026-08-19 20:00:00+00:00` against the `Z` form decides at INDEX 10, a SPACE at 0x20 against `T` at 0x54, and `str(datetime.now(timezone.utc))` produces that form. `2026-08-19T20:00:00.123Z` against `2026-08-19T20:00:00.123456Z` carries `.` as the byte after the seconds for BOTH, so the rule answers nothing, and the compare decides at INDEX 23 with the LATER instant reading as older. A `timespec='minutes'` form has no seconds field at all, so the named position does not exist.147 THE BOUND ON THAT LIST, AND IT IS THE CAUSE FOR PUTTING THE PARSE INSTRUCTION FIRST: it comes from a probe of the stdlib producers and one hand-built form, so a producer outside those is outside the check. NO RULE ABOUT WHICH BYTE DECIDES CAN BE MADE COMPLETE, because a new spelling can move the deciding byte to a position no rule anticipated.148149🔴 **THIS RULE MIRRORS CODE, AND NOTHING ENFORCES THE MIRROR.** The two substitutions above are implemented in `agent_handoff_emitter` and in `task_lifecycle_gate._emit_lead_side_agent_handoff`. One rule thus lives at THREE sites: two emit paths IMPLEMENT it and this step DESCRIBES it. **A change to the identity derivation in either emit path MUST change this step in the SAME commit.** The coupling is stated because it cannot be removed here, and one rule at several sites with nothing to say the sites are coupled is what let a repaired rule and a stale rule stand together in this same file.150151**A NON-MATCHING GROUP IS NOT ALWAYS A DRAIN, AND THE TWO CASES MUST NOT SHARE ONE MESSAGE.** Report an ABSENT task file as `handoff divergence check unavailable for task N: task file absent`. When the task file is PRESENT and no group carries its identity, report `handoff identity mismatch for task N: task file present, no event group carries its identity`. One message for the two cases makes a present file read as a drained one, which is the strongest signal this workflow emits.152153**A SUBSTITUTION IS NOT THE ONLY CAUSE OF A MISMATCH, WHICH IS WHY THE SECOND MESSAGE IS NEEDED AND NOT MERELY TIDY.** The identity terms are the agent and the subject AS THEY STOOD AT EMIT TIME. An edit to either term after an emit leaves the record holding a value that no group carries. The subject term also reaches the `TaskCompleted` emit path from the platform payload rather than from the task record. So a mismatch on a present task file is an ordinary state, it is not evidence of a drain, and it is not always closable by the substitution rule above. Report it as itself, and process each group from the journal copy alone.154155**THIS WORKFLOW DOES NOT RE-SWEEP A TASK IT HAS PROCESSED BEFORE.** Step 2 skips each task_id in the processed list, the Incremental Harvest Workflow discovers the delta against that same list, and the Consolidation Harvest Workflow safety net reads only tasks that are not in it. **A COMPLETED TASK IS NOT A CLOSED TASK.** An agent writes content to a task after the pass that recorded that task as processed, and no trigger in this file collects it.156157**THE RE-SWEEP IS NOT AN OPTIONAL REFINEMENT, AND HERE IS THE FAILURE IT PREVENTS.** A sibling key written after completion has no trigger of its own. A journal snapshot carries such a key only when something else fires a snapshot on that task afterwards, and it carries it as a side effect rather than by design. **So on a task that keeps receiving other writes, the journal picks the key up, and on a task that goes quiet, the key is on the task file alone and the task-store drain removes it.** The quiet task is the one that looks finished, so the loss is silent and this pass reports success while it happens. **On a quiescent completed task, this harvest is the carrier and the journal is not.** To collect that content, run an incremental pass **before the session ends** and put that task_id in its population even though the ledger lists it as processed. **Do not edit the ledger to achieve this**: writes there are append-only, so an id cannot be taken back out, and it does not need to be — the ledger is a dedup record, not a work queue, so nothing reads it to decide what work remains. The cost is that a re-opened task leaves no durable trace, and that trace has no reader.158159Pseudocode for the read. It carries all the selection steps, because a task_id reused across arcs resolves to the wrong arc when any one of them is dropped:160161```python162def emit_side_subject(subject):163 """Mirror of the sentinel BOTH emit paths apply before they hash.164 Change this when either emit path changes, in the SAME commit."""165 return subject if (subject and str(subject).strip()) else "(no subject)"166167168# The values that count as EMPTY for the exception below. Test MEMBERSHIP,169# not truthiness: `not disk_value` is also TRUE for `0` and for `False`, so a170# task-file field that deliberately holds one of those would keep the journal171# value and discard the disk value.172# KEEP `None` IN THIS SET. Drop it and the narrowing itself opens the erasure173# it was written to close: a field explicitly holding null would count as174# CONTENT and overwrite the journal copy with null.175_EMPTY_VALUES = (None, "", [], {})176177178def union_preferring_task_file(journal_handoff, disk_handoff):179 """Task file wins each conflict, apart from present-and-empty. Three states."""180 merged = {**journal_handoff}181 for field, disk_value in disk_handoff.items():182 # PRESENT AND EMPTY on the task file, with content in the journal:183 # keep the journal value. Each other state: the task file wins.184 # A field ABSENT from the task file does not reach this loop, so the185 # journal value survives by construction.186 if disk_value in _EMPTY_VALUES and journal_handoff.get(field):187 continue188 merged[field] = disk_value189 return merged190191192for task_id in unprocessed:193 matches = [e for e in journal_events if e.task_id == task_id]194195 # STEP 2: group by identity. An agent_handoff event carries no `occupant`196 # field, so compute the identity from the event's own (agent, task_subject)197 # with the shared function.198 groups = {}199 for e in matches:200 groups.setdefault(occupant_hash(e.agent, e.task_subject), []).append(e)201202 # EMPTY DICT once the drain removed it, and NOT None. read_task_json is203 # fail-open: a missing file, a malformed file and an IO error all give {}.204 task = read_task_json(task_id, team_name)205 # Hash the SUBSTITUTED subject, because that is what the emit paths206 # hashed. The owner substitution (platform teammate name on an empty207 # owner) is NOT reproducible from the record, so a task carrying it208 # falls through to the identity-mismatch report below.209 # `.get` AND NOT A SUBSCRIPT, AT BOTH KEYS. The guard above tests210 # EMPTINESS, which covers a DRAINED record and does NOT cover a PRESENT211 # BUT PARTIAL one. MEASURED across the task store: 143 of 1421 records212 # carry no `owner` key at all, and THREE of those carry213 # `metadata.handoff`, so they are in this harvest target population.214 # A subscript raises KeyError on the accurate case rule 2 above tells215 # you to route to the identity-mismatch report, because an absent owner216 # is what the emit path replaces with the platform name.217 # `subject` is missing in 0 of 1421 today, and it takes `.get` anyway:218 # read_task_json is FAIL-OPEN, so it returns a SHAPE and not a SCHEMA,219 # and a subscript asserts a guarantee that reader does not make.220 disk_identity = (221 occupant_hash(222 task.get("owner", ""), emit_side_subject(task.get("subject", ""))223 )224 if task else None225 )226 disk_handoff = (task.get("metadata", {}).get("handoff") if task else None) or {}227228 if not groups:229 if not task:230 report_gap(task_id)231 else:232 process(disk_handoff) # task file only, no journal copy to union233 continue234235 # More than one group means the task_id was reused across arcs. Each group236 # is a distinct work unit. Do NOT pick one.237 for identity, events in groups.items():238 # STEP 3: latest ts, and the line that comes after it on an equal ts.239 journal_event = latest_by_ts_then_journal_order(events)240 handoff = journal_event.handoff # content at the last emitting write241 if identity == disk_identity:242 # UNION, task file first, apart from present-and-empty.243 handoff = union_preferring_task_file(handoff, disk_handoff)244 elif not task:245 report(f"handoff divergence check unavailable for task "246 f"{task_id}: task file absent")247 else:248 # The task file is PRESENT and belongs to at most one group.249 # This is an identity mismatch, NOT a drain. Do not reuse the250 # drain wording for it.251 report(f"handoff identity mismatch for task {task_id}: task "252 f"file present, no event group carries its identity")253 process(handoff)254```255256Read all HANDOFFs before proceeding to extraction.257258### Step 3.1: Resolve Sibling Metadata (snapshot fallback)259260A HANDOFF may reference sibling metadata keys on its task (verification records, parked analyses, teachback history, variety rationales). Those siblings die with the task file when the task store drains — but every completed task's non-handoff metadata is also mirrored into the journal as a `task_metadata_snapshot` event. Resolve sibling keys through this fallback:2612621. **Task file** (freshest, and it can be drained): `task_utils.read_task_json(task_id, team_name).get("metadata", {})`, the raw task JSON's `metadata` object, the same accessor the Step 3 pseudocode uses. That reader returns an EMPTY DICT on a missing file, so a drained task gives `{}` rather than a raise, and the fallback below is what covers it.2632. **Snapshot fallback** (GC-proof): read the mirrored snapshots via the existing subcommand (explicit `--session-dir`, masked-read-safe):264265 ```bash266 SNAPSHOTS=$(python3 "{plugin_root}/hooks/shared/session_journal.py" read \267 --session-dir "$SESSION_DIR" --type task_metadata_snapshot)268 ```269270 As in Step 1, `read` prints a **JSON ARRAY** — parse the whole stdout once, then iterate. Each event carries `task_id`, `metadata` (the size-bounded sibling-key payload), `subject`, `occupant`, and optionally `owner` / `task_type` / `truncated`. **Selection**: filter to events with the matching `task_id`; because the platform reuses task_ids across arcs, when you are resolving siblings FOR an `agent_handoff` event, additionally filter to events whose `occupant` equals `occupant_hash(agent, task_subject)` computed from that handoff event's own fields with the SAME shared function — never a local reimplementation. **The runnable command is in Step 3, under HOW TO CALL THE TWO NAMES THIS STEP USES.** It is stated once, there, and referenced here. Aggregate (whole-arc) reads apply the arc-scoped `--since` bound first, exactly as Step 10 does for `variety_assessed`. Take the **latest-`ts`** event within the match, **last-wins on an equal `ts`** (journal events stamp `ts` at second granularity, so a same-second re-emit ties; the later line in journal order is the authoritative one — the same tie-break the artifact-paths supersede uses) — a task may legally carry multiple snapshots (a changed payload after completion re-emits; the latest is the authoritative end-state). A value of shape `{"_truncated": true, ...}` or a top-level `_dropped_keys` list means the full value lived only in the task file — note the truncation in your synthesis, don't fake the missing content.2713. **Graceful degrade**: neither source resolves → record the gap (task_id, key, timestamp) exactly as Step 3's report-gap tier does; never invent content.272273**SIBLING KEY NAMES CARRY NO ORDER.** A task commonly carries a family of related sibling keys, and their names do not record which one is newest. A key that names itself `final` can be the earlier of two. Do not sort sibling keys by name, and do not read a name as a position. **ONE NAME DOES CARRY A RELATION, and it is not a position: a sibling key that amends, corrects or withdraws HANDOFF content SUPERSEDES what it addresses, whatever its date. Synthesize from the amended state, and never bank a claim an amendment withdrew.** Resolve the order of the rest in this priority:2742751. **Use a write-time field the key itself carries**, for example `written_at_utc`. This is the only self-dating source, and it is present only when the agent that wrote the key chose to record it.2762. **Take no other date in the payload as the write time.** A date in the body commonly dates a different event, for example a read of some other file or a measurement of a document. A wrong timestamp is worse than an absent one, because it is executable and produces a confident wrong order.2773. **When no write-time field is present, the set is unordered.** Read every member of the family, synthesize from all of them, and report the ambiguity. Do not present one member as the end state, and do not drop the members you cannot date.278279### Step 3.5: Resolve and Read Phase Artifacts (always)280281Each phase's HANDOFF is the **distilled frame**; the phase's disk artifact (e.g. `docs/preparation/{feature}.md`, `docs/architecture/{feature}.md`, `docs/plans/{slug}-plan.md`, `docs/review/…`) is the **fuller substance**. The lead writes a path-only `artifact_paths` journal event pointing at each phase's artifact(s); that event lives in the journal (outside any worktree), so it survives `git worktree remove` even though the pointed-at file is worktree-ephemeral. **Always** resolve these events and fold the artifact substance into the same synthesis the HANDOFF drives.2822831. **Build the feature set from the journal, the same way Step 1 builds the task population.** `resolve-artifacts` requires a `--feature` value and nothing in your context carries one. Do not take it from your dispatch: each `artifact_paths` event names its own `feature`, so the distinct values across those events ARE the set, and reading them is one command:284285 ```bash286 FEATURES=$(python3 "{plugin_root}/hooks/shared/session_journal.py" read \287 --session-dir "$SESSION_DIR" --type artifact_paths \288 | python3 -c "import json,sys; print('\n'.join(sorted({e['feature'] for e in json.load(sys.stdin) if e.get('feature')})))")289 ```290291 No events, or none carrying a `feature` → the set is empty, there is nothing to resolve, and that is a normal result rather than a gap.2922. **Resolve AND READ, one feature at a time.** Both happen INSIDE the loop (masked-read-safe — uses the Step 0 `$SESSION_DIR`). The `resolve-artifacts` subcommand reads the `artifact_paths` events and applies the supersede-by-`(workflow, feature)`-latest-`ts` dedup for you:293294 ```bash295 while IFS= read -r FEATURE; do296 [ -n "$FEATURE" ] || continue297 ARTIFACTS=$(python3 "{plugin_root}/hooks/shared/pact_harvest.py" resolve-artifacts \298 --session-dir "$SESSION_DIR" --feature "$FEATURE")299 # stdout is a single-line JSON object {workflow: [abs_path, ...]}, e.g.:300 # {"prepare":["/abs/docs/preparation/$FEATURE.md"],"architect":["/abs/docs/architecture/$FEATURE.md"]}301 # Empty (no artifacts for this feature) -> {}. Parse with json.loads, iterate keys.302 # READ the paths HERE, and keep what you read UNDER THIS FEATURE.303 done <<< "$FEATURES"304 ```305306 🔴 **`$ARTIFACTS` IS OVERWRITTEN ON EVERY ITERATION AND NOTHING WARNS YOU.** Read its paths before the next iteration begins. After `done` it holds the LAST feature alone, so a read placed after the loop harvests one feature, discards every other, and reports success.307308 🔴 **DO NOT MERGE THE PER-FEATURE OBJECTS INTO ONE.** The object is keyed by `workflow` ALONE: the feature is consumed by the `--feature` filter and never appears in the result. Every feature runs the same phases, so two features collide on MOST of their keys, and a merge keeps one path-list per workflow while looking perfectly well-formed. Keep the results separate, keyed by feature.309310 **Read the loop variable, never a word-split.** A feature slug can carry a space, and `for FEATURE in $FEATURES` would split one such slug into two names that resolve to nothing.311312 Paths are full-absolute; read them **while the worktree is live** (the `worktree-cleanup` harvest-before-teardown guard guarantees this ordering at the single teardown chokepoint). If a path no longer resolves (file already gone — the accepted abnormal-teardown edge), skip it, note the gap, and degrade to HANDOFF-only for that artifact.313314 The subcommand already filters to this feature, groups by `workflow`, takes the **latest-`ts`** event per `(workflow, feature)`, and returns only the resolved set. Each `artifact_paths` event carries the **COMPLETE** path-list for its `(workflow, feature)` (a full enumeration per emit, not a delta), so the latest event is self-sufficient — the supersede never merges across events. Result: one path-list per `workflow`, FOR THIS ONE FEATURE.3153. **Synthesize ONE entry from BOTH sources together** (NOT verbatim, NOT a second entry). For each work unit, produce a SINGLE pact-memory entry synthesized from the HANDOFF **and** its artifact: the artifact is the fuller substance, the HANDOFF is the distilled frame. **A work unit is an `agent_handoff` group from Step 3, not a feature** — one feature carries many work units, so the artifacts a unit draws on are the ones you read under THAT UNIT'S feature. A ~19 KB artifact becomes a **richer-but-bounded** entry (a few hundred tokens of decisions/lessons informed by the full substance) — do NOT store the raw artifact. Substance flows into the entry's `context`/`decisions`; put the artifact's path in an entity `notes` field (NOT a `files` field — that field is rejected on save).3164. **Dedup** — reuse the existing mechanism; do NOT invent a content-diff. Against existing memory: the Step 6 save-vs-update entity+topic protocol, unchanged — the synthesized HANDOFF+artifact entry enriches an existing entry exactly as a HANDOFF-only entry does. Against the HANDOFF's own content: the only new rule is **sequencing** — because step 3 synthesizes the HANDOFF and artifact into ONE entry, there is no separate artifact-entry to dedup; the single synthesis IS the dedup. (Idempotency: the existing processed-task ledger of Step 2/Step 8 extends to mark a `(workflow, feature)` artifact as read, so an incremental or consolidation re-harvest does not re-read and re-distill the same artifact.)317318### Step 4: Extract Institutional Knowledge319320Focus on:321- Architectural decisions with rationale322- Cross-cutting concerns that affect multiple components323- Stakeholder decisions (user-specified constraints or preferences)324- Patterns established that future work should follow325- Integration points between components326- Risks and uncertainties that warrant tracking327328### Step 5: Capture Organizational State329330Alongside institutional 331332…(truncated)