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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,38 @@ Beta build for the `release/v0.5.0` testing window — not a GitHub release.
- `templates/plan.md` and `templates/AGENTS.thin.md` document the attempt
reference and the new optional keys `attempt_dir`, `worktree_dir`,
`executor_refs` and `concurrency_profiles`.
- `passdown-pickup` and `passdown-handoff` recover interrupted dispatches
(PDN-0004, slice S5):
- Pickup reads the attempt store read-only (`list --unresolved`,
`claims`, `probe`). It classifies every unresolved attempt C1–C11 with
its ownership risk and one proposed action, carried out later through
dispatch *Reconcile*, and reports a handoff attempt missing from the
store. It never writes a receipt, claim, plan or log.
- Pickup's completion check follows the dispatch rule: a task with an
unresolved attempt is never accepted, the latest `Dispatched:` line
decides, and a legacy line counts only for a task with no receipts.
- Handoff lists unresolved attempts in a new optional `open_attempts`
frontmatter key and names them in Caveats / traps. It never resolves
an attempt and never ticks a task that has one.
- Handoff carries forward every ID from the previous handoff's
`open_attempts` until its receipt shows it resolved; an ID whose receipt
is missing stays listed (`# receipt missing`) until the owner confirms
no worker remains. `passdown-attempt list` warns when the store is
absent ("absent, not empty") and reports `idle_seconds` since the last
receipt transition, which pickup uses for C9.
- A missing `jq` is reported as "attempt store not readable" instead of
guessed around. The example workspace gains a handoff with an open
attempt, and `docs/SMOKE_TEST.md` covers both skills against a live
attempt.
- The reference host (`tests/harness/ref-host`) prints the skill's step names,
and a new harness scenario (`tests/interrupt.sh STEPS`) checks that the
host performs the skill's numbered steps by name and in order. The reference
host now also takes the settle inspection a card asks for, and F18b uses a
card that wrongly claims its worker never detaches and releases the late
write inside that window.
write inside that window. For S5, its pickup reads `open_attempts` from a
handoff log's frontmatter, and a new scenario (`tests/interrupt.sh
PICKUP`) checks that the helper, the pickup skill and dispatch Reconcile
use one set of class names, and that pickup changes no file.

### Fixed

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ also why the skills survive any `cwd` and any repo.
|---|---|
| `passdown-intake` | Turns raw notes from an inbox (dropped there by weak capture tools like chat apps) into properly planned work in the right repo |
| `passdown-dispatch` | Routes each task to the main session, an authorized native subagent, or an external CLI; records every delegated attempt and verifies its result before accepting it |
| `passdown-handoff` | Ends every session with a small handoff log: summary, next steps, and the traps that live nowhere else |
| `passdown-pickup` | Starts the next shift: reads the latest handoff and plan state, verifies them against the working tree, and briefs the session |
| `passdown-handoff` | Ends every session with a small handoff log: summary, next steps, open dispatch attempts, and the traps that live nowhere else |
| `passdown-pickup` | Starts the next shift: reads the latest handoff, plan state and any unresolved dispatch attempts, verifies them against the working tree, and briefs the session without changing anything |

**The dispatch gate.** `passdown-dispatch` is a *pre-execution gate*, not just
another skill: before a multi-task plan is executed — by the session itself or
Expand Down
23 changes: 20 additions & 3 deletions docs/SMOKE_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,26 @@ schema, and a session log, so you can diff behavior instead of guessing.
`status`/`branch`/`agent`/`plan` frontmatter; repeat with the same
topic and confirm it creates another file rather than overwriting
- [ ] **passdown-pickup**: in `examples/basic-workspace/`, run pickup and
confirm it reads the newest log's frontmatter, cross-checks
`openspec/changes/pkg-0001-demo/tasks.md`, and produces a briefing
without starting execution
confirm it reads the newest log's frontmatter
(`2026-07-06_passdown-demo-dispatch_claude-101500.md`), cross-checks
`openspec/changes/pkg-0001-demo/tasks.md`, reports attempt
`pd-20260706T100900Z-5b1e7c2a` as named in the handoff but missing
from the store (the example ships no store) with ownership risk, and
produces a briefing without starting execution or writing any file
- [ ] **pickup with a live attempt**: in a scratch Git repository laid out
like the fixture `tests/interrupt.sh` builds in `setup()`
(`docs/plan.md` with tasks 1.1–2.1), run
`REF_REPO=$PWD REF_JOURNAL=$PWD/../journal REF_CRASH_AT=after-first-write tests/harness/ref-host dispatch --mode write-then-sleep`
from the passdown checkout, then run pickup there live. Confirm it
lists the attempt as C3 with ownership risk, probes it, proposes to
wait or cancel (never a second writer), and that `git status` and the
store are unchanged afterwards. Kill the worker's process group when
done
- [ ] **handoff with an open attempt**: end that session with
`passdown-handoff` while the attempt is still unresolved; confirm the
log's frontmatter lists it under `open_attempts`, Caveats / traps
names its class, location and ownership risk, and task 1.1 stays
`[ ]`
- [ ] **pickup flags unverified completion**: in a scratch copy, tick task
1.1 `[x]` and add `- Dispatched: agy (2026-09-21) — rejected: tests
fail; verified: npm test` under it; confirm pickup reports 1.1 as
Expand Down
38 changes: 38 additions & 0 deletions docs/design/PDN-0004-v05-acceptance-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -2025,6 +2025,44 @@ ships it.
- Rollback: revert; dispatch still records receipts, pickup falls back to the
v0.4 plan-only checks.

**Implementation notes (S5).**

- Pickup names only read-only helper commands (`list`, `claims`, `probe`,
`digest`, `validate`) and forbids the mutating ones by name. The class
table in the pickup skill carries the same C1–C11 rows as dispatch
*Reconcile*, with the proposed action and the ownership risk.
- The C9 age threshold uses the attempt's dispatch budget. Cards have no
budget field (§18), so the skill falls back to one hour when none is
recorded. The reference host takes it as `--budget`.
- `tests/interrupt.sh PICKUP` is the S5 acceptance check. It extracts the
class names from the helper's `CLASS_JQ` and from the reference pickup,
and requires them to equal the rows of both skill tables. It also runs
the reference pickup against a live writer and a handoff that names an
attempt missing from the store, and requires every file under `docs/`,
`src/` and the attempt store to be byte-identical afterwards. Dropping a
class row from either skill, or letting pickup write, fails it.
- The reference host now reads `open_attempts` from a handoff log's
frontmatter (F16 writes a real log), and reports an `open_attempts` ID
that has no receipt as *inconsistent* (§23, "Store deleted").
- The example workspace keeps its v0.4 log and adds a later handoff with
one open attempt. Because the example ships no store, pickup there shows
the missing-store case.
- Review round 1 on [#20](https://github.com/vunm-io/passdown/pull/20):
- **C9 measures inactivity.** §13.2 says "no update for longer than the
budget". `list` now reports `idle_seconds`, measured from the receipt's
last transition, next to `age_seconds`. Pickup and the reference host
use it. F16b creates an old attempt, updates it recently, and requires
it not to be orphaned until it has been idle past the budget.
- **An absent store is not an empty one.** `list` warns "absent, not
empty" when the store directory does not exist, and still prints `[]`.
Handoff builds `open_attempts` from the store's unresolved attempts
plus every previous `open_attempts` ID whose receipt does not show it
resolved. A receipt-less ID is kept and marked `# receipt missing`
until the owner confirms no worker remains. The reference host gains
`handoff`, the reference for this rule. Scenario HANDOFF runs it with
a store and, after a pickup, with no store at all, and requires the
store not to be created.

**S6 — Executor reference cards + E-KIRO-1.**
- Files: `passdown-dispatch/references/executors/{claude,codex,kiro-cli}.md`,
`docs/EXECUTOR_SETUP.md` (measuring and writing a card),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
status: IN_PROGRESS
branch: main
agent: claude
plan: openspec/changes/pkg-0001-demo/
open_attempts:
- pd-20260706T100900Z-5b1e7c2a
---

# 2026-07-06 — passdown demo, dispatch paused

## Summary

Routed `pkg-0001-demo` and dispatched task 1.1 to an external executor
under owner policy. The session had to stop while that attempt was still
running, so it is recorded here as open. This log exists to show what a
handoff with an unresolved attempt looks like; the example workspace ships
no attempt store, so pickup reports the attempt as missing from the store.

## What was done

- Routing written into `tasks.md`: 1.1 and 1.2 `[dispatch: external-ok]`,
2.1 `[dispatch: main]`.
- Task 1.1 dispatched as attempt `pd-20260706T100900Z-5b1e7c2a` (external,
reason `owner-policy`, current checkout).

## Next steps

- [ ] Run `passdown-pickup`; resolve attempt `pd-20260706T100900Z-5b1e7c2a`
through the *Reconcile* section of `passdown-dispatch` before
dispatching anything else to this repository.
- [ ] Then dispatch 1.2, and do 2.1 in the main session.

## Caveats / traps

- Attempt `pd-20260706T100900Z-5b1e7c2a` (task
`openspec/changes/pkg-0001-demo/tasks.md#1.1`) is **C3, ownership risk**:
the worker was still running in the current checkout when this session
stopped. Probe it before anything else; do not start another writer in
this repository until it is stopped, and do not tick 1.1 until the host
has accepted it.
Original file line number Diff line number Diff line change
Expand Up @@ -1980,7 +1980,13 @@ cmd_list() {
esac
done
resolve_store
[ -d "$STORE" ] || { [ "$JSON_OUT" = 1 ] && echo '[]'; return 0; }
if [ ! -d "$STORE" ]; then
# An absent store is not a verified empty one: a handoff must not drop
# attempts it knew about because their store is gone.
warn "no attempt store at $STORE (absent, not empty)"
[ "$JSON_OUT" = 1 ] && echo '[]'
return 0
fi
for f in "$STORE"/pd-*/receipt.json; do
[ -f "$f" ] || continue
id="$(jq -r .id "$f")"
Expand All @@ -1999,11 +2005,12 @@ cmd_list() {
claim: (.claim.state // \"claim-free\"), classes: \$classes,
parent_exited: (.handle.parent_exited_at != null), location: .place.location, host: .host,
age_seconds: ((\$now | fromdateiso8601) - (.created_at | fromdateiso8601)),
idle_seconds: ((\$now | fromdateiso8601) - (.transitions | map(.at | fromdateiso8601) | max)),
rev
}" "$f"
done | if [ "$unresolved" = 1 ]; then jq -c 'select(.classes != [])'; else cat; fi |
if [ "$JSON_OUT" = 1 ]; then jq -s -c .; else
jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s"'
jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s\tidle \(.idle_seconds)s"'
fi
}

Expand Down
40 changes: 36 additions & 4 deletions plugins/passdown/skills/passdown-handoff/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The effective configuration should contain:
## passdown
- log_dir: <path for session logs>
- log_language: en # or another language for human-facing logs
- attempt_dir: <dir> # optional; default <git common dir>/passdown/attempts
```

If a required key is still missing, ask where session logs should live and
Expand All @@ -46,6 +47,9 @@ suggest adding it to the appropriate `AGENTS.md`.
branch: <git branch>
agent: <agent identity — the host name (claude|codex|kiro) unless the workspace names agents>
plan: <path to the plan/change this session executed, or none>
open_attempts: # only when delegated attempts are not known to be resolved
- pd-20260921T101530Z-3f9a1c2e
- pd-20260920T090000Z-1a2b3c4d # receipt missing
---
```

Expand All @@ -59,6 +63,25 @@ suggest adding it to the appropriate `AGENTS.md`.
- repo-a/openspec/changes/slug-0009-short-name/
```

`open_attempts` lists every attempt that is not known to be resolved when
the log is written. It combines two sources:
- every ID that `passdown-attempt --json list --unresolved` prints (the
helper in this skill's `scripts/` directory, with `--store
<attempt_dir>` when that key is set) in each repository that holds a
plan this session touched;
- every ID in the `open_attempts` of the previous handoff (the newest
earlier log in `<log_dir>`), unless its receipt exists and `list` shows
it with no recovery class. An ID whose receipt is missing — its store is
absent (the helper warns "absent, not empty") or was deleted — stays
listed, marked `# receipt missing`, until the owner confirms that no
worker for it remains; record that confirmation in Caveats / traps when
you drop it.
Omit the key only when both sources are empty. An absent store is never
evidence that nothing is open. The next shift uses this list to tell a
known open attempt from an orphaned one. If `jq` is missing, carry the
previous handoff's IDs forward unchanged and write "attempt store not
readable: jq missing" in Caveats / traps instead of guessing.

The `agent` value must match the `<agent>` field in the filename. Body
sections:
- **Summary**: 2–5 sentences — what was worked on and the outcome.
Expand All @@ -67,7 +90,10 @@ suggest adding it to the appropriate `AGENTS.md`.
- **Next steps**: checkboxes the next session can start on immediately.
- **Caveats / traps**: known pitfalls discovered this session. This is
the highest-value section — task state lives in the plan, but a fresh
trap lives nowhere else yet (step 4 promotes the durable ones).
trap lives nowhere else yet (step 4 promotes the durable ones). Name
every unresolved attempt here too: its ID, task, recovery class,
location, and whether it has ownership risk (a worker may still be
writing, so nobody may start another writer there).

3. **Sync task state**: if executing against a plan, update its checkboxes
to match reality (for OpenSpec work: `tasks.md` of the change). Reality
Expand All @@ -76,11 +102,14 @@ suggest adding it to the appropriate `AGENTS.md`.
verification passed;
- a delegated task is `[x]` only when its latest `Dispatched:` line is
an accepted verdict as `passdown-dispatch` defines it: `accepted`
with a check the host ran. Lines written before the `accepted` wording
existed count as accepted when they report success and name a host
check after `verified:` — keep those `[x]`. Never tick a delegated
with a check the host ran, and either naming `attempt: <id>` whose
receipt is `accepted` for this task, or a host `main` line, or a legacy
line for a task with no receipt at all. Lines written before the `accepted` wording existed count as accepted when they report success and name a host check after `verified:`
— keep those `[x]` under the same condition. Never tick a delegated
task because the worker reported success, because its files exist, or
because the worker ticked the box itself;
- never tick a task that has an unresolved attempt, whatever its lines
say; it goes back to (or stays) `[ ]` and is named in Caveats / traps;
- delegated work that is still unverified goes back to (or stays) `[ ]`,
and the log's Caveats / traps names it as unverified so the next shift
verifies it before building on it.
Expand All @@ -103,6 +132,9 @@ suggest adding it to the appropriate `AGENTS.md`.

## Rules

- Handoff never resolves an attempt: no `abandon`, `observe`, `verdict`,
`release-claim` or other mutating helper command. It records what is open;
the next shift reconciles it through `passdown-dispatch`.
- The log complements, never duplicates, the plan: plan = which tasks are
done; log = why we stopped here and what to avoid.
- Keep it short enough that the next session actually reads all of it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1980,7 +1980,13 @@ cmd_list() {
esac
done
resolve_store
[ -d "$STORE" ] || { [ "$JSON_OUT" = 1 ] && echo '[]'; return 0; }
if [ ! -d "$STORE" ]; then
# An absent store is not a verified empty one: a handoff must not drop
# attempts it knew about because their store is gone.
warn "no attempt store at $STORE (absent, not empty)"
[ "$JSON_OUT" = 1 ] && echo '[]'
return 0
fi
for f in "$STORE"/pd-*/receipt.json; do
[ -f "$f" ] || continue
id="$(jq -r .id "$f")"
Expand All @@ -1999,11 +2005,12 @@ cmd_list() {
claim: (.claim.state // \"claim-free\"), classes: \$classes,
parent_exited: (.handle.parent_exited_at != null), location: .place.location, host: .host,
age_seconds: ((\$now | fromdateiso8601) - (.created_at | fromdateiso8601)),
idle_seconds: ((\$now | fromdateiso8601) - (.transitions | map(.at | fromdateiso8601) | max)),
rev
}" "$f"
done | if [ "$unresolved" = 1 ]; then jq -c 'select(.classes != [])'; else cat; fi |
if [ "$JSON_OUT" = 1 ]; then jq -s -c .; else
jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s"'
jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s\tidle \(.idle_seconds)s"'
fi
}

Expand Down
Loading
Loading