@mgiles/perk 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +12 -9
  2. package/extension/doors/commitCompact.ts +98 -10
  3. package/extension/doors/draftReviewWaveTools.ts +43 -15
  4. package/extension/doors/dreamWaveTools.ts +475 -0
  5. package/extension/doors/objectiveReviewBrowser.ts +36 -13
  6. package/extension/doors/objectiveStack.ts +1 -1
  7. package/extension/doors/planReviewBrowser.ts +30 -8
  8. package/extension/doors/prReview.ts +156 -49
  9. package/extension/doors/prReviewDynamic.ts +33 -13
  10. package/extension/doors/reviewWaveTools.ts +37 -14
  11. package/extension/factories/objectiveDraft.ts +95 -27
  12. package/extension/factories/objectiveDreamReport.ts +347 -0
  13. package/extension/factories/objectiveSave.ts +74 -1
  14. package/extension/factories/planReview.ts +173 -10
  15. package/extension/index.ts +62 -15
  16. package/extension/substrate/agentScratch.ts +171 -0
  17. package/extension/substrate/bindingDelivery.ts +9 -11
  18. package/extension/substrate/cache.ts +92 -2
  19. package/extension/substrate/command.ts +9 -6
  20. package/extension/substrate/config.ts +6 -1
  21. package/extension/substrate/git.ts +85 -2
  22. package/extension/substrate/result.ts +3 -2
  23. package/extension/substrate/sessionData.ts +6 -4
  24. package/extension/substrate/sessionPointers.ts +3 -4
  25. package/extension/substrate/toolGating.ts +9 -0
  26. package/extension/substrate/workflowState.ts +44 -2
  27. package/extension/surfaces/report.ts +38 -12
  28. package/extension/surfaces/surfaces.ts +129 -7
  29. package/extension/vendor/btw/btw.ts +38 -6
  30. package/extension/waves/adversarialReviewWave.ts +19 -2
  31. package/extension/waves/draftReviewWave.ts +17 -1
  32. package/extension/waves/dreamReducerWave.ts +700 -0
  33. package/extension/waves/dreamReport.ts +1494 -0
  34. package/extension/waves/dreamWave.ts +927 -0
  35. package/extension/waves/harvestWave.ts +1 -1
  36. package/extension/waves/ponytail.ts +104 -0
  37. package/extension/waves/prReviewDynamicWave.ts +115 -34
  38. package/extension/waves/prReviewWave.ts +122 -17
  39. package/extension/waves/reportWave.ts +103 -7
  40. package/extension/worker/readOnlySession.ts +2 -3
  41. package/package.json +6 -3
  42. package/prompts/_fixtures/live.yaml +49 -0
  43. package/prompts/commit-and-compact-continuation.md +13 -0
  44. package/prompts/contexts/adapters/plannotator-objective.md +7 -1
  45. package/prompts/contexts/adapters/plannotator-plan.md +7 -1
  46. package/prompts/stages/conflict-resolution.md +1 -1
  47. package/prompts/stages/learn-dream.md +10 -0
  48. package/prompts/stages/objective-review-browser.md +1 -1
  49. package/prompts/stages/plan-review-browser.md +1 -1
  50. package/prompts/stages/pr-review-browser/active.md +1 -1
  51. package/prompts/stages/pr-review-browser/foreign.md +1 -1
  52. package/prompts/stages/pr-review-dynamic.md +5 -5
  53. package/prompts/stages/pr-review-terminal/active.md +1 -1
  54. package/prompts/stages/pr-review-terminal/foreign.md +1 -1
  55. package/prompts/stages/pr-review-terminal/local.md +1 -1
  56. package/prompts/stages/pr-review.md +5 -5
  57. package/shared/bindings.yaml +3 -0
  58. package/shared/contracts.md +2176 -500
  59. package/shared/registry.yaml +12 -12
  60. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  61. package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
  62. package/shared/schemas/outputs/pr-land.schema.json +3 -3
@@ -1,7 +1,7 @@
1
1
  # perk cross-plane contracts
2
2
 
3
3
  The language-neutral contracts both planes obey, authored once here and bundled into each
4
- build artifact. This document holds the numbered **prose contract sections** (`§8.1`–`§8.57`,
4
+ build artifact. This document holds the numbered **prose contract sections** (`§8.1`–`§8.65`,
5
5
  non-contiguous: `§8.8` is skipped and `§8.6a` exists; no parser): the Python CLI (`perk`)
6
6
  and the TS extension (`@mgiles/perk`) each implement one side, against the exact names/paths/
7
7
  fields pinned in each section. `perk doctor` verifies conformance. The numbering convention:
@@ -32,7 +32,8 @@ The local cache tier — written and read by **both** the CLI (exterior) and the
32
32
  ├── plan.md # cache.plan: the per-worktree plan snapshot for review fidelity (Python-only; transient)
33
33
  ├── plan-ref.json # cache.plan-ref: the active plan->branch ref pointer (local mirror)
34
34
  ├── scratch/runs/<run_id>/ # per-run inter-process workflow files (diffs, generated bodies)
35
- └── data/ # the session data dir (Node 1.2): run-scoped session artifacts
35
+ ├── agent/ # disposable, non-authoritative model intermediates (mode 0700)
36
+ │ └── data/ # pointer-validated run-scoped session artifacts
36
37
  ├── handoff/<run_id>.json # pre-session CLI->extension cold-door state (claimed on session_start)
37
38
  ├── agent-session.json # cache.agent-session: the Linear AgentSession pointer (§8.22)
38
39
  ├── hunk-watch/ # the watch-feedback bridge (§8.58): worktree-local, disposable
@@ -132,12 +133,18 @@ The local cache tier — written and read by **both** the CLI (exterior) and the
132
133
  fixed constant `objective-draft.json` — `OBJECTIVE_DRAFT_ARTIFACT`,
133
134
  `extension/factories/objectiveDraft.ts` — and the path derives exclusively through the accessor seam;
134
135
  the gate's `edit`/`write`/bash blocking is unchanged). The artifact is a **single JSON file**
135
- carrying `{schema_version: 1, title?, prose, roadmap}` — the structured roadmap rides
136
+ carrying `{schema_version: 1, title?, prose, roadmap}` — plus, in a `perk learn dream`
137
+ session, the **tool-written** `dream_report` block `{input, generated_at, parts}` (§8.63;
138
+ `readObjectiveDraft` refuses the WHOLE draft on a malformed block — deliberately stricter
139
+ than the lenient junk→absent `base`/`delivery` handling, because silently dropping a
140
+ malformed report is exactly what §8.63 forbids) — the structured roadmap rides
136
141
  **verbatim** (node-shape validation stays with the Python plane at save time, the
137
142
  `parse_structured_roadmap` path; an empty roadmap is allowed — only creation rejects
138
143
  roadmap-free objectives). **The JSON is storage/transport only** — the human review surface
139
144
  (node 2.2, Plannotator or the first-party editor) displays rendered markdown (the prose + a
140
- markdown roadmap table) derived from the artifact, never raw JSON. Semantics: full rewrite per
145
+ markdown roadmap table; when the draft carries `dream_report`, the stored CANONICAL parts
146
+ append as the final section — the render is the objective+report **approval bundle**,
147
+ §8.63) derived from the artifact, never raw JSON. Semantics: full rewrite per
141
148
  call, non-terminating, NOT a save — `objective_save`/`/objective-save` remain the canonical
142
149
  GitHub persist surface. Failure taxonomy (soft results, never throws): mistyped params →
143
150
  `bad_input`; empty/whitespace prose → `invalid_input`; no session `run_id` → `no_run_id`;
@@ -172,6 +179,61 @@ The local cache tier — written and read by **both** the CLI (exterior) and the
172
179
  dir persist through the LWW rebuild. Consumers fail open to their fallback when validation
173
180
  refuses (the reader returns `null`; mismatched-run_id refusals are silent by design, broken
174
181
  promises — missing file, digest mismatch — warn on stderr).
182
+ - **Agent scratch.** `.perk/workflow/scratch/runs/<run_id>/agent/` is the run-owned directory for
183
+ disposable command/model intermediates. Interior run-directory creation shares one hardened
184
+ boundary (`extension/substrate/cache.ts::ensureRunScratch`): before any write, `run_id` must be
185
+ one non-empty path segment (not `.`/`..`, with no `/`, `\\`, or NUL); every existing component
186
+ from the checkout's `.perk` child through the run root must be a real directory, never a symlink
187
+ or non-directory, and must have no group/world write bit; missing components are materialized in
188
+ order with an explicit maximum mode `0755` even under a permissive umask; and the run root's
189
+ realpath must equal the expected descendant of `realpath(cwd)` (a symlink above `cwd` remains
190
+ legal). All interior run-root writers use that boundary. `ensureAgentScratch` then applies the
191
+ same symlink/non-directory and resolved-containment checks to `agent/`, creates it as POSIX mode
192
+ `0700` from the outset, and re-applies `0700` on reuse. This is static redirected-path protection
193
+ and privacy from other OS users, not a defense against a concurrent process running as the same
194
+ user.
195
+
196
+ The extension provisions the directory before every eligible model turn and injects one hidden
197
+ `customType: "perk:agent-scratch"` block naming the repository-relative current-run path. A
198
+ context is eligible unless branch-LWW workflow mode is explicitly `read-only` or
199
+ `PI_SUBAGENT_CHILD_AGENT` names one of perk's report-only children (`perk.adversarial-reviewer`,
200
+ `perk.draft-reviewer`, `perk.dream-analyst`, `perk.dream-reducer`, `perk.harvest-analyst`, `perk.learn-analyst`,
201
+ `perk.objective-explorer`, `perk.pr-reviewer`, `perk.review-angle-selector`,
202
+ `perk.review-classifier`). Main sessions, `perk.conflict-resolver`, and unknown/custom children
203
+ remain eligible; absent generic foreign-agent metadata, inherited parent mode is the fallback.
204
+ Directory repair happens before prompt deduplication. The exact run-id/path-derived block is
205
+ deduplicated only inside the active post-compaction context window; context filtering keeps one
206
+ exact current-run **scratch custom block** and strips direct inherited/stale scratch custom blocks
207
+ (or all direct copies while ineligible), so a compacted-away block is re-injected. Compaction
208
+ summary prose is not a scratch custom block and may quote an older marker/path; it is deliberately
209
+ not redacted and is neither a live guidance delivery nor authoritative provenance. Only the
210
+ current-run direct block counts as live scratch guidance, and durable decisions still re-read the
211
+ canonical repository/backend source.
212
+ Because this is a universal pre-turn side effect that can become eligible after an in-session
213
+ read-only gate exit, every registry stage declares the existing `cache.scratch` key in `writes`.
214
+
215
+ A write-capable `/btw` side session receives the same rendered block once in its effective
216
+ appended system prompt after scratch custom messages are removed from its seed. Provisioning is
217
+ rechecked before every side-model prompt: deletion is repaired while reusing the session, and a
218
+ transition between unavailable and available scratch recreates the cached session so its
219
+ immutable prompt matches the current turn. The same gate that selects its tools excludes the
220
+ block from the read-only side-session shape; the tool-less summary shape also receives none.
221
+ Direct SDK read-only sessions remain unguided:
222
+ they load with `noExtensions: true` and the bounded `read`/`grep`/`find`/`ls` tool set. With no
223
+ settled run id the resolver is silent; an unsafe id or filesystem/permission failure injects no
224
+ path, warns through the report seam, and continues the turn. A failure is retried on later
225
+ eligible turns, with duplicate warnings suppressed per run within one extension activation and
226
+ suppression cleared after a successful retry. Fork/adopt run-root setup likewise reports and
227
+ continues after settling the derived workflow identity, without an unsafe fallback write.
228
+
229
+ The guidance asks agents to use descriptive non-colliding names instead of shared `/tmp` and to
230
+ re-read canonical repository/backend sources before durable decisions. Agent scratch is never
231
+ canonical evidence: it has no filename policy, atomic per-file writer, manifest, digest, or
232
+ `session_artifacts` pointer. Remote run diagnostics explicitly include hidden `.perk` files but
233
+ exclude the matching `agent/**` subtree from `actions/upload-artifact`; local cleanup remains the
234
+ enclosing run directory's existing `perk state prune` policy (no exit-time deletion). Non-goals:
235
+ no model-facing scratch writer, `PERK_SCRATCH_DIR`, `TMPDIR`, FFF/search change, shell/path
236
+ enforcement, OS sandbox, provenance protocol, or session-exit cleanup.
175
237
  - **Atomic workflow writes + corruption posture.** Every `.perk/workflow/` file write on both
176
238
  planes goes through the per-plane atomic-write seam — `perk/state/cache.py::atomic_write_text`
177
239
  (exterior) / `extension/substrate/cache.ts::atomicWriteFileSync` (interior): a temp file in the
@@ -253,6 +315,25 @@ The local cache tier — written and read by **both** the CLI (exterior) and the
253
315
  dispatch and never re-read from the mutable selector after selection. `--worktree NAME` is
254
316
  directory positioning only (never plan identity or branch — the branch is always
255
317
  `plan-<id>` from selection) and is refused with `--remote` (`invalid_input`).
318
+ - **Positive plan identification (the kind guard).** Explicit-id selection at the four
319
+ guarded doors — the three `select_plan` callers (`implement`, `pr address`/flat `address`,
320
+ `pr ready`) plus `plan resume`'s own read — refuses an existing issue that carries **no
321
+ plan-header** (`PlanState.has_plan_header`, presence-only kind evidence from the backend's
322
+ own storage: body metadata blocks on GitHub, perk attachments on Linear — never a payload
323
+ decode): typed `issue_kind_mismatch` via `plan_selection.require_plan_kind`. A GitHub
324
+ objective-header'd issue names the right door (`perk objective plan <N>`); the hint is
325
+ GitHub-only — a Linear metadata-sentinel issue refuses with the generic message, and a
326
+ Linear **Project** id never reaches the arm (`get_plan` returns `None` → `plan_not_found`,
327
+ the honest miss). A present-but-malformed header still identifies a plan (kind vs health
328
+ are separate concerns) — a **GitHub-only** reachable state: GitHub's tolerant body-block
329
+ read degrades a damaged block to `header={}` with `has_plan_header` true, while a corrupt
330
+ Linear plan-header attachment fails loud inside `get_plan`'s strict decode before
331
+ selection ever classifies kind. A **both-headers** carrier still selects as a plan (its plan
332
+ side can be legitimate mid-incident; `perk objective doctor`'s corruption check is the
333
+ both-headers surface, §8.54). `plan watch`/the positioner are explicitly **not**
334
+ entry-guarded — their wrong-kind protection is the §8.4 merge-only write seam. `pr ready
335
+ --dry-run` performs no backend read, so the offline preview classifies nothing (kind
336
+ included; the existing exception, kept).
256
337
 
257
338
  State keys (registry vocabulary): `cache.plan`, `cache.plan-ref`, `cache.scratch`,
258
339
  `cache.handoff`, `cache.markers`, `cache.session-data`.
@@ -384,13 +465,23 @@ end of the section).
384
465
  | `active_plan_ref` | object \| null | the provider-agnostic plan ref (§8.4); null during early `plan` |
385
466
  | `active_objective` | string \| null | the active objective id (`/objective <id>` sets it, `/objective clear` nulls it) |
386
467
  | `last_review_batch` | object \| null | the last fully processed review batch, appended by `finalize_address` only after publication and thread resolution succeed: `{ pr, counts:{actionable,informational,praise,question}, resolved_thread_ids:[…], at:ISO }` |
387
- | `last_pr_review` | object \| null | the last `/pr-review` (or the experimental `/pr-review-dynamic`) outcome posted via the shared warm `post_pr_review` tool: `{ pr, verdict, angles, comment_count, mode, at:ISO }`; best-effort tier (the PR review is the canonical record) |
468
+ | `last_pr_review` | object \| null | the last `/pr-review` (or the experimental `/pr-review-dynamic`) outcome posted via the shared warm `post_pr_review` tool: `{ pr, verdict, angles, covered_angles, comment_count, mode, at:ISO }`; a recorded wave is PR-bound and single-use, and supplies authoritative ordered `angles` / schema-valid `covered_angles`; standalone posting before any valid wave uses caller-supplied angles for both (or `[]`); best-effort tier (the PR review is the canonical record) |
388
469
  | `last_review` | object \| null | the last review-door outcome posted via the warm `submit_pr_review` tool: `{ pr, event, comment_count, mode, at:ISO }`; best-effort tier (the submitted PR review is the canonical record) |
389
470
  | `session_artifacts` | object \| null | per-name session-artifact provenance pointers `{run_id, name, path, digest, at}` (§8.1); appends carry the **whole merged map** (per-field LWW); strict-append tier |
390
471
  | `objective_node_claim` | object \| null | the objective node this session has claimed `planning` (`{ objective, node }`); written by the warm `objective_node` tool on a successful `planning` transition, cleared on a successful non-planning transition for the same node and after a successful node-linked plan save; best-effort tier (cheaply reconstructable; loud-but-non-fatal) |
391
472
  | `conflict_resolution_attempts` | number | the bounded conflict-resolution re-drive counter: incremented each time `/submit` drives the `perk.conflict-resolver` subagent on a definitively-unmergeable PR (cap `CONFLICT_RESOLUTION_ATTEMPT_CAP = 2`), reset to 0 on a clean submit; best-effort tier (cheaply reconstructable) |
473
+ | `dream_bundle_digest` | string | the dream-wave finalized-bundle digest marker (§8.61): `""` = invalidated (cleared unconditionally at wave entry, BEFORE the stale-bundle removal attempt — the invalidation record); `sha256:<hex>` = the digest of the current finalized run-scratch bundle bytes, set only after a successful finalize write; the §8.63 dream-report recovery refuses unless the marker is present, non-empty, and byte-matches the bundle just read; per-field LWW, no rebuild change |
392
474
  | `perk_version` | string | the running perk (extension) version, stamped when run identity is established (the claim/fork/adopt/mint arms, §8.2) — the session-audit **exact-vintage** basis (the key literal is the cross-plane coordination point; the read side is `perk-dev`'s audit corpus/vintage layer); omitted when only the `perkVersion()` failure sentinel is available; best-effort tier |
393
475
 
476
+ Automated PR-review postability is session-local interior state, not an appended workflow-state
477
+ field: `null` permits the backwards-compatible standalone post; valid static/dynamic wave input
478
+ moves immediately to `pending` before target resolution (`review_wave_unavailable` on either
479
+ verdict); every normalized outcome records `{pr, complete, attempted, covered}`; one successful
480
+ post consumes it (`review_wave_consumed` thereafter). Bad wave input preserves the prior state.
481
+ A mutation-time PR mismatch returns `stale_review_wave` and moves back to `pending`; other post
482
+ failures keep the recorded outcome retryable. `last_pr_review` is appended only after the
483
+ mutation succeeds.
484
+
394
485
  **Persistence channel:** `pi.appendEntry("perk:workflow-state", data)`. (The *other* Pi
395
486
  channel — tool-result `details` — is for state that *is* a tool's output; this is not that.)
396
487
 
@@ -456,10 +547,13 @@ make the engine-required `structured_output` completion call — stripping it fa
456
547
  `outputSchema` run with `structuredOutputFailed`) — a static union of foreign
457
548
  tool names, inert when a package is absent — plus `run_audit_wave` (the gated audit-judge
458
549
  session's wave call: its one write is structurally bound to the cold door's handoff
459
- `audit_bundle_dir`, §8.50 — no caller-supplied path exists) and `run_harvest_wave` (the gated
550
+ `audit_bundle_dir`, §8.50 — no caller-supplied path exists), `run_harvest_wave` (the gated
460
551
  learn-harvest session's wave call: its manifest read is structurally bound to the session's
461
552
  claimed run-scoped scratch path, §8.48 — the relayed param is verified against it and any
462
- other path refused; no worktree writes)) via `pi.setActiveTools`, **snapshot-then-restore** (the restore
553
+ other path refused; no worktree writes), and `run_dream_wave` (the gated learn-dream session's
554
+ wave call: NO parameters — its manifest read AND its one write, the fixed-name run-scratch
555
+ bundle beside that manifest, are both derived from the claimed run's manifest path, §8.61 —
556
+ the no-aimable-writer posture on both sides)) via `pi.setActiveTools`, **snapshot-then-restore** (the restore
463
557
  falls back to the full configured `pi.getAllTools()` set — never a hardcoded list); (2) blocks
464
558
  `edit`/`write` and non-allowlisted `bash` at `tool_call`. The bash sub-allowlist covers read-only
465
559
  inspection commands (read-only `git` queries, `jq`, `curl`, …), read-only `gh` **query**
@@ -597,7 +691,11 @@ create_plan_issue{ title, body, labels[], run_id } -> PlanIssue{ number, url, e
597
691
  add_issue_comment{ issue, body } -> CommentResult{ posted }
598
692
  # POST repos/{o}/{r}/issues/{n}/comments (the plan-body first comment)
599
693
  find_plan_issue{ run_id } -> PlanIssue | null
600
- # GET repos/{o}/{r}/issues?labels=perk:plan&state=open + header run_id match
694
+ # GET repos/{o}/{r}/issues?labels=perk:plan&state=open + header run_id match. EXHAUSTIVE on
695
+ # GitHub: `gh api --paginate --slurp` with per_page=100 scans the FULL open set (never just
696
+ # the default ~30-row first page — a first-page miss would mint a duplicate issue); the
697
+ # parameterized learn/gist/objective finders inherit the same scan. An unexpected slurped
698
+ # page shape raises GitHubError (fail-closed — no partial census).
601
699
  update_plan_issue{ number, title, body_comment } -> PlanUpdate{ number, body_updated, title_updated, dry_run }
602
700
  # find the plan-body comment by marker -> PATCH .../issues/comments/{id} (-F body=@file)
603
701
  # (fallback: POST a fresh comment, body_updated:false) ; PATCH .../issues/{n} (-f title=)
@@ -647,12 +745,27 @@ reopen_pr{ number } -> void
647
745
  update_plan_header{ issue, fields } -> PlanHeaderUpdate{ fields_updated[], dry_run }
648
746
  # GET issue body -> merge fields into the plan-header block -> PATCH .../issues/{n}
649
747
  # rejects unknown header keys (LBYL on the schema); submit sets branch/pr/lifecycle_stage=impl
748
+ # MERGE-ONLY on both backends: refuses (before the dry-run return) when the backend's own
749
+ # storage carries no plan-header for the issue — GitHub: no plan-header body block;
750
+ # Linear: no plan-header attachment (the old run_id-keyed creation fallback is gone).
751
+ # Plan-header creation is confined to create_plan_issue, §8.29 adoption, and the Linear
752
+ # node-plan unification writer (save_node_plan). The refusal rides the backend error
753
+ # channel (GitHubError/IssueBackendError ⇒ github_error at CLI boundaries) — an invariant
754
+ # violation of a later-lifecycle write, not a selection error. Malformed-but-present
755
+ # GitHub shapes keep today's behavior (presence is kind evidence either way): open+close
756
+ # markers with unparseable YAML merge over {} (block replaced wholesale — an incidental
757
+ # self-heal); an open marker with no close marker makes replace_metadata_block a no-op
758
+ # (the write PATCHes an unchanged body while reporting fields updated).
650
759
  prepend_plan_callout{ issue, callout, command } -> bool
651
760
  # GET issue body -> plan.prepend_callout(body, callout, command=) -> PATCH .../issues/{n}
652
761
  # idempotent on `command`; True iff a write occurred (False when already present / dry-run)
653
- get_plan{ number } -> PlanState{ number, url, title, header, pr, state } | null
762
+ get_plan{ number } -> PlanState{ number, url, title, header, pr, state,
763
+ has_plan_header, has_objective_header } | null
654
764
  # gh issue view --json (+ pulls/{n} when the header carries pr); the `perk resume` read.
655
765
  # `state` is the issue's OPEN/CLOSED state (the `replan` OPEN guard reads it).
766
+ # has_plan_header/has_objective_header: presence-only kind evidence from the body's own
767
+ # metadata blocks (the §8.1 kind guard reads them); a malformed block still reads
768
+ # header={} with its flag true.
656
769
  ```
657
770
 
658
771
  - **PR body:** `Closes #<issue>` (so the squash-merge closes the plan) + a `Plan: #<issue>` link
@@ -670,24 +783,54 @@ merge_pr{ number, commit_message? } -> PullRequest (state MERGED
670
783
  - **`Closes #<issue>`** rides in the PR body so the squash-merge closes the plan issue;
671
784
  `commit_message` repeats it belt-and-suspenders. Post-merge state is **derived from PR**, never
672
785
  stored (Q8).
673
- - **Stacked lineage refuses before any mutation.** `perk pr land` (and the delegating `/land`)
674
- applies the §8.47 routing discriminator stacked ⟺ the cache plan-ref carries
675
- `delivery_lineage` OR the fetched plan header does (**header wins**: a stale cached ref must
676
- not silently land a stacked layer) and refuses fail-closed (`stacked_plan`) before
677
- mark-ready/merge: stacked layers land only as one atomic train, never individually.
678
- `--dry-run` refuses on the cached ref only (dry-run stays fully offline by contract; the real
679
- run enforces the header half).
680
- - **The pre-merge plan read is load-bearing.** Land fetches the plan (`get_plan`) right after
681
- the dry-run early-return, before any mutation: a missing plan fails as `plan_not_found`
682
- (submit/ready's exact posture), the header feeds the stacked discriminator, and the squash
683
- title rides the same read.
786
+ - **`perk pr land` is a thin mapper over `Delivery.land`.** The command reconstructs the cached
787
+ plan-ref into one `LandRequest(kind="plan", plan_id, branch, objective_id, consumed_learn,
788
+ delivery_lineage, dry_run)` and makes exactly one
789
+ `resolve_delivery(repo_root).land(request)` call never a consent callback: the plan
790
+ variant (and the objective dry-run) has no confirmation boundary and the façade rejects
791
+ one with `ValueError` (consent belongs to the objective mutation, §8.56). The flat
792
+ kind-guarded request family gives the plan-ref-derived intent fields
793
+ (`objective_id`/`consumed_learn`/`delivery_lineage`) dataclass defaults — an **accepted
794
+ residual**: their omission on a plan request is no longer construction-detectable,
795
+ mitigated because the header-half stacked refusal still protects every real run (only the
796
+ fully offline dry-run relies on the cached half) and the single production construction
797
+ site passes every field explicitly under exact-request pins; `plan_id`/`branch` omission
798
+ stays guard-detectable, and a plan request rejects `run_id` (objective-mutation-only). The
799
+ façade (engine:
800
+ `perk.delivery.land_plan`) owns refusal ordering, the mutation protocol, and the
801
+ finalization dispatch, and returns the strict kind↔detail `LandResult` (nested
802
+ `PrSummary`/`ObjectiveUpdate`/`LearnUpdate`/`Plan` records — the `--json` envelope maps them
803
+ field-for-field, byte-unchanged). Façade failures are the bounded `DeliveryError` vocabulary
804
+ with `phase="land"`: domain refusals (`stacked_plan`, `plan_not_found`, `no_pr`) carry
805
+ `origin="domain"` and render **bare**; infra failures translate to `github_error` with
806
+ `origin="github"` and keep the CLI's `PR land failed\n<detail>` prefix (no Git authority
807
+ call exists on this path, so no speculative `git_error` arm).
808
+ - **Stacked lineage refuses before any mutation.** `Delivery.land` applies the §8.47 routing
809
+ discriminator — stacked ⟺ the request carries the cached ref's `delivery_lineage` OR the
810
+ fetched plan header does (**header wins**: a stale cached ref must not silently land a stacked
811
+ layer) — and refuses fail-closed (`stacked_plan`) before mark-ready/merge: stacked layers land
812
+ only as one atomic train, never individually. The cached half refuses **before** the dry-run
813
+ early return (`--dry-run` stays fully offline by contract — zero authority access before the
814
+ early return, pinned for GitHub and Linear configuration; the real run enforces the header
815
+ half).
816
+ - **The pre-merge plan read is load-bearing.** Land reads the plan
817
+ (`DeliveryPersistence.get_plan`) right after the dry-run early-return, before any mutation: a
818
+ missing plan fails as `plan_not_found` (submit/ready's exact posture), the header feeds the
819
+ stacked discriminator, and the squash title/url ride the same read.
684
820
  - **Deepened squash commit message.** Land passes `merge_pr(commit_message=)` = plain
685
- `"<plan title>\n\nCloses #<issue>"` (the title from the hoisted pre-merge plan read, fallback
686
- `Closes #<issue>` on an empty title). Plain text only — the second of the **two PR targets**
687
- (the GitHub HTML body is the other); HTML never leaks into `git log`.
688
- - **Post-merge finalization is a reusable delivery seam.** The four durable bookkeeping effects
689
- (learn-state stamp §8.36 explicit plan-issue close objective reconciliation learn-issue
690
- consume) live in `perk.delivery.finalize.finalize_landed_plan` **reconstructed inputs only**
821
+ `"<plan title>\n\n<footer>"` composed façade-side from the **authoritative** `PlanState`
822
+ title/url + the persistence backend identity (`DeliveryPersistence.backend_id`): GitHub keeps
823
+ the `Closes #<issue>` footer, non-github backends the `Plan: <id> <url>` line (fallback to
824
+ the bare footer on an empty title; one shared implementation with the stacked singleton —
825
+ `landing.squash_commit_message`). The PR's real `base_ref` is captured **before** the merge
826
+ (the synthetic merged `PullRequest` carries none; an idempotent re-land still sees it). Plain
827
+ text only — the second of the **two PR targets** (the GitHub HTML body is the other); HTML
828
+ never leaks into `git log`.
829
+ - **Post-merge finalization is a package-internal delivery seam.** The four durable bookkeeping
830
+ effects (learn-state stamp §8.36 → explicit plan-issue close → objective reconciliation →
831
+ learn-issue consume) live in `perk.delivery.finalize.finalize_landed_plan` — **no public
832
+ export**; `Delivery.land` binds it through its private land runtime, and stacked
833
+ landing/recovery keep their module-path bindings. **Reconstructed inputs only**
691
834
  (a narrow `LandedPlan` + the captured `pr_base` merge evidence), never the worktree cache, and
692
835
  **convergent-final-state idempotency** over the four effects (never-downgrade stamp,
693
836
  terminal-node skip, backend-idempotent re-closes; sub-steps may re-issue idempotent backend
@@ -696,7 +839,9 @@ merge_pr{ number, commit_message? } -> PullRequest (state MERGED
696
839
  never calls `close_objective` — the aggregate objective close is that caller's obligation after
697
840
  every layer verifies; incremental land keeps the default. Activity reporting (the Linear agent
698
841
  "landed" emission) is worktree-session-scoped and **caller-owned** — it stays in `land_cmd.py`,
699
- outside the seam; the pending-learn marker (worktree-cache state) likewise stays in the caller.
842
+ outside the façade; the pending-learn marker (worktree-cache state) likewise stays in the
843
+ caller (written after the façade call — the durable §8.36 stamp is the authority; the marker
844
+ is the local retry signal).
700
845
 
701
846
  ### Learn ops
702
847
 
@@ -711,8 +856,11 @@ create_learn_issue{ title, body, run_id, plan_number } -> PlanIssue{ number, url
711
856
  # renders a learn-header block { run_id, created, plan } into the body so the finder matches.
712
857
  list_learn_issues{} -> LearnIssueSummary[]{ number, title, url, body }
713
858
  # GET .../issues?labels=perk:learn&state=open (the find_plan_issue list call, label-scoped to
714
- # perk:learn). Returns every open learn issue's full body for the factory inbox; raises on
715
- # infra failure (never masks as empty); skips non-dict / pull_request entries.
859
+ # perk:learn). Returns every open learn issue's full body for the factory inbox "every
860
+ # open" backed by full pagination on GitHub (`gh api --paginate --slurp`, per_page=100;
861
+ # Linear already cursor-paginates); an unexpected page shape raises (fail-closed, no partial
862
+ # census). Raises on infra failure (never masks as empty); skips non-dict / pull_request
863
+ # entries.
716
864
  list_plans_pending_learn{ limit } -> PendingLearnPlan[]{ id, title, url, closed_at }
717
865
  # GET .../issues?labels=perk:plan&state=closed&sort=updated&direction=desc&per_page=<limit>
718
866
  # (Linear: terminal-state label query + plan-header attachment decode, paginated then truncated).
@@ -773,9 +921,10 @@ get_pr_review_context{ pr_number, branch, plan_body } -> PrReviewContext{ pr_num
773
921
  # (`perk pr review-context`) — the materialized `cache.plan` mirror first, else
774
922
  # `IssueBackend.get_plan_body` via the resolver — and passed straight in (best-effort; null
775
923
  # lets the review run from the diff). What the spawned child runs.
776
- # CLI arm: `perk pr review-context --pr <n>` resolves an arbitrary PR by number (existence +
777
- # head ref via `get_pr`, `plan_body` null, clean `pr_not_found` arm); the flagless plan-ref
778
- # resolution is byte-identical.
924
+ # CLI arms: `--pr <n>` resolves an arbitrary PR by number (existence + head ref via `get_pr`,
925
+ # `plan_body` null, clean `pr_not_found` arm). `--expected-pr <n>` stays on the active-plan,
926
+ # plan-body-preserving arm and compares the branch-selected target before context fetch;
927
+ # mismatch is `review_target_changed`. The two flags are mutually exclusive.
779
928
  post_pr_review{ pr_number, summary, comments:[{path,line,body,side?}], event? } -> ReviewPostResult{ ok, mode, pr_number, comment_count }
780
929
  # ONE atomic review via POST .../pulls/{n}/reviews — comments + body + event land together or
781
930
  # not at all. `event` defaults to COMMENT (wire spelling: COMMENT|APPROVE|REQUEST_CHANGES) and
@@ -794,18 +943,41 @@ post_pr_review{ pr_number, summary, comments:[{path,line,body,side?}], event? }
794
943
  # `/pr-review`'s parent-side `post_pr_review` tool, which delegates via
795
944
  # `perk pr review-post --json --batch <path>` (the reviewer children report findings to the
796
945
  # parent; they never post; review-post never passes `event` — hardcoded-COMMENT posture).
946
+ # A recorded wave adds strict positive `expected_pr` to that batch; the CLI compares it with
947
+ # the freshly resolved active PR before mutation (`review_target_changed` on drift). Dry-run
948
+ # validates the field while remaining offline. Standalone batches omit it.
797
949
  add_pr_reaction{ pr_number } -> void
798
950
  # the clean-verdict 👍 (issues-reactions endpoint — idempotent on rerun); a hard error on
799
951
  # failure (mutations raise; nothing review-shaped is lost).
800
952
  ```
801
953
 
802
- The experimental `/pr-review-dynamic` door shares `post_pr_review`/`review-post` and the clean
803
- guard unchanged angle selection is delegated to a fresh `perk.review-angle-selector` lane and
804
- normalized in module-rendered code (`extension/waves/prReviewDynamicWave.ts`); the baseline
805
- `/pr-review` stays canonical. The selector picks from the six-slug additional-angle allowlist
954
+ The static `/pr-review` input remains 2–4 selected angles with `plan-fidelity` mandatory. After
955
+ parameter decode the parent invalidates any older report state, resolves the active target once via
956
+ `perk pr url --json`, and binds every child to that number. Its effective manifest is those angles
957
+ followed by exactly one **required automatic** final `ponytail` lane, outside the input menu/cap.
958
+ Every reviewer uses only `perk pr review-context --expected-pr <bound-number> --json`; target drift
959
+ therefore yields no schema-valid report. Ponytail uses the same `perk.pr-reviewer` model, directive,
960
+ timeout, and report schema family, with invocation-private `skill: "ponytail-review"` source-bound
961
+ by the agent's exact package `skillPath`. Perk preflights package name, `pi.skills`, the exact
962
+ readable skill file, and its frontmatter name. A failed preflight never dispatches/spawns that
963
+ child; static report waves additionally omit it from rendered lane items. The keyed non-retryable
964
+ `skill-unavailable` failure retains Ponytail in the logical attempted manifest and leaves it
965
+ uncovered/incomplete; ordinary failed lanes retain the one bounded retry. No same-named
966
+ project/user skill fallback is possible. A normalized result records the bound PR plus explicit
967
+ effective attempted and covered arrays for §8.3's single-use post state.
968
+
969
+ The experimental `/pr-review-dynamic` door shares the same PR-bound, single-use
970
+ `post_pr_review`/`review-post` state — angle selection is delegated to a fresh
971
+ `perk.review-angle-selector` lane and normalized in module-rendered code
972
+ (`extension/waves/prReviewDynamicWave.ts`); the baseline
973
+ `/pr-review` stays canonical. One Ponytail promise starts independently alongside plan-fidelity
974
+ and the selector, never consumes selector output, and is appended last to `selection.effective`;
975
+ `ponytail` is a reserved custom key and the lane remains outside the selector cap. The selector
976
+ picks from the six-slug additional-angle allowlist
806
977
  (`correctness`/`tests`/`quality`/`api-design`/`code-organization`/`idioms` — the shared
807
978
  seven-angle vocabulary minus the structural `plan-fidelity`), capped at 3 additional angles
808
- (2–4 lanes total, the same window as `/pr-review`; `force_angles` takes 1–3 slugs, merged
979
+ (2–4 selectable lanes, the same window as `/pr-review`, plus automatic final Ponytail;
980
+ `force_angles` takes 1–3 slugs, merged
809
981
  forced → picks → custom). The selector may additionally propose AT MOST ONE change-specific
810
982
  custom angle: validated deterministically in the module-rendered normalization (kebab-case slug
811
983
  3–32 chars, not a reserved lane key, whitespace-collapsed non-empty scope ≤ 300 chars; an
@@ -813,10 +985,21 @@ invalid proposal degrades to no-custom, never a failed selector lane), its lane
813
985
  a fixed scope-definition-only template (the one sanctioned exception to "reviewer tasks come
814
986
  only from the embedded vocabulary"), its per-item report schema locked to echo the custom slug,
815
987
  and the lane retryable through the per-lane `outputSchema` seam on `WaveLane`; the
816
- correctness+tests fallback fires only with zero valid picks AND no valid custom. Both wave
817
- tools (`run_pr_review_wave`, `run_pr_review_dynamic_wave`) persist ordered attempt receipts in
818
- their tool-result details (observability only §8.35's output-free receipt contract; the clean
819
- guard and completeness are unchanged).
988
+ correctness+tests fallback fires only with zero valid picks AND no valid custom. Dynamic Ponytail
989
+ uses the same source-bound preflight/non-retryable failure semantics as static review; its
990
+ pre-launch receipt manifest is `plan-fidelity`, `angle-selector`, `ponytail`, and a pre-selection
991
+ failure still records the deterministic attempted reviewer manifest `plan-fidelity`, `ponytail`.
992
+ The selector, plan-fidelity, Ponytail, fixed/custom fan-out, and static retry tasks all carry the
993
+ same parent-resolved expected PR. Both wave tools persist ordered attempt receipts in their
994
+ tool-result details (observability only — §8.35's output-free receipt contract; completeness is
995
+ unchanged).
996
+
997
+ **Residual source-binding race.** Source-bound waves perform one exact-path preflight per pass and
998
+ each Ponytail child makes the exact file/frontmatter check its first action. Perk cannot atomically
999
+ pin upstream package name resolution between those reads; package files are assumed stable for the
1000
+ short pass. If a file changes or disappears after preflight, the child terminates without a
1001
+ schema-valid report, so Ponytail remains uncovered and the wave incomplete—never accepted as
1002
+ coverage from another source.
820
1003
 
821
1004
  ### PR-review toolbox ops (checkout / cleanup / review-submit)
822
1005
 
@@ -971,12 +1154,18 @@ prompt; the contracts pin the output shape, not the judgment rubric.
971
1154
  - **Input (per-spawn task prompt):** the assigned angle, the PR number, and the absolute path to
972
1155
  the detached read-only head worktree (the checkout above). The child fetches its own context
973
1156
  via `perk pr review-context --pr <n> --json` (`plan_body` may be null).
974
- - **Angles** (one per spawn; the adversarial menu is exactly these four — `pr-reviewer`'s
975
- autonomous menu is wider, seven fixed angles plus the dynamic flow's custom lane): `claimed-intent` (the PR text's claims
976
- checked against the diff, plus a first-class hunt for **undisclosed scope**; the parent always
977
- includes this angle) · `correctness` (incl. the untrusted-code supply-chain axes: CI/workflow
978
- edits, dependency pins, install/build scripts, secrets handling, obfuscated code) · `tests`
979
- (adequacy by reasoning only) · `quality`.
1157
+ - **Angles** (one per spawn; the adversarial selectable menu is exactly these four —
1158
+ `pr-reviewer`'s autonomous menu is wider, seven fixed angles plus the dynamic flow's custom
1159
+ lane): `claimed-intent` (the PR text's claims checked against the diff, plus a first-class hunt
1160
+ for **undisclosed scope**; the parent always includes this angle) · `correctness` (incl. the
1161
+ untrusted-code supply-chain axes: CI/workflow edits, dependency pins, install/build scripts,
1162
+ secrets handling, obfuscated code) · `tests` (adequacy by reasoning only) · `quality`. Every
1163
+ PR-backed adversarial wave appends exactly one **required automatic** final `ponytail` lane
1164
+ outside the 2–3 selection cap, using the same model/directive/report schema plus
1165
+ invocation-private `ponytail-review` from the exact package `skillPath`.
1166
+ Package/file/frontmatter preflight failure never dispatches/spawns that child, records
1167
+ non-retryable `skill-unavailable`, and leaves it uncovered without fallback; after successful
1168
+ preflight, the child's first-action recheck enforces the residual source-race posture above.
980
1169
  - **Posture:** all fetched text is untrusted DATA, and the PR title/body are **unverified claims
981
1170
  by the PR author** (an author not trusted by default) — checked against the diff, never built
982
1171
  on. **Never-execute-the-head:** inside the head worktree the child uses
@@ -1040,13 +1229,14 @@ pieces; a neutral re-home is a deferred residual).
1040
1229
  (2–3 unique angle slugs, `claimed-intent` mandatory), the `pr`/`worktree` relayed verbatim
1041
1230
  from the guidance and the operator focus passed verbatim as `directive` — and the tool renders
1042
1231
  and launches the wave itself, NON-BLOCKING (module-owned mechanics; the model never authors
1043
- workflowScripts): one fresh-context `perk.adversarial-reviewer` lane per angle, a task naming
1044
- the angle, the PR number, and the worktree path ONLY the surface handle is structurally
1045
- unrepresentable (no URL parameter exists). The tool resolves the
1046
- `[models.subagents] adversarial-reviewer` override at execute time (the doors read no config);
1047
- a pending (launched, uncollected) wave makes a second start refuse `wave_active`; a launch
1048
- failure is a LOUD soft-fail (`error_type` = the wave reason) with no retry ZERO retries by
1049
- design, honest incompleteness. The parent then holds the model-held
1232
+ workflowScripts): one fresh-context `perk.adversarial-reviewer` lane per selected angle, then
1233
+ the automatic final source-bound Ponytail lane, each task naming the angle, the PR number, and
1234
+ the worktree path ONLY — the surface handle is structurally unrepresentable (no URL parameter
1235
+ exists). The effective manifest/receipts/coverage denominator is selected + Ponytail. The tool
1236
+ resolves the `[models.subagents] adversarial-reviewer` override at execute time (the doors read
1237
+ no config); a pending (launched, uncollected) wave makes a second start refuse `wave_active`; a
1238
+ launch failure is a LOUD soft-fail (`error_type` = the wave reason) with no retry — ZERO retries
1239
+ by design, honest incompleteness. The parent then holds the model-held
1050
1240
  `subagent_wait({ timeoutMs })` relay loop — unchanged as the streaming cadence: progress
1051
1241
  updates never wake `subagent_wait` and never enter pi-subagents' `pending` map — delivery is
1052
1242
  an injected (`triggerTurn`-bearing) message when a tool call returns — so the timed wait loop
@@ -1374,8 +1564,8 @@ resolution fallback (and the `worktree wipe` guard).
1374
1564
 
1375
1565
  The objective tier's full storage contract lives in **§8.24** (the `ObjectiveStore` seam); the
1376
1566
  mechanics live in `src/perk/objective/` + `src/perk/backends/github/objectives.py`, the land-path
1377
- handlers in `src/perk/cli/commands/pr/land_cmd.py` + `src/perk/delivery/finalize.py`. The
1378
- gateway-level facts:
1567
+ handlers in `src/perk/cli/commands/pr/land_cmd.py` (via `Delivery.land`) + the package-internal
1568
+ `src/perk/delivery/finalize.py`. The gateway-level facts:
1379
1569
 
1380
1570
  - **Storage blocks (perk-namespaced, schema 1).** An objective is an issue + first comment:
1381
1571
  `objective-header` (issue body — compact, queryable: `{ run_id, created, objective_comment_id,
@@ -1539,7 +1729,10 @@ pin + the report-only `cli-version` CLI-vs-repo-pin warning (warn, never fail) +
1539
1729
  fail, no `--fix` arm — §8.6a) + the report-only `subagent-compat` pi-subagents surface probe
1540
1730
  (installed version + source-file markers for the orchestration surfaces perk's guidance assumes;
1541
1731
  `info` when not installed; warn on divergence, never fail; no `--fix` arm — the package stays
1542
- unpinned) + the report-only `subagent-bridge-config` check (reads
1732
+ unpinned) + the report-only `ponytail-compat` exact package/`pi.skills`/two-skill-file/frontmatter
1733
+ probe (`info` when the lazy install is absent; warn on divergence, never fail; no `--fix` arm because
1734
+ operator pins are preserved; known-good remediation `npm:@dietrichgebert/ponytail@4.9.0` +
1735
+ `perk init` + session restart) + the report-only `subagent-bridge-config` check (reads
1543
1736
  `subagents.intercomBridge.mode` from the project `.pi/settings.json` and the user-global
1544
1737
  `~/.pi/agent/settings.json`; warns — never fails, no `--fix` arm — when either scope sets
1545
1738
  `"off"` or `"fork-only"`, either of which silently disables the supervisor channel perk's
@@ -1567,9 +1760,15 @@ Keeping a consumer's pi-loaded perk extension runnable rests on two invariants:
1567
1760
  own `packages` entry **in place** (list position preserved) when its `@mgiles/perk` identity already
1568
1761
  exists but the full spec differs from the desired pin — so a stale `npm:@mgiles/perk@0.0.0` is
1569
1762
  reconciled to `@{__version__}` (extra string duplicates of that identity collapse to one). Only
1570
- perk's own npm identity is version-reconciled; the borrowed npm packages stay unpinned/append-only
1571
- (distinguished by `_npm_name` identity vs `_npm_name(NPM_PACKAGE)`), and a user's other packages
1572
- are never in the desired set so they stay untouched/append-only. The in-body migration strips a
1763
+ perk's own npm identity is version-reconciled; borrowed npm packages normally stay
1764
+ unpinned/append-only (distinguished by `_npm_name` identity vs `_npm_name(NPM_PACKAGE)`), and a
1765
+ user's other packages are never in the desired set so they stay untouched/append-only. The one
1766
+ managed-filter exception is `npm:@dietrichgebert/ponytail`: desired settings always carry one
1767
+ object entry with `extensions`/`skills`/`prompts`/`themes: []`. Reconciliation chooses the first
1768
+ object donor else first match, preserves its exact source pin, non-filter metadata, position
1769
+ relative to unrelated entries, forces all four filters empty, converts a string donor, and drops
1770
+ duplicate identities. Managed-state health canonicalizes a correctly filtered donor to npm
1771
+ identity while ignoring pin/metadata; any omitted or nonempty managed filter is drift. The in-body migration strips a
1573
1772
  repo's legacy **`git:` perk** entry (any ref, **any entry form** — by
1574
1773
  `_package_identity == GIT_PACKAGE`, covering a user-rewritten object-form entry) so the flip from
1575
1774
  the old git wiring converges; a user's unrelated `git:` packages are preserved. **Presence is
@@ -1760,6 +1959,7 @@ perk's workflow skills are prompt-hidden; `transclude` exists for the user-bindi
1760
1959
  | `command:learn-docs` | `perk-learn-docs` | `nudge` |
1761
1960
  | `command:learn-code` | `perk-learn-code` | `nudge` |
1762
1961
  | `command:learn-harvest` | `perk-learn-harvest` | `nudge` |
1962
+ | `command:learn-dream` | `perk-learn-dream` | `nudge` |
1763
1963
  | `command:pr-review` | `perk-pr-review` | `nudge` |
1764
1964
  | `command:pr-review-dynamic` | `perk-pr-review-dynamic` | `nudge` |
1765
1965
  | `command:pr-review-terminal` | `perk-pr-review-terminal` | `nudge` |
@@ -1834,15 +2034,18 @@ The **cross-plane dedup marker is the render header itself** — `BINDING_HEADER
1834
2034
  byte-for-byte to the cold `_HEADER` (Python) by a literal test in **both** planes. The cold door
1835
2035
  already puts `stage:<id>` bindings in a cold-launched session's **initial prompt**, and
1836
2036
  `before_agent_start` fires for that same session, so Mechanism A injects **iff** a launched `stage`
1837
- exists, the resolved render is non-empty, no entry on `ctx.sessionManager.getBranch()`
2037
+ exists, the resolved render is non-empty, no entry in the branch's **compaction-active window**
1838
2038
  already carries `BINDING_HEADER` (the cold prompt OR a prior warm inject), **and** the submitting
1839
- turn's prompt (`event.prompt`) does not carry it either. The prompt scan is load-bearing on the
2039
+ turn's prompt (`event.prompt`) does not carry it either. Before compaction the active window is the
2040
+ full branch; after compaction it begins at the latest compaction's `firstKeptEntryId`, excludes
2041
+ compaction entries themselves, and includes later entries. This distinction is load-bearing because
2042
+ Pi's branch is append-only: historical entries remain readable after they leave model context, and a
2043
+ compaction summary quoting the header is not a live delivery. The prompt scan is load-bearing on the
1840
2044
  launch turn: at `before_agent_start` the just-submitted prompt is **not yet** on the branch, so the
1841
2045
  branch scan alone missed the cold seed on that turn and double-delivered (the fixed hole). The
1842
- injected custom and the
1843
- cold prompt both carry the header idempotent across turns/reloads; after compaction drops the
1844
- original the header disappears and it **re-delivers** (its ongoing value later prompts don't
1845
- carry the header, so the prompt scan stays inert there). Mechanism B is a one-shot
2046
+ injected custom and the cold prompt both carry the header → idempotent across turns/reloads; after
2047
+ compaction drops the original from the active window it **re-delivers** (its ongoing value later
2048
+ prompts don't carry the header, so the prompt scan stays inert there). Mechanism B is a one-shot
1846
2049
  `sendUserMessage` suffix at an invocation distinct from any cold launch, so it cannot auto-double. A
1847
2050
  narrower-than-`planMode` `context` strip removes a **stale** `perk:binding-context` custom (stage
1848
2051
  changed / overlay removed) while **never** stripping a user message that carries the header (a cold
@@ -2632,12 +2835,14 @@ so `init` writes them and `doctor` verifies/repairs them through the one shared
2632
2835
  **`gh auth setup-git`** before invoking
2633
2836
  `perk run-worker` — it installs `gh` as git's https credential helper using the step's
2634
2837
  `GH_TOKEN` (= `PERK_GH_PAT`), so the skills CLI's sync during positioning (step 4 below) can
2635
- clone private skill sources. A final **`Upload run diagnostics`** step (`actions/upload-artifact@v4`) uploads
2636
- `.perk/workflow/scratch/runs/<run_id>/` — the §8.12 durable run-event stream (`events.ndjson`
2637
- and friends), which is otherwise written into the runner's checkout and lost at teardown — as
2638
- artifact `perk-run-<run_id>` for **every real run, pass or fail**
2639
- (`if: always() && inputs.smoke != 'true'`, `if-no-files-found: ignore`; smoke runs write nothing
2640
- and upload nothing). An opt-out repo variable `PERK_ENABLED=false` disables the job without
2838
+ clone private skill sources. A final **`Upload run diagnostics`** step (`actions/upload-artifact@v4`)
2839
+ uploads `.perk/workflow/scratch/runs/<run_id>/` — the §8.12 durable run-event stream
2840
+ (`events.ndjson` and friends), which is otherwise written into the runner's checkout and lost at
2841
+ teardown — as artifact `perk-run-<run_id>` for **every real run, pass or fail**. The upload sets
2842
+ `include-hidden-files: true` so the hidden `.perk` tree is actually retained, and its multiline
2843
+ path excludes `!.perk/workflow/scratch/runs/<run_id>/agent/**` so model-authored agent scratch is
2844
+ never retained remotely (`if: always() && inputs.smoke != 'true'`, `if-no-files-found: ignore`;
2845
+ smoke runs write nothing and upload nothing). An opt-out repo variable `PERK_ENABLED=false` disables the job without
2641
2846
  removing the file. **Auth model:** checkout + push use the `PERK_GH_PAT` PAT, **not** `github.token` — a
2642
2847
  PAT-pushed commit triggers downstream CI (the implement drive commits + `submit` pushes);
2643
2848
  `GITHUB_TOKEN`-pushed commits do not. This is a stated decision Node 2.4 inherited (the
@@ -3483,6 +3688,32 @@ one-stop current shape.
3483
3688
  (`ok:false` + `error`/`error_type` on unavailable / save-failed / bad_input / no_plan /
3484
3689
  no_objective_draft; `ok:true` on verdicts and the sanctioned fail-open skips), so `tool_outcome`
3485
3690
  run events classify it via `details.ok` rather than the `!isError` fallback.
3691
+
3692
+ **The launch chooser (the plannotator arms' wave opt-in).** The moment `plan_review`
3693
+ dispatches to a plannotator arm on an **eligible** round — the injected `WaveLaunch` deps
3694
+ present (the plannotator presence probe + the two door open cores, composed at the
3695
+ `registerPlanReview` call site in `extension/index.ts`; the factory imports nothing from door
3696
+ modules), plannotator loaded, and the review source a **validated draft artifact** (plan arm:
3697
+ `source === "plan-draft"`; objective arm: a non-null raw `objective-draft.json` baseline
3698
+ captured **before** the validated read — the door's fail-closed stale-guard ordering) — an
3699
+ in-TUI chooser is shown BEFORE anything launches: "Browser review + reviewer wave" vs
3700
+ "Browser review only". Shown **every** eligible round (no config key); Esc/dismiss selects the
3701
+ plain flavor (the chooser picks a flavor, never cancels the review); **abort outranks Esc,
3702
+ every dialog result, and the awaited opener** (`signal?.aborted` is checked at entry,
3703
+ re-checked immediately after each awaited dialog before interpreting it, and re-checked after
3704
+ the awaited open core — an interrupted turn never reports a launched wave). The wave choice collects an optional **trimmed**
3705
+ custom review angle (Esc/blank ⇒ none) and delegates to the door-shared open core
3706
+ (`openPlanReviewSurface` / `openObjectiveReviewSurface`), returning the **non-terminating**
3707
+ `details.status: "wave_launched"` result (`subject: "objective"` on the objective arm)
3708
+ carrying the door's guidance **verbatim** — the model launches the wave in the same turn, and
3709
+ the human's browser decision routes back through the door's background decision task (stale
3710
+ guards, degrade token, untrusted-feedback delimiting — all the door's existing code, shared
3711
+ not duplicated). A **synchronous port-pick failure** (a null core return, loudly reported
3712
+ there) falls **open** to the plain blocking review in the same call; an **asynchronous**
3713
+ readiness degrade follows the door's existing loud degrade path and recovers through the next
3714
+ `plan_review` round's chooser ("Browser review only" is one keystroke away — no forced-wave
3715
+ mode exists). Ineligible rounds — a param-tier source, plannotator absent, no injected deps,
3716
+ or the gist arm (no gist wave door exists) — keep today's plain blocking review silently.
3486
3717
  - **The three backends.** All three speak review-first
3487
3718
  (`plan_draft` → `plan_review` → auto-save on approval):
3488
3719
 
@@ -3492,6 +3723,11 @@ one-stop current shape.
3492
3723
  | `plannotator-plan` | `PLAN_ADAPTER_PLANNOTATOR_CONTEXT` | browser bridge | present + `/plan-save` |
3493
3724
  | `tombell-plan` | `PLAN_ADAPTER_TOMBELL_CONTEXT` (conditioned injection, Node 2.6) | first-party in-TUI review | present + `/plan-save` (incl. tombell's own interactive `/plan` `setActiveTools` restriction arm) |
3494
3725
 
3726
+ Under the plannotator selection the authoring context is **flavor-dispatched per stage** (one
3727
+ customType, three contents — §8.42's per-flavor marker dedup): the plan flavor by default, the
3728
+ **objective** flavor in **both** objective stages (`objective-author` **and** `objective-save`
3729
+ — matching `plan_review`'s objective-arm stage routing), and the gist flavor in `gist-author`.
3730
+
3495
3731
  - **Plannotator "Direct Edits" (browser edits of the reviewed document).** Plannotator's
3496
3732
  plan-review browser lets the reviewer edit the reviewed document directly; the edits arrive as
3497
3733
  PROSE inside the existing `feedback` string, never a new envelope field — a `# Direct Edits`
@@ -3553,6 +3789,13 @@ one-stop current shape.
3553
3789
  against a code-review server) → the prior env value ALWAYS restored when the poll ends. The
3554
3790
  URL is deterministic the moment the port is picked, so the door primes its companions and
3555
3791
  injects the guidance immediately, then ends its turn.
3792
+ - **The shared open core:** the door's whole open path is the exported guidance-RETURNING
3793
+ core `openPlanReviewSurface` (start the browser → prime both surfaces → the background
3794
+ readiness + decision tasks → return the composed guidance string, or null on the loudly
3795
+ reported port-pick failure); the command handler is a thin `sendUserMessage` wrapper, and
3796
+ `plan_review`'s wave arm consumes the SAME core — one open path, byte-identical semantics
3797
+ (the `applyPlannotatorDirectEdits` sharing precedent). The wave-arm guidance carries the
3798
+ same `command:plan-review-browser` skill-binding suffix.
3556
3799
  - **The dual prime/clear lifecycle:** the moment the port is picked the door primes BOTH
3557
3800
  companion surfaces — `primeAnnotationSurface({mode: "plan", url})` (the `push_annotations`
3558
3801
  plan mode, §8.4) and `primeDraftReviewContext({draftType: "plan", draft, custom?})`
@@ -3569,12 +3812,21 @@ one-stop current shape.
3569
3812
  construction. The **custom lane is the draft doors' user-input channel** (no `directive`
3570
3813
  param exists), riding the primed context automatically as its own `custom` lane; the
3571
3814
  surface handle is structurally unrepresentable in the wave (no URL parameter exists).
3572
- **Zero retries** honest incompleteness surfaced to the human (the uncovered lane(s)
3573
- named, never papered over); a pending wave makes a second start refuse `wave_active`;
3574
- `collect_draft_review_wave` mirrors the PR pair (`no_wave` / grace-raced `wave_running`
3575
- with the pending wave retained / the typed aggregate `{complete, covered, reports,
3576
- failures}`, `covered` including the custom lane). The `[models.subagents] draft-reviewer`
3577
- override is resolved by the tool at execute time (the door reads no config); the per-call
3815
+ After selected lanes and the optional custom lane, the tool appends exactly one **required
3816
+ automatic** final `ponytail` lane outside both menus/caps. It uses the same
3817
+ `perk.draft-reviewer` model,
3818
+ timeout, and report schema with invocation-private `skill: "ponytail"` source-bound by the
3819
+ agent's exact package `skillPath`; package/file/frontmatter preflight failure omits only that
3820
+ child, records non-retryable `skill-unavailable`, and leaves it in the requested manifest but
3821
+ uncovered, with no same-name fallback. The child's first action rechecks the exact source;
3822
+ post-preflight instability therefore yields no report and remains incomplete under the
3823
+ residual source-race posture above. **Zero retries** — honest incompleteness surfaced to
3824
+ the human (the uncovered lane(s) named, never papered over); a pending wave makes a second
3825
+ start refuse `wave_active`; `collect_draft_review_wave` mirrors the PR pair (`no_wave` /
3826
+ grace-raced `wave_running` with the pending wave retained / the typed aggregate `{complete,
3827
+ covered, reports, failures}`, `covered` ordered across selected + optional custom + Ponytail).
3828
+ The `[models.subagents] draft-reviewer` override is resolved by the tool at execute time (the
3829
+ door reads no config); the per-call
3578
3830
  signal is deliberately not threaded (the wave outlives the call; the module timeout is the
3579
3831
  orphan insurance). Findings are the plan-mode `PlanFinding` shape (`{phrase, severity,
3580
3832
  confidence, body}`), pushed phrase-byte-exact via `push_annotations` (streamed batches
@@ -3635,6 +3887,11 @@ one-stop current shape.
3635
3887
  `plan-review` bridge action carries the rendered objective as `planContent` (arbitrary
3636
3888
  markdown bytes; no plan-specific validation). The door primes its companions and injects
3637
3889
  the guidance immediately, then ends its turn.
3890
+ - **The shared open core:** identical — the exported guidance-RETURNING core
3891
+ `openObjectiveReviewSurface` with the command handler as a thin `sendUserMessage` wrapper;
3892
+ `plan_review`'s objective wave arm consumes the SAME core — one open path, byte-identical
3893
+ semantics — and the wave-arm guidance carries the same `command:objective-review-browser`
3894
+ skill-binding suffix.
3638
3895
  - **The dual prime/clear lifecycle:** identical — `primeAnnotationSurface({mode: "plan",
3639
3896
  url})` + `primeDraftReviewContext({draftType: "objective", draft: rendered, custom?})`;
3640
3897
  both cleared when the bridge settles AND on the readiness-degrade arm (idempotent).
@@ -4068,6 +4325,61 @@ Reconcilable region markers, the `Adopted-from` archive note, and the copyable c
4068
4325
  plan-header)`, Linear the plan-header attachment), consumed by `plan from`'s `already_a_plan`
4069
4326
  refusal.
4070
4327
 
4328
+ **Objective #1892 Node 1.1 amendment — objective `origin` + the open-by-origin lookup.**
4329
+ Additive, store-tier only (no CLI flag, no extension/TS change): machine-created objectives carry
4330
+ a provenance stamp, and the store tier can answer "is an open objective with this origin already
4331
+ live?" authoritatively — the foundation for the dream-launch guard and the save-time conflict
4332
+ re-check (the guard's first save-time consumer is the §8.64 dream save door).
4333
+
4334
+ - **The `origin` header field.** A **closed vocabulary** (`objective.ObjectiveOrigin`, a
4335
+ `StrEnum`; first value `learn-dream`), stored as a `str` in the `objective-header` (typed like
4336
+ `status`/`delivery`, the enum is the domain vocabulary). **Absence-compatible:** rendered only
4337
+ when set (the §8.42 additive-field rule) — every existing objective and every origin-less
4338
+ create/supersede renders byte-identically. **Launch-owned:** never model- or human-supplied —
4339
+ the launch flow injects it from claimed machine state; no interactive surface passes it.
4340
+ - **Write surface = create + the supersession carry, structurally enforced.** `origin` is stamped
4341
+ atomically into the INITIAL header by `create_objective(origin=…)` (never create-then-merge),
4342
+ and both live stores' `supersede_objective` automatically carry the predecessor's stored origin
4343
+ into the successor header (store-side, no parameter; validated — a junk stored value raises
4344
+ BEFORE the successor create; a header-less/sentinel-less predecessor carries nothing).
4345
+ `origin` is deliberately **excluded from `OBJECTIVE_HEADER_FIELDS`**, so the three
4346
+ `update_objective_header` writers reject any post-create origin merge via their existing LBYL
4347
+ check — while a stored origin round-trips untouched through unrelated merges (all three
4348
+ writers merge into the *parsed existing* mapping). The dormant issue-backed store's
4349
+ `create_objective` still stamps origin (it composes a real header); `adopt_source_as_objective`
4350
+ never stamps one (a machine-originated objective is never adopted-from).
4351
+ - **The `origin_value` validator** (`objective/parse.py`): `None` → `None`; a value in the closed
4352
+ vocabulary → the enum; **anything else raises `ValueError`** (fail-closed on junk/tampering,
4353
+ mirroring `delivery_policy`). Stores translate the `ValueError` into their native error at the
4354
+ boundary. (The header-dict origin classifier — the `delivery_policy` twin — is deferred to the
4355
+ first node that reads a specific objective's origin.)
4356
+ - **`find_open_objective_by_origin(origin, exclude_run_id=None) → ObjectiveRef | None`**
4357
+ (declared after `find_objective`): the first **open** match whose header `run_id` ≠
4358
+ `exclude_run_id` (`existed=True`); `None` means *authoritatively none in the exhaustively
4359
+ enumerated open population*. `exclude_run_id` is the caller-exclusion that makes a save-time
4360
+ re-check sound with a single-ref API (the consumer excludes its own run; any returned ref IS a
4361
+ conflict); a candidate with a missing/non-str `run_id` is never treated as excluded
4362
+ (fail-closed). **Exhaustive-or-raise:** an infra failure raises; a **present-but-malformed**
4363
+ header block/attachment raises (uncertainty about a real objective never reads as
4364
+ authoritatively-none); an origin outside the closed vocabulary raises (via `origin_value`);
4365
+ absent/different-known origins are non-matches (skip); the enumeration covers the full relevant
4366
+ open population — never one bounded page. A store that cannot answer authoritatively must
4367
+ RAISE, never return `None`.
4368
+ - **Per-store scope.** GitHub: **all pages** of the open `perk:objective` label population
4369
+ (a new paginated `_list_label_issues_all_pages` sibling; the bounded default-page reads are
4370
+ untouched). Linear project store: **team-scoped in v1** (a cross-team origin-stamped objective
4371
+ is invisible — documented limitation) — every team project swept via its metadata **sentinel**
4372
+ (the sentinel IS the identity; never the Reconcilable-marker heuristic); a sentinel-less
4373
+ project is skipped (the same accepted create-crash window as `find_objective`); a surviving
4374
+ match costs one project-state read (`completed`/`canceled` ⇒ closed, anything else including
4375
+ missing ⇒ open). The dormant issue-backed Linear store **raises**
4376
+ (`"the issue-backed Linear objective store does not support origin lookups"`) — deliberately
4377
+ OUTSIDE the `→ None`/`→ False` no-op family, which would falsely assert authoritatively-none
4378
+ and silently open a fail-closed guard.
4379
+ - **Closed under replan.** The supersession carry keeps the guarded origin population closed
4380
+ across re-authoring; during a deferred-close transfer window (§8.53) both predecessor and
4381
+ successor are visibly origin-stamped, so a concurrent origin-guarded launch correctly refuses.
4382
+
4071
4383
  ## §8.25 · The human-engagement read contract (Objective #682, Node 1.2)
4072
4384
 
4073
4385
  A backend-neutral **READ** surface for human engagement — comments, description edits, and
@@ -4340,9 +4652,11 @@ are verbatim human content". A normally-authored plan leaves it `None`.
4340
4652
  (mirrors `replan`/`resume`; `from` is a valid Click command string). It performs every Linear/GitHub
4341
4653
  read up front (the read-only plan-mode session has no `gh`/Linear access), then re-launches the
4342
4654
  `plan` stage seeded to author a plan over the materialized source. It **refuses** when: the issue is
4343
- not found (`adopt_not_found`), not OPEN (`adopt_not_open`), or already a perk plan
4655
+ not found (`adopt_not_found`), not OPEN (`adopt_not_open`), already a perk plan
4344
4656
  (`AdoptableIssue.already_plan` — backend-decided: GitHub the body block, Linear the plan-header
4345
- attachment → `already_a_plan`, hinting `perk plan replan <id>`).
4657
+ attachment → `already_a_plan`, hinting `perk plan replan <id>`), or a perk **objective**
4658
+ (`AdoptableIssue.already_objective` — presence-only, backend-decided like `already_plan` →
4659
+ `issue_kind_mismatch`; the GitHub message names the right door, `perk objective plan <N>`).
4346
4660
  Engagement is read fail-soft (`render_adopted_engagement` → `<untrusted_adopted_issue_engagement>`;
4347
4661
  `IssueBackendError` → omitted). The source is materialized to `scratch/adopt-<issue_id>.md` (title +
4348
4662
  body wrapped in `<untrusted_adopted_issue>` + the optional engagement block). A **fresh** `run_id`
@@ -4365,6 +4679,17 @@ prints the header/body (now including `adopted_from`) without writes.
4365
4679
  `adopted_from` plan-header field; `doctor` does **not** rewrite or validate the human prose/title —
4366
4680
  the substantive deliverable is this contract section, not a new validating check.
4367
4681
 
4682
+ **Wrong-kind refusal at the writer (the mutation boundary).** `adopt_issue_as_plan` itself
4683
+ refuses an **objective-metadata carrier** before its first mutation, on both backends — GitHub:
4684
+ an `objective-header` body block on the source read it already performs; Linear: an
4685
+ `objective-header` attachment (one extra presence-only read on this rare mutation) — raising the
4686
+ backend error (“wrong kind for plan adoption”). This closes the `perk plan save --adopt-from`
4687
+ direct-invocation bypass and the door-gather→save race window; the door's `already_objective`
4688
+ refusal above is the friendlier typed UX layer over the same rule. **Gists are exempt** in both
4689
+ directions: a gist carries `gist-header`, so no refusal fires and the sanctioned
4690
+ plan-header-beside-gist-header stamp keeps working. Residual: the writer's read→PATCH race
4691
+ window is inherent to non-transactional backends (accepted).
4692
+
4368
4693
  **Backend parity.** Honest on **both** GitHub and Linear (+ clean fake conformers). Live validation
4369
4694
  is a preview-grade observation here (Mode 7); final live
4370
4695
  proof is node 4.3.
@@ -4463,6 +4788,10 @@ reconcile. Mapped issues' titles/bodies are independently preserved verbatim by
4463
4788
  `adopt_not_found` (source `None`); GitHub-only `adopt_not_open` (the source issue is CLOSED, via the
4464
4789
  issue tier's `read_issue.state` — skipped for Linear projects, which have no OPEN/CLOSED);
4465
4790
  `already_an_objective` (the source prose already carries an `objective-header` block);
4791
+ GitHub-only `already_a_plan` (the source issue carries perk's plan metadata —
4792
+ `read_issue.already_plan`, checked in the same issue-tier read arm as the OPEN check; the
4793
+ message points at `perk plan replan <id>` or a fresh objective; Linear sources are Projects
4794
+ with no issue-tier read — honestly skipped, matching the OPEN check's scoping);
4466
4795
  `adopt_unsupported` (a `None` adoption return — in practice the resolver never returns the dormant
4467
4796
  store). Project-level engagement is read fail-soft (`render_adopted_engagement(comments, ())` →
4468
4797
  `<untrusted_adopted_issue_engagement>`; `ObjectiveStoreError` → omitted; per-issue engagement is
@@ -4483,6 +4812,14 @@ produces a fresh perk objective, `existed=False`). `--dry-run` falls through to
4483
4812
  `create_objective(dry_run=True)` compose-preview (the writer returns `None` on dry-run). No
4484
4813
  mutual-exclusion guard is needed (`objective create` has no `--node-id`).
4485
4814
 
4815
+ **Wrong-kind refusal at the writer (the mutation boundary).** GitHub
4816
+ `objectives.adopt_issue_as_objective` (the `adopt_source_as_objective` substrate) refuses a
4817
+ **plan-header carrier** before any mutation, keyed on the source body it already reads
4818
+ (“wrong kind for objective adoption”) — closing the `objective create --adopt-from` direct
4819
+ bypass, symmetric with §8.29's writer guard. The Linear **project** store adopts Projects (no
4820
+ issue-tier carrier — nothing to guard); the dormant issue-backed Linear store's adoption writer
4821
+ is out of scope. Gists stay exempt (a gist carries `gist-header`).
4822
+
4486
4823
  **Backend parity.** Honest on **both** GitHub and Linear (+ clean fake conformers). Live validation
4487
4824
  is preview-grade here (Mode 8); final live proof is Node
4488
4825
  4.3 — no new config key, provider seam, or `EXPECTED_SURFACE` change (a flag, not a new
@@ -4523,8 +4860,9 @@ nothing, the subset being shared).
4523
4860
  `prompts/contexts/adapters/` — with each module's identity marker passed as the `{{ marker }}`
4524
4861
  render var (never a template literal), so the marker the strip handler scans for cannot drift
4525
4862
  from the injected prose; the marker-as-render-var invariant now serves both the strip **and**
4526
- the dedup key (plannotator's two flavors share one customType but dedup per-flavor on their
4527
- distinct markers).
4863
+ the dedup key (plannotator's three flavors plan / objective / gist, the objective flavor
4864
+ serving both objective stages — share one customType but dedup per-flavor on their distinct
4865
+ markers).
4528
4866
 
4529
4867
  **Fail loudly on a missing var.** jinja2 uses `StrictUndefined` (raises `jinja2.UndefinedError`);
4530
4868
  the vendored `miniJinja` renderer matches it — a referenced name that is **absent OR non-string**
@@ -4609,6 +4947,13 @@ Everything outside (1)–(4) is **outside the subset** — `{% for %}`/`{% endfo
4609
4947
  with an allowlist posture (fail on any block matching no recognized construct):
4610
4948
  `tests/test_prompt_grammar.py` (Python) and `extension/substrate/promptGrammar.test.ts` (TS).
4611
4949
  `shared/contracts.md §8.31` is the SSOT for the shared scan algorithm; the two guards mirror it.
4950
+ The Python guard's scanner implementation lives in `perk_dev.prompt_grammar.scan_template`
4951
+ (consumed by both `tests/test_prompt_grammar.py` and the prose-review Assembly preview gate) and
4952
+ scans **whole-source** — unterminated, multiline, stray, nested, or partially matched delimiters
4953
+ are violations rather than unexamined text. That lexical completeness is a deliberate Python-side
4954
+ narrowing relative to the TS runtime tokenizer (`miniJinja.ts` accepts multiline tags and treats
4955
+ stray closers as literal text); the frozen construct set, the TS guard, and both runtime
4956
+ renderers are unchanged.
4612
4957
  The guard checks **construct membership only**, not if/endif nesting balance — structural balance
4613
4958
  is already proven by the golden harness rendering every real template. Widening the subset later
4614
4959
  (e.g. a future template needing `in` or parentheses) is a deliberate decision that amends this
@@ -4632,9 +4977,12 @@ create-new shape sidesteps that gap. The structural siblings are §8.27 (replan
4632
4977
  stage) that *borrows* the `objective-author` stage for launch (exactly like `plan replan` borrows
4633
4978
  `plan` and `objective author --from` borrows `objective-author`). It mints a **fresh** `run_id`
4634
4979
  (the new objective is net-new — no `run_id_override`), refuses `--remote` (objective-author is
4635
- `cold_remote:false`), and refuses a not-found / already-superseded / non-OPEN (GitHub) objective
4636
- (`objective_not_found` / `objective_not_open`). `("replan", ())` joins the `objective` group in the
4637
- parity-smoke `EXPECTED_SURFACE`.
4980
+ `cold_remote:false`), and obtains its one-snapshot title/URL/nodes plus delivery constraints from
4981
+ `Delivery.prepare(PrepareRequest(kind="replan", objective_id=...))`. Prepare owns not-found,
4982
+ already-superseded, non-open, fail-closed policy, journal, train, and claimed-prefix checks; the
4983
+ command retains the objective store only for fail-soft engagement/prose reads and backend wording.
4984
+ Refusals remain `objective_not_found` / `objective_not_open` and the §8.53 codes. `("replan", ())`
4985
+ joins the `objective` group in the parity-smoke `EXPECTED_SURFACE`.
4638
4986
 
4639
4987
  **The carry model.** Only the **unfinished** nodes carry forward (status ∈ {`pending`, `planning`,
4640
4988
  `in_progress`, `blocked`}); `done`/`skipped` nodes stay as **history on the closed old objective**
@@ -4676,11 +5024,12 @@ node-issues), `False` from the dormant issue-backed store (the no-op-family sign
4676
5024
  `supersede_objective(close_predecessor=True)` wraps it fail-open — one implementation, two
4677
5025
  postures.
4678
5026
 
4679
- **Transfer-aware routing.** The `--supersedes` dispatch performs §8.53's D1 classification
4680
- read first: only incremental→incremental takes the plain mutation above; a stacked
4681
- predecessor or an incremental→stacked conversion routes through the §8.53 transfer
4682
- protocol (which owns lineage copy-or-mint, prefix preservation, ownership transfer, deferred
4683
- close, and verification).
5027
+ **Transfer-aware routing.** Every real `--supersedes` save submits one immutable
5028
+ `TransferRequest` to `Delivery.transfer`. The façade acquires the shared operation lock before its
5029
+ single authoritative D1 predecessor read/classification and holds it through the selected
5030
+ mutation. Only incremental→incremental takes the plain mutation above; a stacked predecessor —
5031
+ or an incremental→stacked conversion — routes through the §8.53 transfer protocol (which owns
5032
+ lineage copy-or-mint, prefix preservation, ownership transfer, deferred close, and verification).
4684
5033
 
4685
5034
  **Backend-specific carry-forward.**
4686
5035
  - **GitHub** (a node is a row in one objective issue body): the new objective's roadmap rows are
@@ -4701,8 +5050,10 @@ close, and verification).
4701
5050
  `--adopt-from`: a `--supersedes` worker flag (recovered from the handoff via
4702
5051
  `_supersedes_from_handoff`; explicit flag wins) parses the carry map via the reused
4703
5052
  `objective.parse_adopt_mapping(raw_roadmap)` (the node→issue side-map, interpreted as **move**
4704
- semantics here) and calls `store.supersede_objective(...)`; a `None` return raises
4705
- `supersede_unsupported`. `--supersedes` and `--adopt-from` are **mutually exclusive** (`invalid_input`).
5053
+ semantics here), constructs exactly one `TransferRequest` from raw intent, and consumes only
5054
+ `TransferResult.successor`. Provider filtering, D1 classification, route choice, writer probes,
5055
+ and mutation stay behind Delivery; a `None` store result becomes `supersede_unsupported` there.
5056
+ `--supersedes` and `--adopt-from` are **mutually exclusive** (`invalid_input`).
4706
5057
 
4707
5058
  **Binding + skill.** `command:objective-replan → perk-objective-replan` (nudge) joins
4708
5059
  `shared/bindings.yaml` (mirroring `command:objective-reconcile`) and `DELIVERABLE_COMMAND_TARGETS`
@@ -5091,6 +5442,17 @@ text), derives `manifest.json` itself (`bad_input` when absent), and resolves th
5091
5442
  from `[models.subagents] learn-analyst` at execute time (the wave's workflow-level `model`
5092
5443
  default). The manifest write rule above and the DECISION vocabulary are unchanged.
5093
5444
 
5445
+ **Streaming launch manifests.** `startReportWave` returns one preflight-derived
5446
+ `WaveLaunchManifest = {requested, runnable, preflightFailures}` on both result arms. `requested`
5447
+ preserves the declared lane order; `runnable` is the ordered subset eligible for the rendered
5448
+ workflow after required-skill preflight; `preflightFailures` contains one ordered keyed
5449
+ `skill-unavailable` row per omission. The streaming adversarial/draft start tools expose this
5450
+ nested `launch` shape and say only runnable lanes launched; pending collection still keeps the full
5451
+ requested denominator. If every lane is skipped, no workflow is spawned: the start is unavailable,
5452
+ its receipt has no children, and the same keyed failures appear in the manifest/result without a
5453
+ synthetic wave-level failure. `WaveAttemptReceipt.requestedKeys` remains the pre-launch logical
5454
+ manifest and is not narrowed by this launch vocabulary.
5455
+
5094
5456
  **Attempt receipts (flow-generic).** Every code-owned wave flow records an **output-free**
5095
5457
  `WaveAttemptReceipt` per top-level workflow launch when the completion payload carries the
5096
5458
  projection (pi-subagents ≥ 0.45.0): the child lane key ↔ child `runId` ↔ artifact paths —
@@ -5099,7 +5461,7 @@ attempt (a failed lane and its relaunch stay distinguishable). `status.json.work
5099
5461
  remains the SOLE authority for reports and completeness; receipt absence (an identity-only
5100
5462
  completion) never changes a verdict, completeness, retry selection, or mutation decision —
5101
5463
  receipts are write-only correlation telemetry. The flow tools (`run_learn_wave`,
5102
- `run_harvest_wave`, `run_pr_review_wave`, `run_pr_review_dynamic_wave`, and the single-lane
5464
+ `run_harvest_wave`, `run_dream_wave`, `run_pr_review_wave`, `run_pr_review_dynamic_wave`, and the single-lane
5103
5465
  `classify_review_feedback` / `explore_objective_node`) persist `attempts` in their structured
5104
5466
  tool-result details only (never the model-facing prose); a wave-level soft-failure retains any
5105
5467
  receipt known before the failure in its fail details.
@@ -5263,7 +5625,7 @@ identity).
5263
5625
  | 2 | prompt generation (local vs worker) | canonical templates `prompts/stages/*` via the §8.31 render seam; `_implement_prompt`/`_address_prompt` ↔ `initialPromptFor` ↔ `implementHandoffPrompt`/`addressGuidance` | `tests/test_prompt_parity.py` (live cross-engine byte parity) + goldens; reciprocal substring suites `tests/test_worker_prompt_parity.py` ↔ `extension/worker/worker.test.ts`; binding-content byte parity `tests/test_binding_render_parity.py` (via `extension/testing/renderBindingsLive.ts`) |
5264
5626
  | 3 | submit side effects | one Python door, `perk pr submit --json`; the warm `submit` tool/`/submit` command delegate via `submitPr` (`extension/doors/submit.ts`), and the remote worker drives that same registered tool | `extension/worker/workerE2e.test.ts` (implement HAPPY drives the real tool through the real extension into a stubbed `PERK_BIN` router), `extension/doors/submit.test.ts`, `tests/test_pr_submit.py` |
5265
5627
  | 4 | address terminal criteria | `finalize_address` (`extension/doors/address.ts`) runs submit first, delegates its internal resolve half to `perk pr resolve-threads --json`, and appends `last_review_batch`; the worker requires finalizer success + that write + successful effective submit evidence with `mergeable !== false` | `workerE2e.test.ts` (address HAPPY binds both real door writes to classification), `worker.test.ts` `evaluateTerminal` matrix; post-address the supervisor re-classifies via row 1 |
5266
- | 5 | plan-ref reconstruction + positioning | one function, `resume.reconstruct_plan_ref` — all reconstruction sites converge on it (`cli/plan_selection.py::select_plan` for the explicit-id doors, `plan/resume_cmd.py`, `objective/run_cmd.py`, `run/run_worker.py`, the positioner's restore arm); one validating selector/positioner, `launch.resolve_worktree` (see the positioning semantics below) used by every cold door needing a plan checkout (`implement`/`submit`/`address`/`land`/`learn`/`plan watch`); `run_worker.position_worktree` mirrors `launch_stage`'s positioning, and stacked branch starts share `prepare_stacked_layer` (`require_ready_layer` + `prepare_layer_start`, §8.46) across `resolve_worktree` and `run_worker.position_branch` | `tests/test_plan_ref_parity.py` (the save→reconstruct round trip + the `PlanRef` field census), `tests/test_plan_selection.py`, `tests/test_resume.py`, `tests/test_launch_restore.py` (the non-destructive restore matrix), `tests/test_run_worker.py::test_positioning_parity_local_launch_vs_remote_worker` (artifact byte parity, `run_id` excepted; the explicit-ref twin pins the direct-ref arm), `tests/test_run_worker.py::test_positioning_parity_stacked_local_create_vs_remote_position` (same start SHA + `layer-context.json` parity, timestamps excepted) |
5628
+ | 5 | plan-ref reconstruction + positioning | one function, `resume.reconstruct_plan_ref` — all reconstruction sites converge on it (`cli/plan_selection.py::select_plan` for the explicit-id doors, `plan/resume_cmd.py`, `objective/run_cmd.py`, `run/run_worker.py`, the positioner's restore arm); one validating selector/positioner, `launch.resolve_worktree` (see the positioning semantics below) used by every cold door needing a plan checkout (`implement`/`submit`/`address`/`land`/`learn`/`plan watch`); `run_worker.position_worktree` mirrors `launch_stage`'s positioning, and fresh stacked starts independently call the same execution `Delivery.prepare(PrepareRequest(kind="layer_start", mode="execution", …))` boundary (§8.46) from `resolve_worktree` and `run_worker.position_branch` | `tests/test_plan_ref_parity.py` (the save→reconstruct round trip + the `PlanRef` field census), `tests/test_plan_selection.py`, `tests/test_resume.py`, `tests/test_launch_restore.py` (the non-destructive restore matrix), `tests/test_run_worker.py::test_positioning_parity_local_launch_vs_remote_worker` (artifact byte parity, `run_id` excepted; the explicit-ref twin pins the direct-ref arm), `tests/test_run_worker.py::test_positioning_parity_stacked_local_create_vs_remote_position` (same start SHA + `layer-context.json` parity, timestamps excepted) |
5267
5629
  | 6 | run reporting | **remote-only by design**: `perk/run/run_report.py` derives the §8.15 plan-issue comments + job summary solely from the §8.12 events stream + exit code | `tests/test_run_report.py` (incl. the `RunOutcome` lockstep literals) ↔ `worker.test.ts` (the frozen `assembleOutcome` shapes) |
5268
5630
 
5269
5631
  ### Positioning semantics (`launch.resolve_worktree` — the one selector/positioner)
@@ -5350,9 +5712,9 @@ asymmetry). Selection precedence + the two roots are §8.1. The rest of the post
5350
5712
  scoping applies there. Warm sessions and bare interactive `pi` are likewise untouched.
5351
5713
  8. **The stacked branch-creation gesture differs; the prepared start does not.** A fresh
5352
5714
  stacked layer starts locally via `git worktree add … <parent_sha>` and remotely via an
5353
- in-place `git checkout -b plan-<N> <parent_sha>` (§8.46) — both consume the same
5354
- `LayerContext` + `prepare_layer_start`, land on the same verified parent commit, and write
5355
- the same `layer-context.json` (timestamps excepted).
5715
+ in-place `git checkout -b plan-<N> <parent_sha>` (§8.46) — both independently call the same
5716
+ execution Prepare variant, consume its internal `LayerContext` + verified `parent_sha`, land
5717
+ on the same commit, and write the same `layer-context.json` (timestamps excepted).
5356
5718
  9. **The resume prior-work advisory is `plan resume`-cold-local-only.** When `perk plan resume`
5357
5719
  resolves to `implement` and the plan worktree already exists locally (the D4 reuse arm), the
5358
5720
  door appends `prompts/common/resume-advisory.md` to the implement primer via `launch_stage`'s
@@ -5438,8 +5800,10 @@ an extra user `--skill` stays additive — pi merges explicit skill paths even u
5438
5800
  1. `--no-skills`;
5439
5801
  2. the `include_dirs` whitelist entries (absolute `--skill <dir>`, config order);
5440
5802
  3. the **npm-package skills** (unless `include_packages = false`): from `.pi/settings.json`
5441
- `packages` (strings or `{source}` rows), **`npm:` sources only**
5442
- `.pi/npm/node_modules/<name>`. Local-path sources (the self-repo's `".."`) and `git:` sources
5803
+ `packages` (strings or `{source}` rows), except an object-form package whose `skills` key is
5804
+ exactly `[]` is excluded before package-directory probing and cannot trigger the package-tier
5805
+ wholesale fail-open. Remaining **`npm:` sources only** → `.pi/npm/node_modules/<name>`.
5806
+ Local-path sources (the self-repo's `".."`) and `git:` sources
5443
5807
  are deliberately **not** enumerated — first-party skills come from `.agents/skills` full stop
5444
5808
  (no committed-`skills/` fallback); enumerating the self-repo's local-path (`..`) package would
5445
5809
  also re-import the committed-`skills/` vs `.agents/skills` name-collision noise (the 16-way
@@ -5495,7 +5859,8 @@ stage (§8.50) carries `ask_user_question` + `run_audit_wave` + the research fam
5495
5859
  sessions run GATED (read-only mode), where the gate-ON set governs, so the row exists for the
5496
5860
  keys≡registry pin and the defensive gate-off arm; `run_audit_wave` also joins `PERK_TOOLS`
5497
5861
  and `READ_ONLY_TOOLS` (§8.3's carve-in — the write target is handoff-bound, never
5498
- caller-supplied). **Scoped universe:
5862
+ caller-supplied). `run_harvest_wave` and `run_dream_wave` likewise join `PERK_TOOLS` +
5863
+ `READ_ONLY_TOOLS` with NO stage row at all (cold-only, gate-on — §8.48/§8.61). **Scoped universe:
5499
5864
  `PERK_TOOLS ∪ BORROWED_TOOLS`** — perk's own name-keyed census plus the enumerated
5500
5865
  borrowed-package census (the web-provider union, pi-mono-linear's 25 tools, pi-subagents'
5501
5866
  delegation four, pi-fff's search names — both mode name-sets, `fffind`/`ffgrep`/`fff-multi-grep`
@@ -5611,7 +5976,10 @@ lenient read — an unknown stored scope parses to `None`). Per-backend storage:
5611
5976
  `find_gist_issue{run_id}` (label + header-key scoped — cannot return a plan/learn issue),
5612
5977
  `create_gist_issue{title, body, run_id, scope, dry_run}` (idempotent via the finder; stamps
5613
5978
  `scope` into the header), and `list_gist_issues{} -> GistSummary[]{id, title, url, body, scope,
5614
- adopted}` — every OPEN `perk:gist` issue; raises on infra failure, never masks as empty. The
5979
+ adopted}` — every OPEN `perk:gist` issue; raises on infra failure, never masks as empty. On
5980
+ GitHub `find_gist_issue` / `list_gist_issues` ride the same exhaustive full-open-set read as the
5981
+ plan/learn ops (`gh api --paginate --slurp`, per_page=100; fail-closed on an unexpected page
5982
+ shape). The
5615
5983
  `ObjectiveStore` grows the project-tier pair in the no-op-return family:
5616
5984
  `create_gist_source{title, prose, run_id, dry_run} -> ObjectiveRef | None` (`None` = "no project
5617
5985
  surface" — the CLI falls back to the issue tier; the Linear project store creates/finds the gist
@@ -5715,9 +6083,13 @@ fields are now **written** by stacked authoring: the `objective create` cold doo
5715
6083
  capability preflight; the TS plane carries the reviewed choice
5716
6084
  end-to-end (`objective_draft`/`objective_save` → `--delivery`). The **layer-identity trio**
5717
6085
  (`objective_node_id`/`delivery_lineage`/`predecessor_plan_id`) is now written at node-linked
5718
- plan save (§8.46); the checkpoint pair (`parent_checkpoint_sha`/`published_head_sha`) is
5719
- written by the §8.47 publish operation together, in one write, only after publication
5720
- verification.
6086
+ plan save through `PrepareRequest(kind="plan_identity", mode=…)` (§8.46): Prepare owns the
6087
+ single objective read and returns the independently optional objective base plus nested
6088
+ `PlanIdentity {objective_node_id, delivery_lineage, predecessor_plan_id}`. Every header write arm
6089
+ receives all three identity fields; `PlanRef` remains deliberately narrower and stores **only**
6090
+ `delivery_lineage` as its routing field (no node/predecessor/schema growth). The checkpoint pair
6091
+ (`parent_checkpoint_sha`/`published_head_sha`) is written by the §8.47 publish operation —
6092
+ together, in one write, only after publication verification.
5721
6093
 
5722
6094
  ## §8.45 · Stacked authoring: the reviewed delivery choice + capability preflight
5723
6095
 
@@ -5746,18 +6118,24 @@ facts and the seed instructs `delivery: stacked` without re-asking — the save
5746
6118
  `validate_stacked_roadmap` (errors verbatim under the existing `invalid_roadmap` error_type —
5747
6119
  including the one-node "save it as a standalone plan" message; a fan-out/fan-in DAG passes) →
5748
6120
  the `--adopt-from` refusal (`invalid_input`: in-place adoption of a stacked objective is
5749
- deferred — no roadmap node owns it) → effective probe-base resolution capability preflight
5750
- (**skipped on `--dry-run`**, which stays offline and still validates the bounds) → the write
5751
- gate (checked **last**, so the operator gets full honest capability feedback even while
5752
- gated) the store mutation. Stored `base` semantics are unchanged; the **effective probe
5753
- base** is explicit `--base` `[workflow] base` `git.detect_trunk_branch(repo_root)`
5754
- (mirroring the train read path's `header.base or trunk`).
5755
-
5756
- **The capability preflight** is `perk.delivery.capability.preflight_stacked_authoring(repo_root,
5757
- *, base)` → a `CapabilityReport` of `CapabilityCheck {name, ok, detail}` rows, probe callables
5758
- keyword-injectable (production defaults; tests pass fakes). Import direction stays §8.44's
5759
- (delivery github/substrate; the CLI imports delivery). The checks, each with honest
5760
- expected-vs-observed detail:
6121
+ deferred — no roadmap node owns it) → authoring Prepare (**skipped on `--dry-run`**, which stays
6122
+ offline and still validates the bounds) → the write gate (retired below) → the store mutation.
6123
+ Stored `base` semantics are unchanged. The CLI passes the stored base intent — explicit `--base`
6124
+ `[workflow] base` `None` to Prepare; optional-base trunk fallback now belongs to the
6125
+ façade and still yields the same effective probe base.
6126
+
6127
+ **The capability boundary** is
6128
+ `resolve_delivery(repo_root).prepare(PrepareRequest(kind="authoring",
6129
+ base=<stored-base-or-null>))` → `PrepareResult(kind="authoring", base=<effective-base>)`.
6130
+ `resolve_delivery` remains zero-I/O; `Delivery.prepare` resolves a null base through
6131
+ `DeliveryGit.trunk_branch`, invokes the authorities in the order below, and returns only after
6132
+ every check passes. Capability rows are private implementation details: a complete failed-row set
6133
+ raises one `DeliveryError(error_type="capability_unsupported")` whose message is
6134
+ `This repository cannot take a stacked delivery train against base <repr>:` followed by every
6135
+ `- <name>: <detail>` row in observation order. Import direction stays §8.44's (delivery →
6136
+ github/substrate; the CLI imports delivery). A stacked `--dry-run` does not resolve a `Delivery`
6137
+ or invoke Prepare at all, so its compose-preview remains fully offline. The checks, each with
6138
+ honest expected-vs-observed detail:
5761
6139
 
5762
6140
  - **`native-stack`** — `stacks.stack_capability(repo_root)`: a GraphQL schema-introspection
5763
6141
  read (`__type(name: "PullRequest")` → a `stack` field exists). **Fail closed** (introspection
@@ -5766,8 +6144,9 @@ expected-vs-observed detail:
5766
6144
  - **`merge-rules`** — `stacks.base_merge_rules(repo_root, base)` → `MergeRules
5767
6145
  {squash_allowed, merge_queue_required}`: GraphQL `repository { squashMergeAllowed }` (squash
5768
6146
  direct merge must be allowed) + REST `GET repos/{owner}/{repo}/rules/branches/{base}` (any
5769
- effective `merge_queue` rule ⇒ reject). Strict reads — an infra failure raises
5770
- `GitHubError`, which the composer converts to a failed check (can't verify ⇒ don't promise).
6147
+ effective `merge_queue` rule ⇒ reject). Strict reads — the real authority adapter converts an
6148
+ expected `GitHubError` into its frozen `ProbeError`; Prepare records that discriminant as a
6149
+ failed check and continues (can't verify ⇒ don't promise).
5771
6150
  - **`remote-base`** — `git.remote_branch_head`: the observed remote base SHA. An absent remote
5772
6151
  base branch is a **failed check** (honest message), never a crash; without it the push
5773
6152
  probes are skipped.
@@ -5778,10 +6157,10 @@ expected-vs-observed detail:
5778
6157
  failure). The detail states — on success AND failure — that the probe proves **server
5779
6158
  capability and authentication, not branch write permission**.
5780
6159
 
5781
- A preflight failure fails the save with `error_type="capability_unsupported"`, the message
5782
- carrying every failed check's expected-vs-observed detail. The probes run against the
5783
- Git/GitHub plane **regardless of issue backend** (GitHub is the universal PR plane even when
5784
- objectives live on Linear).
6160
+ Prepare aggregates independent failures: native stack, merge rules, and remote base are always
6161
+ observed in order; push-URL resolution and one atomic probe per URL run only after a positive
6162
+ remote-base SHA, and all URLs must pass. The probes run against the Git/GitHub plane **regardless
6163
+ of issue backend** (GitHub is the universal PR plane even when objectives live on Linear).
5785
6164
 
5786
6165
  **The write gate (retired).** Stacked authoring shipped behind a development-only environment
5787
6166
  opt-in (a missing opt-in failed the save with a typed gated refusal) until the publication
@@ -5901,14 +6280,15 @@ ambiguous** — it proves neither presence nor absence, so it raises `JournalApp
5901
6280
  without a retry (only a rescan that proved absence earns the retry). The remote effect an event
5902
6281
  guards must not proceed past an ambiguous append.
5903
6282
 
5904
- **The rest of the stored train state** writes through `TrainPersistence`'s typed writers — thin
5905
- compositions over the §8.42 merge-write seams, enforcing the write-together rules structurally
5906
- (no single-field surface): `write_checkpoints` (the `parent_checkpoint_sha` +
5907
- `published_head_sha` pair in ONE `update_plan_header` write), `transfer_plan_ownership`
5908
- (`objective_id` + `objective_node_id`), `stamp_layer_identity` (`delivery_lineage` +
5909
- `predecessor_plan_id`; an explicit null predecessor is contract-legal for the bottom layer), and
5910
- `write_delivery_lineage` (`update_objective_header`; lineage *minting* stays the authoring
5911
- node's concern).
6283
+ **The rest of the stored train state** uses the §8.42 merge-write seams. `TrainPersistence`
6284
+ retains the reusable typed writers: `write_checkpoints` writes the `parent_checkpoint_sha` +
6285
+ `published_head_sha` pair in ONE `update_plan_header` call, and `write_delivery_lineage` delegates
6286
+ to `update_objective_header` (lineage *minting* stays the authoring node's concern). Transfer's
6287
+ private roll-forward core instead uses its issue seam's generic `update_plan_header` once per
6288
+ atomic group: ownership (`objective_id` + `objective_node_id`), stacked identity
6289
+ (`delivery_lineage` + `predecessor_plan_id`, whose bottom value may be null), or all four explicit
6290
+ nulls when clearing stacked metadata. Read-before-write checks preserve idempotence; no
6291
+ transfer-only persistence wrappers remain.
5912
6292
 
5913
6293
  **Engagement exclusion.** Journal comments carry a `perk:*` sentinel in either encoding, so the
5914
6294
  existing engagement classifier (§8.25) classifies them `perk` and the engagement renderers drop
@@ -5923,20 +6303,155 @@ deliberately untouched (no cross-plane consumer yet — mirroring §8.42's postu
5923
6303
 
5924
6304
  ## §8.44 · The DeliveryTrain projection + stack status (read path)
5925
6305
 
5926
- **The projection.** `perk.delivery.train.reconstruct_train` rebuilds one **immutable**
5927
- `DeliveryTrain` projection of a stacked objective from the durable authorities the objective
5928
- store (policy, lineage, roadmap), the plan issues (layer identity + checkpoints), the journal
5929
- fold (§8.43), Git refs, and GitHub PR + native-stack state. Pure orchestration over narrow
5930
- injected Protocols declared in `train.py` (`ObjectiveReader`/`PlanReader`/`JournalReader`/
5931
- `GitProbe`/`GitHubProbe`); the production wiring is the leaf `perk/delivery/observe.py`
5932
- (`RepoGitProbe`, `GatewayGitHubProbe`, `resolve_train_reads(repo_root)`, and the composed
5933
- `reconstruct_repo_train` convenience) — `observe.py` + `capability.py` (§8.45) + `layer.py`
5934
- (§8.46) + `finalize.py` (the land path) are the delivery leaves touching `perk.substrate.git` /
5935
- `perk.github`. Import
5936
- direction stays §8.43's: nothing in
5937
- `perk/backends/` or `perk/github/` imports `perk.delivery`. Read-only end to end; the
5938
- projection works from a **fresh clone** — no local worktree or branch is authoritative, and
5939
- local absence is never an error.
6306
+ **The projection and public façade boundary.** `perk.delivery.train.reconstruct_train` remains the
6307
+ internal pure core that rebuilds one **immutable** `DeliveryTrain` projection from the durable
6308
+ authorities — the objective store (policy, lineage, roadmap), plan issues (layer identity +
6309
+ checkpoints), the journal fold (§8.43), Git refs, and GitHub PR + native-stack state. The canonical
6310
+ repository-scoped APIs are `resolve_delivery(repo_root).status(StatusRequest(objective_id))`,
6311
+ one flat frozen `Delivery.prepare(PrepareRequest(...))` family,
6312
+ `Delivery.transfer(TransferRequest(...))`, `Delivery.publish(PublishRequest(...))`, and
6313
+ `Delivery.sync(SyncRequest(...), consent=...)`.
6314
+ The sync keyword is required at the call
6315
+ boundary: a caller must explicitly pass a callback or deliberately pass `None` for automation's
6316
+ auto-approval policy; omission can never silently select mutation consent. `resolve_delivery`
6317
+ performs ZERO I/O. `Delivery.status` returns a `StatusResult` whose invariant is exactly one
6318
+ non-null branch:
6319
+ `train` OR the successful incremental `no_train_reason`, alongside `objective_id`, `objective_url`,
6320
+ and `redirected_from`.
6321
+
6322
+ Prepare's legal request/result variants are closed shapes (an unknown `kind` retains the exact
6323
+ `unknown prepare kind: <repr>` diagnostic; every other illegal shape raises `ValueError`):
6324
+
6325
+ - `authoring`: optional request `base`, no mode/ids; result carries only effective `base`;
6326
+ - `replan`: required nonblank objective, no other fields; result carries one nested
6327
+ `ReplanContext` from the single objective snapshot (`objective_id`/URL/title/nodes, policy,
6328
+ base/lineage) plus claimed `ReplanClaim` rows and open-PR plan pairs in delivery order. An
6329
+ incremental objective has empty claimed/open-PR facts; stacked preparation additionally gates
6330
+ the journal, bound status projection, structural blockers, and claimed prefix;
6331
+ - `plan_identity`: explicit `mode=strict|best_effort`, required objective, optional node (strict
6332
+ requires it), no base/plan request fields; result carries independently optional objective
6333
+ `base` and nested `PlanIdentity`, or — only for a best-effort expected read failure —
6334
+ `notice=str(exc)` with base/identity absent. The empty string is a valid notice and
6335
+ `notice is not None` is the failure discriminator;
6336
+ - planning `layer_start`: `mode=planning`, required objective, optional requested node; result
6337
+ carries exactly one internally-valid nested `PlanningDecision`; and
6338
+ - execution `layer_start`: `mode=execution`, required plan, nullable objective solely for the
6339
+ defensive missing-objective refusal; result carries the internal `layer.LayerContext` plus a
6340
+ nonblank verified `parent_sha`.
6341
+
6342
+ `TransferRequest` is frozen intent only: predecessor id, run id, title, prose, nullable base,
6343
+ immutable roadmap nodes, ordered immutable raw `(node_id, issue_id)` carries, and successor
6344
+ `delivery=incremental|stacked`. It deliberately performs no constructor normalization or
6345
+ validation. `Delivery.transfer` applies the pure recoverability predicate before lock/I/O: closed
6346
+ delivery value, non-empty unique-node roadmap, known dependencies, acyclic delivery order,
6347
+ unique/nonblank/subset carry keys, and nonblank string carry identities. `TransferResult` preserves
6348
+ the existing `{predecessor_id, successor, operation_id, abandoned_operation_id, rolled_forward,
6349
+ journaled}` shape without a constructor matrix; plain incremental→incremental returns the
6350
+ null-operation arm.
6351
+
6352
+ `SyncRequest` is the other closed flat family. It carries `mode=cascade|continue|abort` plus a
6353
+ required nonblank objective. Cascade requires a nonblank journal `run_id`; optional `include_base`,
6354
+ `dry_run`, `adopt_node`, `trigger_plan_id`, and raw `trigger_run_id` obey this matrix: base and
6355
+ adoption are exclusive; a trigger plan composes with neither; trigger run requires trigger plan;
6356
+ dry run may compose with base, adoption, or trigger. Continue/abort carry no cascade field.
6357
+ `SyncResult` moves the existing flat operation result unchanged and deliberately has no new
6358
+ constructor combination guards: its additive historically reachable arms are enforced by the
6359
+ operation protocol, not a partial discriminator. Its frozen nested records are `Layer`, `Cascade`
6360
+ (the consent preview), and `AbortPreview`; these add no separate package-root names.
6361
+
6362
+ `PublishRequest` is the closed flat publish/ready family:
6363
+
6364
+ - common fields are `kind=layer|ready`, required nonblank `plan_id` (accepted with or without one
6365
+ leading `#`), and `dry_run=false`;
6366
+ - either dry-run kind accepts only those common fields and returns before route classification or
6367
+ any authority call;
6368
+ - a real layer requires nonblank journal `run_id`, permits one optional nonblank raw
6369
+ `trigger_run_id`, and rejects delivery/objective fields; and
6370
+ - a real ready requires `delivery=incremental|stacked`, rejects run fields, requires no objective
6371
+ for incremental, and permits a null stacked objective solely for the defensive `not_stacked`
6372
+ refusal (a present objective is nonblank).
6373
+
6374
+ `PublishResult {kind, plan_id, dry_run, layer?, ready?}` carries the canonical bare plan id and
6375
+ exactly one matching nested detail. `Ready {pr, was_draft}` preserves the gateway PR and its
6376
+ pre-mutation draft fact. `Layer` is exactly `{pr, branch, header_update, plan_embedded, pr_checked,
6377
+ parent_branch, operation_id, stack_number, stack_size, stack_position, parent_checkpoint_sha,
6378
+ published_head_sha, resumed, converged_noop, cascade}`. The stack triple is all-null or all-positive
6379
+ with position within size. A real layer has nonblank branches/checkpoints, a non-dry-run header
6380
+ update, and `pr_checked=true`; a direct result has no operation id exactly on a converged no-op. A
6381
+ cascade carries `SyncResult` directly, mirrors its operation/resume/no-op fields, and carries no
6382
+ stack triple. There is no replacement operation wrapper. The dry-run layer and ready sentinels,
6383
+ including PR number/url/draft/state/existed facts and the layer's exact synthetic header update,
6384
+ are constructor-validated; malformed combinations raise `ValueError`.
6385
+
6386
+ The nested detail vocabulary adds no package-root exports: `PlanIdentity`; `PlanningNode`;
6387
+ `PlanningContext {position, layer_count, delivery_lineage, base, predecessor_node_id,
6388
+ predecessor_plan_id, parent_branch, observed_parent_head_sha}` (one-based position/count); and
6389
+ `PlanningDecision {kind, objective_id, objective_title, objective_url, requested_node_id, node,
6390
+ reason, skipped_claim_ids, context}`. Decision `kind` is exactly `ready | build_blocked |
6391
+ in_flight | wrong_candidate | complete | node_not_found | terminal | blocked | no_actionable`;
6392
+ node/reason/skipped/context presence is shape-validated (context only on train-derived `ready`; the
6393
+ all-layers-published graph fallback may be `ready` without context). The package root exports
6394
+ `PublishRequest`, `PublishResult`, `TransferRequest`, and `TransferResult` but no publication or
6395
+ transfer core/runtime/error. Removing `DeliveryOperationFacts`, `LayerBodyFacts`,
6396
+ `PublicationError`, `PublicationResult`, `TrainRowFacts`, and `publish_layer` plus adding the two
6397
+ Transfer values left the exact 61-name `perk.delivery.__all__` of that era; the atomic
6398
+ objective-landing migration has since completed the cut to the **canonical 20-name surface**
6399
+ (`Delivery`, `resolve_delivery`, `DeliveryError`, the three authority ABCs, and the seven
6400
+ request/result families) — every operation module, the journal/persistence machinery, and
6401
+ the landing readiness/mutation internals are module-path-internal only.
6402
+
6403
+ The façade composes three nominal ABC authorities, with each interface, real adapter, and owned
6404
+ constructor-configured fake moving in lockstep. `DeliveryPersistence` aggregates objective, plan,
6405
+ and journal reads plus `get_plan_body(*, issue_id)`, `update_plan_header(*, issue_id, fields)`,
6406
+ prepared/outcome appends, checkpoint-pair writes, transfer carry normalization, objective lookup
6407
+ by run id, supersede creation, and supersession finalization. `DeliveryGit` exposes its bound `repo_root`,
6408
+ aggregates trunk/fetch/exact-ref-fetch/local-commit-resolution/ref/ancestry/worktree/base reads,
6409
+ adds `push_with_exact_lease(branch, *, expected_remote_sha)`, plus Prepare's push-URL resolution and
6410
+ no-op atomic probe, and owns
6411
+ the genuine sync Git behavior: exact-leased atomic push; temp-ref update/delete/list; detached
6412
+ worktree add/remove/prune; detached checkout/rebase; retained-worktree rebase/dirty state; and
6413
+ worktree-scoped commit resolution. It reuses substrate `RefUpdate`, rebase results, `GitError`, and
6414
+ `PushRejectedError` unchanged. `DeliveryGitHub` aggregates stable PR and tolerant native-stack
6415
+ reads plus Prepare's host stack-capability/base merge-rule facts. Its existing branch lookup is the
6416
+ rich all-state `pr_for_branch(branch) -> PullRequest|null`; its strict read is
6417
+ `strict_stack(number) -> StackRestFacts|null`. Publication/ready add the distinct `get_pr`,
6418
+ `create_pr`, `update_pr_body`, `update_pr_base`, `reopen_pr`, `mark_pr_ready`, `create_stack`, and
6419
+ `append_stack` effects; sync adds active-writer plan observation. No duplicate branch/stack/full-PR
6420
+ endpoint exists. The production
6421
+ `RepoDeliveryPersistence`, `RepoDeliveryGit`, and
6422
+ `RepoDeliveryGitHub` adapters live in `perk/delivery/observe.py`. Persistence resolves the
6423
+ backend-aligned objective store, issue backend, and `TrainPersistence` only on its first read,
6424
+ caches them only after backend identities agree, and keeps no partial cache after a failed attempt.
6425
+ The Git and GitHub adapters likewise store only constructor data and observe on method calls.
6426
+ Publication binds these same instances plus bound `status`/`sync` into private `_PublishContext`;
6427
+ private immutable `_PublishRuntime` owns only operation-id minting, clock, sleep, and PR-body
6428
+ validation. Transfer binds the same instances into private `TransferSeams` plus aggregate Git and
6429
+ GitHub authorities; recovery continues to compose `TransferSeams` directly. Its private runtime
6430
+ owns only the operation lock, operation-id minting, and clock. The bound-status bridge unwraps only
6431
+ the exact reconstruction/store/issue/persistence causes expected by the shared transfer core.
6432
+ Cause-aware bridges around the status-oriented Git/GitHub reads unwrap only a
6433
+ `TrainReconstructionError` whose direct cause is the matching raw `GitError`/`GitHubError`; every
6434
+ other typed failure stays typed and fails closed. Both Publish dry-run arms return before every
6435
+ context method, so assignment-only resolution remains offline under GitHub and Linear config.
6436
+
6437
+ For a new authority call whose consumer must record a failure and continue independent checks, the
6438
+ aggregate ABC owns frozen nested success/error discriminants: Git `PushUrlsResult`,
6439
+ `AtomicPushResult`, and `ProbeError`; GitHub `MergeRules` and `ProbeError`. Only the real adapter
6440
+ catches the expected substrate/gateway exception and converts it; fakes return constructor-seeded
6441
+ discriminants without catching programming errors. The existing terminal status methods retain
6442
+ their typed exceptions. No speculative subgateway split is warranted: each capability belongs to
6443
+ one existing aggregate authority, while narrow pure-core reader roles remain internal. There is no
6444
+ dry-run authority implementation because objective-create dry run omits Prepare entirely.
6445
+
6446
+ Import direction stays §8.43's: nothing in `perk/backends/` or `perk/github/` imports
6447
+ `perk.delivery`. The projection is read-only and works from a **fresh clone** — no local worktree
6448
+ or branch is authoritative, and local absence is never an error. Branch-sensitive laziness is
6449
+ load-bearing: status reads objective policy before fallback trunk resolution, so an incremental
6450
+ objective returns the no-train branch without Git or GitHub work; authoring Prepare never resolves
6451
+ persistence, issue backends, credentials, or configuration and touches only its Git/GitHub
6452
+ authorities. Identity Prepare performs exactly one objective read and no Git/GitHub call; planning
6453
+ delegates to exactly one status reconstruction; execution delegates to status and then exact
6454
+ parent-ref verification.
5940
6455
 
5941
6456
  **Layer axes.** Layer state is orthogonal, never one lossy enum: `intent`
5942
6457
  (`skipped|unplanned|planned|canceled` — skipped nodes contract out of the rendered layers;
@@ -6031,25 +6546,68 @@ projection-only PENDING ordering surrogates, never returned/persisted) → roadm
6031
6546
  `delivery_order` over the effective nodes → the node↔plan join (idempotent per layer — a plan
6032
6547
  preloaded by the cancellation proof is never re-joined, so canonical findings emit once) →
6033
6548
  PUBLISH coverage + checkpoint topology → predecessors → Git observation (reusing the fetch) →
6034
- PRs → publication → membership → prefix → base → readiness. `DeliveryTrain` additionally
6549
+ PRs → publication → membership → prefix → base → readiness. Production reconstruction also
6550
+ captures `objective_title` and the exact active-state `objective_nodes` tuple on the immutable
6551
+ train for planning Prepare; both are defaulted internal projection inputs and are deliberately
6552
+ absent from `TrainOut`, so status human/JSON bytes do not grow. `DeliveryTrain` additionally
6035
6553
  carries the §8.54 projection facts `projected_canceled_nodes` /
6036
6554
  `repairable_canceled_nodes` (default-empty tuples of `ProjectedCancellation(node_id,
6037
6555
  persisted_status)`).
6038
6556
 
6039
6557
  **Failure-posture split.** Stable authorities hard-fail: a failed objective read, plan join,
6040
- journal **carrier** read, or `git fetch` is a command failure `TrainReconstructionError`
6041
- with a stable `error_type` (`objective_not_found | invalid_delivery_policy | invalid_train |
6042
- git_error | github_error | supersession_corruption`). Only two reads degrade: the **preview**
6043
- native-stack read (membership `unknown` + information `stack_read_unavailable`, never a
6044
- blocker but unverifiable membership still declassifies the affected layers' publication to
6045
- drift: the information posture governs the *finding*, not the verification bar) and journal
6046
- **corruption** (`JournalCorruptionError` the `journal_corruption` blocker; unresolved-
6047
- operation facts report unknown). A superseded objective **redirects forward** along
6048
- `superseded_by` (cycle guard + depth cap 50; breach ⇒ `supersession_corruption`) to the active
6049
- objective and reports `redirected_from`. An incremental objective short-circuits before any
6050
- Git/GitHub work into the successful `NoDeliveryTrain` answer; a junk `delivery` value fails
6051
- closed (`invalid_delivery_policy`). `delivery: stacked` without a lineage renders the train
6052
- with the `missing_lineage` blocker and skips the journal fold (report, don't abort).
6558
+ journal **carrier** read, or `git fetch` is a status failure. At the pure-core seam this remains
6559
+ `TrainReconstructionError`; `Delivery.status` translates ONLY the declared status codes into
6560
+ `DeliveryError` with the same message and stable `error_type` (`objective_not_found |
6561
+ invalid_delivery_policy | invalid_train | git_error | github_error |
6562
+ supersession_corruption`). The private status allowlist remains exactly those six even though
6563
+ `DeliveryError`'s façade-wide vocabulary is the bounded union of status, Prepare, Transfer,
6564
+ Publish, and sync codes.
6565
+ Prepare adds `capability_unsupported | invalid_input | missing_lineage |
6566
+ stacked_predecessor_missing | unknown_layer | node_not_build_ready | parent_missing |
6567
+ parent_unverified`. Expected
6568
+ objective-store, issue-backend, and train-persistence exceptions normalize to `github_error`.
6569
+ `DeliveryError` rejects unknown codes, and a status error outside its six-code subset propagates
6570
+ rather than silently widening status. Prepare reuses the status-owned
6571
+ trunk and remote-branch methods without changing their status messages: for a typed `git_error`
6572
+ wrapper it preserves the chained substrate `GitError` text when that guarded cause exists, else the
6573
+ wrapper text; a non-`git_error` reconstruction failure remains unexpected and propagates.
6574
+ `DeliveryError` additionally owns sync's bounded operation codes (`not_stacked`,
6575
+ `unresolved_operation`, `sync_conflict_pending`, `claimed_prefix_malformed`, `active_writer`,
6576
+ `dirty_worktree`, `writer_observation_unavailable`, `remote_drift`, `pr_drift`,
6577
+ `membership_drift`, `stale_parent`, `base_unobserved`, `multiple_push_urls`,
6578
+ `atomic_push_unsupported`, `rebase_conflict`, `push_rejected`, `sync_drift`,
6579
+ `postcondition_unverified`, `adopt_blocked`, `no_continuation`, `continuation_stale`,
6580
+ `continuation_invalid`, `rebase_in_progress`, `operation_in_progress`) and boundary codes
6581
+ `journal_corruption`, `journal_record_too_large`, and `invalid_config`. Publish/ready add only
6582
+ `delivery_error`, `stack_capability_lost`, `pr_already_merged`, `remote_settling_timeout`,
6583
+ `stack_registration_drift`, `stack_registration_failed`, `publication_drift`, `no_pr`,
6584
+ `pr_not_open`, `layer_not_published`, and `structural_blockers`; status's private subset remains
6585
+ exactly the six codes above.
6586
+
6587
+ Every `DeliveryError` emitted by `Delivery.publish` carries a jointly-present constrained
6588
+ `phase=layer|cascade|ready` and `origin=domain|git|github|delivery`; existing operations may leave
6589
+ both absent. Package-internal `PublicationError` and pure layer refusals become domain errors with
6590
+ their original code/message. Bound sync errors become `(cascade,delivery)`; bound layer status,
6591
+ objective-store/persistence/journal/reconstruction failures become `delivery_error/(layer,delivery)` except
6592
+ record-size, which preserves `journal_record_too_large`; raw exact-lease rejection and Git failures
6593
+ become `(layer,git)`; raw GitHub/issue failures become `(layer,github)`. Ready's pure selection and
6594
+ reviewability refusals become `(ready,domain)`; a status reconstruction cause preserves its domain
6595
+ code/message, while backend/objective/persistence/journal and raw GitHub failures become
6596
+ `github_error/(ready,github)`. Unexpected exceptions are not wrapped. CLI presentation maps these
6597
+ facts back to the established submit/ready prefixes and bytes.
6598
+
6599
+ Only two reads
6600
+ degrade: the **preview** native-stack read (membership `unknown` + information
6601
+ `stack_read_unavailable`, never a blocker — but unverifiable membership still declassifies the
6602
+ affected layers' publication to drift: the information posture governs the *finding*, not the
6603
+ verification bar) and journal **corruption** (`JournalCorruptionError` → the
6604
+ `journal_corruption` blocker; unresolved-operation facts report unknown). A superseded objective
6605
+ **redirects forward** along `superseded_by` (cycle guard + depth cap 50; breach ⇒
6606
+ `supersession_corruption`) to the active objective and reports `redirected_from`. An incremental
6607
+ objective short-circuits before fallback trunk detection, fetch, or any GitHub work into the
6608
+ successful `StatusResult.no_train_reason` branch; a junk `delivery` value fails closed
6609
+ (`invalid_delivery_policy`). `delivery: stacked` without a lineage renders the train with the
6610
+ `missing_lineage` blocker and skips the journal fold (report, don't abort).
6053
6611
 
6054
6612
  **The GitHub-native read adapter.** `perk/github/stacks.py` splits the reads by schema
6055
6613
  stability: `pr_delivery_facts(number, repo_root)` reads the **stable** GraphQL surface
@@ -6096,7 +6654,10 @@ reports no pending continuation, and an unparseable manifest file reports a
6096
6654
  than being hidden; (b) the orphaned-sync-residue observation through recover's shared
6097
6655
  classifier (§8.51), **fail-honest**: a Config-load or git/fs read failure — and the
6098
6656
  classifier's own unparseable-manifest skip — reports `observed: false` plus the reason;
6099
- `observed: true` with empty lists means *genuinely clean*. The reported worktrees include
6657
+ `observed: true` with empty lists means *genuinely clean*. This status-only path calls the
6658
+ package-internal read-only `recover.observe_orphans` classifier directly; it is deliberately not a
6659
+ second `RecoverRequest` variant, takes no operation lock, and performs no cleanup. The reported
6660
+ worktrees include
6100
6661
  the stale worktree-admin entries (directory gone, inventory record left) beside the on-disk
6101
6662
  ones — both are would-be sweep targets. An unobserved state is never
6102
6663
  serialized as clean empty lists (the Config load is tolerant only in that it degrades the
@@ -6106,9 +6667,12 @@ observation, never the command).
6106
6667
  (`commands/objective/stack/`, the recursive group-dir template; the group carries `status` +
6107
6668
  `sync` (§8.49) + `recover` (§8.51) + `land` (§8.55 dry-run readiness + §8.56 the landing
6108
6669
  mutation); the shared objective resolution and run-id resolution live in
6109
- `stack/shared.py`). Resolution:
6110
- explicit argument the plan worktree's `cache.plan-ref` `objective_id` a typed
6111
- `no_objective` refusal. Envelope (`ObjectiveStackStatusOut`, snapshotted at
6670
+ `stack/shared.py`). It is a migrated façade consumer: after objective-id resolution it constructs
6671
+ one lazy repository service and makes one `Delivery.status(StatusRequest(...))` call; it never
6672
+ imports or assembles the pure reconstruction readers. Resolution: explicit argument → the plan
6673
+ worktree's `cache.plan-ref` `objective_id` → a typed `no_objective` refusal. A `DeliveryError`
6674
+ preserves the declared status error type/message at the command boundary. Envelope
6675
+ (`ObjectiveStackStatusOut`, snapshotted at
6112
6676
  `shared/schemas/outputs/objective-stack-status.schema.json`): `{success, error_type,
6113
6677
  objective{id,url,redirected_from}, delivery: incremental|stacked, train|null, no_train|null,
6114
6678
  operations[], continuation|null, orphaned_residue}` — the last three are the additive
@@ -6134,13 +6698,14 @@ remain read-only (the dry-run push is a no-op); build-ready derivation now rides
6134
6698
  projection (§8.46); suffix synchronization has since landed as its own operation (§8.49);
6135
6699
  recovery and the warm stack surface (`/objective-stack` + the typed stack tools) have since
6136
6700
  landed (§8.51); atomic landing has since landed (§8.55 readiness + §8.56 the mutation);
6137
- doctor findings are a later node's contract.
6701
+ doctor findings have since landed (§8.54, the unified drift-finding policy).
6138
6702
 
6139
6703
  ## §8.46 · Stacked build readiness + parent-aware execution
6140
6704
 
6141
- **Build readiness is a derived fact of the projection — never a node status.**
6142
- `reconstruct_train` computes a frozen `BuildReadiness {next_node_id, ready, reason}` at the end
6143
- of the pipeline and carries it on `DeliveryTrain.build_readiness`. `next_node_id` is the first
6705
+ **Build readiness is a derived fact of the projection — never a node status.** The internal pure
6706
+ projection computes a frozen `BuildReadiness {next_node_id, ready, reason}` at the end of the
6707
+ pipeline and carries it on `DeliveryTrain.build_readiness`; repository consumers obtain that
6708
+ projection through `Delivery.status`, never by assembling the pure readers. `next_node_id` is the first
6144
6709
  layer in delivery order whose `publication` is not `PUBLISHED` (`None` when every layer is
6145
6710
  published, or the layer list is empty/all-skipped — the contiguous-prefix invariant makes the
6146
6711
  candidate's predecessor published by construction; a violation is already a `prefix_gap`
@@ -6153,57 +6718,68 @@ untouched. `perk objective stack status` surfaces it additively: the `--json` `t
6153
6718
  gains `next_build_ready {node_id, ready, reason}` (schema snapshot regenerated) and the human
6154
6719
  render one line (`next build-ready: <id>` / `build blocked: <reason>`).
6155
6720
 
6156
- **Stacked selection replaces dep-terminal planning gating.** For a stacked objective the single
6157
- planning candidate is the readiness-derived next node — roadmap DAG deps still shape
6158
- `delivery_order` but stop acting as a separate terminal-status planning gate, which is what
6159
- permits planning layer k+1 while layer k is published-but-unmerged. The one shared seam is
6160
- `stacked_selection(repo_root, state)` (`commands/objective/shared.py`): `None` for incremental
6161
- (byte-identical behavior), else a `StackedSelection {kind, node, ready, reason, train}` with
6162
- `kind build_blocked | plannable | in_flight | no_candidate` (`no_candidate` = every layer
6163
- published consumers fall through to the existing graph classification; completion semantics
6164
- unchanged). It reconstructs the train live (`reconstruct_repo_train`, the composed
6165
- `observe.py` convenience); `TrainReconstructionError` maps to a typed CLI error preserving its
6166
- `error_type`. Three consumers: the **plan door** (`perk objective plan` auto-select takes the
6167
- candidate; `build_blocked` refuses with `error_type="node_not_build_ready"` + a
6168
- `perk objective stack status <N>` hint; an explicit `--node` must equal the candidate or the
6169
- same refusal names the actually-ready node; `in_flight` keeps the incremental
6170
- `objective_in_flight` message shape), **`perk objective next`** (`next_node` becomes the
6171
- plannable candidate or `null`, plus an additive `build_ready {ready, reason}` block), and the
6172
- **`objective run` supervisor** (a new honest `action: "build_blocked"` report arm, exit 0,
6173
- carrying `reason` + the stack-status remediation). **`--dry-run` skips train reconstruction**
6174
- on the plan door and the run supervisor (offline graph classification kept) — and BOTH
6175
- dry-run payloads gain `"build_readiness": "unchecked (dry-run)"` (stacked only; incremental
6176
- payloads stay byte-identical), saying so rather than pretending the check ran. `objective show`
6177
- stays a status renderer; the run supervisor's repair and lower-address prioritization has landed
6178
- in §8.52.
6721
+ **Stacked planning replaces dep-terminal gating with one planning Prepare snapshot.** For a
6722
+ stacked objective the single live planning candidate is the readiness-derived next node — roadmap
6723
+ DAG deps still shape `delivery_order` but stop acting as a separate terminal-status planning gate,
6724
+ which permits planning layer k+1 while layer k is published-but-unmerged. After the plan door's
6725
+ initial objective read chooses stacked versus incremental, a real stacked launch calls exactly
6726
+ one `Delivery.prepare(PrepareRequest(kind="layer_start", mode="planning", objective_id,
6727
+ node_id?))`; that operation calls `Delivery.status` exactly once and performs no extra persistence
6728
+ read. The returned train is the sole post-Prepare authority: title/URL/node presentation, graph
6729
+ fallback, and resumable claims come from its captured objective snapshot; base/lineage/order,
6730
+ position/count, predecessor branch/head, blockers, and readiness come from the same immutable
6731
+ projection. The initial read is never reused for those facts. Incremental planning stays
6732
+ byte-identical; stacked `--dry-run` remains offline on the existing graph path and reports
6733
+ `"build_readiness": "unchecked (dry-run)"`.
6734
+
6735
+ Planning classification returns the nested decision vocabulary from §8.44. A non-ready train is
6736
+ `build_blocked`; a pending or planning-without-plan candidate is `ready` unless an explicit other
6737
+ node was requested (`wrong_candidate`); in-progress or planning-with-plan is `in_flight` before
6738
+ that comparison; another candidate status is `build_blocked`. Without a readiness candidate, the
6739
+ captured dependency graph yields explicit `ready | in_flight | node_not_found | terminal |
6740
+ blocked`, or automatic `ready | in_flight | complete | no_actionable`; a graph-fallback `ready`
6741
+ contains no train context. `skipped_claim_ids` comes from that same graph and excludes the selected
6742
+ node. The CLI mapper preserves every existing refusal code/message (`node_not_build_ready`,
6743
+ `objective_in_flight`, `no_actionable_node`) and consumes the decision's title/URL/node/context for
6744
+ seed, lookup completion, mark, engagement, notes, and handoff.
6745
+
6746
+ Hard failures are distinct from decisions and preserve exact precedence: `redirected_from` (the
6747
+ only supersession signal — provider normalization such as `007`→`7` with no redirect is accepted)
6748
+ is checked before train/no-train; no train is `invalid_train`; a readiness candidate absent from
6749
+ `train.layers` is `unknown_layer`; and a ready child whose predecessor has no plan/branch is
6750
+ `stacked_predecessor_missing`. Planning never exact-fetches a parent or returns a parent SHA.
6751
+ Prepare runs before the existing `update_objective_node(..., planning)` write, but this is an
6752
+ **observation followed by a non-CAS mark, not a lease or atomic claim**: concurrent edits,
6753
+ supersession after observation, and duplicate launches remain possible and are outside this
6754
+ contract.
6755
+
6756
+ `stacked_selection(repo_root, state)` remains unchanged for **`perk objective next`** and the
6757
+ **`objective run` supervisor**: it still returns `StackedSelection {kind, node, ready, reason,
6758
+ train}`, calls status once, supplies `build_ready {ready, reason}` to next, and drives the
6759
+ supervisor's honest `action: "build_blocked"`/remediation arms. The run supervisor's dry-run status
6760
+ omission and repair/lower-address prioritization remain §8.52 behavior.
6179
6761
 
6180
6762
  **Predecessor context seeds stacked planning.** The plan door's seed gains a stacked-only DATA
6181
- block (`_layer_context_block` the `layer_context` template var; incremental seeds stay
6763
+ block (`_layer_context_block` is pure presentation over `PlanningContext`; incremental seeds stay
6182
6764
  byte-identical): the layer's position in the delivery order; for a child layer the predecessor
6183
- node/plan, its branch, and the verified remote head from the reconstructed train's observed
6184
- facts (the objective base for the bottom layer); a note that `origin/<parent branch>` is
6185
- already fetched and locally inspectable; and the explicit statement that perk records **no
6186
- planning-time parent SHA** — later movement of the predecessor/codebase is a normal
6187
- implementation danger.
6188
-
6189
- **Save-time layer identity.** A **node-linked** save (`objective_id` + `node_id`, real run)
6190
- reads the linked objective **strictly** a failed read fails the save (`github_error`) and a
6191
- proven-missing objective is a typed `objective_not_found` refusal; a save that cannot
6192
- determine the delivery policy must not guess or proceed unstamped (unlinked saves keep the
6193
- fail-soft base lookup). When the objective is
6194
- stacked, `perk plan save` composes the **layer-identity trio** into the plan header on every
6195
- write arm (initial create, the re-save `update_plan_header` merge, and the `save_node_plan`
6196
- unification arm via the same rendered fields): `objective_node_id = node_id`,
6197
- `delivery_lineage` = the objective header's lineage, `predecessor_plan_id` = the
6198
- delivery-order predecessor node's linked plan id (`None`/absent on the bottom layer). Two
6199
- typed refusals fire **before any write**: a stacked objective with a missing/invalid
6200
- `delivery_lineage` is **`missing_lineage`** (an unstamped routing field would silently send a
6201
- child layer down the incremental path), and a
6202
- non-bottom save whose predecessor node has no linked plan is
6203
- **`stacked_predecessor_missing`**. `parent_checkpoint_sha`/
6204
- `published_head_sha` stay unwritten — the durable checkpoint pair is publication-owned.
6205
- Incremental saves stay byte-identical. A dry run composes the trio best-effort from the same
6206
- read and omits it when the objective is unreadable.
6765
+ node/plan, its branch, and the status-observed remote head (the objective base for the bottom
6766
+ layer); a note that `origin/<parent branch>` is already fetched and locally inspectable; and the
6767
+ explicit statement that perk records **no planning-time parent SHA** later movement of the
6768
+ predecessor/codebase is a normal implementation danger.
6769
+
6770
+ **Save-time layer identity.** `perk plan save` delegates its one objective read and identity
6771
+ policy to `PrepareRequest(kind="plan_identity")`: `mode=strict` only for a real node-linked save;
6772
+ objective-only real saves and every dry run use `best_effort`. Strict expected read failures map
6773
+ to the established `github_error`, and proven absence to `objective_not_found`. Best-effort
6774
+ returns `notice=str(exc)` (including the empty string) for an expected read failure and otherwise
6775
+ treats absence silently; unexpected exceptions propagate. The independently optional normalized
6776
+ objective base and nested `PlanIdentity` come from the same snapshot. A no-node request returns
6777
+ the base without policy/lineage/order validation. A node request applies the existing pure policy:
6778
+ incremental has no identity; stacked requires a nonblank lineage, target membership, and a linked
6779
+ predecessor for non-bottom layers (`missing_lineage`, `invalid_input`, or
6780
+ `stacked_predecessor_missing` before any write). Every initial/re-save/unification arm receives all
6781
+ three identity fields. `PlanRef` persists only lineage, and the publication-owned checkpoint pair
6782
+ stays unwritten. Dry-run output prints a best-effort notice when one exists and omits identity.
6207
6783
 
6208
6784
  **The `PlanRef` routing field.** `PlanRef`/`PlanRefModel`/`PlanRefOut` grow one nullable field,
6209
6785
  `delivery_lineage` (amending §8.42's deliberate-non-growth note): stamped at save, recovered by
@@ -6211,47 +6787,33 @@ read and omits it when the objective is unreadable.
6211
6787
  reconstructor in lockstep). It only **routes** a launch into the stacked path — every decision
6212
6788
  still reconstructs the train fresh; the ref is never train-authoritative.
6213
6789
 
6214
- **`LayerContext` + the one parent-preparation path** (`perk/delivery/layer.py`, a delivery leaf
6215
- touching `perk.substrate.git` alongside `observe.py`/`capability.py`). Frozen
6216
- `LayerContext {objective_id, node_id, plan_id, delivery_lineage, predecessor_plan_id, base,
6217
- parent_branch, branch}` `parent_branch` equals `base` for the bottom layer, else the
6218
- predecessor layer's branch (the train's branch resolution: plan-header `branch` else
6219
- `plan-<plan-id>`); `branch` is this layer's `plan-<plan_id>`. `derive_layer_context(train, *,
6220
- plan_id)` is pure (typed `unknown_layer`/`stacked_predecessor_missing` errors);
6221
- `require_ready_layer` additionally requires the layer to BE the readiness candidate
6222
- (`node_not_build_ready` carrying the exact veto). `prepare_layer_start(repo_root, ctx, *,
6223
- fetch=…, remote_head=…, resolve_commit=…)` (keyword-injectable probes, the `capability.py`
6224
- precedent) fetches exactly the parent ref, reads the live remote head, and verifies the commit
6225
- resolves locally **always the LATEST head, never a stored checkpoint**; an absent remote
6226
- parent (`parent_missing`) or an unresolvable head (`parent_unverified`) is a typed error naming
6227
- the expected ref. The result is the **operational** record: `cache.write_layer_context(root,
6228
- ctx, parent_sha)` atomically writes `.perk/workflow/layer-context.json` (`LayerContextOut`:
6229
- the full context + verified `parent_sha` + `prepared_at`) session-scoped, gitignored with the
6230
- workflow dir, and **NEVER authoritative** (publication re-verifies live; the durable
6231
- `parent_checkpoint_sha`/`published_head_sha` pair stays publication-owned).
6232
-
6233
- **Local launch (fresh creation only).** `resolve_worktree`'s fresh-create arm, when
6234
- `plan_ref.delivery_lineage` is set and no `origin/plan-<id>` exists: reconstruct the train,
6235
- require the ready candidate, `prepare_layer_start`, then `git worktree add` **at the verified
6236
- `parent_sha`** (a real commit, not a moving ref) and write `layer-context.json` into the fresh
6237
- worktree. An explicit `--base` on a stacked layer is a typed `invalid_input` refusal (the
6238
- parent is derived, never chosen). Explicit `--worktree NAME`, reuse, `worktree: none`, and the
6239
- resume arm (an existing `origin/plan-<id>`) are untouched the parent-aware path only governs
6240
- creation; a stacked dry run reports `"stacked layer parent derived at create"` and stays
6241
- offline.
6242
-
6243
- **Remote positioning relocates into `run_worker`.** The managed workflow's "Check out the plan
6244
- branch" shell step is **deleted** (§8.14 amended; the §8.13 input contract and `gh auth
6245
- setup-git` are unchanged); `run_worker` now calls `position_branch(repo_root, plan_ref, base)`
6246
- before `position_worktree`: an existing remote `plan-<N>` (either policy) → checkout +
6247
- `reset --hard origin/plan-<N>`; absent + incremental → create from `origin/<base>`
6248
- (behavior-equivalent to the removed shell arms; `base` = the dispatched input, else the plan's
6249
- pinned base, else the detected trunk — the `base` param is now consumed); absent + stacked →
6250
- the same readiness gate + `prepare_layer_start`, then `git checkout -b plan-<N> <parent_sha>`
6251
- and `layer-context.json`. The branch-creation **gesture** intentionally differs (local
6252
- `worktree add` vs remote in-place `checkout -b`) — a named §8.38 difference; both consume the
6253
- same `LayerContext` + `prepare_layer_start`, and local/remote parity is proven for fresh
6254
- creation (same start SHA, byte-identical `layer-context.json`, timestamps excepted).
6790
+ **`LayerContext` is internal; execution Prepare is the sole fresh-start proof.** The frozen
6791
+ internal `LayerContext {objective_id, node_id, plan_id, delivery_lineage, predecessor_plan_id,
6792
+ base, parent_branch, branch}` and pure `derive_layer_context`/`require_ready_layer` core remain in
6793
+ `perk.delivery.layer`, but none is exported from `perk.delivery`. Both launch paths independently
6794
+ call `Delivery.prepare(PrepareRequest(kind="layer_start", mode="execution", plan_id,
6795
+ objective_id?))`; there is no launch wrapper. It refuses a missing objective id before lookup,
6796
+ performs one status reconstruction, rejects no-train, locates the plan layer, proves it is
6797
+ build-ready, then derives the context. Execution does not inspect `redirected_from`; the freshly
6798
+ reconstructed active train remains authoritative. The bottom layer uses objective base; a child
6799
+ requires predecessor identity/plan/branch.
6800
+
6801
+ Prepare next calls `DeliveryGit.fetch_refs` once for the exact parent branch,
6802
+ `remote_branch_sha(parent_branch)` once, then `resolve_commit(observed_sha)` once. A fetch or remote
6803
+ observation failure is `git_error`; an absent remote branch is `parent_missing`; an observed SHA
6804
+ that does not resolve locally is `parent_unverified`. No fallback is permitted: the result's
6805
+ nonblank `parent_sha` is the verified latest remote head, never a stored checkpoint. Deferred
6806
+ publication still calls callback-only `prepare_layer_start` internally; that core has no
6807
+ repository/global defaults.
6808
+
6809
+ **Local and remote creation consume the same result.** Local `resolve_worktree` runs execution
6810
+ Prepare immediately before `git worktree add <parent_sha>`; remote `position_branch` runs it
6811
+ immediately before `git checkout -b plan-<N> <parent_sha>`. Neither path fetches or derives the
6812
+ parent independently. Both write the returned context and SHA to `layer-context.json`; commit
6813
+ start and artifact match (timestamps and path gesture excepted). `DeliveryError` remains a typed
6814
+ CLI refusal, and an explicit local `--base` remains `invalid_input`. Existing branch/worktree reuse,
6815
+ `worktree: none`, resume, incremental creation, and stacked dry-run behavior are unchanged; the
6816
+ boundary governs fresh creation and never resets an active layer.
6255
6817
 
6256
6818
  **Status.** Everything here is inert for incremental objectives; stacked authoring and layer
6257
6819
  publication are supported (the §8.45 dogfood gate passed —
@@ -6273,20 +6835,29 @@ reconstruction, byte-identical behavior (the additive envelope fields serialize
6273
6835
  (The §8.45 development write gate, which this route reused while stacked delivery was under
6274
6836
  development, is retired — the dogfood gate passed.)
6275
6837
 
6276
- **The publish operation** is `perk.delivery.publish.publish_layer` (a gateway-touching
6277
- delivery leaf; every effectful callable keyword-injectable, the `capability.py` pattern). Its
6278
- required automatic-cascade dependencies are `remote_writers` and `worktree_root`; the injectable
6279
- `synchronize` seam defaults to §8.49's operation. Submit owns the identity-field composition
6280
- (`header_fields`, a `pr_number fields` builder
6281
- reusing the incremental fields + `_merge_impl_run_ids`) and the PR-body composition; the
6282
- operation owns WHEN they are written. `PreparedRecord.run_id` resolves `--run-id` → the plan
6283
- header's `run_id`; both absent is a typed `invalid_input` refusal (a defensive arm — the
6284
- header run id is stamped at save). The protocol, in order:
6285
-
6286
- 1. **Reconstruct** the train fresh (`reconstruct_repo_train`, via the plan header's
6287
- `objective_id`); no train / plan not a layer `not_stacked`.
6838
+ **The publish operation** is
6839
+ `resolve_delivery(repo_root).publish(PublishRequest(kind="layer", ...))`; the private
6840
+ `perk.delivery.publish._dispatch` engine is bound to the façade's three aggregate authorities and
6841
+ bound `status`/`sync` methods. Its immutable private runtime contains only clock/sleep/id minting
6842
+ and PR-body validation. Publication acquires no stack-operation lock. Automatic cascade calls the
6843
+ same façade's bound `sync`, whose dispatcher acquires the non-reentrant lock exactly once; there is
6844
+ no resolver or callback bundle inside publish.
6845
+
6846
+ For a real layer, the façade re-reads the current plan, requires only presence plus nonblank
6847
+ `objective_id` (never a fresh lineage-header gate), and best-effort reads its body (`IssueBackendError`
6848
+ means no embed). That one fresh plan owns title, body truth, header merge, and route facts. The
6849
+ operation composes the exact stacked PR body and identity fields internally: canonical branch, PR
6850
+ id, lifecycle stage, and explicit-trigger-only order-preserving `impl_run_ids` merge via
6851
+ `plan.merge_untrusted_str_list`. Submit retains route selection, eager config validation,
6852
+ resolved journal/raw trigger ids, Linear emission, mergeability probing, envelope, and rendering;
6853
+ it performs no stacked body/header/operation inference. `PreparedRecord.run_id` resolves
6854
+ `--run-id` → the plan header's `run_id`; both absent is a typed `invalid_input` refusal (a defensive
6855
+ arm — the header run id is stamped at save). The protocol, in order:
6856
+
6857
+ 1. **Reconstruct** the train fresh through the bound `Delivery.status` using the plan header's
6858
+ `objective_id`; no train / plan not a layer → `not_stacked`.
6288
6859
  2. **Route on the checkpoint-claimed prefix before reading the publish journal fold.** Derive it
6289
- through §8.49's public `derive_claimed_prefix` helper. If this plan is claimed and a claimed
6860
+ through §8.49's package-internal `derive_claimed_prefix` helper. If this plan is claimed and a claimed
6290
6861
  successor exists, delegate immediately to trigger-scoped synchronization (§8.52); sync owns the
6291
6862
  journal routing for this arm, including unresolved SYNC/ADOPT and pending continuations. Never
6292
6863
  route on `published_prefix_len`: operational drift may declassify a claimed successor and must
@@ -6388,7 +6959,18 @@ before) and prepare FRESH in the same invocation. Anything mixed/unrelated →
6388
6959
  `stack_registration_failed`, `postcondition_unverified`, `publication_drift`, `git_error`,
6389
6960
  `github_error` — plus the §8.46 layer codes passed through verbatim (`parent_missing`,
6390
6961
  `parent_unverified`, `stacked_predecessor_missing`): honest self-describing preparation
6391
- failures, deliberately not folded into a vaguer code.
6962
+ failures, deliberately not folded into a vaguer code. It remains package-internal and is
6963
+ translated once to `(phase=layer, origin=domain)`; aggregate infrastructure and bound-status/sync
6964
+ failures follow §8.44's contextual table.
6965
+
6966
+ **Result and caller boundary.** Direct publication returns the backend's real identity
6967
+ `PlanHeaderUpdate`; direct convergence returns an exact empty synthetic update. Cascade returns the
6968
+ bound `SyncResult` directly and performs only the explicit-trigger `impl_run_ids` merge-write (or an
6969
+ empty update without a trigger). `PublishResult.Layer` carries those facts plus embed/body-check,
6970
+ stack, checkpoint, resume/no-op, and parent-target facts. Submit's local mergeability probe targets
6971
+ the returned parent branch and published head; Linear PR-opened emission is suppressed exactly when
6972
+ `cascade` is present. JSON and human bytes remain unchanged because serialization supplies the fixed
6973
+ operation `kind="sync"` around the nested result.
6392
6974
 
6393
6975
  **Surface changes.** The github tier gains the `stack` state key; the submit stage reads
6394
6976
  `[cache.plan-ref, github.plan, github.objective, github.stack]` and writes
@@ -6510,21 +7092,23 @@ evidence report).
6510
7092
 
6511
7093
  ## §8.49 · Published-suffix synchronization (the sync operation + `perk objective stack sync`)
6512
7094
 
6513
- **The operation** is `perk.delivery.sync.synchronize_train` (a gateway-touching delivery leaf
6514
- mirroring §8.47's publish shape: every effectful callable keyword-injectable, production
6515
- defaults, tests pass fakes): change a published stacked layeror re-anchor the whole train
6516
- onto an advanced objective base (`--base`) and move every published successor with it, as
6517
- one transaction. `RemoteWriterProbe` is a **required** parameter (no fail-open default); the
6518
- delivery plane declares the Protocol (`active_plan_ids(plan_ids) frozenset`, raising
6519
- `WriterObservationError` on ANY observation failure), the CLI wires the production adapter.
7095
+ **The operation** is the public `Delivery.sync(SyncRequest(...), consent=...)` façade over the
7096
+ private `perk.delivery.sync` transactional engine: change a published stacked layer — or re-anchor
7097
+ the whole train onto an advanced objective base (`--base`)and move every published successor
7098
+ with it as one transaction. `Delivery.sync` uses one documented local import, binds its three
7099
+ aggregate authorities plus `self.status`, and reads the immutable private `_DEFAULT_SYNC_RUNTIME`
7100
+ at invocation time. The runtime owns only worktree-root configuration, the operation lock,
7101
+ continuation-manifest/containment/path helpers, clock, sleep, and operation-id minting; no
7102
+ persistence/Git/GitHub behavior or public constructor seam lives there. Tests replace the whole
7103
+ frozen runtime symbol in a scoped monkeypatch, never mutate it.
6520
7104
 
6521
7105
  **The operation universe is the checkpoint-claimed prefix — never `published_prefix_len`.**
6522
7106
  The train classifier truncates its verified prefix on exactly the discrepancies sync exists
6523
7107
  to diagnose (publication drift, membership divergence), which would make the drift refusals
6524
7108
  unreachable — a drifted bottom layer would read as a false no-op; a drifted upper layer would
6525
7109
  silently shrink a lower-layer cascade. `published_prefix_len` stays a status fact only. The
6526
- public `derive_claimed_prefix(train)` helper is the single derivation consumed by sync and
6527
- publish routing. The claimed prefix: the maximal contiguous run, from the bottom of delivery order, of layers
7110
+ package-internal `derive_claimed_prefix(train)` helper remains the single derivation consumed by
7111
+ sync, publish routing, transfer, and deferred recovery. The claimed prefix: the maximal contiguous run, from the bottom of delivery order, of layers
6528
7112
  carrying plan identity, a branch, a PR number, and the FULL checkpoint pair — **starting
6529
7113
  above the bottom-contiguous LANDED run** (§8.44): landed layers are terminal, never claimed,
6530
7114
  so a partially-landed train's remainder cascades over the advanced base (`claimed[0]` expects
@@ -6535,9 +7119,11 @@ deletion/retarget). Malformed claims
6535
7119
  a LANDED layer above a non-landed claimed layer)
6536
7120
  are the typed refusal `claimed_prefix_malformed`.
6537
7121
 
6538
- **The operation lock.** Every mutating stack operation on a repo sync (all modes),
6539
- continue, abort, recover (§8.51), and land (§8.56) — runs under ONE machine-local
6540
- non-blocking `flock` at
7122
+ **The operation lock.** Every `Delivery.sync` request (cascade, continue, or abort) acquires the
7123
+ private runtime's operation lock exactly ONCE around dispatch; recover (§8.51), transfer (§8.53),
7124
+ and land (§8.56) use the same lock in their own dispatches (land's is runtime-bound inside
7125
+ `Delivery.land`'s objective mutation arm; its dry-run preview is lock-free). It is a machine-local non-blocking
7126
+ `flock` at
6541
7127
  the main checkout (`.perk/workflow/stack-operation.lock`,
6542
7128
  `perk/delivery/oplock.py::stack_operation_lock`); a busy lock is the typed refusal
6543
7129
  `operation_in_progress` (never a wait — concurrent invocations are an operator error to
@@ -6559,7 +7145,9 @@ Post-push arms never need the temp refs (an applied push holds the candidates re
6559
7145
  unapplied push's resume arm abandons and recomputes fresh). Orphaned (process-killed,
6560
7146
  manifest-less) `sync-*` residue is inert until `recover`'s orphan sweep (§8.51) collects it.
6561
7147
 
6562
- 1. **Reconstruct fresh** (`reconstruct_repo_train`); no train / no lineage → `not_stacked`.
7148
+ 1. **Reconstruct fresh** through the context's bound `Delivery.status(StatusRequest(...))`; every
7149
+ fresh/re-entry reconstruction uses that same service and authority instances. A successful
7150
+ no-train result / no lineage → `not_stacked`; a status `DeliveryError` propagates unchanged.
6563
7151
  2. **Continuation gate**: any manifest for this lineage → `sync_conflict_pending` (the
6564
7152
  message names the manifest path and the retained worktree; clearing is manual until the
6565
7153
  continue/abort surface). An unparseable manifest is treated as PRESENT — fail closed,
@@ -6578,7 +7166,8 @@ manifest-less) `sync-*` residue is inert until `recover`'s orphan sweep (§8.51)
6578
7166
  `missing_lineage`; that member is unreachable here because step 1 already classifies a
6579
7167
  lineage-less train as `not_stacked`, but the shared set is context-free for §8.52 consumers.
6580
7168
  5. **Preflight every claimed layer** (all refusals before any candidate work): remote head ==
6581
- the `published_head_sha` checkpoint → else `remote_drift` (adoption is a later node); a
7169
+ the `published_head_sha` checkpoint → else `remote_drift` (the `--adopt` arm below
7170
+ re-anchors exactly one such layer); a
6582
7171
  fresh strict PR-facts read per layer — OPEN, base == the expected predecessor branch (the
6583
7172
  objective base for the bottom layer), head == the checkpoint → else `pr_drift`; native
6584
7173
  membership exactly the claimed PRs (`not_applicable` below two) → else `membership_drift`;
@@ -6602,11 +7191,12 @@ manifest-less) `sync-*` residue is inert until `recover`'s orphan sweep (§8.51)
6602
7191
  locally-changed head that lacks it → `stale_parent` (the actionable rebase-first arm); an
6603
7192
  UNCHANGED claimed layer whose published head lacks it → `claimed_prefix_malformed`
6604
7193
  (an internally inconsistent stored pair — broken stored state).
6605
- 7. **Capability**: >1 configured push URL `multiple_push_urls` (`--atomic` is atomic within
6606
- ONE receiving repository — no pretended distributed atomicity); then the shared
6607
- per-push-URL no-op probe (`capability.probe_atomic_push_urls`, the §8.45 probe factored
6608
- out) pinned to the bottom affected layer's branch at its verified remote head failure is
6609
- `atomic_push_unsupported`. This discharges §8.47's recorded deviation.
7194
+ 7. **Capability**: the Git authority resolves configured push URLs; >1 URL
7195
+ `multiple_push_urls` (`--atomic` is atomic within ONE receiving repository — no pretended
7196
+ distributed atomicity). The same authority runs the no-op atomic probe against the sole URL,
7197
+ pinned to the bottom affected layer's branch at its verified remote head; private capability
7198
+ formatters preserve §8.45's caveat strings. Failure is `atomic_push_unsupported`. This
7199
+ discharges §8.47's recorded deviation without a public probe helper.
6610
7200
  8. **Candidate calculation** in ONE isolated worktree (`<worktree_root>/sync-<operation_id>`;
6611
7201
  temp refs `refs/perk/sync/<operation_id>/<branch>`; the freshly minted operation ULID
6612
7202
  names all residue). Bottom-up over the affected set: source = the local head when locally
@@ -6620,7 +7210,7 @@ manifest-less) `sync-*` residue is inert until `recover`'s orphan sweep (§8.51)
6620
7210
  A manifest WRITE failure keeps the guard armed — residue is cleaned, nothing is retained
6621
7211
  — and still classifies as `rebase_conflict` inside the typed boundary (the message says
6622
7212
  retention failed and why).
6623
- 9. **Approval gate**: the ordered `SyncCascade` (per-ref before→after, node ids, PR numbers,
7213
+ 9. **Approval gate**: the ordered `SyncResult.Cascade` (per-ref before→after, node ids, PR numbers,
6624
7214
  base facts) → the `approve` callback (`None` = auto-approve). Declined → the guard
6625
7215
  cleans; the declined result returns — no journal record, nothing mutated.
6626
7216
  10. **Post-approval re-observation** (closing the arbitrary-pause race before the journal
@@ -6636,15 +7226,18 @@ manifest-less) `sync-*` residue is inert until `recover`'s orphan sweep (§8.51)
6636
7226
  `after: {branches: [{ref, sha}], prs: [{number, head_sha, base}], base_parent:
6637
7227
  sha|null}` — the candidates; PR bases unchanged by construction (sync moves heads,
6638
7228
  never branch names).
6639
- 12. **One atomic push** (`git.push_atomic_with_leases`): every affected ref in ONE
7229
+ 12. **Zero or one atomic push.** Build the update set by excluding every affected ref whose
7230
+ candidate equals its observed before SHA. An empty set (including checkpoint-only adoption)
7231
+ issues **zero pushes**; otherwise `git.push_atomic_with_leases` issues ONE
6640
7232
  `push --atomic --porcelain --no-verify --no-signed --no-follow-tags
6641
- --recurse-submodules=no` to `origin` with `-c push.pushOption=` cleared, each ref under
6642
- `--force-with-lease=refs/heads/<branch>:<exact before sha>` (never an absence lease —
7233
+ --recurse-submodules=no` to `origin` with `-c push.pushOption=` cleared, each included ref
7234
+ under `--force-with-lease=refs/heads/<branch>:<exact before sha>` (never an absence lease —
6643
7235
  sync never pushes creations). A rejection → refetch and classify: all-at-before →
6644
7236
  append `abandoned` (observed = the all-before proof) + `push_rejected` (retry = rerun
6645
7237
  sync); an unreadable refetch → `postcondition_unverified` (unresolved); mixed →
6646
7238
  `sync_drift` (unresolved, fail closed). Individual refs are NEVER retried.
6647
- 13. **Verify postconditions**: refetch every affected branch head == candidate (else
7239
+ 13. **Verify postconditions**: refetch **every affected branch**, including refs excluded as
7240
+ no-op updates — head == candidate (else
6648
7241
  `sync_drift`); PR facts through the bounded settle poll (up to five observations through
6649
7242
  the injectable observe/sleep seam — GitHub's PR-head propagation lags a push) before a
6650
7243
  mismatch classifies as `pr_drift`; an unreadable read → `postcondition_unverified`;
@@ -6656,10 +7249,12 @@ manifest-less) `sync-*` residue is inert until `recover`'s orphan sweep (§8.51)
6656
7249
  verified branches + PR heads). A crash between the checkpoint writes and completion
6657
7250
  reconstructs as roll-forward — merge-writes + idempotent byte-identical appends.
6658
7251
 
6659
- **`--dry-run` (the side-effect-free preview).** Runs the full protocol up to the approval
6660
- boundary — preflight, capability, candidate calculation in the isolated worktree — then stops
6661
- and returns the would-be cascade as the `dry_run: true` result arm: **no journal record, no
6662
- push, no checkpoint write, no confirmation** (the preview needs no consent nothing mutates).
7252
+ **`--dry-run` (the pre-consent preview).** Runs the full protocol up to the approval boundary —
7253
+ preflight, capability, candidate calculation in the isolated worktree — then stops and returns
7254
+ the would-be cascade as the `dry_run: true` result arm: **no consent call, journal record, remote
7255
+ push, checkpoint write, or continuation manifest**. Candidate calculation can create local temp
7256
+ refs/worktree residue; cleanup is best-effort, each failure is a loud result note, and noted residue
7257
+ is valid orphan-sweep input — “dry run” never falsely promises no local side effects.
6663
7258
  The conflict arm is retention-free: a dry-run rebase conflict writes NO manifest (the guard
6664
7259
  stays armed, residue is cleaned) and classifies as `rebase_conflict` with a message naming the
6665
7260
  dry run. A pending unresolved operation still routes per step 3 — but the unresolved-kind
@@ -6825,7 +7420,10 @@ operation simply never happened. Like `--continue`, no cascade flags compose.
6825
7420
  **The result arms** (`SyncResult`; invariant: `operation_id` non-null ⟺ a prepared record was
6826
7421
  journaled by, or resumed by, this invocation). Identity fields (`objective_id`,
6827
7422
  `objective_url`, `redirected_from`) ride the result so the CLI never re-reconstructs;
6828
- `base_advanced` is the §8.44 status notice, independent of `base_cascaded`.
7423
+ `base_advanced` is the §8.44 status notice, independent of `base_cascaded`. The moved frozen data
7424
+ is `SyncResult` plus nested `Layer`, `Cascade`, and `AbortPreview`; only `SyncRequest` and
7425
+ `SyncResult` are package-root sync-family exports. No `SyncResult.__post_init__` combination matrix
7426
+ is added: the table below and operation protocol remain the authority for additive reachable arms.
6829
7427
 
6830
7428
  | arm | operation_id | abandoned_operation_id | no_op | declined | resumed | base_cascaded | affected |
6831
7429
  | --- | --- | --- | --- | --- | --- | --- | --- |
@@ -6849,39 +7447,38 @@ non-refusal notes, e.g. failed cleanup or manifest retirement). The JSON envelop
6849
7447
  `notes` verbatim; both the cold human renderer and the warm TypeScript tools render every
6850
7448
  one, so machine-routed success can never hide leftover residue.
6851
7449
 
6852
- **The error vocabulary is bounded.** `SyncError.error_type` {`not_stacked`,
6853
- `unresolved_operation`, `sync_conflict_pending`, `claimed_prefix_malformed`, `active_writer`,
6854
- `dirty_worktree`, `writer_observation_unavailable`, `remote_drift`, `pr_drift`,
6855
- `membership_drift`, `stale_parent`, `base_unobserved`, `multiple_push_urls`,
6856
- `atomic_push_unsupported`, `rebase_conflict`, `push_rejected`, `sync_drift`,
6857
- `postcondition_unverified`, `invalid_input`, `adopt_blocked`, `no_continuation`,
6858
- `continuation_stale`, `continuation_invalid`, `rebase_in_progress`,
6859
- `operation_in_progress`}. The operation deliberately PROPAGATES raw
6860
- infra errors (`GitError`/`GitHubError`); the CLI boundary maps those to `git_error`/
6861
- `github_error`, passes `TrainReconstructionError.error_type` through verbatim, and adds its
6862
- own boundary codes (`confirmation_required`, `journal_corruption`, `no_objective`,
6863
- `not_a_repo`) — the full envelope vocabulary is the union of those layers.
7450
+ **The error vocabulary is bounded.** Private `SyncError` is a named `DeliveryError` subclass
7451
+ retained for recover/transfer compatibility; it accepts the same façade-wide bounded vocabulary.
7452
+ `Delivery.sync` preserves any `DeliveryError` unchanged and maps only expected boundary failures:
7453
+ raw `GitError` `git_error`; `GitHubError` and backend/store/persistence failures →
7454
+ `github_error`; an allowed `TrainReconstructionError` code/message passes through;
7455
+ `JournalCorruptionError` `journal_corruption`; `JournalRecordTooLarge`
7456
+ `journal_record_too_large` with the exact cap detail (an outcome-append failure may leave the
7457
+ prepared operation unresolved); and private config failure → `invalid_config`. Unexpected
7458
+ exceptions propagate. The command adds only its separately-owned `confirmation_required`,
7459
+ `no_objective`, and `not_a_repo` arms.
6864
7460
 
6865
7461
  **The cold worker.** `perk objective stack sync [OBJECTIVE] [--base] [--dry-run]
6866
7462
  [--adopt NODE] [--continue] [--abort] [--run-id RUN_ID] [--yes] [--json]`
6867
7463
  (`commands/objective/stack/sync_cmd.py`). The control-flag matrix is validated FIRST as
6868
7464
  typed `invalid_input`: `--continue`/`--abort` are mutually exclusive with each other and
6869
7465
  with every cascade flag; `--adopt` × `--base` is refused; `--adopt`/`--base` × `--dry-run`
6870
- compose. Objective resolution mirrors
6871
- `status`'s exactly; `run_id` resolves `--run-id` the **ACTIVE** objective header's
7466
+ compose. After repo resolution the command keeps its existing eager validation-only
7467
+ `require_config(ctx)` call (same `invalid_input` precedence and malformed-TOML wording), then
7468
+ objective resolution mirrors `status`'s exactly. It resolves one zero-I/O `Delivery`, builds one
7469
+ `SyncRequest`, and calls `Delivery.sync` exactly once. Cascade `run_id` resolves `--run-id` → the **ACTIVE** objective header's
6872
7470
  `run_id` (the fallback follows `superseded_by` forward, the same walk the reconstruction
6873
7471
  performs — syncing through a superseded objective never journals the predecessor's run
6874
- identity), both absent → `invalid_input`. The production `GhaRemoteWriterProbe` lives in
6875
- `perk.run.writer_probe` and queries the gateway run listing with a **server-side status filter**
6876
- (queued + in-progress, one call each — active runs can
6877
- never be displaced off a newest-first page by completed runs; the existing 100-cap bounds
6878
- *simultaneously active* runs) and matches plan ids via the managed run-name convention; any
6879
- listing failure propagates as the probe's typed error → `writer_observation_unavailable`.
6880
- Explicit sync passes no exclusion. Automatic submit excludes a writer only as the pair
6881
- `(exclude_run_id, exclude_plan_id)`, after `PERK_RUN_ID`, a consumed implement/address handoff,
6882
- and the active plan-ref corroborate the caller-supplied id as this process's run on this plan.
6883
- Neither field excludes alone; uncorroborated ids exclude nothing. Only that exact run+plan pair is
6884
- skipped because its already-committed work is the trigger; every other active writer still blocks.
7472
+ identity), both absent → `invalid_input`. Continue/abort set no cascade fields and never resolve a
7473
+ run id. `RepoDeliveryGitHub.active_writer_plan_ids` owns the production observation. It queries the
7474
+ gateway run listing with a **server-side status filter** (queued + in-progress, one call each —
7475
+ active runs can never be displaced off a newest-first page by completed runs; the existing 100-cap
7476
+ bounds *simultaneously active* runs) and matches plan ids via the managed run-name convention; any
7477
+ listing failure becomes `WriterObservationError` → `writer_observation_unavailable`. Explicit sync
7478
+ passes no trigger context. Automatic submit supplies raw `(trigger_plan_id, trigger_run_id)`; the
7479
+ adapter excludes a writer only after `PERK_RUN_ID`, a consumed implement/address handoff, and the
7480
+ active plan-ref corroborate that exact pair. Neither field excludes alone; uncorroborated ids
7481
+ exclude nothing. Only the exact run+plan pair is skipped; every other active writer still blocks.
6885
7482
  Confirmation: the `approve` callback renders the cascade to **stderr** and confirms via
6886
7483
  `click.confirm(..., err=True)` — interactive `--json` never contaminates stdout; `--yes`
6887
7484
  auto-approves; non-interactive without `--yes` → the typed `confirmation_required` refusal
@@ -6889,19 +7486,17 @@ auto-approves; non-interactive without `--yes` → the typed `confirmation_requi
6889
7486
  `--abort` gets its own confirmation render (the preview: operation id, conflict node,
6890
7487
  retained worktree, and — on the uncontained/unparseable arms — that ONLY the manifest file
6891
7488
  will be deleted) under the same `--yes`/non-interactive discipline; `--dry-run` needs no
6892
- confirmation (it stops before the approval boundary); `--continue` routes to
6893
- `continue_train_sync` and journals under the manifest's captured run identity (`--run-id`
7489
+ confirmation (it stops before the approval boundary); `--continue` uses
7490
+ `SyncRequest(mode="continue")` and journals under the manifest's captured run identity (`--run-id`
6894
7491
  is ignored). The `--json` envelope `ObjectiveStackSyncOut` (snapshotted at
6895
7492
  `shared/schemas/outputs/objective-stack-sync.schema.json`), declaration order pinned:
6896
7493
  `{success, objective{id,url,redirected_from}, operation_id|null,
6897
7494
  abandoned_operation_id|null, no_op, declined, resumed, base_cascaded, base_advanced,
6898
- affected: [{node_id, plan_id, branch, pr_number, before_sha, after_sha}], dry_run,
7495
+ affected: [{node_id, plan_id, branch, pr_number, before_sha, after_sha}], notes:[str], dry_run,
6899
7496
  adopted_node|null, continued, aborted}` (the last four are the additive control-surface
6900
- growth); failures use the
6901
- `{success, error_type, message}` fail shape with `SyncError.error_type` verbatim; the
6902
- reconstruction seam's `TrainReconstructionError.error_type` vocabulary passes through the
6903
- same way (the stack-status convention), and a corrupt journal read fails as
6904
- `journal_corruption`. Exit
7497
+ growth); failures use the `{success, error_type, message}` fail shape with one caught
7498
+ `DeliveryError`'s code/message verbatim; command-owned `UserFacingCliError` still covers
7499
+ flag/config/confirmation/no-objective validation. Exit
6905
7500
  discipline: 0 = success (incl. no-op, declined, dry-run, continued, aborted), 1 = typed
6906
7501
  operation failures, 2 = not-a-repo. The explicit command remains the owner of base advancement,
6907
7502
  adoption, conflict continuation, abort, and preview; ordinary submit/address propagation delegates
@@ -7037,13 +7632,68 @@ exits 2.
7037
7632
 
7038
7633
  ## §8.51 · Stack recovery (`perk objective stack recover`) + the warm stack surface
7039
7634
 
7040
- **The operation** is `perk.delivery.recover.recover_operations` **conclude-only** recovery:
7041
- classify every unresolved stack operation against fresh authority, conclude the one selected
7042
- target (deterministic roll-forward, a confirmed abandon-with-proof, or a confirmed
7043
- accept-prefix breach), run the LAND finalization-convergence pass, then sweep orphaned
7044
- machine-local sync residue. Retry is never recover's verb — the report's detail names the
7045
- owning command (`stack sync`, `/submit`, `stack land`). Runs under the shared operation lock (§8.49);
7046
- `--dry-run` reports everything and mutates nothing.
7635
+ **The operation** is `Delivery.recover(RecoverRequest(...), consent=...)` behind the
7636
+ repository-scoped delivery façade. The closed request family is a strict TWO-kind
7637
+ discriminator: `operation_conclusion` (conclude-only recovery) and `cancellation_metadata`
7638
+ (the §8.54 metadata repair), each with the flat fields `{objective_id, action, dry_run,
7639
+ operation_id}`. For `operation_conclusion`, `action {report, abandon, accept_prefix}`
7640
+ replaces the old pair of service booleans, and a supplied operation id is carried verbatim
7641
+ (including `""`) so target selection remains the authority and returns `operation_not_found`
7642
+ for a nonmatch. `cancellation_metadata` accepts only a nonblank `objective_id` plus optional
7643
+ `dry_run`: an acting action or ANY operation id (even `""`) is rejected at construction, and
7644
+ a non-`None` consent callback is rejected with `ValueError` before dispatch or authority
7645
+ access — the variant has no operation target, no generic action verb, and no confirmation
7646
+ boundary.
7647
+
7648
+ The frozen `RecoverResult` is the matching strict wrapper: `kind` plus exactly the one
7649
+ detail matching it (`operation_conclusion: OperationConclusion | None`,
7650
+ `cancellation_metadata: CancellationMetadata | None` — one kind↔detail constructor guard,
7651
+ no forwarding properties, no cross-variant "must stay empty" matrix). Nested
7652
+ `OperationConclusion` carries the complete operation report (`objective_id, objective_url,
7653
+ redirected_from, dry_run, selection_required, operations, swept_worktrees, swept_refs,
7654
+ sweep_failures, sweep_skipped, landed_layers, objective_closed, reconcile_evidence, notes`)
7655
+ over the existing nested `Operation`, `MergedPrefix`, `RemainderPr`, `LandedLayer`,
7656
+ `SweepFailure`, `AbandonPreview`, and `AcceptPrefixPreview` records, with additive
7657
+ operation-produced combinations and no new guards; its `reconcile_evidence` annotation stays
7658
+ deferred/type-only to avoid a façade↔landing import cycle. Nested `CancellationMetadata`
7659
+ carries `{objective_id, actions, failed, aborted, dry_run, unavailable}` over
7660
+ `CancellationAction{code, node_id, outcome, error}` — the §8.54 repair pass without exposing
7661
+ the internal diagnostics vocabulary (`failed` stays separate from `actions`).
7662
+
7663
+ **The `cancellation_metadata` lifecycle** is pinned against operation-conclusion
7664
+ **machinery**: dispatched before worktree-root/config resolution and before the operation
7665
+ lock, it resolves no worktree config, takes no stack-operation lock, appends no journal
7666
+ event and writes no checkpoint, classifies/concludes no operation, asks for no consent, runs
7667
+ no finalization/convergence/close, and sweeps no residue. Read-only train reconstruction is
7668
+ explicitly retained and required — it IS the repair's fresh safety proof, and it inherently
7669
+ reads the journal fold, runs `git fetch`, and observes branches/PRs/stack membership through
7670
+ the façade's reconstruction bridge; the only mutation is the conditional attachment write
7671
+ through the §8.54 writer capability. A backend whose persistence authority answers no writer
7672
+ is a successful empty pass before any reconstruction. `perk objective stack recover` remains
7673
+ an operation-conclusion-only command — the repair's sole production caller is doctor's
7674
+ `--fix` (§8.54).
7675
+
7676
+ The operation-conclusion variant classifies every unresolved stack operation against fresh
7677
+ authority, concludes the
7678
+ one selected target (deterministic roll-forward, a consented abandon-with-proof, or a consented
7679
+ accept-prefix breach), runs the LAND finalization-convergence pass, then sweeps orphaned
7680
+ machine-local sync residue. `consent` receives either preview type; `None` auto-approves an
7681
+ explicitly requested library action, while the cold command always supplies its
7682
+ interactive/headless callback. Retry is never recover's verb — the report's detail names the owning command (`stack
7683
+ sync`, `/submit`, `stack land`). Runs under the shared operation lock (§8.49); `--dry-run` reports
7684
+ everything and mutates nothing.
7685
+
7686
+ The private engine receives one `_RecoverContext` bound to the same three aggregate authorities as
7687
+ all other façade operations plus `_RecoverRuntime` for worktree-root loading (the existing sync
7688
+ config helper), lock, continuation/on-disk enumeration, per-layer finalization, sleep, and clock.
7689
+ The aggregate growth is exact: persistence adds `close_objective`; Git adds
7690
+ `worktree_admin_paths`; GitHub adds `merge_async_probe` and `merged_evidence`. Every other
7691
+ journal/objective/plan/ref/PR/stack effect reuses an existing authority method. Runtime and
7692
+ aggregate adapters are package-internal; no repo path, config, backend, lock, clock, factory, or
7693
+ probe callback crosses `RecoverRequest`. Config resolves before one lock acquisition, and that
7694
+ single non-reentrant lock stays held through classification, consent, from-scratch
7695
+ reclassification, conclusion/convergence, result metadata reads, and the final sweep (the
7696
+ cancellation variant branches away before both, above).
7047
7697
 
7048
7698
  **The phased protocol.** (0) **Fold-first TRANSFER routing (§8.53)**: read the REQUESTED
7049
7699
  objective's succession journal before any train gate — a sole unresolved TRANSFER dispatches
@@ -7056,10 +7706,16 @@ recorded manifest + the `run_id` successor lookup: successor found + corroborate
7056
7706
  supersedes/lineage corroboration) → `all_after`, rolled forward automatically through
7057
7707
  `transfer.roll_forward_transfer` under the same held lock; absent → `all_before`, abandonable
7058
7708
  with the `successor_absent` proof under `--abandon` (confirmed + re-classified); an
7059
- undecodable manifest or a corroboration mismatch → a report-only `mixed` row. Hints name the
7709
+ undecodable manifest or a corroboration mismatch → a report-only `mixed` row. `accept_prefix` is
7710
+ LAND-only: fold-first TRANSFER rejects it as `accept_blocked` before successor classification,
7711
+ mutation, finalization, or sweep, including the otherwise-automatic all-after arm. Hints name the
7060
7712
  predecessor id (the documented recovery entry for an interrupted transfer is
7061
- `recover <predecessor-id>`); the transfer seams ride an injectable factory
7062
- (`transfer_seams_factory`); `TransferError` passes through the CLI under §8.53's vocabulary.
7713
+ `recover <predecessor-id>`). Recovery binds `TransferSeams` from the same aggregate persistence
7714
+ authority plus the façade's cause-aware reconstruction bridge; `TransferError` remains a
7715
+ package-internal `DeliveryError` subtype and passes through unchanged. After the selected TRANSFER
7716
+ report/conclusion, the result objective URL/state is read before `_sweep`; a typed metadata-read
7717
+ failure therefore leaves every orphan untouched and cleanup is truly the final authority/effect
7718
+ phase.
7063
7719
  (0b) **Fold-first sole-PUBLISH routing (§8.54)**: when the ACTIVE train's fold — read after
7064
7720
  reconstruction (which follows supersession forward), the SAME snapshot the classifier
7065
7721
  consumes; the requested fold walks predecessors only, so a successor-recorded PUBLISH is
@@ -7226,7 +7882,7 @@ still classified), except under `--abandon`/`--accept-prefix`, where acting ambi
7226
7882
  LAND rolls forward automatically through `landing.roll_forward_land` (above); `all_after`
7227
7883
  PUBLISH reports (its roll-forward already lives in `/submit`'s own resume — the report says
7228
7884
  so). `--abandon` requires `all_before` (else `abandon_blocked`), renders the
7229
- `AbandonPreview` through the `approve` callback, and **re-classifies after confirmation**
7885
+ `AbandonPreview` through the union `consent` callback, and **re-classifies after confirmation**
7230
7886
  (the human may pause arbitrarily long on the prompt; a post-confirmation observation change
7231
7887
  is `abandon_blocked`, nothing journaled) before appending the `abandoned` outcome (observed
7232
7888
  = the all-before proof; for LAND, reason `recovered_before_state` + the reobserved rows).
@@ -7254,12 +7910,14 @@ are collected as explicit
7254
7910
  `sweep_failures: [{target, error}]`, never silent, never aborting the remaining sweep.
7255
7911
  Typed refusals never sweep (the sweep runs only after a successful conclude/report phase).
7256
7912
 
7257
- **Errors.** `RecoverError.error_type` {`operation_ambiguous`, `operation_not_found`,
7258
- `abandon_blocked`, `accept_blocked`, `unsupported_operation_kind`, `operation_in_progress`,
7259
- `not_stacked`,
7260
- `invalid_input`}; the roll-forward tail raises §8.49's own arms (`sync_drift`, `pr_drift`,
7261
- `postcondition_unverified`, …) which pass through verbatim; infra and reconstruction errors
7262
- map at the CLI boundary exactly as sync's.
7913
+ **Errors.** Recovery uses the single bounded `DeliveryError`; its recovery-specific additions are
7914
+ `operation_ambiguous`, `operation_not_found`, `abandon_blocked`, `accept_blocked`, and
7915
+ `unsupported_operation_kind`, while it reuses `operation_in_progress`, `not_stacked`,
7916
+ `invalid_input`, the sync/transfer tail codes, and journal/config/infra codes already in the façade
7917
+ vocabulary. Existing `DeliveryError`s pass unchanged. The façade boundary translates allowed
7918
+ reconstruction codes, journal corruption/oversize, raw Git, GitHub/backend/persistence, and sync
7919
+ config failures; unexpected programming/filesystem exceptions propagate. The cold worker catches
7920
+ that one error family without changing code/message.
7263
7921
 
7264
7922
  **The cold worker.** `perk objective stack recover [OBJECTIVE] [--dry-run]
7265
7923
  [--operation ULID] [--abandon] [--accept-prefix] [--yes] [--json]`
@@ -7268,7 +7926,11 @@ conclude-only recovery needs no run identity. `--dry-run` × `--abandon`/`--acce
7268
7926
  and `--abandon` × `--accept-prefix` are `invalid_input`
7269
7927
  (preview first, then act; one conclusion per invocation). Both confirmations follow sync's
7270
7928
  discipline (stderr
7271
- render, `--yes` auto-approve, non-interactive without `--yes` → `confirmation_required`).
7929
+ render, `--yes` auto-approve, non-interactive without `--yes` → `confirmation_required`). After
7930
+ flag-first validation and the retained eager validation-only config read, the command resolves one
7931
+ Delivery, maps the booleans to one closed action, constructs one `RecoverRequest`, calls
7932
+ `Delivery.recover` once with the union consent callback, and renders the existing DTO. It catches
7933
+ one `DeliveryError`; no low-level recovery/Git/GitHub/backend error ladder remains.
7272
7934
  Envelope `ObjectiveStackRecoverOut` (snapshotted at
7273
7935
  `shared/schemas/outputs/objective-stack-recover.schema.json`), declaration order pinned:
7274
7936
  `{success, objective{id,url,redirected_from}, dry_run, selection_required, operations:
@@ -7282,7 +7944,9 @@ objective_closed, reconcile_evidence|null (§8.56's shape), notes[]}` with `clas
7282
7944
  {reported, rolled_forward,
7283
7945
  abandoned, accepted_prefix, declined}`; the LAND fields are trailing additive growth
7284
7946
  (`merged_layers`/`remainder` are the external-prefix structured preview, dry-run included —
7285
- empty on other rows); under `dry_run` the swept lists carry the WOULD-BE targets. The human
7947
+ empty on other rows); the DTO serializes the unwrapped `OperationConclusion` detail — the
7948
+ wrapper discriminator never reaches the envelope; under `dry_run` the swept
7949
+ lists carry the WOULD-BE targets. The human
7286
7950
  render prints the landed rows, the close line, and the copyable `/objective-reconcile <id>`
7287
7951
  hint on close-with-evidence. Exit
7288
7952
  discipline: 0 = successful classification/report/no-op/actions (including declined and
@@ -7343,16 +8007,19 @@ since §8.54, the non-recoverable cancellation/checkpoint-topology/journal-histo
7343
8007
  `checkpoint_after_abandoned_publish`); only the two PENDING codes (`publish_outcome_pending`,
7344
8008
  `canceled_publication_pending`) are excluded — a live unresolved PUBLISH concludes via
7345
8009
  recover / the owning `/submit` (§8.51's sole-PUBLISH route), never as identity corruption.
7346
- Sync's
7347
- structural gate, supervisor veto classification, and reviewability consume this same public set;
7348
- no caller maintains a context-specific copy. Public `ClaimedLayer` facts plus
7349
- `derive_claimed_prefix(train) -> tuple[ClaimedLayer, ...]` are likewise the one
7350
- checkpoint-claimed-universe contract shared by publish routing and sync mutation.
7351
-
7352
- **Automatic lower-layer submit.** After reconstructing, `publish_layer` derives the claimed prefix
7353
- *before* reading publish's journal fold. A claimed plan with a claimed successor delegates to
7354
- `synchronize_train(..., trigger_plan_id=<plan>, approve=None)`; the submit gesture is the consent,
7355
- so there is no second prompt and no warm/headless split. Sync therefore owns unresolved-operation
8010
+ Sync's structural gate, supervisor veto classification, and reviewability consume this same
8011
+ public set; no caller maintains a context-specific copy. Package-internal `ClaimedLayer` facts plus
8012
+ `derive_claimed_prefix(train) -> tuple[ClaimedLayer, ...]` remain the one
8013
+ checkpoint-claimed-universe contract shared by publish routing, sync mutation, transfer, and
8014
+ recovery; they are no longer package-root API.
8015
+
8016
+ **Automatic lower-layer submit.** After reconstructing, the bound Publish context derives the
8017
+ claimed prefix *before* reading publish's journal fold. A claimed plan with a claimed successor
8018
+ calls the same façade instance's `sync(SyncRequest(mode="cascade", objective_id=<objective>,
8019
+ run_id=<resolved journal id>, trigger_plan_id=<plan>, trigger_run_id=<raw invoking id>),
8020
+ consent=None)`; the submit gesture is the consent, so there is no second prompt and no
8021
+ warm/headless split. Publish takes no operation lock; the nested sync dispatcher takes its ordinary
8022
+ lock exactly once, so a non-reentrant lock is never self-entered. Sync therefore owns unresolved-operation
7356
8023
  and pending-continuation routing for this arm. `published_prefix_len` never selects it: a successor
7357
8024
  declassified by PR/membership/remote drift remains checkpoint-claimed and reaches sync's typed
7358
8025
  preflight instead of turning a lower submit into a top republish. The automatic path never includes
@@ -7377,16 +8044,15 @@ null on no-op), with a note naming the concluded id: `concluded unresolved opera
7377
8044
  (roll-forward) before cascading`. A completed older operation can therefore never report that it
7378
8045
  published a newer trigger head.
7379
8046
 
7380
- **Writer exclusion and result contract.** The production probe is
7381
- `perk.run.writer_probe.GhaRemoteWriterProbe`. Automatic submit supplies an exclusion only when
7382
- `_corroborated_remote_run_id` matches the caller-supplied id to inherited `PERK_RUN_ID`, a consumed
7383
- implement/address handoff, and this worktree's active plan-ref. The probe then excludes exactly the
7384
- matching `(run_id, plan_id)` pair; neither field excludes alone, an arbitrary/header-derived id
7385
- excludes nothing, and every other active writer (including one on the same plan) still blocks.
7386
- Explicit sync passes no exclusion. Cascade success returns frozen
7387
- `DeliveryOperationFacts {kind:"sync", operation_id|null, abandoned_operation_id|null, resumed,
7388
- no_op, affected: tuple[SyncedLayer,…], notes: tuple[str,…]}` on `PublicationResult.operation`;
7389
- non-cascade publish/republish/converge arms keep `operation=None`. Publish reconstructs after sync
8047
+ **Writer exclusion and result contract.** `RepoDeliveryGitHub` owns the private
8048
+ `_corroborated_remote_run_id` proof and active-writer observation. Automatic submit passes the raw
8049
+ caller id as trigger context, distinct from the separately resolved journal run id; the adapter
8050
+ excludes only when inherited `PERK_RUN_ID`, a consumed implement/address handoff, and this
8051
+ worktree's active plan-ref corroborate the exact `(run_id, plan_id)` pair. Neither field excludes
8052
+ alone, an arbitrary/header-derived id excludes nothing, and every other active writer (including
8053
+ one on the same plan) still blocks. Explicit sync passes no trigger context. Cascade success returns the exact frozen `SyncResult` in
8054
+ `PublishResult.Layer.cascade`; there is no copied operation record. Non-cascade
8055
+ publish/republish/converge arms keep `cascade=None`. Publish reconstructs after sync
7390
8056
  so a roll-forward-then-fresh-no-op cannot return the pre-roll-forward checkpoint snapshot. The
7391
8057
  target layer's PR is fetched from that fresh projection-correlated number, and its published
7392
8058
  checkpoint must agree with the affected after-row (or the fresh checkpoint on a sync no-op);
@@ -7397,10 +8063,12 @@ affected:[{node_id, plan_id, branch, pr_number, before_sha, after_sha}], notes:[
7397
8063
  `operation_id` remains the compatibility alias and carries the sync id. The warm submit decoder
7398
8064
  reduces a valid block to `{kind, operation_id, no_op, affected_count, notes}`, drops the whole block
7399
8065
  when malformed without sinking submit, renders cascade/no-op suffixes, and reports every note.
7400
- `SyncError.error_type` passes through the submit fail envelope verbatim. On cascades an explicit
7401
- `--run-id` merges into `impl_run_ids`; a header-derived run id is not newly stamped, and the
7402
- already-existing PR emits no duplicate Linear PR-opened event. Worktree config/probe construction
7403
- exists only on the stacked route; incremental submit remains independent of it.
8066
+ `DeliveryError.error_type` passes through the submit fail envelope verbatim. On cascades an
8067
+ explicit `--run-id` merges into `impl_run_ids`; a header-derived run id is not newly stamped, and
8068
+ the already-existing PR emits no duplicate Linear PR-opened event. Stacked submit preserves its
8069
+ eager config validation and `invalid_config` envelope but passes no worktree root or writer probe;
8070
+ the private runtime may read config again only when sync candidate/continuation work needs it.
8071
+ Incremental submit remains independent of config.
7404
8072
 
7405
8073
  **`finalize_address` is the only model-facing address finalizer.** Parameters remain
7406
8074
  `{threads:[{thread_id, comment?}], pr?, counts?}`. After the parent commits its own fixes, the tool
@@ -7439,24 +8107,31 @@ fetch feedback. `objective run` renders the additive `repair_required` action wi
7439
8107
  copyable owning remedy, but never auto-runs sync/recover. Dry-run remains the existing offline graph
7440
8108
  classification.
7441
8109
 
7442
- **Stacked `/ready` consumes the train.** `require_reviewable_layer(train, plan_id, mutating)` first
7443
- requires a known layer whose publication axis is exactly `PUBLISHED`; failure is
7444
- `layer_not_published` with axes and findings. A mutating draft additionally requires no unresolved
8110
+ **`/ready` delegates selected intent to Publish.** Selection remains command policy. Explicit
8111
+ input is parse-normalized even offline and a real run uses `select_plan(main_repo_root(...))` with
8112
+ its one canonical read; the no-argument form reads the invoking checkout's `cache.plan-ref` and
8113
+ performs its existing one plan read there. The command never writes a selector. From that selected
8114
+ snapshot it derives only plan id, header-wins `delivery=stacked|incremental`, and the stacked
8115
+ objective id, then calls one `Delivery.publish(PublishRequest(kind="ready", ...))`. Dry-run passes
8116
+ only kind/id/dry-run and returns through the façade before backend/config/credential/GitHub calls.
8117
+
8118
+ Incremental ready derives canonical `plan-<id>` internally and uses the all-state
8119
+ `DeliveryGitHub.pr_for_branch`: absence is `no_pr`; any returned draft is sent to `mark_pr_ready`
8120
+ regardless of OPEN/CLOSED/MERGED and a gateway rejection remains `github_error/(ready,github)`;
8121
+ any non-draft is the idempotent already-ready result regardless of state. This intentionally
8122
+ preserves the pre-migration all-state edge rather than adding an OPEN gate.
8123
+
8124
+ Stacked ready calls bound status once, derives the target, and fetches the full `PullRequest` from
8125
+ the projection-correlated number before any gate/mutation (`no_pr` on absent number/object).
8126
+ `require_reviewable_layer(train, plan_id, mutating=false)` first requires a known target whose
8127
+ publication axis is exactly `PUBLISHED`; failure is `layer_not_published` with axes/findings and
8128
+ therefore precedes interpretation of a fresh close race. A fetched non-OPEN PR is then
8129
+ `pr_not_open`. Only an OPEN draft enters `mutating=true`, which additionally requires no unresolved
7445
8130
  operation (`unresolved_operation`) and no train-wide structural blocker (`structural_blockers`,
7446
- including `missing_lineage`). Operational drift on unrelated layers does not block review.
7447
- `mutating=false` validates only target identity/publication, so an already-ready PR remains
7448
- idempotent even when a later global veto exists.
7449
-
7450
- The worker keeps dry-run offline and first, then reads the plan and applies submit's header-wins
7451
- lineage discriminator. Incremental behavior is unchanged. Stacked ready reconstructs the train,
7452
- locates the layer, and fetches the full `PullRequest` from the projection-correlated number before
7453
- any gate/mutation (`no_pr` on absent number/object). The validation-only target gate then preserves
7454
- `layer_not_published` for a projection-classified merged/closed/wrong-base or otherwise drifted
7455
- target. Only when the projection still says `PUBLISHED` does the fetched state apply: a non-OPEN PR
7456
- (the post-reconstruction close race) refuses as `pr_not_open`; an OPEN draft enters the mutating gate
7457
- then `mark_pr_ready`; OPEN already-ready succeeds without a write after the validation-only gate.
7458
- `LayerError`/`TrainReconstructionError` codes pass through; backend/persistence failures map to
7459
- `github_error`. The fetched PR supplies the unchanged output envelope's number and URL.
8131
+ including `missing_lineage`), before `mark_pr_ready`. OPEN already-ready succeeds without a write
8132
+ after the validation-only gate, even when a later global veto exists. Operational drift on
8133
+ unrelated layers does not block review. Publish returns the fetched PR plus its original
8134
+ `was_draft`; contextual errors map back to the unchanged ready envelope and bytes.
7460
8135
 
7461
8136
  **Status.** Ordinary `/submit` and `/address` now converge published suffixes automatically;
7462
8137
  explicit sync remains the owner of base advancement, adoption, continuation/abort, preview, and
@@ -7465,19 +8140,21 @@ Atomic landing and ordinary-land stacked refusal remain separate later contracts
7465
8140
 
7466
8141
  ## §8.53 · Objective replan transfer (stacked supersession — the convergence protocol)
7467
8142
 
7468
- **When it engages (the D1 routing matrix).** `objective create --supersedes` performs ONE
7469
- fail-closed predecessor classification read before the supersede mutation
7470
- `get_objective(old)` `objective.delivery_policy(header)`: not-found `objective_not_found`;
7471
- a classifier `ValueError` `invalid_delivery_policy` (junk never silently classifies a stacked
7472
- predecessor as incremental); an infra failure fails the save. The resulting state snapshot +
7473
- policy are threaded into `run_transfer` (which never re-reads/re-classifies the predecessor), and
7474
- the already-resolved store instance is reused: routing and planning consume the same ONE read.
7475
- Routing keys on the **authoritative policy classifier**, never on lineage presence:
8143
+ **The public boundary and D1 routing matrix.** `objective create --supersedes` submits one frozen
8144
+ intent-only `TransferRequest` to `Delivery.transfer`; no predecessor snapshot, policy, provider,
8145
+ store, probe, callback, clock, lock, or factory crosses the boundary. Transfer validates the
8146
+ roadmap/dependency/carry shape before any authority call, canonicalizes the predecessor id, then
8147
+ acquires the shared operation lock. While holding it, the façade performs exactly ONE fail-closed
8148
+ authoritative predecessor read/classification `get_objective(old)`
8149
+ `objective.delivery_policy(header)`: not-found `objective_not_found`; a classifier `ValueError`
8150
+ `invalid_delivery_policy`; an infra failure fails the save and completes the selected
8151
+ mutation without a second read. Routing keys on the **authoritative policy classifier**, never on
8152
+ lineage presence:
7476
8153
 
7477
8154
  | predecessor → successor | path |
7478
8155
  | --- | --- |
7479
- | incremental → incremental | the plain §8.32 store mutation, byte-identical apart from the classification read |
7480
- | stacked → any | the full **journaled** transfer protocol (`perk.delivery.transfer.run_transfer`) |
8156
+ | incremental → incremental | the plain §8.32 store mutation under the same lock; no journal/Git/GitHub/status work |
8157
+ | stacked → any | the full **journaled** transfer protocol behind `Delivery.transfer` |
7481
8158
  | incremental → stacked | the transfer orchestration **minus the journal** (the §8.43 append gate requires stored-lineage equality and the predecessor stores none); interruption tolerance is by-construction — run_id-keyed convergent creation + idempotent merge-writes + close-last. Residual: cross-session abandonment of this arm is not journal-discoverable (drift-diagnostic territory). |
7482
8159
 
7483
8160
  A stacked-policy predecessor with a missing/blank/junk `delivery_lineage` refuses fail-closed
@@ -7486,12 +8163,14 @@ A stacked-policy predecessor with a missing/blank/junk `delivery_lineage` refuse
7486
8163
  the completed TRANSFER) stays readable via the predecessor id. `--dry-run` stays offline (the
7487
8164
  transfer never engages).
7488
8165
 
7489
- **The protocol** (lock-first the §8.49 machine-local operation lock is acquired before the
7490
- journal fold, planning, and probes, and held through completion): fold → rerun routing → plan →
7491
- **prepare create stamp verify finalize complete**. The module is
7492
- `perk/delivery/transfer.py` (every effectful callable keyword-injectable with production
7493
- defaults); `roll_forward_transfer(seams, record)` is the lock-ASSUMED conclusion core shared by
7494
- the save's same-run rerun and recover's all-after arm.
8166
+ **The protocol** (the lock is acquired before D1, journal fold, planning, and probes, and held
8167
+ through completion): fold → rerun routing → plan → **prepare → create → stamp → verify → finalize
8168
+ complete**. Fresh dispatch is private `perk/delivery/transfer.py` machinery reached only through
8169
+ `Delivery.transfer`; its `_FreshTransfer` carries `TransferSeams`, aggregate Git/GitHub
8170
+ authorities, and the explicitly typed carry-normalization callable required only by fresh
8171
+ dispatch. `_TransferRuntime` is the whole private clock/id/lock test seam. There is no
8172
+ `run_transfer` compatibility entrypoint. `roll_forward_transfer(seams, record)` remains the
8173
+ lock-ASSUMED conclusion core shared by same-run rerun and recover's all-after arm.
7495
8174
 
7496
8175
  **Planning (the preflight, split by predecessor policy — D13).** A stacked predecessor:
7497
8176
  reconstruct the train, `refuse_structural_blockers`, `derive_claimed_prefix` (**"published" for
@@ -7513,10 +8192,10 @@ detail, **nothing written when planning raises**:
7513
8192
  the K claimed plans **in exact order, each exactly once, none dropped** → `prefix_mismatch`
7514
8193
  (also: a duplicate carry, or a cited plan that does not exist on the predecessor). A node's
7515
8194
  carried plan identity is its `carry_map` entry (Linear — the plan IS the node-issue) else its
7516
- `pr` backlink (GitHub), bare-normalized. The save boundary filters `adopt_issue` identities by
7517
- backend before manifest construction: Linear retains them; GitHub passes an empty carry map
7518
- because its store contract ignores `adopt_issue`. Node ids/descriptions may change freely
7519
- (ownership writes the NEW node id).
8195
+ `pr` backlink (GitHub), bare-normalized. The persistence authority normalizes raw request
8196
+ carries behind the façade: Linear preserves insertion order; GitHub returns an empty map because
8197
+ its store contract ignores `adopt_issue`. No caller or transfer core reads a provider id. Node
8198
+ ids/descriptions may change freely (ownership writes the NEW node id).
7520
8199
  - **Suffix reshaping + the open-PR guards**: below the prefix, reshaping is arbitrary — except
7521
8200
  every predecessor plan with an OPEN PR is **mandatory-carry** (dropping one → `dropped_open_pr`
7522
8201
  until the PR closes), and a policy-**changing** replan (stacked↔incremental, either direction)
@@ -7567,16 +8246,17 @@ fingerprint (target project + node-id-prefixed title + clean description + phase
7567
8246
  label); if its later objective-node attachment write was interrupted, the found-arm resumes the
7568
8247
  unique matching issue, refuses ambiguity/conflict, and only mints when no match exists. Each write
7569
8248
  is idempotent. Stamp: per carried plan, derived from the
7570
- manifest alone — claimed-prefix plans get `transfer_plan_ownership` (objective + NEW node id)
7571
- only; carried-unpublished plans under a stacked successor additionally get
7572
- `stamp_layer_identity` (lineage + the successor-delivery-order predecessor plan id, explicit
7573
- null when the layer below is unplanned); carried plans under an incremental successor instead
7574
- get `clear_delivery_metadata` (the four stacked fields to explicit null in ONE write). Every
7575
- stamp is skipped when the stored values already match (idempotent rerun, no duplicate header
7576
- effects). Verify (**before finalize**; failure `transfer_unverified`, journal unresolved,
7577
- predecessor open, no auto-abandon): a stacked successor requires a fresh
7578
- `reconstruct_repo_train` whose full `(node_id, plan_id|null)` projection equals the recorded
7579
- manifest projection exactly (a never-materialized carried node fails here), zero structural
8249
+ manifest alone — claimed-prefix plans receive one generic grouped `update_plan_header` ownership
8250
+ write (`objective_id` + NEW `objective_node_id`); carried-unpublished plans under a stacked
8251
+ successor additionally receive one grouped identity write (`delivery_lineage` + the
8252
+ successor-delivery-order `predecessor_plan_id`, explicit null when the layer below is unplanned);
8253
+ carried plans under an incremental successor instead receive the four stacked fields as explicit
8254
+ nulls in ONE generic write. Every group is skipped when stored values already match (idempotent
8255
+ rerun, no duplicate header effects); the former three transfer-only persistence wrappers do not
8256
+ exist. Verify (**before finalize**; failure `transfer_unverified`, journal unresolved,
8257
+ predecessor open, no auto-abandon): a stacked successor requires a fresh train reconstruction
8258
+ through the seams' bound `reconstruct` (the façade's cause-preserving bridge) whose full
8259
+ `(node_id, plan_id|null)` projection equals the recorded manifest projection exactly (a never-materialized carried node fails here), zero structural
7580
8260
  blockers, and `derive_claimed_prefix` equal to `before.claimed_prefix` (plan ids, branches,
7581
8261
  checkpoint pairs, order); an incremental successor verifies by direct reads (roadmap rows match
7582
8262
  the projection; every carried plan re-reads with the successor ownership pair and all four
@@ -7599,26 +8279,37 @@ stamped by THIS run's successor re-finalizes idempotently and returns success (t
7599
8279
  interrupted-finalize tail and the idempotent re-save); any other stamp → `objective_not_open`.
7600
8280
  The incremental→stacked arm's lineage resolution is likewise rerun-convergent: a same-run
7601
8281
  successor's stored lineage wins over copy-or-mint (a fresh mint mid-convergence would fork the
7602
- train identity). `perk objective stack recover` owns cross-session conclusion (§8.51's TRANSFER
7603
- arm).
7604
-
7605
- **The door posture** (`objective replan`, §8.32). The door classifies the predecessor policy
7606
- fail-closed and, for a stacked predecessor, refuses on any unresolved journal operation
7607
- (TRANSFER → `transfer_incomplete` + the recover hint; other kinds → `unresolved_operation`),
7608
- reconstructs the train, applies the same structural identity/topology blocker gate as save, and
7609
- renders a `<stacked_delivery_facts>` scratch block: the
8282
+ train identity). `perk objective stack recover` owns cross-session conclusion through
8283
+ `Delivery.recover(RecoverRequest(kind="operation_conclusion", ...))` (§8.51's TRANSFER arm).
8284
+ That arm binds `TransferSeams` directly from the façade's aggregate persistence and cause-aware
8285
+ status bridge—never from a caller factory—and rejects the LAND-only `accept_prefix` action before
8286
+ successor observation or any effect.
8287
+
8288
+ **The door posture** (`objective replan`, §8.32). The door calls
8289
+ `Delivery.prepare(PrepareRequest(kind="replan", objective_id=...))` once. Prepare reads the
8290
+ objective once, classifies policy fail-closed and, for a stacked predecessor, refuses on any
8291
+ unresolved journal operation (TRANSFER → `transfer_incomplete` + the recover hint; other kinds →
8292
+ `unresolved_operation`), calls bound `Delivery.status`, applies the same structural
8293
+ identity/topology blocker gate as save, and returns only the facts needed to render a
8294
+ `<stacked_delivery_facts>` scratch block: the
7610
8295
  claimed-prefix MUST-carry listing (exact order), the mandatory-carry open-PR plans, and the
7611
8296
  immutability facts. The seed's delivery re-ask is **pre-publication only** (§8.45): a published
7612
8297
  predecessor's seed instructs `delivery: stacked` without re-asking.
7613
8298
 
7614
- **Errors.** `TransferError.error_type` {`policy_immutable`, `base_immutable`,
7615
- `prefix_mismatch`, `dropped_open_pr`, `pr_exists`, `missing_lineage`, `transfer_incomplete`,
7616
- `transfer_unverified`, `transfer_manifest_oversize`, `unresolved_operation`, `dirty_worktree`,
7617
- `active_writer`, `writer_observation_unavailable`, `claimed_prefix_malformed`,
7618
- `operation_in_progress`, `objective_not_found`, `objective_not_open`,
7619
- `invalid_delivery_policy`, `invalid_roadmap`, `supersede_unsupported`, `invalid_input`} — the
7620
- CLI maps them verbatim; infra raises (git/GitHub/store/persistence) propagate under their own
7621
- vocabulary, always leaving any prepared operation unresolved (recoverable).
8299
+ **Errors.** Package-internal `TransferError` subclasses `DeliveryError` for recovery reuse;
8300
+ `Delivery.transfer` is the sole fresh boundary. Its bounded domain codes are
8301
+ {`policy_immutable`, `base_immutable`, `prefix_mismatch`, `dropped_open_pr`, `pr_exists`,
8302
+ `missing_lineage`, `transfer_incomplete`, `transfer_unverified`, `transfer_manifest_oversize`,
8303
+ `unresolved_operation`, `dirty_worktree`, `active_writer`, `writer_observation_unavailable`,
8304
+ `claimed_prefix_malformed`, `operation_in_progress`, `objective_not_found`,
8305
+ `objective_not_open`, `invalid_delivery_policy`, `invalid_roadmap`, `supersede_unsupported`,
8306
+ `invalid_input`}. Existing Delivery/Transfer errors pass unchanged; reconstruction keeps its
8307
+ bounded code/message; journal corruption → `journal_corruption`; raw Git → `git_error`; raw
8308
+ GitHub/issue/train-persistence → `github_error`; objective-store failure → `github_error` with
8309
+ `objective create failed\n…`. A cause-aware bound-status bridge restores the original
8310
+ reconstruction/store/issue/persistence exception to the shared core, preserving domain versus
8311
+ infrastructure verification behavior. Unexpected programming errors propagate. The CLI consumes
8312
+ one `DeliveryError` envelope; any prepared failure remains unresolved and recoverable.
7622
8313
 
7623
8314
  **Residuals (flagged).** The **D14 Linear creation window**: Linear cannot make its first write
7624
8315
  run-id-discoverable (discovery IS the sentinel header attachment), so a crash inside the
@@ -7627,11 +8318,15 @@ sentinel ⇒ invisible to `find_objective`/journal/train, and no predecessor-tou
7627
8318
  happened (carried moves run only after the sentinel — the pinned ordering invariant), so the
7628
8319
  rerun's all-before proof stays safe; re-creation may strand the residue project (inherited from
7629
8320
  the plain create path; GitHub has no such window — its issue POST carries the run-id header
7630
- atomically). This is the ONLY accepted Linear materialization window: after the sentinel, even a
7631
- fresh node issue whose create succeeded before its attachment is recoverable through the atomic
7632
- create-time fingerprint described above. The non-journaled incremental→stacked arm's cross-session abandonment is not
7633
- journal-discoverable. An oversize manifest refuses rather than truncating. The Linear transfer
7634
- path is fake-proven; live proof belongs to the Linear smoke gate.
8321
+ atomically). This is the ONLY accepted Linear materialization window for the journaled route:
8322
+ after the sentinel, even a fresh node issue whose create succeeded before its attachment is
8323
+ recoverable through the atomic create-time fingerprint described above. The non-journaled
8324
+ incremental→stacked arm's cross-session abandonment is not journal-discoverable. In particular,
8325
+ real Linear process death after a carried node MOVE but before plan ownership/finalization is
8326
+ **not proven** by this refactor: no durable operation record binds a later run to the preflighted
8327
+ request. Designing that recovery posture is a separately reviewed behavior change, not a
8328
+ permissive inference from partial state. An oversize manifest refuses rather than truncating. The
8329
+ journaled Linear transfer path is fake-proven; live proof belongs to the Linear smoke gate.
7635
8330
 
7636
8331
  ## §8.54 · Native cancellation projection + unified train drift diagnostics
7637
8332
 
@@ -7720,6 +8415,17 @@ Protocol (declared in `diagnostics.py`, implemented only by `LinearProjectObject
7720
8415
  `write_node_cancellation_status`) is the ONE narrow train repair: a conditional, ATTACHMENT-ONLY
7721
8416
  compare-and-write (`expected_status`/`new_status`, `require_native_canceled: bool|None`,
7722
8417
  `require_no_raw_publish_claims`, `dry_run`) returning `APPLIED | ALREADY_CONVERGED | STALE`.
8418
+ Production ownership lives behind `Delivery.recover(RecoverRequest(kind=
8419
+ "cancellation_metadata", objective_id, dry_run))` (§8.51): the persistence authority exposes
8420
+ the writer through the optional capability `DeliveryPersistence.
8421
+ native_cancellation_metadata_writer()` — a concrete default-`None` method (the
8422
+ unsupported-backend posture) with a quoted type-only annotation, overridden only by the lazy
8423
+ production adapter (which returns the resolved objective store exactly when it structurally
8424
+ satisfies the Protocol, through its one aligned resolution, with no extra objective read and
8425
+ failed-resolution non-caching) and the owned fake. `None` is a successful empty pass before
8426
+ any reconstruction; only the Recover engine consumes the Protocol and
8427
+ `repair_projected_cancellations` in production — the Protocol is never a package-root export
8428
+ or a fourth aggregate authority.
7723
8429
  The writer performs a FRESH state-bearing read at the effect boundary, compares the attachment
7724
8430
  status, requires native canceled for the forward write, rechecks raw PR/checkpoint claims, and
7725
8431
  upserts ONLY the `objective-node` attachment — never the generic status update, never a
@@ -7739,15 +8445,33 @@ conditional validation with no write/compensation. This is not distributed atomi
7739
8445
  prevents stale snapshots from writing and compensates observed drift. Doctor never repairs plan
7740
8446
  identity, checkpoints, journal history, branches, PRs, or native stack membership.
7741
8447
 
7742
- **The two-part doctor.** Expected authority-read failures (issue-backend / objective-store /
7743
- train-persistence errors) normalize onto the typed reconstruction failure at doctor's
7744
- diagnosis and repair boundaries a routine plan/journal outage is the modeled `unavailable`
7745
- state, never an escape. `perk objective doctor` resolves the requested objective through
7746
- `train.resolve_active_objective` ONCE manifest detection/repair and train
7747
- reconstruction/repair all target the ACTIVE id (`objective` reports it; additive
7748
- `redirected_from` preserves the requested id; a predecessor is never mutated by
7749
- `doctor OLD --fix`). The report is two parts: the existing Linear manifest drift plus the exact
7750
- `DeliveryTrain` findings on every backend, each annotated with the diagnosis policy
8448
+ **The two-part doctor.** Both report diagnosis and the train repair are façade consumers. One
8449
+ zero-I/O `Delivery`
8450
+ is constructed per command and reused for initial diagnosis, post-manifest re-diagnosis, the
8451
+ cancellation Recover pass, and the
8452
+ final remaining-findings read. Each diagnosis calls
8453
+ `Delivery.status(StatusRequest(objective_id=active_id))`; its bounded `DeliveryError` becomes the
8454
+ modeled `unavailable` state with the same error type/message, so a routine plan/journal/store
8455
+ outage is never an escape. The cancellation repair's effect-boundary proof lives inside the
8456
+ Recover engine: a private reconstruction closure over the façade's cause-aware bridge, pinned
8457
+ to the request objective on every call, normalizing expected
8458
+ issue/objective/train-persistence read failures to
8459
+ `TrainReconstructionError(error_type="github_error")` so the repair core answers its modeled
8460
+ unavailable arm; no public read API is reintroduced. Doctor itself is a thin request/result
8461
+ mapper: for a currently stacked diagnosis it constructs exactly
8462
+ `RecoverRequest(kind="cancellation_metadata", objective_id=active_id, dry_run=dry_run)`,
8463
+ calls the shared `Delivery.recover` once with no consent, and maps the strict
8464
+ `CancellationMetadata` detail into `_TrainFixOut`; a bounded `DeliveryError` raised before a
8465
+ modeled detail exists (e.g. lazy persistence capability resolution failed) is treated as an
8466
+ unavailable/aborted repair pass — the final diagnosis still runs and the assembled report
8467
+ keeps `success: true` with the exit-1 posture. Current incremental/unavailable diagnoses
8468
+ short-circuit before any Recover call, exactly as before.
8469
+
8470
+ `perk objective doctor` resolves the requested objective through `train.resolve_active_objective`
8471
+ ONCE — manifest detection/repair and train reconstruction/repair all target the ACTIVE id
8472
+ (`objective` reports it; additive `redirected_from` preserves the requested id; a predecessor is
8473
+ never mutated by `doctor OLD --fix`). The report is two parts: the existing Linear manifest drift
8474
+ plus the exact `DeliveryTrain` findings on every backend, each annotated with the diagnosis policy
7751
8475
  (`TrainFindingOut: code, severity, node_id, plan_id, message, repairable, remediation`).
7752
8476
  `TrainDiagnosisOut` (field order load-bearing): `state: stacked|incremental|unavailable,
7753
8477
  objective_id, redirected_from, error_type, message, blockers[], information[]` — stacked
@@ -7764,12 +8488,31 @@ repair aborted first — no train action, initial diagnosis remains), `unavailab
7764
8488
  records the verification failure)). Sequence: initial manifest/train reports → existing
7765
8489
  manifest repair → reconstruct if the manifest changed → per-action fresh conditional repair →
7766
8490
  final diagnosis in `remaining`. Top-level payload order stays
7767
- `success,error_type,objective,drift,fix` then appends `redirected_from,train,train_fix`
7768
- (without `--fix`, `train_fix` is null). An assembled report keeps `success: true`/null error;
7769
- the EXIT code conveys unavailability/aborted repair: detect-with-findings 0; detect-unavailable
7770
- 1; manifest-abort 1; current-train-unavailable 1; write/verification abort 1; fix-succeeded
7771
- (report-only drift remaining) 0; not-a-repo the fail envelope 2; active-resolution/store
7772
- failure the fail envelope 1.
8491
+ `success,error_type,objective,drift,fix` then appends `redirected_from,train,train_fix,
8492
+ corruption` (without `--fix`, `train_fix` is null). An assembled report keeps `success:
8493
+ true`/null error; the EXIT code conveys unavailability/aborted repair: detect-with-findings 0;
8494
+ detect-unavailable 1; manifest-abort 1; current-train-unavailable 1; write/verification abort
8495
+ 1; fix-succeeded (report-only drift remaining) 0; not-a-repo the fail envelope 2;
8496
+ active-resolution/store failure the fail envelope 1.
8497
+
8498
+ **The both-headers corruption signature (report-only).** A third check rides every doctor
8499
+ report: `corruption: [_CorruptionFindingOut{code, carrier, message, remediation}]` (appended
8500
+ last — additive). The check resolves the ACTIVE objective's **issue-tier carrier** via the
8501
+ §8.43 `journal_carrier_id` (GitHub → the objective issue; Linear project store → the metadata
8502
+ **sentinel** issue's identifier — so a sentinel bearing both attachments is detected) and reads
8503
+ it **presence-only** via `IssueBackend.read_issue` (never `get_plan`, whose header-`pr` chase
8504
+ could abort the whole report on a PR-lookup infra failure). A carrier whose read shows
8505
+ `already_plan AND already_objective` yields exactly one `both_headers` finding (its `carrier`
8506
+ field = the resolved carrier id); a healthy or unresolvable carrier yields `[]`. Cost: up to
8507
+ two bounded reads per report. Semantics: **report-only** (`--fix` never touches it — no repair
8508
+ code path exists), **direction-neutral** (the signature cannot prove which header is the
8509
+ stray one; remediation is provenance inspection — issue history, each header's `run_id` — with
8510
+ manual removal, or supersession via `perk objective replan <active>` when the objective side is
8511
+ live), **active-objective-targeted** (a superseded predecessor is redirected away — the
8512
+ supersession IS the worked remediation), **exit-0** (a detected finding is still a clean
8513
+ report), and the human render prints the `Corruption:` part only when detected (clean runs'
8514
+ output is byte-unchanged). An `IssueBackendError` from the check fails the report as
8515
+ `github_error` (the assembly boundary's posture).
7773
8516
 
7774
8517
  **Compatibility.** No provenance ⇒ byte-existing behavior; the stack-status JSON shape is
7775
8518
  unchanged (new findings and `intent: canceled` are values inside existing string fields);
@@ -7795,10 +8538,14 @@ wires *this* module and `sync.py` imports `observe`); it owns its observation vi
7795
8538
  `LandObservationError`) and never sees `perk.github` types. The shared remote-writer seam
7796
8539
  (`RemoteWriterProbe` + `WriterObservationError`) lives in the leaf `perk/delivery/writers.py`
7797
8540
  (moved from `sync.py`, which re-exports both names) so mutating and readiness preflights share
7798
- one fail-closed observation contract. Production wiring:
7799
- `observe.GatewayLandObservations(repo_root, base=…)` over `perk/github/stacks.py`
7800
- (`pr_land_facts` / `base_merge_rules` / `stack_capability`), wrapping `GitHubError` into
7801
- `LandObservationError` except the capability bool, which passes through (below).
8541
+ one fail-closed observation contract. Production wiring is aggregate-backed: the landing
8542
+ engine constructs `observe.GatewayLandObservations(github, base=…)` over the bound
8543
+ `DeliveryGitHub` (`pr_land_facts` / `base_merge_rules` / `stack_capability`), wrapping the
8544
+ aggregate's failures (`GitHubError` on the readiness read; the frozen merge-rules
8545
+ `ProbeError`, whose message is the same `str(exc)` bytes) into `LandObservationError` —
8546
+ except the capability bool, which passes through (below) — plus the fail-closed
8547
+ `observe._AggregateWriterProbe` over `DeliveryGitHub.active_writer_plan_ids` (exact
8548
+ forwarding, no trigger exclusions).
7802
8549
 
7803
8550
  **Dispositions.** `READY | BLOCKED | NOTHING_TO_LAND`. READY iff ≥1 **non-landed** layer and ZERO blockers
7804
8551
  (information never vetoes — advisory threads, failed optional checks, and a clean ACTIVE
@@ -7932,9 +8679,13 @@ GitHub's own `mergeStateStatus: BLOCKED` is the covering authority for that case
7932
8679
  (`commands/objective/stack/land_cmd.py`; the group's land verb). **No remote mutation
7933
8680
  anywhere in this section**: bare `land` (no `--dry-run`) is the §8.56 landing mutation on
7934
8681
  this same argv shape (it replaced the historical `land_unimplemented` refusal).
7935
- `--dry-run` resolves the objective (explicit arg → worktree plan-ref → `no_objective`),
7936
- reconstructs the train with exactly `stack status`'s exception→envelope mapping, maps
7937
- `NoDeliveryTrain` typed `not_stacked` (exit 1), assesses, and reports. Envelope
8682
+ `--dry-run` resolves the objective (explicit arg → worktree plan-ref → `no_objective`) and
8683
+ makes exactly one `Delivery.land(LandRequest(kind="objective", objective_id, dry_run=True))`
8684
+ call lock-free, consent-free, run-id-free; the engine reconstructs the train (a
8685
+ train-less objective is the typed `not_stacked` with the `Objective #N: <reason>` dry-run
8686
+ message shape, exit 1; reconstruction failures keep exactly `stack status`'s typed
8687
+ envelope mapping through the façade's land boundary), assesses, and returns the
8688
+ readiness-only detail the command maps onto the envelope. Envelope
7938
8689
  (`ObjectiveStackLandOut`, snapshotted at
7939
8690
  `shared/schemas/outputs/objective-stack-land.schema.json`; field order load-bearing):
7940
8691
  `{success, error_type, objective{id,url,redirected_from}, dry_run, disposition, base,
@@ -7968,21 +8719,27 @@ train is only the `NOTHING_TO_LAND` disposition.
7968
8719
 
7969
8720
  ## §8.56 · Objective landing (the journaled atomic merge)
7970
8721
 
7971
- **The operation.** `perk.delivery.landing.land_train(repo_root, *, objective_id, run_id,
7972
- remote_writers, approve=…, …seams)` is the landing **mutation** behind bare
7973
- `perk objective stack land` — a thin consumer of the §8.55 readiness projection
7974
- (`assess_land_readiness`, consumed as-is, never re-derived) plus the §8.43 journal
7975
- (`TrainPersistence.append_prepared`/`append_outcome`), the per-layer finalize seam
7976
- (`perk.delivery.finalize.finalize_landed_plan`), and the machine-local operation lock
7977
- (oplock scope grows to sync + recover + **land**; a busy lock is the typed
7978
- `operation_in_progress`). No extra merge modes, no queue emulation, no generalized landing
7979
- abstraction. Injection shape mirrors `sync.py`: one public entry with keyword-injectable
7980
- seams defaulting to production wiring. `land.py` stays the pure readiness core (its import
7981
- direction forbids `observe`/gateway imports); the mutation lives in the new
7982
- `perk/delivery/landing.py`. The squash-message helper is the public pure
8722
+ **The operation.** `Delivery.land(LandRequest(kind="objective", objective_id, run_id,
8723
+ dry_run=False), consent=…)` is the landing **mutation** behind bare
8724
+ `perk objective stack land` — the façade-bound engine (`perk/delivery/landing.py`, no
8725
+ public entry) is a thin consumer of the §8.55 readiness projection
8726
+ (`assess_land_readiness`, consumed as-is, never re-derived) plus the §8.43 journal through
8727
+ the aggregate persistence (`append_prepared`/`append_outcome`), the per-layer finalize seam
8728
+ (`perk.delivery.finalize.finalize_landed_plan`, bound through the private landing runtime),
8729
+ and the machine-local operation lock (oplock scope grows to sync + recover + **land**; a
8730
+ busy lock is the typed `operation_in_progress`; acquired before reconstruction and held
8731
+ through consent, merge, verification, finalization, and close the dry-run preview is
8732
+ lock-free). `consent` is the Land family's confirmation callback over the composed
8733
+ `LandReadiness` (`None` auto-approves); it fires for both the READY land plan and the
8734
+ NOTHING_TO_LAND completion preview, and the façade rejects a callback on the non-mutating
8735
+ shapes (`kind="plan"` and the objective dry-run) with `ValueError` — never
8736
+ accept-and-ignore. The request always carries a nonblank `run_id` on the mutation and never
8737
+ on the preview (construction-guarded). No extra merge modes, no queue emulation, no
8738
+ generalized landing abstraction. `land.py` stays the pure readiness core (its import
8739
+ direction forbids `observe`/gateway imports). The squash-message helper stays the pure
7983
8740
  `landing.squash_commit_message(*, issue, url, backend_id, title)` (byte-identical to the
7984
8741
  incremental `pr land` footer format — GitHub `Closes #N`, non-github `Plan: <id> — <url>`;
7985
- `pr/land_cmd.py` imports itone implementation, no drift).
8742
+ one implementation, no drift) module-path internal, no root export.
7986
8743
 
7987
8744
  **The wire contract (GitHub's stacked-PR merge API, public preview).** Submit:
7988
8745
  `PUT /repos/{owner}/{repo}/pulls/{top}/merge-async` with EXACTLY
@@ -8005,14 +8762,29 @@ must land even where merge-async preview enrollment is absent). Gateway surface
8005
8762
  (`perk/github/stacks.py`): `submit_merge_async` / `merge_pr_direct` are **total** (the
8006
8763
  `--include` status + `Retry-After` classification; a spawn failure folds into the ambiguous
8007
8764
  `status=None` arm; an unparseable 2xx/409 body leaves `state=None` — ambiguous, never a
8008
- guessed success); `merge_async_result` / `pr_merged_evidence` (per-PR
8765
+ guessed success); `pr_merged_evidence` (per-PR
8009
8766
  `state + baseRefName + headRefName + headRefOid + mergeCommit.oid` — the identity fields
8010
8767
  the verification corroborates; a zero-exit reply carrying an explicitly-null PR node is the
8011
- ordinary lookup miss, `None`) are **strict** (they decide whether a journal outcome may be
8012
- appended — junk raises, never degrades).
8013
-
8014
- **The protocol, in order.** (1) The operation lock. (2) Reconstruct
8015
- (`observe.resolve_train_reads` + `reconstruct_train`); `NoDeliveryTrain` or a null
8768
+ ordinary lookup miss, `None`) is **strict** (it decides whether a journal outcome may be
8769
+ appended — junk raises, never degrades). The landing engine reaches every landing effect
8770
+ and observation through the aggregate `DeliveryGitHub`: `submit_merge_async` /
8771
+ `merge_pr_direct` (the two mutations above), the **total** `merge_async_probe` as the poll
8772
+ (the four live states pass through — `merged` carries `details.sha`; an `expired`/
8773
+ `unreadable` probe consumes the tick exactly like the historical tolerated per-tick
8774
+ strict-reader failure), and `merged_evidence` for the post-approval re-observation, per-PR
8775
+ verification, and abandon proof (`merge_commit_sha` is ignored pre-merge; the raw
8776
+ `GitHubError` posture and drift/unproven message bytes are unchanged).
8777
+ Operation-conclusion recovery reaches the same handle probe and strict merged evidence
8778
+ through `DeliveryGitHub.merge_async_probe` and
8779
+ `DeliveryGitHub.merged_evidence`; objective close likewise uses
8780
+ `DeliveryPersistence.close_objective`. Recovery binds LAND issue reads only to the existing
8781
+ `train.PlanReader` shape; the landing mutation additionally reads the provider identity
8782
+ through `DeliveryPersistence.backend_id()`. The package-internal
8783
+ per-layer `finalize_landed_plan(close_objective_on_complete=False)` calls are private
8784
+ `_RecoverRuntime`/`_LandingRuntime` machinery, not a fourth public authority or request field.
8785
+
8786
+ **The protocol, in order.** (1) The operation lock. (2) Reconstruct (the façade's
8787
+ cause-preserving train-reconstruction bridge); `NoDeliveryTrain` or a null
8016
8788
  `delivery_lineage` → typed `not_stacked`. (3) Assess (§8.55). (4) **NOTHING_TO_LAND** →
8017
8789
  `approve` with the completion preview ("nothing to merge; close objective #N") — declined ⇒
8018
8790
  `outcome: declined`; approved ⇒ the **state-aware close** (`landing.state_aware_close`,
@@ -8023,12 +8795,16 @@ failure is a typed error, never fail-open) ⇒ `outcome: completed_without_merge
8023
8795
  note). The approval pause is a race boundary: node terminality is REVALIDATED on the fresh
8024
8796
  fetch — a node added/reopened during the pause ⇒ typed `land_drift`, nothing closed (a
8025
8797
  stale NOTHING_TO_LAND snapshot never closes an incomplete objective). **No journal** (no remote train mutation to guard; the close is
8026
- idempotent/convergent). (5) **BLOCKED** → the typed refusal `land_blocked` carrying the full
8027
- composed readiness. (6) **READY**: for `singleton_squash` the load-bearing pre-merge
8798
+ idempotent/convergent). (5) **BLOCKED** → the **in-band refusal detail**: the mutation arm
8799
+ returns the readiness-only `LandResult.Objective` (`outcome: null`, the full composed
8800
+ readiness embedded) before consent — no exception, `DeliveryError` stays payload-free; the
8801
+ CLI maps it to its exit-1 `land_blocked` envelope (below). (6) **READY**: for `singleton_squash` the load-bearing pre-merge
8028
8802
  `get_plan` read happens NOW (missing ⇒ typed `plan_not_found`; it supplies the squash
8029
8803
  title/url + the tolerantly-parsed `consumed_learn`); then `approve(readiness)` — the
8030
8804
  rendered land plan; declined ⇒ `outcome: declined`, nothing journaled. (7) **Re-observe**
8031
- every layer PR after the arbitrary approval pause (`stacks.pr_delivery_facts`: OPEN, head ==
8805
+ every layer PR after the arbitrary approval pause (the strict aggregate
8806
+ `DeliveryGitHub.merged_evidence` — `PrMergedEvidence` carries every inspected identity
8807
+ fact, `merge_commit_sha` ignored pre-merge: OPEN, head ==
8032
8808
  plan `head_sha`, base == expected base ref, head ref == branch); any mismatch/read failure →
8033
8809
  typed `land_drift`, nothing journaled. (8) **Prepared** (journal-first, read back; the
8034
8810
  one-unresolved gate and `JournalAppendAmbiguous` propagate typed). (9) **Submit** — classification
@@ -8055,10 +8831,11 @@ one identical retry, with the same preserved-ambiguity rule — only a `merged`
8055
8831
  (including the already-merged idempotent arm, which recovers an applied-but-unconfirmed
8056
8832
  first attempt) concludes it; anything else leaves `pending`. **No `accepted` event ever on
8057
8833
  the singleton** — there is no handle. (10) **Poll** (async
8058
- arm): up to 60 ticks, injected `sleep(1)`; `pending` continues; `merged` ⇒ verification;
8834
+ arm): up to 60 ticks, injected `sleep(1)`, over the total `merge_async_probe`; `pending`
8835
+ continues; `merged` ⇒ verification (its `sha` is the journaled `reported_sha`);
8059
8836
  `failed` ⇒ abandon-with-proof then `land_failed`; `enqueued` ⇒ stop immediately,
8060
- `outcome: unexpected_enqueued` (unresolved); per-tick read failures are tolerated within the
8061
- budget; exhaustion ⇒ `outcome: pending`. (11) **Abandon-with-proof** (terminal
8837
+ `outcome: unexpected_enqueued` (unresolved); an `expired`/`unreadable` probe consumes the
8838
+ tick (the historical tolerated per-tick read failure); exhaustion ⇒ `outcome: pending`. (11) **Abandon-with-proof** (terminal
8062
8839
  non-application only): every layer PR re-observed OPEN at its exact expected head ⇒ append
8063
8840
  `abandoned` and let the typed failure propagate (retry is legal — the operation is
8064
8841
  resolved); ANY contradiction or read failure ⇒ NO outcome append, `outcome: pending` (never
@@ -8135,24 +8912,44 @@ pre-merge objects reachable) / Git objects.
8135
8912
  `unexpected_enqueued` mean the LAND operation stays **unresolved** — never success, never
8136
8913
  failure (§8.51's `stack recover` concludes it once the merge settles or expires — the
8137
8914
  recorded operation identity, the journaled `accepted` UUID handle or the prepared `mode`,
8138
- is the recovery probe's input). `LandError.error_type` (exit 1): `not_stacked | land_blocked |
8139
- land_drift | land_failed | merge_async_unavailable | merge_request_conflict | plan_not_found
8140
- | operation_in_progress | confirmation_required`; reconstruction/persistence/backend errors
8141
- keep their existing typed passthrough at the CLI boundary (the `sync_cmd` ladder, plus
8142
- `journal_corruption` and `git_error`); exit 2 = not-a-repo. `land_blocked` renders the full
8143
- readiness report to stderr (the shared human renderer with a "landing readiness" heading)
8144
- and attaches the dry-run-shaped readiness payload to the JSON fail envelope under the
8145
- `readiness` key.
8915
+ is the recovery probe's input). Failures are the bounded `DeliveryError` vocabulary with
8916
+ `phase="land"` (exit 1). Engine refusals/protocol classifications `not_stacked` (both
8917
+ message shapes preserved: the dry-run's `Objective #N: <reason>` and the mutation's
8918
+ `objective N has no delivery train (<reason>)`), `plan_not_found`, `land_drift`,
8919
+ `merge_request_conflict`, `merge_async_unavailable`, `land_failed`,
8920
+ `operation_in_progress` carry `origin="domain"`. The boundary origin rule:
8921
+ `train.TrainReconstructionError` passes its code through when it is a known delivery code
8922
+ (else normalizes to `github_error`), with origin derived from the final code (`git_error` →
8923
+ `git`; `github_error`, including the unknown-code fallback → `github`; every other
8924
+ recognized code → `domain`); `GitHubError`/`IssueBackendError`/`ObjectiveStoreError`/
8925
+ `TrainPersistenceError` (including `JournalAppendAmbiguous`) → `github_error`/`github`;
8926
+ `JournalCorruptionError` → `journal_corruption`/`delivery`; a defensive `GitError` →
8927
+ `git_error`/`git` (the engine makes no Git authority calls). `JournalRecordTooLarge` is
8928
+ deliberately **not** translated — an oversize append propagates as the unexpected
8929
+ programming error it always was (both phases; a typed mapping would be a behavior change
8930
+ and a façade-only translation would break invariant 20 post-verification). A consent
8931
+ callback raising (the CLI's typed `confirmation_required` refusal) propagates
8932
+ untranslated. Exit 2 = not-a-repo. `land_blocked` is a **CLI-authored envelope code** (like
8933
+ `confirmation_required`/`no_objective`): the CLI maps the in-band BLOCKED detail to the
8934
+ exit-1 fail envelope carrying the verbatim message
8935
+ `objective <id> is not ready to land: <"[code] message"-joined blockers or 'blocked'>`,
8936
+ renders the full readiness report to stderr (the shared human renderer with a "landing
8937
+ readiness" heading), and attaches the dry-run-shaped readiness payload to the JSON fail
8938
+ envelope under the `readiness` key.
8146
8939
 
8147
8940
  **The cold worker.** `perk objective stack land [OBJECTIVE] [--dry-run] [--run-id ID]
8148
- [--yes] [--json]` — `--dry-run` stays the §8.55 read-only preview (behavior unchanged; the
8941
+ [--yes] [--json]` — a thin request→façade→map on **both arms** (envelope/exit semantics
8942
+ unchanged). `--dry-run` maps the §8.55 read-only preview (behavior unchanged; the
8149
8943
  envelope preserves the §8.55 field prefix/order with the §8.56 mutation fields appended as
8150
8944
  trailing nulls/empties; no consent, `--yes`/`--run-id` ignored). Bare
8151
8945
  `land` requires GitHub auth (`require_github`), resolves the run id (explicit `--run-id` →
8152
8946
  the ACTIVE objective header's `run_id` → typed `invalid_input`; `stack/shared.py::
8153
- resolve_run_id`, shared with sync), and confirms: `--yes` auto-approves (still rendering
8947
+ resolve_run_id`, shared with sync caller intent reconstruction stays CLI-side, and its
8948
+ store-walk failures keep their typed envelopes at the command boundary), passes the
8949
+ consent callback to `Delivery.land`, and confirms: `--yes` auto-approves (still rendering
8154
8950
  what it approved); a non-interactive session without `--yes` is the typed
8155
- `confirmation_required` refusal BEFORE any prompt. The success envelope grows the trailing
8951
+ `confirmation_required` refusal BEFORE any prompt (raised inside the callback,
8952
+ propagating through the façade untranslated). The success envelope grows the trailing
8156
8953
  fields `{outcome, operation_id, merge_async_uuid, landed_layers: [{node_id, plan_id,
8157
8954
  pr_number, merge_commit_sha, learn_state, plan_issue_closed, nodes_marked, finalized,
8158
8955
  base_sha, head_sha}],
@@ -8283,6 +9080,12 @@ suites that pin the edited prose in the same change (prompt-prose edits touch no
8283
9080
  fixture — §8.31's Tier B is engine-vs-engine — but extension context/factory tests and binding
8284
9081
  guards may pin strings).
8285
9082
 
9083
+ **Review-flow application.** The automated, adversarial, and draft-review launch statements own
9084
+ only flow choreography and compact labels: they name Ponytail as required automatic coverage and
9085
+ teach the requested/runnable launch vocabulary, but never restate its full rubric or source-check
9086
+ procedure. The bound review skills and reviewer agent definitions remain the canonical detail and
9087
+ judgment carriers for ownership, exact-source recheck, and the residual filesystem race.
9088
+
8286
9089
  **Scope.** The objective's migration nodes cover the named stage families; all other perk-owned
8287
9090
  stage prose (e.g. the `skills` door family) is held to this rule as ordinary maintenance going
8288
9091
  forward.
@@ -8462,3 +9265,876 @@ construction site — it ships standalone into the wheel and cannot import the c
8462
9265
  two sites are pinned together by a path-parity test). The family is disposable local cache:
8463
9266
  ignored by run GC, retained for the worktree's life, removed with the worktree; never copied to
8464
9267
  GitHub/Linear.
9268
+
9269
+ ## §8.59 · The learn-dream gather core (manifest contract)
9270
+
9271
+ The pure exterior gather core for the `perk learn dream` factory (`perk/learn/dream.py`);
9272
+ the public door is `perk learn dream` (§8.65 — the §8.48-style door text), and the TS
9273
+ analyst wave is the decoder side (§8.60 pins this same schema version). `commit_sha` and
9274
+ `run_id` are **door-supplied parameters** — the core never captures HEAD, syncs, or
9275
+ preflights clean-tree/origin (the door owns all of that, §8.65).
9276
+
9277
+ **The manifest.** Versioned JSON (schema_version the string `"1"` — dream's own version line,
9278
+ independent of harvest's), written run-scoped at
9279
+ `.perk/workflow/scratch/runs/<run_id>/dream-manifest.json` (`DREAM_MANIFEST_FILENAME`):
9280
+
9281
+ ```json
9282
+ { "schema_version": "1",
9283
+ "commit_sha": "<door-supplied>",
9284
+ "registry_mode": "clusters",
9285
+ "doc_count": 63,
9286
+ "total_bytes": 1160620,
9287
+ "findings": {
9288
+ "structural": {
9289
+ "stale_pointers": [ { "doc": "docs/learned/pi/context-injection.md",
9290
+ "pointer": "perk/run/launch.py::_gone",
9291
+ "reason": "missing-symbol" } ],
9292
+ "broken_doc_paths": [ { "doc": "docs/learned/pi/context-injection.md",
9293
+ "target": "../workflow/renamed.md" } ],
9294
+ "duplicate_cues": [ { "key": "when touching the extension api.",
9295
+ "docs": [ "docs/learned/pi/context-injection.md",
9296
+ "docs/learned/pi/extension-api.md" ] } ],
9297
+ "missing_frontmatter": [ "docs/learned/pi/untitled.md" ] },
9298
+ "advisory": {
9299
+ "distillation_issues": [ { "doc": "docs/learned/pi/context-system.md",
9300
+ "problem": "missing" } ],
9301
+ "source_code_blocks": [ { "doc": "docs/learned/pi/extension-api.md",
9302
+ "language": "ts", "lines": 14 } ],
9303
+ "overlong_cues": [ { "doc": "docs/learned/pi/tui-surfaces.md", "length": 214 } ],
9304
+ "cue_hazards": [ { "doc": "docs/learned/pi/subagents.md",
9305
+ "hazard": "space-hash" } ],
9306
+ "empty_clusters": [ "prose-governance" ] } },
9307
+ "lanes": [
9308
+ { "id": "pi-extension-1",
9309
+ "rollup": "Pi SDK/extension substrate craft — …",
9310
+ "docs": [ { "path": "docs/learned/pi/context-injection.md",
9311
+ "title": "…", "read_when": "…",
9312
+ "cluster": "pi-extension", "bytes": 12345 } ] } ] }
9313
+ ```
9314
+
9315
+ `registry_mode` ∈ `"clusters" | "categories"`; per-doc `bytes` is the raw file byte size;
9316
+ `doc_count`/`total_bytes` are the corpus count and the per-doc-bytes sum; the per-doc cue field
9317
+ is named `read_when` (matching the harvest manifest and the frontmatter key); `None`
9318
+ title/cue/cluster/rollup values are carried as JSON `null`, never dropped.
9319
+
9320
+ **The two-source partition.** Lanes join the committed cluster registry
9321
+ (`docs/learned/clusters.yaml` — ids + rollups + **file order**, the presentation SSOT matching
9322
+ the `docs-sync` rendering) with each doc's `cluster` frontmatter: per registry cluster, members
9323
+ = the docs whose `cluster` matches, path-sorted, chunked sequentially at `MAX_LANE_DOCS` (the
9324
+ shared harvest cap, 8); lane ids `<cluster>-<n>`, 1-based per cluster; **every chunk lane of a
9325
+ cluster carries that cluster's `rollup`**; an empty cluster emits no lane. The **category
9326
+ fallback** applies only to a **truly absent** registry (`load_cluster_registry` → `None`):
9327
+ `partition_lanes` semantics — `<category>-<n>` ids in sorted-group order — with `rollup: null`
9328
+ and `registry_mode: "categories"`.
9329
+
9330
+ **The refusal vocabulary** (all `UserFacingCliError`s): `no_learned_docs` (empty corpus);
9331
+ `invalid_registry` (a present-but-broken registry — the loader's precise reason relayed);
9332
+ `incomplete_registry` (any doc whose `cluster` is undeclared or names no registry id — every
9333
+ offending doc listed; the docs-sync posture, never a silent fallback); `invalid_input` (the
9334
+ symlinked corpus root, escaping docs, and unreadable doc bytes — snapshot honesty, never a
9335
+ silent 0). Byte measurement runs **before** the partition, so an unreadable doc is refused
9336
+ `invalid_input`, never misnamed `incomplete_registry` (the never-raising scan degrades an
9337
+ unreadable doc's frontmatter — its `cluster` included — to `null`): readability precedes
9338
+ membership. **Dream refuses where harvest filters**: an enumerated doc whose resolved path
9339
+ escapes `docs/learned/` is a refusal naming every escaping doc — a complete-corpus audit never
9340
+ silently narrows the corpus, so a completed gather's doc set is exactly the
9341
+ `read_learned_docs` enumeration.
9342
+
9343
+ **Findings.** One `check_docs` call mapped into the pinned **closed** sets above (the field
9344
+ vocabularies are `docs_sync`/`docs_scan`'s, by reference — dream only *reads* the existing
9345
+ scanners, never widens the docs-check report). Every family is filtered by its **owner-doc
9346
+ field only** against the manifest path set: a `broken_doc_paths` row's `target` (and a
9347
+ `stale_pointers` row's `pointer`) never participates in filtering — a broken target is by
9348
+ definition not a corpus member; it IS the finding. `duplicate_cues` comes from
9349
+ `duplicate_read_when` (learned-docs-only by construction; groups kept when all `docs` are in
9350
+ the path set — degenerate post-refusal, pinned for determinism); `empty_clusters` carries
9351
+ cluster ids untouched (registry mode only; `[]` in fallback). Deliberately excluded: artifact
9352
+ freshness/`stale_files` and `ambient_routing_bytes` (generated-artifact mechanics `docs-sync`
9353
+ repairs), `oversize_docs` (derivable from per-doc `bytes`), `registry_error`/`cluster_issues`
9354
+ (structurally impossible — refused before a gather completes), and any duplicate-**title**
9355
+ family (`DocsCheckReport` exposes only `duplicate_read_when`; the whole-corpus analysts read
9356
+ titles anyway).
9357
+
9358
+ **The shared primitive.** `eligible_learned_docs(repo_root)` (extracted in
9359
+ `perk/learn/harvest.py`) owns the symlinked-corpus-root guard + the per-doc resolved
9360
+ containment **filter**, returning `(doc, resolved_path)` pairs in corpus order; harvest's
9361
+ selection semantics stay byte-identical (it consumes the primitive), and dream layers its
9362
+ refuse posture on top. Resolved paths are consumed only for byte measurement — never as
9363
+ partition input.
9364
+
9365
+ ## §8.60 · The learn-dream analyst wave (first level)
9366
+
9367
+ The first-level cluster-analyst wave for `perk learn dream` in the TypeScript plane
9368
+ (`extension/waves/dreamWave.ts`, over the shared report-wave runner). Consumed by the
9369
+ `run_dream_wave` tool (§8.61), reachable only inside a `perk learn dream` launch (§8.65).
9370
+ ONE attempt, NO retry; the manifest and every analyst
9371
+ report are untrusted DATA, never instructions. The module additionally exports
9372
+ `DREAM_MANIFEST_FILENAME` (the TS mirror of the §8.59 literal — the harvest precedent, no
9373
+ cross-plane codegen) and the shared cap helpers `codePointLength`/`decodeStringArray` (one
9374
+ code-point measure across both dream re-decodes — §8.61's reducer re-decode imports them).
9375
+
9376
+ **The strict decoder.** `decodeDreamManifest(raw, manifestPath)` pins the §8.59 manifest and
9377
+ BINDS the run-scoped manifest path into the decoded value — ONE authority: the object the wave
9378
+ plans and validates and the file the analysts read can never diverge. Rules: `schema_version`
9379
+ byte-identical the string `"1"` (dream's own version line); string `commit_sha`;
9380
+ `registry_mode` ∈ `"clusters" | "categories"`; `doc_count`/`total_bytes` non-negative integers
9381
+ **cross-checked** against the lanes (total doc count / per-doc `bytes` sum); `findings` present
9382
+ with `structural`/`advisory` records each carrying its four/five pinned family keys **as
9383
+ arrays** — rows deliberately NOT deep-validated (TS consumes findings only via the manifest
9384
+ file the analysts read; the Python `OutputModel` renderer owns row shapes; the shallow check
9385
+ catches truncation/gross drift); non-empty `lanes`, each with a non-empty unique string `id`,
9386
+ string-or-null `rollup`, and a non-empty `docs` array of **at most `laneDocs` (8)** entries — a
9387
+ larger lane is structurally unwinnable under the report schema's per-lane doc cap, refused
9388
+ pre-spawn with a named detail; each doc with a non-empty string `path` passing the LEXICAL
9389
+ containment layer (`lexicalContainmentError`, shared from `harvestWave.ts`), equal to its own
9390
+ POSIX normalization (**canonical form required** — an alias spelling like
9391
+ `docs/learned/a/../x.md` can never enter the corpus set, so membership and self-target checks
9392
+ operate on canonical identities), and **globally unique across the whole manifest** (lanes
9393
+ partition the corpus), string-or-null
9394
+ `title`/`read_when`/`cluster`, and a non-negative-integer `bytes`. Any deviation refuses the
9395
+ whole wave pre-spawn with a named detail; unknown extra keys are ignored (forward-compat rides
9396
+ `schema_version`).
9397
+
9398
+ **Code-owned orchestration lane keys** (the §8.50 audit-wave pattern): the run key is
9399
+ `<sanitized lane id>.<ordinal>` (invalid chars collapsed to `-`, leading non-alnum stripped,
9400
+ stem clamped, global 1-based ordinal); the SEMANTIC manifest lane id rides the lane `label`,
9401
+ the module-private lane plan, and the task text — producer lane ids are deliberately NOT
9402
+ run-key-bounded (category-fallback and long-cluster ids never fail the run-key contract), so
9403
+ the decoder performs no run-key conformance check. Lane planning is module-private: callers
9404
+ see only the entrypoint's typed outcome, never orchestration keys or the plan shape.
9405
+
9406
+ **The closed report schema.** `DREAM_ANALYST_REPORT_SCHEMA`: `additionalProperties: false` at
9407
+ every level, all fields required, no if/then conditionals, no `pattern` constraints on
9408
+ path/pointer fields. Every `maxItems`/`maxLength` reads from the ONE exported
9409
+ `DREAM_ANALYST_CAPS` SSOT — `laneDocs: 8` (also the decoder's lane bound and `docs.maxItems`),
9410
+ `rationaleChars: 500`, `preserveItems: 4`, `preserveItemChars: 300`, `evidenceItems: 6`,
9411
+ `evidenceItemChars: 250`, `overlapSignals: 8`, `overlapNoteChars: 250`, `harvestFollowups: 5`,
9412
+ `followupTitleChars: 150`, `followupEvidenceChars: 250`, `uncertainties: 6`,
9413
+ `uncertaintyChars: 300` — consumed by the schema, the decoder, and the re-decode alike. String
9414
+ caps are measured in **Unicode code points** (JSON Schema `maxLength` semantics; UTF-16
9415
+ `.length` would reject engine-valid astral strings). Per-doc rows carry
9416
+ `{path, disposition (keep|revise|merge-into|retire), merge_target (string|null), rationale,
9417
+ preserve[], evidence_checked[], confidence (high|medium|low)}`; report-level fields are
9418
+ `overlap_signals[] {doc, counterpart, note}`, `harvest_followups[] {title, pointer, evidence}`,
9419
+ `uncertainties[]`, and the three required omission counters `overlap_signals_omitted`/
9420
+ `harvest_followups_omitted`/`uncertainties_omitted` (non-negative integers — omission
9421
+ accounting is report-level only).
9422
+
9423
+ **The composed defensive re-decode.** `decodeDreamAnalystReport(report, laneDocPaths,
9424
+ corpusDocPaths)` — whitelisted construction (an extra input key never survives; every miss a
9425
+ named detail): `docs` rows' path set EXACTLY the lane's doc set (no duplicates/extras/missing),
9426
+ normalized to **manifest lane-doc order** (deterministic downstream bundles); the
9427
+ **merge-target rule** — `merge-into` ⇒ `merge_target` a **byte-exact member of the manifest's
9428
+ corpus path set** (membership in the canonical producer-written set subsumes containment and
9429
+ defeats `docs/learned/a/../x.md` aliases) and ≠ the row's own path, any other disposition ⇒
9430
+ `merge_target === null`; overlap `counterpart` follows the same membership + ≠ rule (`doc` ∈
9431
+ the lane's docs); follow-up `pointer` non-empty, no pointer stamping (destination survival is
9432
+ the dream-report node's validation); every cap re-checked in code points.
9433
+
9434
+ **Strict completeness.** `runDreamAnalystWave(adapter, {manifest, model?}, signal?)` (the
9435
+ manifest carries its decode-time-bound `manifestPath`) runs `flow: "dream-analyst"` under
9436
+ `completeness: "strict"`, forwarding the caller's `signal` (cancellation at the glue boundary)
9437
+ — one failed/undecodable lane ⇒ `complete: false`; a schema-valid report failing the re-decode
9438
+ is a `malformed-report` failure. Failures surface in the dream-specific
9439
+ `DreamLaneFailure {lane, reason, detail}` shape — `lane` is the SEMANTIC manifest lane id, or
9440
+ `null` for wave-level failures and the defensive unplanned-key arm (a raw orchestration key is
9441
+ named only in `detail`, never surfaced as a lane identity). Decoded analyses are RETAINED even
9442
+ when incomplete — honest coverage for the tool's refusal and the incomplete-analysis outcome.
9443
+ The outcome additionally carries `requestedKeys` — the code-owned orchestration keys in launch
9444
+ order, receipt-correlation telemetry ONLY (they correlate with `receipt.children[*].key`; the
9445
+ semantic lane identity stays `lane`) — the additive widening the §8.61 attempt receipts build
9446
+ from. **Single-lane manifests are valid** — dream has NO direct-analysis path (the harvest
9447
+ single-lane refusal is deliberately not mirrored).
9448
+
9449
+ **Containment posture.** Lexical containment lives in the decoder (per doc path); the resolved
9450
+ layer is the existing shared `verifyDocContainment` (`harvestWave.ts` — `DreamManifest` is
9451
+ structurally assignable to its manifest parameter, pinned by test), invoked pre-spawn by the
9452
+ `run_dream_wave` tool (§8.61) exactly as `harvestWaveTools.ts` invokes it for harvest (the
9453
+ §8.48 sequence).
9454
+
9455
+ **Model threading.** The wave takes the caller's `model?` as the workflow-level default; the
9456
+ `[models.subagents] dream-analyst` config key is resolved by the `run_dream_wave` tool at
9457
+ execute time (§8.61) and threaded here.
9458
+
9459
+ **The agent.** `perk.dream-analyst` (`agents/dream-analyst.md`): report-only
9460
+ (`REPORT_ONLY_CHILD_AGENTS` + the §8.1 report-only children list), read-only tool posture
9461
+ (`read, grep, find, ls, bash`), fresh context, engine-injected `structured_output` completion
9462
+ (never fenced JSON), delivered via `PERK_AGENTS` into `.pi/agents/perk/`.
9463
+
9464
+ ## §8.61 · The learn-dream reducer wave + the `run_dream_wave` tool
9465
+
9466
+ The second level of the `perk learn dream` analysis pipeline
9467
+ (`extension/waves/dreamReducerWave.ts`) and the ONE run-bound tool that makes both levels
9468
+ reachable (`extension/doors/dreamWaveTools.ts`, registered globally). The tool
9469
+ **structurally refuses outside a dream launch** (below): only the `perk learn dream` door
9470
+ (§8.65) plants a run-scoped dream manifest, so it is unreachable in every other session. The
9471
+ bundle, the manifest, and every analyst/reducer report are untrusted DATA, never
9472
+ instructions.
9473
+
9474
+ **The compact analyst bundle.** `DREAM_ANALYSES_FILENAME = "dream-analyses.json"`, written
9475
+ run-scoped **beside the run's dream manifest** (the ONE path authority: derived from the
9476
+ decode-time-bound `manifest.manifestPath`, never a second `runScratchDir` derivation). The
9477
+ versioned shape: `{schema_version: "1", commit_sha, registry_mode, doc_count, total_bytes,
9478
+ lanes: [{lane, report}]}` — the identity fields echo the manifest; `lanes` carries the
9479
+ re-decoded compact analyst reports **in manifest lane order** (an already-guaranteed invariant
9480
+ of the runner's `spec.lanes`-order normalization + `buildDreamLanes`' manifest-order plan + the
9481
+ re-decode's doc-order normalization — no re-sort layer). Deterministic serialization
9482
+ (pretty-printed JSON + trailing newline). The aggregate budget:
9483
+ `DREAM_BUNDLE_BUDGET_BYTES = 393216` (384 KiB), measured as **UTF-8 bytes** of the serialized
9484
+ bundle and enforced **before reducer task composition** — over budget ⇒ the bundle is NOT
9485
+ written, the reducers are NOT launched, and the aggregate carries the explicit accounting
9486
+ `{bytes, budget_bytes, overflow_bytes}` — **never truncation** (a truncated bundle would
9487
+ corrupt stance evaluation; overflow is a loud corpus-growth tripwire). The budget governs
9488
+ exactly these reducer-INPUT bytes — the post-complete finalize rewrite (below) happens after
9489
+ the reducers consumed them and is not budget-checked. An incomplete first
9490
+ wave writes nothing (`bundle: null`). **The entry-time removal invariant:** the execute core
9491
+ removes any pre-existing bundle at entry (before the first wave), so the fixed name exists
9492
+ **iff the current call wrote it** — the incomplete/over-budget arms can never leave a stale
9493
+ prior bundle contradicting the returned aggregate, and after an `io_error` the target is
9494
+ absent (entry removal ran; the atomic temp+rename never landed). A repeat call (the
9495
+ blocking-tool retry — no guard state, the audit/harvest posture) is therefore always
9496
+ self-consistent. A **failed entry-time removal** refuses `io_error` BEFORE any spawn — a
9497
+ typed refusal with empty `{analyses, attempts}` extras, never an uncaught throw (launching
9498
+ over an irremovable stale bundle would break the invariant); the digest marker (below) was
9499
+ already cleared, so whatever files the failed cleanup left behind are refused by the
9500
+ dream-report recovery.
9501
+
9502
+ **The finalize-in-place rewrite + the `dream_bundle_digest` marker (the reviewed §8.61
9503
+ widening).** Reducer stances were previously never persisted (reducer reports lived only in
9504
+ the tool result); the dream-report draft path (§8.63) needs them to survive pi restarts, so
9505
+ after a **fully complete** two-level wave — and only after the **post-wave revalidation
9506
+ bracket** (§8.65) passes: evaluated only when BOTH waves completed, BEFORE the finalize
9507
+ write; drift ⇒ NO finalize, NO marker set (the entry clear stands — the analyses-only shape
9508
+ is left behind and recovery refuses it, so a drifted wave is structurally undraftable), the
9509
+ aggregate records the drift and `complete: false` — the execute core atomically REWRITES the
9510
+ existing `dream-analyses.json` via
9511
+ `finalizeDreamBundle(manifest, analyses, reducers, manifestDigest)`:
9512
+ the same wrapper fields (`schema_version` stays `"1"`) plus `manifest_digest` — the
9513
+ `sha256:<hex>` digest of the on-disk manifest BYTES the wave read and decoded, extending the
9514
+ marker's bundle-byte authentication to the manifest itself (an at-rest manifest edit that
9515
+ preserves the echoed identity fields still refuses at recovery) — plus `reducers`, an array
9516
+ in the fixed `DREAM_REDUCER_ANGLES` order, each entry the **raw echo shape**
9517
+ `{angle, ...report}` (exactly the shape `decodeDreamReducerReport` accepts, the angle
9518
+ echoed). Deliberately NOT a second
9519
+ file: one fixed name means the analyses-only mid-wave shape and the finalized shape are
9520
+ mutually exclusive states of one path — a cross-attempt MIXED state is structurally
9521
+ impossible; the `reducers` key is present **iff** finalized, and an incomplete reducer wave
9522
+ naturally leaves the analyses-only shape behind (recovery refuses it). The recovery-side
9523
+ decode is `decodeFinalizedDreamBundle(raw, manifest, manifestDigest)` — strict, fail-closed,
9524
+ every miss a named detail: **the pinned unknown-key policy** — the persisted format is CLOSED
9525
+ at every level this decoder authors (the wrapper: exactly `{schema_version, commit_sha,
9526
+ registry_mode, doc_count, total_bytes, manifest_digest, lanes, reducers}`; each lane entry:
9527
+ `{lane, report}`; each reducer entry: the raw echo keys) with unknown keys refusing; a
9528
+ missing `reducers` key refuses as not-finalized; the identity fields must equal the
9529
+ manifest's; `manifest_digest` must equal the caller's digest of the manifest bytes just read
9530
+ (the manifest-authentication link); `lanes` must pair the
9531
+ manifest's lanes EXACTLY (same ids, same order); `reducers` must carry exactly the three
9532
+ angles in fixed order (the byte-exact angle echo refuses duplicates/reorders); INSIDE a row
9533
+ the reused row decoders (`decodeDreamAnalystReport` over the manifest-derived lane/corpus
9534
+ path sets, `decodeDreamReducerReport` over `nonKeepProposals(analyses)`) stay the **single
9535
+ row authorities** — whitelisted construction means an extra row-level key is IGNORED and
9536
+ never survives into typed values (no fork of the row decoders). This
9537
+ closed-wrapper/whitelist-projected-row split is the decided policy, pinned by tests on both
9538
+ sides. **The marker lifecycle:** the freshness authority is the `dream_bundle_digest`
9539
+ workflow-state field (§8.3) — a bare run-scratch file is never trusted by the recovery
9540
+ consumer (the session-artifacts digest-pointer doctrine). The execute clears it (`""`)
9541
+ unconditionally at entry BEFORE the stale-bundle removal attempt — the invalidation record
9542
+ that keeps the removal `io_error` refusal fail-closed for downstream consumers — and sets it
9543
+ to the sha256 of the finalized bytes (`digestSessionData`, the `sha256:<hex>` convention)
9544
+ only after the finalize write succeeds. The entry clear is **verified**: `markers.clear()`
9545
+ returns the append+read-back result, and an UNVERIFIED clear refuses `io_error` before ANY
9546
+ filesystem work or spawn — with the old digest possibly still live, proceeding into a failed
9547
+ removal would leave the prior bundle + prior digest PAIR recoverable as fresh, so the wave
9548
+ stops instead (no mutation happens, and the untouched prior finalized state remains exactly
9549
+ what it was). The marker seam is injected into the execute core (`markers: {clear, set}`;
9550
+ the registered tool wires the production `appendWorkflowState` pair); `set` stays
9551
+ loud-but-non-fatal — a failed set warns via the read-back check and leaves the marker
9552
+ cleared by the entry clear, so recovery refuses; re-running the wave repairs it. A
9553
+ finalize-write throw is the SECOND post-launch `io_error` fail arm, mirroring the
9554
+ analyst-bundle arm's `{analyses, attempts}` extras retention (the message names the
9555
+ finalize).
9556
+
9557
+ **The three fixed reducer angles** (`DREAM_REDUCER_ANGLES`, fixed order everywhere — lanes,
9558
+ normalized reports, and the vocabulary the dream-report validation's disagreement rule
9559
+ references): `consolidation-preservation` (reconcile merge/retire proposals, detect
9560
+ cross-cluster redundancy, ensure unique durable content has a surviving home, reject merge
9561
+ cycles and retiring merge targets), `currency-accuracy` (challenge claims against current
9562
+ repository truth, distinguish obsolete knowledge from still-valid rationale, prioritize
9563
+ misleading guidance), `knowledge-architecture` (document boundaries, clusters, routing cues,
9564
+ distillation/read cost, harvest-follow-up quality). **The agent:** `perk.dream-reducer`
9565
+ (`agents/dream-reducer.md`): report-only (`REPORT_ONLY_CHILD_AGENTS` + the §8.1 report-only
9566
+ children list), read-only tool posture (`read, grep, find, ls, bash`), fresh context,
9567
+ engine-injected `structured_output` completion (never fenced JSON), stronger-tier default
9568
+ model (`anthropic/claude-fable-5`, fallback `anthropic/claude-sonnet-4-5` — the reducers are
9569
+ the judgment-heaviest lanes), delivered via `PERK_AGENTS` into `.pi/agents/perk/`.
9570
+
9571
+ **The closed reducer schema.** `DREAM_REDUCER_REPORT_SCHEMA`: `additionalProperties: false`
9572
+ at every level, all fields required, no if/then, no `pattern`; every `maxItems`/`maxLength`
9573
+ reads from the ONE `DREAM_REDUCER_CAPS` SSOT — `stances: 120`, `stanceReasonChars: 300`,
9574
+ `stanceEvidenceItems: 4`, `stanceEvidenceItemChars: 250`, `angleFindings: 8`,
9575
+ `angleFindingChars: 400`, `uncertainties: 6`, `uncertaintyChars: 300` (string caps in Unicode
9576
+ code points, the shared `codePointLength`). Fields: `angle` (the echoed identity, enum =
9577
+ the three slugs), `stances[]` `{doc, disposition ∈ revise|merge-into|retire, stance ∈
9578
+ endorse|challenge, reason, evidence_checked[]}`, `angle_findings[]`, `uncertainties[]`, and
9579
+ the three non-negative-integer omission counters `stances_omitted`/`angle_findings_omitted`/
9580
+ `uncertainties_omitted`.
9581
+
9582
+ **The stance vocabulary.** A stance is `endorse` or `challenge` with a required non-empty
9583
+ `reason` — there is deliberately **no abstain value**; `disposition` is a **defensive echo**
9584
+ of the analyst proposal being stanced (mismatch = malformed lane — the audit echoed-identity
9585
+ precedent); `evidence_checked` records what the selective verification actually touched (the
9586
+ dream-report node's destructive evidence bar consumes it). **Silence counts as
9587
+ non-endorsement**: the re-decode never requires stance coverage — empty `stances` is valid —
9588
+ and the consumption rule (an unstanced destructive proposal cannot proceed) is the
9589
+ dream-report node's evidence bar, not this decode. **The destructive-first priority** (an
9590
+ instruction-layer obligation bounded by the schema cap, pinned in the def prose): the two gate
9591
+ angles (consolidation-preservation, currency-accuracy) stance every `merge-into`/`retire`
9592
+ proposal FIRST and only then `revise` proposals; if the cap truncates, the overflow is counted
9593
+ in `stances_omitted` and the resulting silence is **explicitly conservative** — a documented,
9594
+ accounted, fail-safe form of incomplete stance coverage (no pre-wave refusal on proposal
9595
+ count; `stances: 120` ≥ the non-keep proposal count of any plausible corpus). **The
9596
+ selective-evidence posture** (def prose): verify cited evidence — follow the analysts'
9597
+ `evidence_checked` pointers and read the specific named docs/code sites, read-only — never
9598
+ broadly rescan the corpus, never re-run gather commands, never read docs beyond the
9599
+ cited/named ones.
9600
+
9601
+ **The re-decode + the wave.** `decodeDreamReducerReport(report, angle, proposals)` —
9602
+ whitelisted construction, named details, code-point caps via the shared helpers: the echoed
9603
+ `angle` must equal the assigned angle byte-exact and the typed result OMITS it (the aggregate
9604
+ names the angle once, on `DreamReducerAnalysis.angle`); each stance row's `doc` must be a
9605
+ member of the ordered non-keep proposal universe (`nonKeepProposals(analyses)` — a flat-map
9606
+ over the analyses' docs filtered to `revise`/`merge-into`/`retire`, inheriting the manifest
9607
+ ordering) with the disposition echo rule above, no duplicate rows, stances normalized to the
9608
+ proposal order. `runDreamReducerWave(adapter, {manifestPath, bundlePath, proposals, model?},
9609
+ signal?)` runs `flow: "dream-reducer"` under `completeness: "strict"`, ONE attempt, NO retry,
9610
+ three fixed lanes — key = label = the angle slug (code-owned, run-key-safe by construction),
9611
+ agent `perk.dream-reducer`, short code-composed task text (the angle, the bundle path read
9612
+ FIRST, the manifest path, the untrusted-DATA + structured_output lines — the judgment rubric
9613
+ lives in the def). Failures surface in the angle-named `DreamReducerFailure {angle, reason,
9614
+ detail}` shape (`null` = wave-level); a schema-valid report failing the re-decode is a
9615
+ `malformed-report` failure with the angle identity; `complete` = runner complete AND zero
9616
+ decode failures; decoded reports retained when incomplete, normalized to the fixed angle
9617
+ order. `DreamReducerOutcome` carries `requestedKeys` (= the three slugs) from birth — the
9618
+ receipt-correlation twin of §8.60's outcome field. Reducers launch even when the proposal
9619
+ universe is EMPTY (a keep-heavy corpus still gets angle findings/uncertainties).
9620
+
9621
+ **The tool binding (`run_dream_wave`).** NO parameters (the §8.50 no-param shape,
9622
+ `additionalProperties: false`, empty `properties`): the execute recovers the session's claimed
9623
+ `run_id` from the rebuilt workflow-state and derives the ONE manifest path
9624
+ `runScratchDir(run_id)/dream-manifest.json` (`DREAM_MANIFEST_FILENAME`) — the manifest read
9625
+ AND the bundle write are both derived from the claimed run, so no caller-supplied path exists
9626
+ (the `run_audit_wave` no-aimable-writer posture, BOTH sides) — the structural boundary
9627
+ justifying the `READ_ONLY_TOOLS` carve-in (§8.3), beside `PERK_TOOLS`; deliberately NO
9628
+ `STAGE_TOOLS`/drive coverage (cold-only, gate-on — the harvest census posture). The pre-launch
9629
+ refusal ladder (each arm before any spawn): no claimed run ⇒ `bad_state`; no run-scoped dream
9630
+ manifest ⇒ `bad_state` (the structural refusal outside a dream launch); unparseable JSON ⇒
9631
+ `bad_input`; `decodeDreamManifest(raw, manifestPath)` refusal ⇒ `bad_input`;
9632
+ `verifyDocContainment` refusal ⇒ `bad_input` (the §8.48 sequence). Both `[models.subagents]`
9633
+ keys (`dream-analyst`, `dream-reducer`) are resolved at execute time via `subagentModel` and
9634
+ threaded as each wave's workflow-level `model?` default; production runs the RPC adapter,
9635
+ tests the in-memory adapter.
9636
+
9637
+ **The result posture.** Every post-launch outcome — with the exception of the two write
9638
+ `io_error` fail arms below — is **ok** with the full typed normalized
9639
+ aggregate — `{complete, analysis: {complete, analyses, failures}, bracket, bundle, reducers:
9640
+ {launched, skip_reason, complete, reports, failures}, attempts}` — `complete` = both waves
9641
+ complete AND the bracket ok; `bracket` is `{ok, detail}` when evaluated and `null` when an
9642
+ earlier incomplete **ok** arm skipped it (incomplete analysis, budget-exceeded, incomplete
9643
+ reducers — the bracket fn is never invoked on those arms; the `io_error` **fail** arms return
9644
+ the failure-details shape below — error fields plus `{analyses, attempts}` — which carries no
9645
+ `bracket` field at all);
9646
+ the `skip_reason` vocabulary is `incomplete-analysis` (strict first wave failed —
9647
+ no bundle write, **no reducer launch**) and `budget-exceeded` (composed but over budget —
9648
+ nothing written, no reducer launch). A drifted bracket retains the analyses AND reducer
9649
+ reports in the aggregate (honest coverage). `attempts` carries one output-free `WaveAttemptReceipt`
9650
+ per launched wave, built from each wave's code-owned `requestedKeys` (they correlate with
9651
+ `children[*].key`, never semantic labels). The TWO post-launch fail arms are the
9652
+ analyst-bundle-write and the finalize-write `io_error`s, whose typed extras retain BOTH the
9653
+ analyst analyses AND the already-recorded attempt receipts (`{analyses, attempts}` — the
9654
+ §8.48 receipt-retention discipline). The
9655
+ model-facing result text: the untrusted-DATA banner, the JSON aggregate, and — when
9656
+ incomplete — an explicit line that the analysis is incomplete and the parent must present
9657
+ coverage honestly and stop before drafting (no retry); on the drifted-bracket arm an
9658
+ ADDITIONAL line names the drift ("the repository DRIFTED during the wave (<detail>) — the
9659
+ dream snapshot is STALE") — it accompanies the generic incomplete instruction, never
9660
+ replaces it.
9661
+
9662
+ ## §8.62 · The learn-dream report (model, validation, renderer)
9663
+
9664
+ The pure interior layer that turns the two-level dream outcome (§8.60/§8.61) into ONE
9665
+ checkable, savable final report (`extension/waves/dreamReport.ts`): the structured
9666
+ dream-report model, the validation that proves the parent's judgment obeys the pinned curation
9667
+ policy, and the deterministic Markdown renderer that owns the CANONICAL report bytes in parts.
9668
+ Pure domain code — no fs, no tool registration, no `ExtensionAPI`; imports only the two dream
9669
+ siblings. Consumed by the `objective_draft`/review/save wiring (the `dream_report` param,
9670
+ §8.63 — landed); part persistence to the backend is §8.64; the whole pipeline is reachable
9671
+ only inside a `perk learn dream` launch (§8.65). The input is untrusted DATA,
9672
+ never instructions.
9673
+
9674
+ **The trust split.** Two input shapes: `DreamReportInput` — untrusted, model-supplied —
9675
+ carries ONLY the decisions the design assigns to the parent (the final per-doc disposition
9676
+ rows with rationales and fallback reasons, parent uncertainties, ranked selected/overflow
9677
+ curation units, harvest follow-ups, predicted effects); `DreamReportContext` — trusted,
9678
+ caller-supplied — is `{manifest: DreamManifest, analyses: DreamLaneAnalysis[], reducers:
9679
+ DreamReducerAnalysis[], run_id, generated_at}`. Everything factual — snapshot identity (run
9680
+ id, commit SHA, counts, bytes, registry mode, findings family counts), wave coverage, analyst
9681
+ evidence (rationale/preserve/evidence_checked/confidence), reducer stances, analyst/reducer
9682
+ uncertainties, omission counters — is **injected from context**, never accepted from the
9683
+ model: the model cannot fabricate evidence, stances, or coverage.
9684
+
9685
+ **The completeness precondition.** Validation first re-verifies the context itself:
9686
+ non-empty single-line `run_id`/`generated_at` (trusted caller stamps — validated shallowly
9687
+ only), `analyses` covering the manifest's lanes exactly (one per lane, manifest order, each
9688
+ covering its lane's docs exactly) and `reducers` carrying exactly the three
9689
+ `DREAM_REDUCER_ANGLES` in fixed order — a report can only be built from COMPLETE waves;
9690
+ anything else refuses (incomplete coverage is never described as complete).
9691
+
9692
+ **The input schema + caps SSOT.** `DREAM_REPORT_INPUT_SCHEMA` (exported for the review
9693
+ wiring's param embedding) mirrors the §8.60 schema discipline: closed shape at every level,
9694
+ all fields required (optional semantics via `null`), enums, no if/then, no `pattern`; every
9695
+ `maxItems`/`maxLength` reads from the ONE `DREAM_REPORT_CAPS` SSOT — `rows: 512`,
9696
+ `rowRationaleChars: 300`, `fallbackReasonChars: 300`, `uncertainties: 12`,
9697
+ `uncertaintyChars: 300`, `selectedUnits: 64`, `overflowUnits: 64`, `unitTitleChars: 150`,
9698
+ `unitDocs: 32`, `unitRationaleChars: 400`, `unitNodeChars: 32`, `harvestFollowups: 12`,
9699
+ `followupTitleChars: 150`, `followupPointerChars: 250`, `followupEvidenceChars: 250`,
9700
+ `followupDestinationChars: 400`, `predictedNoteChars: 300` (string caps in Unicode code
9701
+ points, the shared `codePointLength`; `rows`/`selectedUnits`/`overflowUnits` are static
9702
+ schema bounds — the real gates are the exact path-set equality, the ≤12-distinct-node cap,
9703
+ and the exact partition). **The single-line rule:** every model-supplied string field refuses
9704
+ `\r`/`\n` and other C0 control characters with a named detail — the renderer places these
9705
+ strings in table cells and bullets, so line structure stays renderer-owned.
9706
+
9707
+ **Per-doc validation (downgrade-only).** `rows` path set = the manifest's authored-doc path
9708
+ set EXACTLY (no missing/extra/duplicate rows; byte comparison over the §8.60 canonical
9709
+ identities), normalized to manifest lane/doc order in the composed report. Exactly one
9710
+ disposition per doc from `DREAM_DISPOSITIONS`; `merge_target` non-null iff
9711
+ `disposition === "merge-into"`; `rationale` required non-empty on every row.
9712
+ **Downgrade-only against the analyst proposal** (destructiveness order `keep(0) < revise(1) <
9713
+ merge-into/retire(2)`, the two destructive dispositions incomparable): the final level must
9714
+ be ≤ the analyst level; a final destructive row must match the analyst proposal EXACTLY —
9715
+ same disposition AND byte-identical `merge_target` (a different target or a merge↔retire swap
9716
+ is an unendorsed new action, refused); `keep → revise` is an escalation and refuses.
9717
+ `fallback_reason` is REQUIRED non-empty exactly when the final disposition differs from the
9718
+ analyst proposal and must be `null` when it doesn't.
9719
+
9720
+ **The destructive evidence bar.** For every row whose FINAL disposition is `merge-into` or
9721
+ `retire`, over the INJECTED reducer stances for that doc's proposal: an explicit `endorse`
9722
+ from `consolidation-preservation` AND from `currency-accuracy`, and NO `challenge` from ANY
9723
+ of the three reducers (knowledge-architecture included); silence counts as non-endorsement (a
9724
+ missing gate-angle stance blocks eligibility). Anything else refuses with a named detail
9725
+ naming the only legal moves — downgrade to `revise`/`keep` with a `fallback_reason`; the
9726
+ parent never resolves upward. Eligibility is computed only from context stances, never from
9727
+ model input; the bar is necessary, not sufficient (an eligible proposal MAY still be
9728
+ downgraded).
9729
+
9730
+ **Merge-target survival.** Over FINAL dispositions: every `merge_target` must be a member of
9731
+ the manifest corpus path set (existence — revalidated even though analyst-matching rows
9732
+ already guarantee it) and must have a final disposition of `keep` or `revise` (survival).
9733
+ Survival structurally forbids merge chains and cycles — a merge-into doc can never be a
9734
+ target — so acyclicity is subsumed (2-cycles and chains refuse via the survival detail).
9735
+
9736
+ **The unit partition.** A curation unit is `{title, docs, rationale}`; selected units
9737
+ additionally carry a non-empty `roadmap_node`. **Many-to-one node mapping**: several selected
9738
+ units MAY name the same node; the cap is `DREAM_REPORT_MAX_ROADMAP_NODES = 12` DISTINCT
9739
+ `roadmap_node` values across the selected units. **Exact partition**: the union of all units'
9740
+ `docs` (selected + overflow) must equal EXACTLY the set of docs whose final disposition is
9741
+ non-keep — no doc in two units, no empty unit, no final-keep doc in any unit, every unit doc
9742
+ a corpus member (accepted work can neither vanish silently nor double-count; final-`keep`
9743
+ merge targets are named by the row's `merge_target`, not re-listed). Rank is positional
9744
+ (input order), selected and overflow ranked independently; overflow units carry no node.
9745
+ Unit atomicity is execution-time guidance owned by the activation prose — the validator
9746
+ checks only mapping shape, cap, and partition.
9747
+
9748
+ **Harvest follow-ups.** Each `destination` must be a corpus doc path whose final disposition
9749
+ is `keep`/`revise`, or a cluster id (the manifest's per-doc `cluster` values) named by at
9750
+ least one final-`keep`/`revise` doc — a destination pointing at a merged-away or retired doc
9751
+ refuses (repoint at the survivor). `pointer` non-empty; `pointer`/`evidence` capped strings.
9752
+
9753
+ **Predicted effects.** Model-supplied `docs_after`/`bytes_after` (non-negative integers) plus
9754
+ an optional single-line `note`; `docs_before`/`bytes_before` are injected from the manifest.
9755
+ TYPE sanity only — deliberately NO directional/quota rule (a growth prediction is valid,
9756
+ pinned by a vacuity-proof test); the renderer states “predictions are not quotas”.
9757
+
9758
+ **Two-stage bounded error collection** (the deliberate deviation from the fail-fast decoder
9759
+ posture, justified by the interactive redraft loop this validator gates): **structural decode
9760
+ fails fast** (context re-verification, non-object input, schema-shape misses including caps
9761
+ and the single-line rule — whitelisted construction cannot proceed over malformed input;
9762
+ first named detail wins, a one-element `details`); **semantic rules collect** (the per-doc,
9763
+ evidence-bar, survival, partition, and destination rules plus the renderer's defensive arm)
9764
+ up to `DREAM_REPORT_MAX_VALIDATION_DETAILS = 25` named details in deterministic order
9765
+ (validation phase order, then manifest doc order within a phase), overflow appending one
9766
+ final synthetic detail counting the omitted violations.
9767
+ `validateDreamReport(input, context)` returns `{ok: true, report}` (the composed
9768
+ JSON-serializable `DreamReport` — snapshot, findings counts, coverage, rows joined with
9769
+ analyst evidence + stances, uncertainties by source, reducer findings, units, follow-ups,
9770
+ effects) or `{ok: false, details}`.
9771
+
9772
+ **The deterministic renderer.** `renderDreamReport(report)` → `{ok: true, parts: string[]}`
9773
+ or `{ok: false, detail}` — a pure function of the composed report (no clock, no locale, no
9774
+ environment); the renderer owns the CANONICAL report bytes. Constants:
9775
+ `DREAM_REPORT_PART_MAX_CHARS = 60_000` — the per-part cap measured in Unicode CODE POINTS
9776
+ (the `journal.py` `JOURNAL_EVENT_MAX_CHARS` precedent — that precedent is a single-body
9777
+ refusal cap; the part-splitting semantics are new here), under GitHub's 65,536-char comment
9778
+ limit with margin for the persistence-side storage markers (the renderer never emits marker
9779
+ HTML) — and `DREAM_REPORT_PART_HEADER_RESERVE = 200`, the fixed per-part packing allowance
9780
+ for the part header. Pipeline: the report renders to an ordered stream of Markdown blocks;
9781
+ blocks are greedily packed into parts under `cap − reserve`; splits happen only at block
9782
+ boundaries; a table split re-emits the table header row in the next part; bullet-list
9783
+ sections (§7 Uncertainties, §8 Reducer findings) pack per bullet line — a block group splits
9784
+ at line boundaries, header re-emission applying only to tables — keeping the single-block
9785
+ defensive refusal structurally unreachable under the caps arithmetic; after packing, each
9786
+ part is prefixed with its header — part 1 `# Dream report — <run_id>`, continuations
9787
+ `# Dream report — <run_id> (continued, part <i> of <n>)`. A single block exceeding the budget
9788
+ is a defensive refusal (named, never truncated). Fixed section order: 1 Snapshot (run id, `DREAM_REPORT_SCHEMA_VERSION = "1"`,
9789
+ commit SHA, generated-at, registry mode, doc count, total bytes) · 2 Findings summary
9790
+ (per-family counts) · 3 Wave coverage (analyst lanes + reducer angles tables with omission
9791
+ counters) · 4 Dispositions (ONE table, manifest order: path, cluster, analyst proposal, final,
9792
+ merge target, analyst confidence, rationale) · 5 Non-keep evidence (per FINAL non-keep doc:
9793
+ injected analyst rationale/preserve/evidence_checked + every injected reducer stance) ·
9794
+ 6 Fallbacks (rendered directly from the rows carrying a non-null `fallback_reason`, manifest
9795
+ order: doc, analyst proposal, final, reason — a final-`keep` fallback renders its stances
9796
+ here, so every reducer stance renders exactly once, §5 or §6) · 7 Uncertainties
9797
+ (parent, then analyst by lane, reducer by angle, labeled) · 8 Reducer findings (the injected
9798
+ `angle_findings` — a deliberate minor addition beyond the node's section list) · 9 Selected
9799
+ curation units (rank-ordered) · 10 Overflow · 11 Harvest follow-ups · 12 Predicted effects
9800
+ (with the explicit not-quotas line). Rendering hygiene: model strings are single-line by
9801
+ validation; INJECTED strings may carry newlines/pipes — in a table cell or bullet, `|` is
9802
+ escaped and internal newline runs collapse to a single space (deterministic sanitization; the
9803
+ typed report retains exact strings).
9804
+
9805
+ **The entry point.** `buildDreamReport(input, context)` — validate, compose, render, and
9806
+ enforce the part budget in ONE call, returning `{ok: true, report, parts}` or
9807
+ `{ok: false, details}` (the renderer's single-detail defensive arm wrapped into a one-element
9808
+ `details`) — so the draft path validates BEFORE review and an approved report is always
9809
+ savable.
9810
+
9811
+ ## §8.63 · The `dream_report` objective draft/review wiring
9812
+
9813
+ The concrete `dream_report` field on the objective draft/review/save path — deliberately NOT
9814
+ a generic companion abstraction (one consumer, one kind). The dream arms are structurally
9815
+ reachable only inside a `perk learn dream` launch (§8.65 — a session outside one has no
9816
+ run-scoped `dream-manifest.json`). Absence-compatible by construction: every existing
9817
+ objective path stays **byte-identical** without the field.
9818
+
9819
+ **The shared param vocabulary.** `objective_draft` and `objective_save` both carry an
9820
+ optional `dream_report` parameter embedding the §8.62 `DREAM_REPORT_INPUT_SCHEMA` by
9821
+ identifier as `DREAM_REPORT_PARAM_SCHEMA` (`extension/factories/objectiveDraft.ts` — the leaf
9822
+ owning the shared vocabulary, the `DELIVERY_PARAM_SCHEMA`/`ROADMAP_PARAM_SCHEMA` pattern)
9823
+ plus the gate description ("required inside a dream session, refused outside one"). The
9824
+ shared `decodeObjectiveSaveParams` decodes it as a tri-state plain object (absent →
9825
+ `undefined`, present-but-not-a-plain-object → strict-fail); deep validation stays with the
9826
+ gate resolver.
9827
+
9828
+ **The ONE gate resolver.** `resolveDreamReportGate(ctx, input, generatedAt)`
9829
+ (`extension/factories/objectiveDreamReport.ts`) implements the whole matrix ONCE — both
9830
+ `writeObjectiveDraft` and `saveObjective` consume its typed outcome
9831
+ (`absent` | `block` | `refuse{errorType, detail}`); no parallel branch/message
9832
+ implementations. "Dream session" is detected structurally, exactly like `run_dream_wave`: the
9833
+ session's claimed `run_id` + the existence of `runScratchDir(run_id)/dream-manifest.json` (no
9834
+ claimed run counts as non-dream). The matrix (identical at draft-write and save): non-dream +
9835
+ absent → `absent` (unchanged, byte-identical behavior); non-dream + present → refuse
9836
+ `invalid_input` (refusing rather than silently dropping it); dream + absent → refuse
9837
+ `invalid_input` (the objective and its report review as ONE bundle — draft-time enforcement
9838
+ means a report-less dream bundle can never reach review, so an approval is always savable,
9839
+ the §8.62 "validates BEFORE review" promise); dream + present → recover trusted context →
9840
+ **the revalidation-bracket re-check** (§8.65 — after context recovery authenticates the
9841
+ manifest, `bracket(ctx.cwd, manifest.commit_sha)` runs at draft-write AND save, both
9842
+ consumers flowing through this one resolver; drift refuses `bad_state` — "the repository
9843
+ moved since the dream snapshot (<detail>) — the analysis is stale; re-run perk learn dream";
9844
+ non-dream paths never reach the bracket; the resolver's optional fourth parameter defaults to
9845
+ the production `revalidationBracket`, injected only by tests) →
9846
+ `buildDreamReport(input, context)` → refuse on any failure, else yield the block. The gate
9847
+ reads ONE workflow-state snapshot with error distinction: an UNREADABLE state (a throwing
9848
+ branch read) refuses `bad_state` BEFORE the matrix — never conflated with a confirmed
9849
+ non-dream session (a transient read failure must not surface as `absent`). Failure
9850
+ taxonomy (soft results, never throws): gate violations and `buildDreamReport` validation
9851
+ refusals → `invalid_input` (the bounded ≤25 named details ride the message, newline-joined);
9852
+ an unreadable workflow state, context-recovery failures (missing/stale/tampered/undecodable
9853
+ run-scratch state — "re-run the dream wave"), and the save-time stored-parts mismatch →
9854
+ `bad_state`.
9855
+
9856
+ **Trusted-context recovery** (module-internal, fail-closed, every arm a named detail):
9857
+ (1) read + parse the run-scoped manifest and `decodeDreamManifest(raw, manifestPath)` (the
9858
+ strict §8.60 decoder, path bound at decode time; no `verifyDocContainment` — the report path
9859
+ reads no doc files, so the lexical decode suffices; resolved containment stays the wave
9860
+ tool's pre-spawn concern); (2) **the freshness check** — the `dream_bundle_digest` marker
9861
+ (§8.3/§8.61, read from the gate's one workflow-state snapshot) must be present, non-empty,
9862
+ and equal the digest of the bundle bytes just read (missing/empty/mismatch refuses); (3)
9863
+ `decodeFinalizedDreamBundle(parsedBundle, manifest, digest-of-manifest-bytes-just-read)`
9864
+ (§8.61 — the analyses-only mid-wave shape refuses here, and the bundle's bound
9865
+ `manifest_digest` authenticates the manifest itself: the marker covers the bundle bytes and
9866
+ the bundle covers the manifest bytes, so an at-rest manifest edit refuses); the recovered
9867
+ context is `{manifest, analyses, reducers, run_id, generated_at}`.
9868
+
9869
+ **The artifact block.** A valid dream draft stores `dream_report: {input, generated_at,
9870
+ parts}` in `objective-draft.json` — **tool-written only** (the model never writes the
9871
+ artifact): `writeObjectiveDraft` runs the gate, stamps `generated_at` ONCE
9872
+ (`new Date().toISOString()`), and stores the validated input beside the rendered CANONICAL
9873
+ parts. `readObjectiveDraft` validates the block via `decodeDreamReportBlock` (a plain-object
9874
+ `input`, a non-blank `generated_at`, a non-empty all-string `parts`) and refuses the WHOLE
9875
+ draft on a malformed block (warn + `null`) — deliberately stricter than the lenient
9876
+ junk→absent handling of `base`/`delivery` (§8.1).
9877
+
9878
+ **One approval bundle.** `renderObjectiveDraft` appends the stored parts as the final section
9879
+ (`trimEnd()` + `"\n\n"` + `parts.join("\n\n")` + `"\n"`; the parts carry their own
9880
+ `# Dream report — <run_id>` headers), so the review surfaces need ZERO plumbing:
9881
+ `plan_review`'s objective arm and the browser door both review via
9882
+ `readObjectiveDraft` + `renderObjectiveDraft`, the browser's stale-draft guard covers the
9883
+ report bytes for free (it compares raw artifact bytes), and DENY routes the ordinary
9884
+ full-redraft `objective_draft` loop (no new machinery).
9885
+
9886
+ **Save-time re-validation.** `saveObjective` accepts `dream_report` as ONE carrier with two
9887
+ sources: the direct tool path wraps only a PRESENT decoded value as `{input}` (the save
9888
+ stamps `generated_at`; an `{input: undefined}` carrier is never constructed — presence is the
9889
+ `opts.dream_report === undefined` boundary); the approval path (`objectiveApprovalSave`)
9890
+ passes the artifact block through whole — stored stamp AND stored parts. Before the cold-door
9891
+ call the gate re-runs against freshly recovered context, and when stored parts are present
9892
+ they are byte-compared (`JSON.stringify` equality) against the re-rendered parts — a mismatch
9893
+ (run-scratch drift or artifact tamper between draft-write and save) refuses `bad_state` with
9894
+ nothing saved and the read-only gate left on. The one `generated_at` stamp is what keeps the
9895
+ re-render deterministic. On success the parts cross to the Python save door through the
9896
+ run-scoped transfer file and are durably persisted as the report companion — **§8.64** (which
9897
+ superseded the original §8.63 deferral); no new cold-door flag exists.
9898
+ No new ok-details fields on either tool (the visible review bundle and the normal ok results
9899
+ already carry the signal). `STAGE_TOOLS`/`READ_ONLY_TOOLS` are untouched: `objective_draft`
9900
+ stays read-only-safe (the dream arm only READS run scratch; the marker rides the ordinary
9901
+ session-entry channel), `objective_save` stays gate-excluded.
9902
+
9903
+ ## §8.64 · Dream companion persistence + convergent save ordering
9904
+
9905
+ The dream save's durable half (Objective #1892 Node 4.3): the run-scoped **transfer file**
9906
+ carries the reviewed CANONICAL parts from the extension to the `perk objective create` save
9907
+ door; the door stamps `origin` (§8.24), re-checks the open-by-origin conflict, persists the
9908
+ parts as the immutable **dream report companion** on the objective's **report carrier**, and
9909
+ publishes the per-backend human artifact — all BEFORE activation (activation stays LAST, cold-
9910
+ door success only). The producing session is the `perk learn dream` launch (§8.65).
9911
+
9912
+ **The transfer file.** `dream-report-transfer.json`, run-scoped scratch
9913
+ (`run_scratch_dir(root, run_id)`), filename constant mirrored in both planes
9914
+ (`perk.learn.dream_companion.DREAM_REPORT_TRANSFER_FILENAME` ↔
9915
+ `extension/factories/objectiveSave.ts` — parity-pinned): `{schema_version: "1", run_id, parts}`.
9916
+ Written atomically by `saveObjective` on the dream arm only — after the gate yields `block`
9917
+ (and after the approval-path byte-compare), BEFORE the cold door; a write throw is the soft
9918
+ `errorType: "scratch_failed"` failure (the `runColdDoor` stdin-staging precedent) — the cold
9919
+ door is NOT invoked, nothing activates, the read-only gate stays on. Non-dream saves write
9920
+ nothing (byte-identical). **No `origin` field**: the transfer has one producer and one meaning,
9921
+ so the door derives `ObjectiveOrigin.LEARN_DREAM` on the validated dream arm itself — origin
9922
+ stays launch-owned (no `--origin` flag exists; manual/direct saves have no transfer file and
9923
+ never stamp it; a manual retry with the same `--run-id` IS convergence). `run_id` is the
9924
+ cross-run mismatch guard. Door-side decode is a `StrictInputModel`
9925
+ (`DreamReportTransferModel`): `schema_version` literal `"1"`, `run_id` must equal the door's
9926
+ resolved run id, `parts` a non-empty list of non-empty strings — malformed refuses
9927
+ `invalid_input` (never ignored). **Structural launch evidence:** a present transfer requires
9928
+ the run-scoped `dream-manifest.json`; transfer-without-manifest refuses `invalid_input`. A
9929
+ transfer combined with `--supersedes`/`--adopt-from` refuses `invalid_input`. `--dry-run`
9930
+ stays fully offline and byte-identical (the transfer arc, the guard, and the companion are all
9931
+ skipped; payload unchanged).
9932
+
9933
+ **Save-door ordering (the dream arm, strictly).** (1) transfer decode + the shared
9934
+ part-invariance rule — before ANY network, the GitHub auth probe included (`require_github`
9935
+ runs only after the offline refusal ladder, so a malformed transfer refuses `invalid_input`
9936
+ even unauthed — never masked by `github_unauthed`); an unreadable / invalid-UTF-8 transfer
9937
+ file refuses the same `invalid_input`, never a raw crash; (2) the existing stacked block stays byte-positioned (strict train validation +
9938
+ `Delivery.prepare` exactly where they run today — a dream+stacked save keeps the
9939
+ pre-persistence capability gate); (3) the **origin conflict re-check** immediately before
9940
+ create: `find_open_objective_by_origin(origin=LEARN_DREAM, exclude_run_id=<run id>)` — a
9941
+ returned ref refuses `error_type="origin_conflict"` naming the existing objective id + url;
9942
+ exhaustive-or-raise means a lookup failure fails closed; the residual re-check→create race is
9943
+ documented (below), not closed — guard adjacency to create minimizes the window; (4)
9944
+ `create_objective(…, origin=LEARN_DREAM)` — origin stamped atomically at initial creation;
9945
+ the find-then-return `run_id` idempotency recovers an interrupted dream save's later steps;
9946
+ (5) companion convergence: carrier = `journal_carrier_id(objective_id)` (`None` → raise) →
9947
+ `persist_parts` (create-once, byte-compared) → `publish_dream_artifact` (Linear real / GitHub
9948
+ no-op) → `update_objective_header({"dream_report": <carrier_id>})` LAST; (6)
9949
+ `post_status_update` stays byte-positioned (after the try-block, fresh-create only) — the
9950
+ accepted consequence: a companion failure after create skips it, and the converging retry
9951
+ (`existed=True`) skips it permanently (bookkeeping, never load-bearing); (7) the `--json`
9952
+ payload is byte-identical (no new machine fields — the header field is the durable reference);
9953
+ one `user_output` narration line reports the converged part count + carrier; (8) activation
9954
+ LAST: `saveObjective` appends `active_objective` + the budget marker only after cold-door
9955
+ success — any failure in 1–5 exits non-zero, nothing activates, the gate stays on, retry
9956
+ converges. Failure mapping: `CompanionConflictError` → `error_type="companion_conflict"`;
9957
+ `CompanionAppendAmbiguous` → `"companion_ambiguous"`.
9958
+
9959
+ **The companion core** (`perk/learn/dream_companion.py` — backend contracts only; mirrors the
9960
+ §8.43 journal disciplines without reusing the journal). The **report carrier** is
9961
+ `ObjectiveStore.journal_carrier_id` — GitHub: the objective issue itself (normalized id);
9962
+ Linear: the Project metadata sentinel's identifier. **Marker grammar** (dual-encoding):
9963
+ canonical HTML `<!-- perk:learn-dream-report:<run_id>:<i> -->`; the parser also accepts the
9964
+ inline-code rewrite `` `perk:learn-dream-report:<run_id>:<i>` ``. `<i>` is a canonical 1-based
9965
+ decimal (no leading zeros/signs — any other spelling in a marked comment is corruption).
9966
+ Marker-text detection is substring-based; a comment carrying the marker text MUST parse
9967
+ strictly (marker on the PHYSICAL first line — leading blank lines/indentation are corruption,
9968
+ never normalized away, for foreign-run comments identically; identity from the first line;
9969
+ marker-text count > 1 in one body = corruption; an edited marked comment = corruption); an
9970
+ unmarked comment is unrelated untrusted DATA. A comment body is `marker + blank line + part`. **Dual-candidate
9971
+ byte-identity:** a stored body converges iff byte-equal to the verbatim render OR the local
9972
+ transcode candidate (the marker-line inline-code rewrite derived by the same rule as
9973
+ `to_linear_markdown`, never imported from the Linear backend — with invariant content the only
9974
+ rewritten line is perk's own marker line, so the candidate is exact). **`persist_parts`**: one
9975
+ complete `read_comments` scan (foreign-run companion comments parse strictly but never
9976
+ participate; an index outside `1..N` for this run is corruption — a stale longer render never
9977
+ silently tolerated; conflicting duplicates under one key are corruption); per index, present +
9978
+ byte-equal → idempotent skip, differing → loud `CompanionConflictError`; absent → POST with
9979
+ the rescan-one-retry ambiguity policy (a raised POST is AMBIGUOUS; the complete rescan
9980
+ decides; only proven absence earns the one retry; a failed rescan or a still-unproven write
9981
+ raises `CompanionAppendAmbiguous` — never a blind re-POST).
9982
+
9983
+ **The three-point invariance rule** (ONE shared rule, parity-pinned fixtures across both
9984
+ planes — `tests/parity/dream_report_invariance.json`): parts must be **transcode-invariant** —
9985
+ non-empty; no perk HTML-comment marker; no literal `perk:learn-dream-report` marker text; no
9986
+ exact `<details><summary><code>…</code></summary>` / `</details>` wrapper line (the shapes
9987
+ `to_linear_markdown` rewrites/drops); no line boundary other than `\n` (`\r`, VT, FF, FS/GS/RS,
9988
+ NEL, U+2028/U+2029 — the transcoder's `splitlines()` + `"\n".join` normalizes every other form,
9989
+ which would defeat the dual-candidate byte comparison forever); full comment body ≤ 65,000 chars
9990
+ (`COMPANION_COMMENT_MAX_CHARS`, the backstop under GitHub's 65,536; §8.62 already caps parts
9991
+ at 60,000 code points). Enforced at THREE points: TS-side in the gate resolver (draft-write
9992
+ AND save — approved ⇒ savable), door-side at transfer decode (before anything durable), and as
9993
+ the `persist_parts` pre-POST backstop.
9994
+
9995
+ **The per-backend human artifact** (`publish_dream_artifact(repo_root, …)` in
9996
+ `perk/backends/resolve.py`, ONE function keyed off the committed `[issues]` selection — no
9997
+ strategy objects). GitHub → an **immediate return** (the marker-keyed parts on the objective
9998
+ issue ARE the visible artifact). Linear → the `perk/backends/linear/dream_report.py`
9999
+ `publish_dream_artifact` flow: presence probe first (the new `project_external_links` read —
10000
+ skip when a Resources link labeled `Dream report (<run_id>)` exists), else
10001
+ `LinearClient.upload_file(filename="dream-report-<run_id>.md", content_type="text/markdown",
10002
+ content)` — one call owning the whole `fileUpload` reservation (sized by the actual bytes) +
10003
+ signed-PUT choreography (the PUT rides the same injectable transport, propagating the
10004
+ reservation headers + `Content-Type`) and returning the asset URL — → the existing
10005
+ `create_entity_external_link(project_id, label, url=<assetUrl>)`. The uploaded bytes are the
10006
+ canonical parts joined `"\n\n"` — verbatim, never transcoded (a file asset, not a comment).
10007
+ **Fail-loud** (part of the convergent sequence, never fail-open bookkeeping) — each boundary
10008
+ failure fails the save; retry converges. Uploaded assets are workspace-auth-gated (fine — the
10009
+ artifact serves workspace humans). Non-binding recorded intent: once `gh` grows general-file
10010
+ issue attachment (cli/cli#14194; today's preview `--attach` is images/video only), the GitHub
10011
+ arm may attach the rendered report file — a desire, not a promise.
10012
+
10013
+ **The `dream_report` header field.** `OBJECTIVE_HEADER_FIELDS` gains `dream_report` —
10014
+ merge-writable by design (recorded AFTER create per the activation-last ordering; `origin`
10015
+ stays excluded). Value = the carrier id only (GitHub: the objective issue's own normalized
10016
+ number; Linear: the sentinel identifier) — the header already carries the run's `run_id`,
10017
+ parts are keyed `(run_id, index)`, and discovery is the marker scan (no comment-id list). No
10018
+ `ObjectiveHeader` dataclass field; a superseding successor deliberately does not carry it (the
10019
+ report stays with the run that produced it).
10020
+
10021
+ **Documented residuals** (accepted, not closed): (1) the origin-guard re-check→create race
10022
+ (non-atomic by design; adjacency minimizes the window); (2) the store-tier interrupted-create
10023
+ posture — `create_objective`'s find-then-return on a `run_id` hit does not converge a
10024
+ partially created objective (a pre-existing window shared by every objective save; this node's
10025
+ OWN steps are each convergent); (3) the Linear pre-sentinel orphan window — a project created
10026
+ but its sentinel create crashed is invisible to `find_objective` AND
10027
+ `find_open_objective_by_origin`; a dream retry creates a fresh project and the orphan lingers.
10028
+ Guidance: an orphan is a project with no `Perk: objective metadata` issue and no perk header
10029
+ attachment — delete it manually; it carries no perk state and no report parts; (4) the Linear
10030
+ orphan-asset window — a crash after `fileUpload`/PUT but before the link write leaves an
10031
+ uploaded asset with no discoverable run key; the retry uploads a fresh asset and links it;
10032
+ unreferenced workspace assets are inert.
10033
+
10034
+ ## §8.65 · The learn-dream activation (door + preflight + revalidation bracket)
10035
+
10036
+ **The door.** `perk learn dream` — a seeded cold door (the seeded-door pipeline,
10037
+ `perk/cli/commands/learn/dream_cmd.py`) borrowing the `objective-author` stage descriptor via
10038
+ `prompt_override` (no new registry stage), `binding_trigger="command:learn-dream"`, options =
10039
+ exactly the shared seeded-door family (`--worktree`, `--dry-run`, `--remote` local-only,
10040
+ `--json`, `--no-sync` phrased for the pre-gather sync, trailing `pi_args`). **No `--from`
10041
+ exists, and the door actively rejects the spelling**: the gather closure scans `pi_args`
10042
+ FIRST — before the banner, the sync, or any side effect — and any token `== "--from"` or
10043
+ starting with `--from=` refuses `invalid_input` naming `perk learn harvest --from` as the
10044
+ partial-corpus door; every other unknown token keeps the family's pi passthrough semantics.
10045
+ Cold-only (no warm `/learn-dream` door — an objective non-goal) and backend-light: no
10046
+ `require_github`, `backend_errors=()` — the only backend read is the origin guard below,
10047
+ wrapped explicitly.
10048
+
10049
+ **The preflight (ordering, exact).** (1) the `--from` rejection above; (2)
10050
+ `launch.resolve_target(stage, remote)` — the local-only rejection before any side effect;
10051
+ (3) the gated launch banner; (4) the **`GitError → git_error` fail-closed boundary** opens —
10052
+ one `try/except git.GitError` around every git probe below (the `create_cmd.py` precedent):
10053
+ an unprovable probe (e.g. `git status` cannot run) becomes a typed `git_error` refusal, never
10054
+ a traceback and never an assumed-clean snapshot; (4a) the ONE pre-gather guarded fast-forward
10055
+ (`_sync_main_checkout`, only when `not dry_run and not no_sync`; `run_seeded_door` gets
10056
+ `no_sync=True` unconditionally so the in-launch sync never fires — the harvest
10057
+ one-revision-boundary discipline); (4b) the **single SHA capture** — the STRICT resolver
10058
+ `git.head_commit` runs exactly ONCE per invocation: an UNBORN head (a `--verify --quiet`
10059
+ verification failure) refuses `invalid_input` ("commit once"), while any other probe failure
10060
+ raises `GitError` into the boundary's `git_error` arm — a broken probe never misreports as
10061
+ "commit once"; (4c) the **clean-checkout requirement** — `git.is_dirty` (untracked included)
10062
+ refuses the distinct type `dirty_checkout` (a distinct repair action: commit/stash); runs on
10063
+ `--dry-run` too; (4d) the **index-flag refusal** — `git.index_flagged_paths` (any
10064
+ assume-unchanged or skip-worktree entry, the `ls-files -v` lowercase/`S` tags) refuses
10065
+ `invalid_input` naming the flagged paths (≤10 shown): either bit hides edits from
10066
+ `git status`, so 4c's proof would not be a proof (a sparse checkout is the common producer);
10067
+ (5) the gather (`gather_dream`) — its §8.59 refusals pass through the door envelope
10068
+ unchanged; (6) the **tracked-corpus rule** (still inside the GitError boundary), BOTH
10069
+ directions — the gathered doc-path set must EQUAL the tracked learned corpus (the tracked
10070
+ `docs/learned/**/*.md` set minus the generated `index.md` — the `read_learned_docs`
10071
+ enumeration rule): gathered ⊆ tracked because `git status --porcelain` omits gitignored
10072
+ files, so an IGNORED doc could be gathered while the tree reports clean (refused
10073
+ `invalid_input` naming every offender as not reproducible from the stamped commit;
10074
+ plain-untracked docs already refused at 4c); tracked ⊆ gathered because the gather
10075
+ enumerates the FILESYSTEM, so a tracked doc absent from disk (a sparse/skip-worktree
10076
+ checkout) would silently narrow a "whole-corpus" audit (refused `invalid_input` naming every
10077
+ missing doc); (7)
10078
+ the **pre-launch active-origin guard** (real launch only — skipped entirely on `--dry-run`,
10079
+ which stays offline): `resolve_objective_store(repo_root)` +
10080
+ `find_open_objective_by_origin(origin=LEARN_DREAM, exclude_run_id=None)` wrapped in ONE
10081
+ `try/except (ObjectiveStoreError, IssueBackendError)` → `origin_lookup_failed` (**fail-closed**
10082
+ — the wrap covers the store resolution AND the lookup); a returned ref refuses
10083
+ `origin_conflict` naming `#<id>` + url ("complete or close it before dreaming again"); the
10084
+ guard runs BEFORE the run id is minted and before any scratch write (`exclude_run_id=None`
10085
+ because a freshly minted run can have no stored objective; the §8.64 save-time re-check owns
10086
+ the current-run exclusion); (8) mint + `write_manifest` (`OSError` → `manifest_write_failed`);
10087
+ (9) the seed render — `stages/learn-dream.md` with exactly three string vars
10088
+ (`manifest_path`, `doc_count`, `lane_count`; lane ids/cluster names stay DATA in the
10089
+ manifest, never interpolated into instruction text).
10090
+
10091
+ **The `--dry-run` posture.** Offline (the origin guard is never evaluated), side-effect-free
10092
+ outside run scratch, validates ALL local preconditions (the `--from` rejection, HEAD, the
10093
+ clean check, the gather refusals, the tracked-corpus rule), and **materializes on dry-run**
10094
+ (the manifest is written — the harvest posture). The full `--json` dry-run payload keys,
10095
+ exactly: `{success, error_type, manifest_path, commit_sha, registry_mode, doc_count,
10096
+ lane_count, lane_ids, total_bytes, origin_guard: "not-evaluated", launched: false}`.
10097
+
10098
+ **The error vocabulary** (one envelope): `remote_blocked`, `invalid_input` (the `--from`
10099
+ spelling, an unborn HEAD, the index-flag refusal, the gather's §8.59 `invalid_input` arms,
10100
+ both tracked-corpus arms), `dirty_checkout`, `git_error`, `no_learned_docs`, `invalid_registry`, `incomplete_registry`,
10101
+ `origin_conflict`, `origin_lookup_failed`, `manifest_write_failed`, `not_a_repo`. Stable
10102
+ exits: `0` ok · `1` op-failure/refusal · `2` not-a-repo.
10103
+
10104
+ **The carrier map** (§8.57): the seed (`prompts/stages/learn-dream.md`) is the launch-flow
10105
+ carrier — the manifest read, the ONE no-argument `run_dream_wave` call (single-lane included
10106
+ — dream has no direct-analysis path), the uniform incomplete rule (ANY tool failure — a
10107
+ pre-spawn `bad_state`/`bad_input` refusal, any `io_error` arm, or an ok aggregate with
10108
+ `complete: false`, the drifted bracket included — is an INCOMPLETE audit: report honestly,
10109
+ STOP before `objective_draft`, no retry, never a direct corpus read), the clean-audit stop,
10110
+ and the review-first authoring loop. The seed hardcodes NO skill pointer; the
10111
+ `perk-learn-dream` skill's read path rides the `command:learn-dream` nudge binding (§8.9),
10112
+ and the skill carries the judgment detail only: the closed dispositions, the destructive
10113
+ evidence bar + disagreement rule (downgrade-only), the truth-then-leverage ranking, the
10114
+ unit/≤12-distinct-node selection shape, the report-only harvest follow-ups, the
10115
+ `dream_report` param fields, and the `perk-objective-author` cross-reference.
10116
+
10117
+ **The revalidation bracket.** `revalidationBracket(cwd, expectedSha, probes?)`
10118
+ (`extension/substrate/git.ts`) — the module's ONE deliberately **fail-closed** composition:
10119
+ drift when HEAD cannot be resolved, when HEAD ≠ `expectedSha` (naming both SHAs), when
10120
+ cleanliness cannot be verified, when the tree is dirty, when the index-flag state cannot be
10121
+ verified, or when the index carries assume-unchanged/skip-worktree flags
10122
+ (`indexHidesChanges` — either bit hides edits from the status probe, the same hazard the
10123
+ door's 4d refusal closes at launch time); ok otherwise. `/.perk/workflow/`
10124
+ is gitignored, so run-scratch writes (the manifest, `dream-analyses.json`) never trip the
10125
+ tree-clean check. Two wiring points, by reference: the post-wave check inside
10126
+ `executeDreamWave` (§8.61 — after both waves complete, BEFORE the finalize write; drift skips
10127
+ the finalize AND the marker set, so a drifted wave is structurally undraftable) and the
10128
+ before-drafting/save re-check inside `resolveDreamReportGate` (§8.63 — after context
10129
+ recovery, at draft-write AND save; drift refuses `bad_state` with "re-run perk learn dream").
10130
+
10131
+ **The claim, narrowed honestly.** The bracket proves **end-state equality** — HEAD unchanged
10132
+ and tree clean at each check against the stamped `commit_sha` — never mid-wave byte
10133
+ immutability. Accepted residuals (documented, not closed): (1) a transient
10134
+ modify-and-restore during the wave window is invisible (a revalidation bracket, not a frozen
10135
+ checkout — no physically frozen/materialized-commit snapshot in v1, the objective's stated
10136
+ non-goal — a transient flag-edit-unflag inside the window is the same class); (2) an
10137
+ ignored `docs/learned` file appearing MID-session is invisible to the
10138
+ tree-clean check (launch-time trackedness is door-enforced; the mid-session blind spot is the
10139
+ same window class); (3) the §8.64 gate-check→create race window is unchanged (the save-time
10140
+ origin re-check + adjacency own it).