@mgiles/perk 1.0.1 → 1.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 (120) hide show
  1. package/README.md +7 -21
  2. package/extension/checkpoints/checkpoints.ts +2 -1
  3. package/extension/doors/address.ts +2 -1
  4. package/extension/doors/ciExecutor.ts +3 -2
  5. package/extension/doors/land.ts +2 -1
  6. package/extension/doors/learn.ts +239 -33
  7. package/extension/doors/learnCode.ts +100 -0
  8. package/extension/doors/learnDocs.ts +4 -3
  9. package/extension/doors/lifecycleGates.ts +2 -1
  10. package/extension/doors/prReview.ts +20 -35
  11. package/extension/doors/prReviewLocal.ts +229 -0
  12. package/extension/doors/ready.ts +2 -1
  13. package/extension/doors/selfcheck.ts +2 -1
  14. package/extension/doors/submit.ts +17 -19
  15. package/extension/factories/implementHere.ts +116 -0
  16. package/extension/factories/objective.ts +2 -1
  17. package/extension/factories/objectivePlan.ts +4 -24
  18. package/extension/factories/objectiveSave.ts +5 -15
  19. package/extension/factories/planMode.ts +5 -1
  20. package/extension/factories/planReview.ts +103 -11
  21. package/extension/factories/planSave.ts +16 -1
  22. package/extension/index.ts +42 -3
  23. package/extension/substrate/bindings.ts +1 -1
  24. package/extension/substrate/cache.ts +2 -2
  25. package/extension/substrate/command.ts +25 -0
  26. package/extension/substrate/config.ts +12 -13
  27. package/extension/substrate/consoleCapture.ts +90 -0
  28. package/extension/substrate/git.ts +34 -0
  29. package/extension/substrate/miniJinja.ts +480 -0
  30. package/extension/substrate/paths.ts +38 -0
  31. package/extension/substrate/prompts.ts +15 -24
  32. package/extension/substrate/providers.ts +1 -1
  33. package/extension/substrate/sessionData.ts +1 -1
  34. package/extension/substrate/sessionPointers.ts +155 -0
  35. package/extension/substrate/toolGating.ts +8 -0
  36. package/extension/surfaces/surfaces.ts +10 -3
  37. package/extension/worker/worker.ts +111 -25
  38. package/extension/workerMain.ts +5 -3
  39. package/package.json +1 -5
  40. package/prompts/README.md +56 -5
  41. package/prompts/_fixtures/cases.yaml +52 -131
  42. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  43. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  44. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  45. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  46. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  47. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  48. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  49. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  50. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  51. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  52. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  53. package/prompts/_fixtures/live.yaml +281 -0
  54. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  55. package/prompts/_fixtures/templates/cond_if.md +7 -0
  56. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  57. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  58. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  59. package/prompts/_fixtures/templates/trim_block.md +5 -0
  60. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  61. package/prompts/stages/conflict-resolution.md +4 -0
  62. package/prompts/stages/learn-code.md +8 -0
  63. package/prompts/stages/learn-docs.md +7 -6
  64. package/prompts/stages/learn-orchestrate.md +6 -0
  65. package/prompts/stages/learn.md +1 -1
  66. package/prompts/stages/objective-author/adopt.md +12 -0
  67. package/prompts/stages/objective-author/file.md +9 -0
  68. package/prompts/stages/objective-author/seed.md +9 -0
  69. package/prompts/stages/objective-plan/seed.md +2 -1
  70. package/prompts/stages/objective-reconcile.md +7 -0
  71. package/prompts/stages/objective-replan.md +14 -0
  72. package/prompts/stages/objective-save.md +9 -0
  73. package/prompts/stages/plan-from/adopt.md +10 -0
  74. package/prompts/stages/plan-from/file.md +9 -0
  75. package/prompts/stages/pr-review.md +6 -0
  76. package/prompts/stages/replan.md +13 -0
  77. package/prompts/stages/skills/create-from.md +15 -0
  78. package/prompts/stages/skills/create.md +9 -0
  79. package/prompts/stages/skills/refine.md +9 -0
  80. package/shared/README.md +7 -1
  81. package/shared/bindings.yaml +12 -0
  82. package/shared/contracts-history.md +167 -0
  83. package/shared/contracts.md +1307 -342
  84. package/shared/registry.yaml +3 -3
  85. package/shared/schemas/contracts/bindings.schema.json +38 -0
  86. package/shared/schemas/contracts/providers.schema.json +89 -0
  87. package/shared/schemas/contracts/registry.schema.json +98 -0
  88. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  89. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  90. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  91. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  92. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  93. package/shared/schemas/outputs/init-report.schema.json +419 -0
  94. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  95. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  96. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  97. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  98. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  99. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  100. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  101. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  102. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  103. package/prompts/_fixtures/golden/address-action.txt +0 -10
  104. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  105. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  106. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  107. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  108. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  109. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  110. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  111. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  112. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  113. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  114. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  115. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  116. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  117. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  118. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  119. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  120. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
@@ -20,13 +20,13 @@ Source decisions: `Q1` (workflow-state), `Q2` (layout + run_id), `Q3` (verified
20
20
 
21
21
  ---
22
22
 
23
- ## §8.1 · `.pi/workflow/` layout (Q2)
23
+ ## §8.1 · `.perk/workflow/` layout (Q2)
24
24
 
25
25
  The local cache tier — written and read by **both** the CLI (exterior) and the extension
26
26
  (interior). Fixed layout:
27
27
 
28
28
  ```
29
- .pi/workflow/
29
+ .perk/workflow/
30
30
  ├── plans/ # materialized plan cache (canonical copy stays in GitHub)
31
31
  ├── plan.md # cache.plan: the materialized plan body (transient per-worktree mirror)
32
32
  ├── plan-ref.json # cache.plan-ref: the active plan->branch ref pointer (local mirror)
@@ -61,6 +61,37 @@ The local cache tier — written and read by **both** the CLI (exterior) and the
61
61
  dir artifacts and is declared in `writes` by the read-only authoring stages — `plan`,
62
62
  `objective-plan`, and `objective-author` (`cache.scratch` still names the broader substrate).
63
63
 
64
+ **perk-owned dot-path construction seam.** Construction of the **perk-owned** dot-path
65
+ families — the perk dir, the config files (`config.toml`/`local.toml`), the required-perk-version
66
+ pin (`.perk/required-perk-version`, constructed via `paths.required_version_file`; Python-only,
67
+ not mirrored in the TS guard, like skills), the committed managed-state file
68
+ (`.perk/managed-state.toml`, constructed via `paths.managed_state_file`; Python-only, not
69
+ mirrored in the TS guard — the TS plane never reads it), the repo-skills dir
70
+ (`.perk/skills`), and the workflow dir — is confined to a per-plane seam: `perk/substrate/paths.py`
71
+ + `extension/substrate/paths.ts` (perk dir / config / skills) plus `cache.workflow_dir` /
72
+ `workflowDir` for the workflow family. Each family is independently redirectable from its single
73
+ helper (Objective #878 migrates them to `.perk/` one phase at a time). The **workflow family now
74
+ resolves to `.perk/workflow/`**. The **repo-skills family has moved**: it now resolves to
75
+ `.perk/skills` via `repo_skills_dir`. The **config family has moved**: it now resolves to
76
+ `.perk/config.toml` (committed) / `.perk/local.toml` (gitignored) on **both planes** —
77
+ `config_dir`/`configDir` return `root/".perk"` and the filename constants are
78
+ `config.toml`/`local.toml`. `.perk/config.toml` is the repo **initialization marker**: `perk init`
79
+ **refuses** a legacy-only repo (a committed `.pi/perk.toml` with no `.perk/config.toml`) with
80
+ `error_type="legacy_config"` (exit 2) and an actionable `perk doctor --fix` remediation — never
81
+ warn-and-seed over legacy. `perk doctor` diagnoses the legacy config ("legacy config not migrated")
82
+ and `perk doctor --fix` **migrates it secret-safely** (an idempotent `_MIGRATIONS` entry:
83
+ move-when-target-absent / remove-when-byte-identical / error-on-conflict; committed and local
84
+ migrate independently so the secret is never promoted into the committed file). The legacy
85
+ `.pi/perk.toml` / `.pi/perk.local.toml` paths are constructed only via the allowlisted
86
+ `paths.legacy_config_file` / `paths.legacy_local_config_file` helpers (Python; migration source
87
+ only — never read); the TS plane reads the `.perk/` target only and has no legacy helpers. The
88
+ confinement is guard-tested in both planes (`tests/test_paths_guard.py`,
89
+ `extension/pathsGuard.test.ts`): a family-scoped source scan bans a quoted `".pi"` segment built
90
+ adjacent to a legacy config follow-segment **and** a quoted `".perk"` segment adjacent to a current
91
+ perk-owned follow-segment, outside the seams. **Pi-native** `.pi/...` paths (`.pi/settings.json`,
92
+ `.pi/agents/`, `.pi/npm`, `.pi/APPEND_SYSTEM.md`, `~/.pi/agent`) are
93
+ explicitly *not* perk-owned and stay hand-built at their Pi-native sites.
94
+
64
95
  **The plan-draft file tool (Node 2.1).** The tool `plan_draft` (interior-only; no Python
65
96
  twin) is the first session-data producer: it writes the working plan during read-only plan
66
97
  authoring. It is allowlisted in `READ_ONLY_TOOLS` (`extension/substrate/toolGating.ts`) as a **narrow
@@ -150,14 +181,16 @@ The local cache tier — written and read by **both** the CLI (exterior) and the
150
181
  `--fix` arm**: deletion is *exclusively* `perk state prune`) and the `perk state prune`
151
182
  command (alias `gc`; `--dry-run`/`--max-age-days`/`--json`). Policy home: `perk/state/gc.py`
152
183
  (exterior-owned; no TS twin). (erk accumulated session dirs precisely because GC was undefined.)
153
- - `.gitignore`: `.pi/workflow/` transient subtrees are not committed; `plans/` may be cached
154
- locally but GitHub is canonical. `init` manages the relevant `.gitignore` entries (incl.
155
- `/.pi/workflow/plan-ref.json` and `/.pi/workflow/plan.md` local mirrors; the canonical plan
156
- lives in GitHub). The materialized `plan.md` body is transient and must never be tracked;
157
- `perk doctor --fix` untracks a legacy-committed copy and drops any stray ungrouped ignore line
158
- (#43).
184
+ - `.gitignore`: the **whole `.perk/workflow/` cache tree** is gitignored (a single
185
+ `/.perk/workflow/` entry managed by `init`) it is runtime/cache state, not durable source, so
186
+ there is **no committed `.gitkeep`**; a fresh clone has no tracked workflow artifact. The
187
+ canonical plan lives in GitHub; the materialized `plan.md` body and `plan-ref.json` mirror are
188
+ transient local copies and must never be tracked. `perk doctor --fix` untracks a legacy-committed
189
+ copy and drops any stray ungrouped ignore line, and migrates a legacy `.pi/workflow/` cache
190
+ forward (untracking a tracked `.gitkeep`, moving the `plan-ref.json`/`agent-session.json` mirrors
191
+ when the target is absent; disposable scratch is left for the user to delete).
159
192
  - **`plan-ref.json` (`cache.plan-ref`, T2b):** the provider-agnostic plan-ref payload (§8.4)
160
- written verbatim. One active ref per checkout/worktree (`.pi/workflow/` is per-checkout). The
193
+ written verbatim. One active ref per checkout/worktree (`.perk/workflow/` is per-checkout). The
161
194
  **Python cold door** (`perk plan-save`) writes it on a real save; the **extension** reads it
162
195
  on `session_start` to reconcile `active_plan_ref` (§8.3). The cross-plane contract is the
163
196
  *file* (`perk/state/cache.py` ↔ `extension/substrate/cache.ts`), not a shared module.
@@ -378,7 +411,7 @@ is active** and **never throwing** (logged-not-thrown, like checkpoints):
378
411
  - **Threshold-triggered compaction** (the `trigger-compact.ts` pattern) — on `turn_end`, **only
379
412
  when `active_objective != null`**, read `ctx.getContextUsage()` and call `ctx.compact({…})` when
380
413
  usage crosses a threshold (default `0.8`; overridable via `[objective] compact_threshold` in
381
- `.pi/perk.toml`, read through `extension/substrate/config.ts` — written as a **quoted** value because the
414
+ `.perk/config.toml`, read through `extension/substrate/config.ts` — written as a **quoted** value because the
382
415
  TOML subset reads only strings). The decision is the pure `shouldCompact(usage, threshold)`;
383
416
  compaction is best-effort (`onError` logs and continues). The custom cheaper-model
384
417
  `session_before_compact` summary is **deferred** — T9 ships the simpler `ctx.compact` trigger.
@@ -560,7 +593,7 @@ append every advancing `turn_end`), and a separate entry avoids LWW-append smell
560
593
  record. The interior (`extension/checkpoints/checkpoints.ts`) seeds an ordered step list from the plan body's
561
594
  `## Steps` numbered list (read from the `cache.plan` body cache) on `session_start` — **only** in an
562
595
  active workflow (`active_plan_ref != null`), **only once** (a later session keeps the existing
563
- entry). The `cache.plan` body (`.pi/workflow/plan.md`) is **materialized by the Python cold door**:
596
+ entry). The `cache.plan` body (`.perk/workflow/plan.md`) is **materialized by the Python cold door**:
564
597
  `perk implement` (`launch._materialize_plan_body`) fetches the plan body from GitHub
565
598
  (`github.get_plan_body` → the `plan-body` block in the issue's first comment, parsed by
566
599
  `plan.extract_plan_body`) and writes it into the worktree alongside the plan-ref + handoff
@@ -574,7 +607,7 @@ hard gate); idempotent on resume (an already-correct symlink is left untouched,
574
607
  entry is never clobbered). **After** materialization (and only when the cold door **freshly
575
608
  created** the worktree, never on idempotent reuse/dry-run), the cold door runs the project's
576
609
  `[worktree] setup` commands (`launch.run_worktree_setup`) — an ordered array of shell command lines
577
- read from `.pi/perk.toml` (overlay-aware) — each via `bash -lc` with `cwd` = the worktree and
610
+ read from `.perk/config.toml` (overlay-aware) — each via `bash -lc` with `cwd` = the worktree and
578
611
  inherited stdio, **aborting the launch** (a `UserFacingCliError`) on any non-zero exit / timeout /
579
612
  missing `bash` (a half-built environment is worse than a clear failure; the worktree is left for a
580
613
  fixed re-run). This is **Python-plane-only** (no TS twin — the extension never creates worktrees);
@@ -583,7 +616,7 @@ deliberately does **not** (CI environment setup belongs to the GHA composite act
583
616
  **opt-in + inert-by-default (D4)**: perk plans are prose, so when no `## Steps` list is
584
617
  present the checkpoint degrades to inert (no entry, no crash); the `perk-plan` skill documents the
585
618
  optional `## Steps` section as the forward path. Cross-plane contract: the **file** `cache.plan`
586
- (`.pi/workflow/plan.md`), written by Python and read by TS. State is **rebuilt on `session_start`, `session_tree`, AND
619
+ (`.perk/workflow/plan.md`), written by Python and read by TS. State is **rebuilt on `session_start`, `session_tree`, AND
587
620
  `session_compact`** (the `session_compact` re-render — rebuild + render only, NO re-seed, mirroring
588
621
  `session_tree` — was adapted from `@juicesharp/rpiv-todo`; its `catch` arm swallows the pi-core
589
622
  stale-`ctx` compaction race silently — the proxy `/stale after session replacement/` error fired
@@ -617,9 +650,10 @@ which case perk **vacates `installPerkFooter`** (install-site runtime vacating k
617
650
  fail-safe to install; see §8.10's footer interface-seam note) and the foreign footer is the sole
618
651
  footer surface. perk's default-owned footer composes one line, in charter order, perk identity
619
652
  (`perk v<version>`), the 🎯 objective segment, the 📋 checkpoints segment (left group), then git
620
- branch, model, context usage (`<pct>%/<window>`, warning >70 / error >90), and guest extension
621
- statuses (right-aligned), with the extended D9 drop order on overflow (guests model branch →
622
- context checkpoints; identity + objective never drop). The composed `perk` status slot
653
+ branch, model, thinking level (bare level text, dim; read live via `pi.getThinkingLevel()`, shown
654
+ whenever a model is present including `off`), context usage (`<pct>%/<window>`, warning >70 / error
655
+ >90), and guest extension statuses (right-aligned), with the extended D9 drop order on overflow
656
+ (guests → thinking → model → branch → context → checkpoints; identity + objective never drop). The composed `perk` status slot
623
657
  **remains published** (the `createPerkStatus` dual-publish is deliberate) and is the RPC-visible
624
658
  surface — `setFooter` is an RPC no-op. The `v<version> loaded` startup notify is **retired**
625
659
  (charter D7: identity is standing footer state, not a transition) — `session_start` no longer
@@ -706,7 +740,10 @@ Enforced by the source-scan guard `extension/surfacesGuard.test.ts` (node:test,
706
740
  **Tool-gating (P2.T1).** The `mode` field **structurally gates tools** — enforcement, not
707
741
  prompting. When `mode == "read-only"` the interior (`extension/substrate/toolGating.ts`):
708
742
  (1) restricts the active tool set to `READ_ONLY_TOOLS` (`read`/`grep`/`find`/`ls`/`bash` +
709
- `ask_user_question` + `plan_review` + the **`web` seam** providers' research tools — the **union**
743
+ `ask_user_question` + `plan_review` + the `plan_draft`/`objective_draft` session-data carve-outs +
744
+ `objective_node` (never touches the worktree — it delegates a bounded node transition to the
745
+ canonical Python plane, and the objective-plan factory's `objective_node_claim` carrier can only
746
+ be written inside the gated session) + the **`web` seam** providers' research tools — the **union**
710
747
  of all provider tool names: `web_search`/`code_search`/`fetch_content`/`get_search_content`
711
748
  (`pi-web-access`, the default), `ollama_web_search`/`ollama_web_fetch` (`@ollama/pi-web-search`),
712
749
  and `web_fetch` (`@juicesharp/rpiv-web-tools`); foreign tool names are inert
@@ -737,7 +774,7 @@ a `/plan` command, a `Ctrl+Alt+P` shortcut, and a `--plan` flag all flip `gating
737
774
  hidden plan-authoring prompt layer under its own `perk:plan-context` customType (keyed off the
738
775
  read-only gate; stripped from `context` when off — the same hygiene T1 applies to
739
776
  `perk:mode-context`), optionally extended by a `[workflow] plan_authoring` addendum read from
740
- `.pi/perk.toml` + `perk.local.toml` (`extension/substrate/config.ts`, the TS twin of `perk/substrate/config.py`'s
777
+ `.perk/config.toml` + `local.toml` (`extension/substrate/config.ts`, the TS twin of `perk/substrate/config.py`'s
741
778
  overlay). `isPlanModeActive` (in `extension/factories/planSave.ts`) now reads perk's own `mode == "read-only"`
742
779
  (the P1.T3b `plan-mode-state` soft coupling is gone). The `plan_save` **tool** is structurally
743
780
  unreachable while read-only (T1's allowlist excludes it), so there is no auto-exit on the tool path;
@@ -966,11 +1003,18 @@ routes to `/land`.
966
1003
  to `perk pr review-post` (the existing cold door) via `runColdDoor` (stdin `--batch`). The review is
967
1004
  **advisory `COMMENT` only** — `event` is hardcoded `COMMENT` in the gateway, so the parent can never
968
1005
  approve/request-changes.
1006
+ - **Optional ad-hoc operator directive.** Everything after `/pr-review` is captured verbatim as a
1007
+ **free-form operator directive** and threaded into the angle-selection step of the seed guidance
1008
+ (the same inline-conditional mechanism the template uses for the optional `model` var — no new
1009
+ tool, registry, or door change). It biases **angle selection and per-reviewer emphasis only**,
1010
+ honored as DATA from the human: Plan-fidelity stays mandatory, the **2–3-reviewer cap** holds, and
1011
+ the **clean/actionable posting bar is unchanged**. An empty directive (no args) renders the
1012
+ byte-identical seed as before.
969
1013
  - **Configurable models via the agent-keyed `[subagents]` table (#196).** Every perk-owned project
970
- agent's model is configurable through one flat `[subagents]` table in `.pi/perk.toml` (overlaid by
971
- `.pi/perk.local.toml`), keyed by the bare agent name — `pr-reviewer`, `review-classifier`,
972
- `objective-explorer`, `conflict-resolver` (matching each def's `name:` frontmatter and the
973
- `perk.<name>` invocation).
1014
+ agent's model is configurable through one flat `[subagents]` table in `.perk/config.toml` (overlaid by
1015
+ `.perk/local.toml`), keyed by the bare agent name — `pr-reviewer`, `review-classifier`,
1016
+ `objective-explorer`, `conflict-resolver`, `learn-analyst` (matching each def's `name:` frontmatter
1017
+ and the `perk.<name>` invocation).
974
1018
  Each configured value is injected as a **per-call inline `model` override** on that agent's
975
1019
  `subagent` spawn (the agent's frontmatter `model` stays the default when the key is unset). This
976
1020
  is wired at the authored spawn sites: the warm TS doors (`prReviewGuidance`,
@@ -1191,10 +1235,11 @@ close_and_label_consolidated{ issue } -> bool
1191
1235
  is the other); HTML never leaks into `git log`.
1192
1236
  - **`/learn` (D10).** The `learn capture` worker (`perk learn capture --json --body <file>`) reads
1193
1237
  the agent-captured learnings markdown from a run-scoped scratch file (the stdin-less worker
1194
- pattern), `create_learn_issue`, posts a back-link comment on the plan issue (best-effort), and
1238
+ pattern), `create_learn_issue`, posts a back-link comment on the plan issue (best-effort), stamps
1239
+ the canonical `learn_state: captured` (§8.36, strictly — before the marker clear), and
1195
1240
  clears `pending-learn`. The warm `/learn` (`extension/doors/learn.ts`) takes an optional `summary`:
1196
- present → scratch + delegate + mirror the marker-clear; absent → the thin TS-only marker-clear
1197
- (graceful — no empty issue). `learn` now reads `[cache.markers, cache.plan-ref]` and writes
1241
+ present → scratch + delegate + mirror the marker-clear; absent → delegate to the `perk learn
1242
+ skip` cold door (§8.36the canonical skip recording; no longer a TS-only marker-clear). `learn` now reads `[cache.markers, cache.plan-ref]` and writes
1198
1243
  `[cache.markers, github.learn, github.comments]` (the `github.learn` vocabulary key is new).
1199
1244
  The warm door's `learn_issue` decode is **lenient** (render-only field): a `success: true`
1200
1245
  envelope yields the captured-ok terminating result and mirrors the marker-clear even when the
@@ -1307,6 +1352,16 @@ validate_pr_body(body, *, pr_number) -> string[] (empty == vali
1307
1352
  **raises** (`error_type: pr_check_failed`) on failure. A thin `perk pr check --json` (active
1308
1353
  plan-ref → find PR → `get_pr_body` → `validate_pr_body`) is the supervisor surface (exit 0 valid /
1309
1354
  1 invalid·op-failure / 2 not-a-repo).
1355
+ - **`pr url` (the active-PR locator).** A thin read-only `perk pr url --json` worker (active
1356
+ plan-ref → `resolve_plan_worktree_name` → `find_pr_for_branch`) emits `{pr:{number,url}}` (exit
1357
+ 0 ok / 1 no-plan·no-PR·op-failure / 2 not-a-repo), mirroring `pr review-context`'s resolution
1358
+ path. It fronts the warm `/pr-review-local` command: perk resolves the active PR's URL in Python
1359
+ (canonical) and bridges to plannotator's published `code-review` `pi.events` action
1360
+ (`plannotator:request` with `action: "code-review"`, `payload: {prUrl, cwd}`, one `respond(...)`
1361
+ reply — no handshake/no timeout; envelope pinned against `@plannotator/pi-extension@0.21.2`
1362
+ `plannotator-events.ts`) to open the browser code-review UI on the active PR. `/pr-review-local`
1363
+ is a plain warm command (no registry stage, no model tool); presence is detected by plannotator's
1364
+ `/plannotator-review` command being registered, independent of the selected plan provider.
1310
1365
  - **Draft → ready is a deliberate gesture (D6).** Submit keeps the PR **draft**; perk does **not**
1311
1366
  auto-publish (unlike erk's `finalize_pr`). The new `perk pr ready` (warm `/ready`, `extension/
1312
1367
  ready.ts`) is the explicit review gate — `mark_pr_ready` if draft, idempotent. Land's
@@ -1402,11 +1457,14 @@ Linear, `perk init` / `doctor --fix` proactively ensure the five `perk:*` labels
1402
1457
  scope — §8.21.)
1403
1458
 
1404
1459
  **The `pending-learn` semaphore (P1.T5b; Q2/Q5).** An existence-only `cache.markers` file
1405
- (`.pi/workflow/markers/pending-learn`, name shared as `PENDING_LEARN` in both planes): **`land`
1460
+ (`.perk/workflow/markers/pending-learn`, name shared as `PENDING_LEARN` in both planes): **`land`
1406
1461
  sets it** (after a successful merge), **`learn` clears it**. While present it signals the
1407
1462
  land→learn cycle is open and the worktree is not yet releasable (a future `worktree remove` /
1408
1463
  `doctor` honors it). `learn` is **thin and TS-only** this phase — it clears the marker; the
1409
- agentic capture + a `perk:learn` label/issue is Phase 2.
1464
+ agentic capture + a `perk:learn` label/issue is Phase 2. **Since §8.36** the marker is demoted to
1465
+ cache/friction-semaphore: the canonical post-merge learn state lives on the plan-header
1466
+ `learn_state` field, and the marker is only the local retry signal + the legacy resolution
1467
+ fallback (and the `worktree wipe` guard).
1410
1468
 
1411
1469
  ### Authored (P2.T9 — objective storage + mechanics)
1412
1470
 
@@ -1607,7 +1665,7 @@ uses existing state keys (`github.learn`, `github.plan`, `cache.scratch`).
1607
1665
 
1608
1666
  - **The factory cold door + warm command.** `perk learn docs` (`commands/learn/docs_cmd.py`, no
1609
1667
  alias): `list_learn_issues` → materialize the inbox
1610
- `.pi/workflow/scratch/learn-docs-inbox.md` (a `## Learning #<n>` section per issue, each body in
1668
+ `.perk/workflow/scratch/learn-docs-inbox.md` (a `## Learning #<n>` section per issue, each body in
1611
1669
  `<untrusted_learning>`) → `launch_stage(plan_stage, prompt_override=<seed>)` (a read-only
1612
1670
  plan-mode session). `--gather` materializes the inbox + emits `{ inbox_path, learn_numbers }`
1613
1671
  with no launch (the warm path + tests consume this); `--dry-run` gathers + prints; `--remote` is
@@ -1658,12 +1716,18 @@ uses existing state keys (`github.learn`, `github.plan`, `cache.scratch`).
1658
1716
  session) holds the **compressed** routing index — the realization of the PRIOR_ART §6
1659
1717
  "compressed index must be ambient" finding (a retrieval-tier index is too brittle). Both index
1660
1718
  layers are refreshed **by `/learn-docs` plans**, never by `perk init` (and neither path is
1661
- gitignored — they are committed). erk's heavier machinery (tripwire generation, per-category
1662
- auto-indexes, `docs sync` codegen, multi-agent session preprocessing) is deliberately deferred.
1719
+ gitignored — they are committed). As of node 6.1 a `/learn-docs` plan regenerates both index
1720
+ layers by running `perk learn docs-sync` (from each doc's frontmatter) never by hand. erk's
1721
+ remaining heavier machinery (tripwire generation, per-category auto-indexes, multi-agent session
1722
+ preprocessing) is deliberately deferred.
1663
1723
  - **The judgment layer** is `skills/perk-learn-docs/SKILL.md`: read the inbox as untrusted DATA →
1664
- cluster by cross-cutting theme → `docs/learned/<category>/` placement author a bounded docs
1665
- plan with a `## Steps` list `plan_save` with `consumed_learn`; plus the ported content-quality
1666
- rules (cross-cutting insight only, explain *why* not *what*, the One Code Rule / source pointers).
1724
+ **verify placement** (the knowledge-placement hierarchy, emitting a `SHOULD_BE_CODE` follow-up
1725
+ step when a doc-destined learning belongs in code/comment/docstring/schema/user-docs) cluster by
1726
+ cross-cutting theme `docs/learned/<category>/` placement (cleanup-first) author a bounded docs
1727
+ plan with a `## Steps` list (routing regenerated via `docs-sync`) → `plan_save` with
1728
+ `consumed_learn`; plus the ported content-quality rules (cross-cutting insight only, explain *why*
1729
+ not *what*, the One Code Rule / source pointers). The sibling `skills/perk-learn-code/SKILL.md` is
1730
+ the code-routing curator for the pre-stamped `SHOULD_BE_CODE` learnings (see §8.35, node 7.1).
1667
1731
 
1668
1732
  ## §8.5 · The `init` machine surface (T5; cli-vs-pi §3.2)
1669
1733
 
@@ -1693,12 +1757,19 @@ already happened before the sync); `skills_conflict` short-circuits before any c
1693
1757
  project: { projects_ok, projects_error, # project-backed objective readiness (Node 4.2);
1694
1758
  missing_state_types[], states_error } | null }, # null unless auth_ok && team_ok; non-fatal
1695
1759
  capabilities: string[], # the managed inventory (perk/convergence/capabilities.py)
1696
- changes: string[], # converged/seeded pieces ([] ⇒ already converged)
1760
+ changes: string[], # converged/seeded pieces ([] ⇒ already converged);
1761
+ # init also records .perk/managed-state.toml as a
1762
+ # convergence side effect — a changes line appears only
1763
+ # when the file is created/updated (the one-time
1764
+ # backfill), preserving the pure-delta invariant
1765
+ warnings: string[], # non-fatal clear-report lines (e.g. repo-authored-skills
1766
+ # structural errors / untracked SKILL.md); kept separate
1767
+ # from `changes` so `changes` stays a pure delta list
1697
1768
  handoff: string|null } # path to the post-init markdown on-ramp
1698
1769
  ```
1699
1770
 
1700
1771
  The **post-init handoff** (`handoff`) is an *agent-readable* markdown at
1701
- `.pi/workflow/post-init.md` (gitignored; regenerated each init) — distinct from the §8.1
1772
+ `.perk/workflow/post-init.md` (gitignored; regenerated each init) — distinct from the §8.1
1702
1773
  machine run-handoff JSON. It is the Phase-0 dogfood on-ramp.
1703
1774
 
1704
1775
  **Capability inventory.** `perk/convergence/capabilities.py` is the declared SSOT of what `init` manages
@@ -1736,24 +1807,42 @@ GitHub readiness is **non-fatal** (`warn`, never `fail`); doctor **never mutates
1736
1807
  checks: [ { name, group, status, message, detail, remediation } ], # status ∈ ok|warn|info|fail
1737
1808
  summary: { passed: int, warnings: int, failed: int },
1738
1809
  fixed: string[], # repairs applied by --fix ([] otherwise)
1739
- fix_errors: string[] } # --fix repairs that FAILED (e.g. a skills sync error;
1810
+ fix_errors: string[], # --fix repairs that FAILED (e.g. a skills sync error;
1740
1811
  # rendered loudly; the post-fix re-verify keeps the
1741
1812
  # failing check, so the exit code stays honest)
1813
+ artifact_health: [ # one row per managed-artifact registry descriptor
1814
+ { key, path, kind, # (managed_artifacts(), sorted by key)
1815
+ status, # up-to-date | not-installed | locally-modified |
1816
+ # changed-upstream | state-missing
1817
+ recorded_version: string|null, # the .perk/managed-state.toml row (null = no recorded row)
1818
+ recorded_hash: string|null,
1819
+ desired_hash: string,
1820
+ observed_hash: string|null } ] } # null = not installed
1742
1821
  ```
1743
1822
 
1823
+ **Artifact health is report-only/diagnostic** (the `artifact-health` check reports `ok`/`info`/
1824
+ `warn`, never `fail`): the dry-run managed convergence stays authoritative for pass/fail, and
1825
+ `--fix` repairs through the existing convergence **then** records `.perk/managed-state.toml`
1826
+ (content-gated — the write lands on `fixed` only when the file is created/updated, keeping a
1827
+ second `--fix` at `fixed == []`).
1828
+
1744
1829
  **Groups.** `environment` (tools; required tools missing = `fail`; optional tools (e.g. ast-grep)
1745
1830
  missing = `warn`) · `github` (auth/access; non-fatal `warn`) ·
1746
1831
  `linear` (verify-gated Linear readiness — auth/team/labels; present only when the committed
1747
1832
  `[issues] backend` is `"linear"`; warn-level, the github D3 mirror; `--fix` ensures the five perk
1748
1833
  labels — §8.21) · `runner` (remote-runner prereqs; report-only, non-fatal — §8.16) ·
1749
1834
  `package` (settings wiring + perk-package ref reconcile + the `extension-install` install-ownership
1750
- check; `--fix` also migrates a former git-clone consumer forward by removing the orphaned clone — §8.6a) ·
1835
+ check + the `required-perk-version` managed check over the committed `.perk/required-perk-version`
1836
+ pin + the report-only `cli-version` CLI-vs-repo-pin warning (warn, never fail);
1837
+ `--fix` also migrates a former git-clone consumer forward by removing the orphaned clone — §8.6a) ·
1751
1838
  `repository` (gitignore/agents blocks + config present/valid) ·
1752
1839
  `registry` (the registry self-check) · `skills` (the skills-CLI manifest fragment + the
1753
- fail-level `skills-delivery` substrate check §8.9) · `bindings` / `providers` (rolled-up
1840
+ fail-level `skills-delivery` substrate check + the `repo-skills` repo-authored-skills fragment check
1841
+ — §8.9) · `bindings` / `providers` (rolled-up
1754
1842
  non-fatal config checks — §8.9/§8.10) · `issues` (the fail-level `[issues]` selection check:
1755
- linear requires a committed `team` — §8.21) · `state` (the `.pi/workflow/` cache layout +
1756
- handoff-blob integrity). Managed-piece checks are filtered by `capabilities.applicable(self_repo)`; infra checks
1843
+ linear requires a committed `team` — §8.21) · `state` (the `.perk/workflow/` cache layout +
1844
+ handoff-blob integrity + the report-only `artifact-health` classification over the managed-state
1845
+ registry). Managed-piece checks are filtered by `capabilities.applicable(self_repo)`; infra checks
1757
1846
  always run. Human render (stderr) follows the three-way condensed rule per group (collapse a clean
1758
1847
  group; else expand only its failures/warnings); `--verbose` expands every check.
1759
1848
 
@@ -1822,6 +1911,40 @@ Keeping a consumer's pi-loaded perk extension runnable rests on two invariants:
1822
1911
  scope. `tests/test_packaging.py` now also guards the **wired + install pin lockstep** against the
1823
1912
  version SSOT (`test_npm_pin_lockstep`: `_perk_npm_entry()` and `_pinned_spec()` both track
1824
1913
  `_pyproject_version()`), beyond the existing `__version__` `test_version_lockstep`.
1914
+ - **Two report-only CLI-vs-repo surfaces consume the committed pin.** Both compare
1915
+ `perk.__version__` against `.perk/required-perk-version` (via `read_version_pin`) — a *different
1916
+ axis* from the npm wired/installed pins above:
1917
+ - **The runtime stderr warning** (`perk/cli/version_check.py`, hooked in the root group
1918
+ callback): one soft line on an interactive mismatch, **never fatal**. Pinned suppression
1919
+ ladder (cheap gates before any I/O): `PERK_SKIP_VERSION_CHECK` (any non-empty value;
1920
+ documented as `=1`) · `CI` non-empty · non-TTY stderr · `--version`/`--help` in argv (covers
1921
+ subcommand `--help`, which runs the group callback) · any `--json`/machine-output command ·
1922
+ the `run-worker` worker path · outside a git repo · missing pin. An unreadable pin is
1923
+ reported softly (never swallowed); doctor owns the loud diagnosis.
1924
+ - **The `cli-version` doctor check** (group `package`, offline, appended right after the
1925
+ managed checks): `ok` on match, **`warn`** on mismatch (never `fail` — a running CLI cannot
1926
+ install itself), `info` when the pin is missing (presence/drift is the
1927
+ `required-perk-version` managed check's job). Distinct from `settings-wiring` /
1928
+ `extension-install` (the npm axis) and from the `required-perk-version` managed check (file
1929
+ drift + `--fix`, which reconverges the pin to the running CLI). On a mismatch both the
1930
+ managed **fail** and the `cli-version` **warn** fire, deliberately — two remedies, two
1931
+ directions (upgrade the CLI vs reconverge the pin); `--fix` never touches the warn.
1932
+ - **A third report-only startup surface: the post-upgrade notice** (`perk/cli/version_check.py`,
1933
+ hooked right after the warning in the root group callback — warning first, notice second; both
1934
+ may appear). It consumes the **user-level max-seen store** `~/.perk/last-seen-version`
1935
+ (constructed only via `paths.user_perk_dir()`/`paths.last_seen_version_file()` — the one
1936
+ perk-owned path outside the repo), not the committed pin. Semantics: max-seen — on the first
1937
+ unsuppressed interactive run after an upgrade it records the new version **then** shows one
1938
+ line pointing at `perk release-notes` (record-then-notice: an unrecordable store shows
1939
+ nothing, or the notice would repeat); first-run and garbled stored content record silently;
1940
+ an equal stored version is a no-op with no write; downgrades never lower the max seen. Its
1941
+ suppression ladder is the warning's **shared six gates** (`_suppressed`: env opt-out · `CI` ·
1942
+ non-TTY · `--version`/`--help` · `--json` · `run-worker`) with **no repo gate** — it fires
1943
+ outside a git repo, like `perk release-notes` itself — and suppressed invocations perform
1944
+ **no store I/O** (a machine run never consumes the notice). Failure posture: **silent
1945
+ degrade** (any `OSError`/`Path.home()` failure → no-op, never a crash or a stderr report —
1946
+ the store is a UX nicety with no remediation surface); accordingly there is **no doctor
1947
+ check and no init convergence** for `~/.perk` (the store self-heals).
1825
1948
 
1826
1949
  ---
1827
1950
 
@@ -1844,7 +1967,7 @@ checks the prompt** (the converged context actually reached the model via Pi's
1844
1967
  `getSystemPromptOptions()`, available only on a command context). selfcheck logs only derived
1845
1968
  booleans/counts — never the raw prompt text (the options expose the full system prompt).
1846
1969
 
1847
- The `.pi/workflow/.perk-t3.json` diagnostics sentinel additionally records **`run_mode`** — Pi's
1970
+ The `.perk/workflow/.perk-t3.json` diagnostics sentinel additionally records **`run_mode`** — Pi's
1848
1971
  `ctx.mode` (`tui`/`rpc`/`json`/`print`) — distinct from the workflow **`mode`** (`read-only`/
1849
1972
  `read-write`) that drives tool gating. `run_mode` is observability `ctx.hasUI` (a binary) can't
1850
1973
  express; it is written from `ctx.mode` on both `session_start` and `session_tree`.
@@ -1891,6 +2014,7 @@ skills, so a pointer suffices; `transclude` exists for the user-binding case):**
1891
2014
  | `stage:learn` | `perk-learn` | `nudge` |
1892
2015
  | `command:objective-reconcile` | `perk-objective-reconcile` | `nudge` |
1893
2016
  | `command:learn-docs` | `perk-learn-docs` | `nudge` |
2017
+ | `command:learn-code` | `perk-learn-code` | `nudge` |
1894
2018
  | `command:pr-review` | `perk-pr-review` | `nudge` |
1895
2019
 
1896
2020
  **Validation depth (shape-only, registry-free):** the loaders/validators check that
@@ -1900,8 +2024,8 @@ non-empty `<id>`, and that no `trigger` repeats. They do **not** check that a `s
1900
2024
  target actually exists — that cross-contract, target-existence validation is **`doctor`**'s job.
1901
2025
 
1902
2026
  **Resolver — `shipped-defaults ⊕ user-bindings` (Node 1.2, pure + unit-tested both planes):** a
1903
- user **skill-binding overlay** is authored in `.pi/perk.toml` as a `[[bindings]]` array-of-tables
1904
- (`trigger`/`skill`/`mode` strings); `.pi/perk.local.toml` overlays it with a **whole-array replace**
2027
+ user **skill-binding overlay** is authored in `.perk/config.toml` as a `[[bindings]]` array-of-tables
2028
+ (`trigger`/`skill`/`mode` strings); `.perk/local.toml` overlays it with a **whole-array replace**
1905
2029
  (local wins — the local array supersedes the committed one entirely, never merged element-wise,
1906
2030
  mirroring the leaf-replace overlay for scalars). Both planes parse this into the same binding shape
1907
2031
  (`perk/substrate/config.py` → `Config.user_bindings`; `extension/substrate/config.ts` → `PerkConfig.bindings`) and
@@ -1984,7 +2108,7 @@ Pi package no longer declares `pi.skills`, so Pi never discovers the package `sk
1984
2108
  `skills/<name>` fallback covers only the window before `skills update --sync` has run — and
1985
2109
  **target-existence**
1986
2110
  — `stage:<id>` must be a `registry.load_registry().stage_ids()` member, and `command:<id>` must be in
1987
- `DELIVERABLE_COMMAND_TARGETS = {objective-reconcile, learn-docs}` (the only command triggers perk's
2111
+ `DELIVERABLE_COMMAND_TARGETS = {objective-reconcile, learn-docs, learn-code, …}` (the only command triggers perk's
1988
2112
  delivery layer fires; a `command:<id>` outside it never fires). Every binding finding is a **`warn`**
1989
2113
  (loud-but-non-fatal, D1): `perk doctor` stays exit-0 over a binding misconfiguration — the
1990
2114
  `bindings` check owns user-binding *config* only. The delivery **substrate** (perk's own skills
@@ -2028,12 +2152,43 @@ dangling-pointer warning, which stays a last-resort signal).
2028
2152
  post-fix re-verify keeps the failing `skills-delivery` check so the exit code reflects the
2029
2153
  still-broken state.
2030
2154
 
2155
+ **Repo-authored skills (the `.perk/skills/` → manifest-fragment convergence).** A repo may author
2156
+ its **own** skills under `.perk/skills/<name>/SKILL.md`; perk renders them into a second skills-CLI
2157
+ manifest fragment `.agents/manifest.d/perk-repo-skills.yaml` (beside the perk-managed `perk.yaml`),
2158
+ under a self-referential GitHub source derived from the repo's identity (`github.repo_identity` →
2159
+ `perk-<repo>` alias, `url`, default-branch `ref`). The substrate is
2160
+ `repo_skills.build_repo_skills_manifest`; the wiring is a **verify-gated convergence gesture**
2161
+ `converge_repo_skills_manifest(root, *, apply)` — **not** a `ManagedConvergence` (rendering a valid
2162
+ fragment does a GitHub read, and managed convergences run unconditionally in offline unit tests), so
2163
+ it runs beside `sync_skills` under `verify` only. **`.agents/manifest.yaml` is never mutated.**
2164
+
2165
+ - **Convergence:** valid skills → write the fragment on a byte-difference (`<path>: created|updated`
2166
+ only on a real delta); no skills + no errors → remove a stale fragment (`<path>: removed`);
2167
+ errors present → **never** write or remove (a transient bad edit never clobbers a previously-good
2168
+ fragment). Idempotent (`apply=True/False` compute the same change list).
2169
+ - **`perk init` posture:** the fragment is converged **before** `sync_skills` (so the skills CLI
2170
+ sees the declared source). Structural errors + untracked warnings are **non-fatal** — `init`
2171
+ exits 0 and keeps converging, surfacing them on the new **`InitReport.warnings`** field (§8.5).
2172
+ Only the sync-time remote `missing-skill` stays fatal (`skills_sync_failed`, exit 2).
2173
+ - **`doctor` check:** a verify-gated **`repo-skills`** check (group `skills`, report-only, beside
2174
+ `skills-delivery`). First match wins: structural `errors` (bad SKILL.md / source-alias collision /
2175
+ no GitHub remote) → **`fail`**; on-disk fragment drift (incl. a stale fragment to prune) →
2176
+ **`fail`**; untracked SKILL.md → **`warn`**; declared+converged → **`ok`**; no repo-authored
2177
+ skills → **`ok`**.
2178
+ - **`doctor --fix`:** re-converges the fragment (`apply=True`) **before** the sync; structural
2179
+ errors ride loudly on `DoctorReport.fix_errors`; the post-fix re-verify re-runs `repo-skills`.
2180
+ - **Repo-aware sync remediation:** `sync_skills` takes the declared repo-authored skill **names**
2181
+ (`repo_skill_names`). They are folded into the post-sync presence loop (a free backstop for a CLI
2182
+ that exits 0 but skips an unresolvable skill) and gate one appended remediation clause on every
2183
+ failure message — "commit + push the new `.perk/skills/` skill to your default branch, then re-run"
2184
+ — emitted **only when** repo-authored skills are declared (no per-skill stderr parsing).
2185
+
2031
2186
  ## §8.10 · Provider selection (the supported-set registry + the `[providers]` selection)
2032
2187
 
2033
2188
  The **third parsed cross-plane contract**, `shared/providers.yaml` (sibling of `registry.yaml`
2034
2189
  and `bindings.yaml`), is the **supported set** — the catalog of plan/todo/askuser/footer/web *providers* perk
2035
2190
  knows how to wire — distinct from the per-repo **selection** (a flat `[providers]` table in
2036
- `.pi/perk.toml`, which is just a pointer into the catalog). It is bundled automatically via the
2191
+ `.perk/config.toml`, which is just a pointer into the catalog). It is bundled automatically via the
2037
2192
  `shared/` force-include (wheel → `perk/_shared/`, npm tarball → `shared/`) and read by both planes
2038
2193
  through independent readers: **`perk/substrate/providers.py`** (`load_providers` / `validate` /
2039
2194
  `resolve_providers`, returning `ProviderSet`/`Provider` + the shared `Issue`/`FindingSeverity` findings,
@@ -2099,13 +2254,13 @@ untouched by the plan-seam deferral.
2099
2254
  check that any repo *selection* names a real provider — that cross-file validation is **`doctor`**'s
2100
2255
  job (mirroring how bindings target-existence lives in doctor, not the loaders).
2101
2256
 
2102
- **The `[providers]` selection — flat string table in `.pi/perk.toml`:** a per-repo selection with
2257
+ **The `[providers]` selection — flat string table in `.perk/config.toml`:** a per-repo selection with
2103
2258
  one key per seam (`plan` / `todo` / `askuser` / `footer` / `web`), values are **bare provider-id strings** (the TS narrow-TOML
2104
2259
  reader `parseTomlSubset` reads string values only; richer structure lives in `providers.yaml`).
2105
2260
  Both planes parse it raw (`perk/substrate/config.py` → `Config.providers`; `extension/substrate/config.ts` →
2106
2261
  `PerkConfig.providers`); resolution against the supported set is `init`/`doctor` in Python and the
2107
2262
  `extension/substrate/providers.ts` `resolveProviders` resolver in TS (added Node 2.2, consumed by `planMode`). An **absent table or absent key → the seam's
2108
- `default: true` provider** (zero behavior change, the no-config default). `perk.local.toml` overlay
2263
+ `default: true` provider** (zero behavior change, the no-config default). `local.toml` overlay
2109
2264
  wins (standard local-override precedence). The pure resolver
2110
2265
  `perk.substrate.providers.resolve_providers(selection, providers)` returns `ResolvedProviders { plan, todo,
2111
2266
  askuser, footer, web, issues }`: an absent key falls back to the default **silently**; an unknown id or a seam mismatch
@@ -2137,13 +2292,13 @@ _providers_check`). A `ProvidersError` on the *bundled* file is a `fail` (cannot
2137
2292
  install; "Reinstall perk"); an `ERROR` shape `Issue` on the bundled file is a `fail`. The repo
2138
2293
  selection is resolved against the supported set and any resolver `issue` (unknown id / seam
2139
2294
  mismatch) is a single **`warn`** (loud-but-non-fatal — `perk doctor` stays exit-0 over a selection
2140
- typo), remediation pointing at `.pi/perk.toml [providers]` / `perk init`. There is **no** separate
2295
+ typo), remediation pointing at `.perk/config.toml [providers]` / `perk init`. There is **no** separate
2141
2296
  package-wired / orphan check — that drift is owned by the `settings-wiring` managed convergence
2142
2297
  (which `doctor` already dry-runs); `_providers_check` owns only what convergence cannot repair (an
2143
2298
  invalid bundled file, a selection naming a non-existent / wrong-seam provider).
2144
2299
 
2145
2300
  **`[compaction]` → `settings.json` `compaction` convergence (init-owned, #206):** a `[compaction]`
2146
- table in `.pi/perk.toml` tunes pi's **interactive** global auto-compaction for `perk <stage>`
2301
+ table in `.perk/config.toml` tunes pi's **interactive** global auto-compaction for `perk <stage>`
2147
2302
  sessions by converging into the committed `.pi/settings.json` `compaction` object (pi reads that
2148
2303
  natively at session boot). It is **Python-plane-only** — the extension never reads it (pi consumes
2149
2304
  `settings.json` itself), so `extension/substrate/config.ts` is untouched. Three snake_case keys map to pi's
@@ -2155,14 +2310,14 @@ ill-typed/absent keys are dropped (pi fills defaults). The convergence composes
2155
2310
  `perk/convergence/init/settings.py::_converge_compaction`), so it stays in the `settings-wiring` `ManagedConvergence` —
2156
2311
  `doctor` dry-runs/fixes it for free, **no** new check. **Committed-only read** (the deliberate
2157
2312
  divergence from `[providers]`' overlaid `load_config` read): `[compaction]` is read from committed
2158
- `.pi/perk.toml` **only**, never the `perk.local.toml` overlay, so the committed `settings.json`
2313
+ `.perk/config.toml` **only**, never the `local.toml` overlay, so the committed `settings.json`
2159
2314
  stays a deterministic function of committed config (no stray per-user git diff). Per-user overrides
2160
2315
  belong in pi's native global `~/.pi/agent/settings.json` (pi merges it under project settings).
2161
2316
  **Write semantics are non-destructive write-when-present / leave-when-absent:** when `[compaction]`
2162
2317
  is present, its mapped keys merge over any existing `settings.json` `compaction` dict (perk keys
2163
2318
  win; unrelated hand-added keys survive; unspecified keys are left to pi's defaults); when
2164
2319
  **absent**, `settings.json` is left untouched (perk cannot prove ownership of a bare `compaction`
2165
- key, so removal is unsafe — removing `[compaction]` from `perk.toml` leaves a stale block to clean
2320
+ key, so removal is unsafe — removing `[compaction]` from `config.toml` leaves a stale block to clean
2166
2321
  up by hand). A malformed-TOML error defers to the config check (treated as empty here, mirroring
2167
2322
  `_converge_provider_packages`). perk's headless worker (`compaction: { enabled: false }`) and the
2168
2323
  objective threshold compaction (`[objective] compact_threshold`) are orthogonal and unaffected.
@@ -2212,7 +2367,7 @@ exactly as in a warm session (§8.4).
2212
2367
  | `worktree` | absolute path, already positioned | the cold-door/runner positioning (`perk/run/launch/__init__.py`), **not** the worker (Gap 7) |
2213
2368
  | `stage` | `"implement" \| "address"` | the only `doors.cold_remote: true` read-write stages (`shared/registry.yaml`) |
2214
2369
  | `run_id` | ULID, present as `PERK_RUN_ID` in env | minted by positioning; the worker **inherits** it and never re-mints |
2215
- | handoff / plan-ref / plan-body | files under `<worktree>/.pi/workflow/` | materialized by positioning; the worker does not re-write them |
2370
+ | handoff / plan-ref / plan-body | files under `<worktree>/.perk/workflow/` | materialized by positioning; the worker does not re-write them |
2216
2371
  | `initialPrompt` | string | re-derived by `initialPromptFor(stage, planRef)` — the TS twin of `perk/run/launch/prompts.py._implement_prompt`/`_address_prompt` (parity asserted reciprocally in `extension/worker/worker.test.ts` + `tests/test_worker_prompt_parity.py`); the resolved skill-binding suffix is delivered by the cold door and is **deferred to Phase 2** |
2217
2372
  | `model` + `auth` | `Model` + `AuthStorage`/`ModelRegistry` | explicit worker input, else env-var key resolution (`ANTHROPIC_API_KEY` etc., Gap 5); **no model ⇒ a fail-soft `failed`/`no_model` outcome, never a throw** |
2218
2373
  | `budget` | `{ maxTurns, maxTokens, wallClockMs }` | worker input; the watchdog that drives abort (Gap 2) |
@@ -2220,16 +2375,25 @@ exactly as in a warm session (§8.4).
2220
2375
 
2221
2376
  ### Determinism invariants (fixed by the worker; not caller-tunable)
2222
2377
 
2223
- - **`cwd = worktree`, `agentDir = throwaway temp dir`** (Gap 4): the project tier loads (perk's
2224
- `@mgiles/perk` via the managed `.pi/settings.json`, the managed `AGENTS.md`/`APPEND_SYSTEM.md`); the
2225
- user-global tier (extensions/settings/skills/models/auth) is locked out. The
2226
- `createAgentSessionServices` factory builds the `DefaultResourceLoader` internally from
2227
- `cwd`/`agentDir` the runtime path does **not** take a pre-built loader (recipe correction #1).
2228
- - **Compaction-off + retry-off** via `SettingsManager.inMemory({ compaction:{enabled:false},
2229
- retry:{enabled:false} })` (Gap 3) **AND** the **no-active-objective invariant**: positioning never
2230
- writes an `active_objective`, so `objective.ts`'s `turn_end` `ctx.compact` is inert. Together
2231
- these kill both SDK auto-compaction and perk's threshold compaction. The worker must **never**
2232
- call `/objective`/`objective_save` in the driven session.
2378
+ - **`cwd = worktree`, `agentDir = throwaway temp dir`** (Gap 4): the project tier **actually
2379
+ resolves** the managed `.pi/settings.json` `packages` list — perk's `@mgiles/perk` **plus** the
2380
+ borrowed packages (`npm:pi-subagents` etc.), the same package set as a warm session — alongside
2381
+ the managed `AGENTS.md`/`APPEND_SYSTEM.md`. Missing `npm:` packages **auto-install** into the
2382
+ project-scope root `.pi/npm` at session construction (an install failure throws a loud
2383
+ `failed`/`drive_error` outcome; installs are skipped under `PI_OFFLINE`) — §8.14's composite
2384
+ worker-deps step pre-installs the pinned `@mgiles/perk` there for consumers. The user-global tier
2385
+ (extensions/settings/skills/models/auth) stays locked out via the throwaway `agentDir` (its
2386
+ `settings.json` does not exist an empty global tier). The `createAgentSessionServices` factory
2387
+ builds the `DefaultResourceLoader` internally from `cwd`/`agentDir` the runtime path does
2388
+ **not** take a pre-built loader (recipe correction #1).
2389
+ - **Compaction-off + retry-off** via disk-layered settings — `SettingsManager.create(worktree,
2390
+ throwawayAgentDir)` + `applyOverrides({ compaction:{enabled:false}, retry:{enabled:false} })`
2391
+ (Gap 3; the SDK's sanctioned "with overrides" shape). The overrides ride the **merged** settings
2392
+ view only (what the compaction/retry getters read); package resolution reads the per-scope raws,
2393
+ so the overrides cannot leak into it. **AND** the **no-active-objective invariant**: positioning
2394
+ never writes an `active_objective`, so `objective.ts`'s `turn_end` `ctx.compact` is inert.
2395
+ Together these kill both SDK auto-compaction and perk's threshold compaction. The worker must
2396
+ **never** call `/objective`/`objective_save` in the driven session.
2233
2397
  - **`ctx.hasUI === false`** (Gap 6): the session binds with `{ uiContext: undefined, mode: "json" }`,
2234
2398
  so every perk UI surface takes its headless `console.error` fallback.
2235
2399
  - **Rebind defensiveness** (Gap 1): the worker is built on `createAgentSessionRuntime` (the
@@ -2261,6 +2425,18 @@ The drive terminates on the **first** of:
2261
2425
  4. **Post-acceptance model error** (with retry off, an assistant `message_end` with
2262
2426
  `stopReason:"error"`) → `failed`/`model_error`.
2263
2427
 
2428
+ **The terminating-tool preflight.** Immediately post-bind (before the driving `prompt()`), the
2429
+ stage's terminating perk tool must be registered — `implement` → `submit`, `address` →
2430
+ `resolve_review_threads` — else the drive fails fast with a **zero-turn** `failed` outcome carrying
2431
+ `error.type "no_extension_tools"` under the existing `model_error` terminal signal (the `no_model`
2432
+ precedent: preflight failures reuse `model_error` + a distinct `error.type`; no new `TerminalSignal`
2433
+ vocabulary). This closes disk discovery's silent-zero arm (a missing/unparseable `.pi/settings.json`
2434
+ or an unresolvable local-path package yields zero tools without throwing) — the cause is on the
2435
+ worker's stderr (drained settings errors + extension load errors), and the event stream stays a
2436
+ well-formed `run_started`→`run_finished` pair. The check is presence-gated on the session's
2437
+ `extensionRunner` and deliberately does **not** require the `subagent` tool for `address` (the live
2438
+ subagent-under-worker smoke stays the carried risk below).
2439
+
2264
2440
  ### Outcome shape (frozen; **additive-stable** — 1.3 may add fields, existing fields keep meaning)
2265
2441
 
2266
2442
  ```jsonc
@@ -2283,9 +2459,10 @@ stream's terminal `run_finished` event (§8.12) — the same frozen object, carr
2283
2459
  channel.
2284
2460
 
2285
2461
  > **Open dependency (carried risk).** The `address` drive's seeded prompt instructs the model to
2286
- > spawn `perk.review-classifier` via the borrowed `pi-subagents` `subagent` tool. The worker's
2462
+ > spawn `perk.review-classifier` via the borrowed `pi-subagents` `subagent` tool. `pi-subagents`
2463
+ > now loads in the worker from the managed settings `packages` list (Gap 4 above). The worker's
2287
2464
  > address prompt now also injects the configured classifier model when `[subagents]
2288
- > review-classifier` is set in the worktree's `.pi/perk.toml` (#196), as a per-call inline `model`
2465
+ > review-classifier` is set in the worktree's `.perk/config.toml` (#196), as a per-call inline `model`
2289
2466
  > override byte-identical to `_address_prompt`'s parity twin. The **subagent-under-worker live
2290
2467
  > smoke** stays the open-#6 dependency (§8.3, T6) **deferred to the Phase-3 `doctor workflow`**;
2291
2468
  > Node 1.2 does not prove it.
@@ -2332,8 +2509,8 @@ durable file out-of-process.
2332
2509
  - **Default sink** (when `eventSink` is absent) = a run-scoped NDJSON **file** sink built from
2333
2510
  `opts.worktree` + the resolved `run_id` (`env.PERK_RUN_ID`, the same source `assembleOutcome`
2334
2511
  uses). It appends one JSON object + `\n` per event to `runEventsPath(cwd, runId)` =
2335
- `<cwd>/.pi/workflow/scratch/runs/<runId>/events.ndjson` — a **cache-tier** artifact (the
2336
- `.pi/workflow/scratch/` tree is gitignored), co-located with the run's read-only-child scratch.
2512
+ `<cwd>/.perk/workflow/scratch/runs/<runId>/events.ndjson` — a **cache-tier** artifact (the
2513
+ `.perk/workflow/scratch/` tree is gitignored), co-located with the run's read-only-child scratch.
2337
2514
  - **No-op when `run_id` is empty** — keeps the offline drive tests (which set no `PERK_RUN_ID`)
2338
2515
  write-free; `workerMain` always has `PERK_RUN_ID`, so a real run always writes the file.
2339
2516
  - **Fail-soft** — each append (and the emitter's `sink(...)` call) is try/caught and swallowed with a
@@ -2371,6 +2548,7 @@ class Runner(Protocol):
2371
2548
  def observe(self, handle: RunHandle, *, repo_root) -> RunObservation: ...
2372
2549
  def cancel(self, handle: RunHandle, *, repo_root) -> None: ...
2373
2550
  def retry(self, handle: RunHandle, *, failed_only, repo_root) -> None: ...
2551
+ def discover(self, *, repo_root, limit) -> list[DiscoveredRun]: ...
2374
2552
  ```
2375
2553
 
2376
2554
  - **`dispatch`** triggers the run and returns the **verified** handle (verified = the runner-side
@@ -2383,8 +2561,20 @@ class Runner(Protocol):
2383
2561
  `cancel`/`retry` are §8.18). `retry` re-runs the existing run (same `run_ref`); `failed_only`
2384
2562
  re-runs only the failed jobs. `GitHubActionsRunner.retry` shells `github.rerun_workflow_run`
2385
2563
  (`gh run rerun [--failed]`), wrapping `github.GitHubError` as `RunnerError` exactly as `cancel`.
2386
-
2387
- The value types (all frozen dataclasses, JSON-stable via `to_data`/`from_data`):
2564
+ - **`discover`** enumerates the runner's perk runs from the **canonical remote source**,
2565
+ newest-first each runner owns its run-name/token convention. `GitHubActionsRunner.discover`
2566
+ calls `github.list_workflow_runs(workflow="perk-run.yml", limit=…)` (a single REST page, at
2567
+ most 100 runs), parses each listing's rendered run-name via `parse_run_name`, **skips**
2568
+ unparseable titles and `stage == SMOKE_STAGE` (`"smoke"` — its canonical home is `runner.py`),
2569
+ and reconstructs a `RunHandle` per run (`run_ref` = the GHA numeric id, `runner` = the routed
2570
+ ref, `kind = "github-actions"`). Wraps `GitHubError` as `RunnerError` like the other ops. The
2571
+ thin orchestration seam above it is `perk/run/discovery.py` (`discover_runs` /
2572
+ `find_discovered_run`) — a sibling module because `cache` imports `runner`.
2573
+
2574
+ The value types: `RunHandle` is a frozen `@dataclass` whose JSON boundary is `RunHandleModel`
2575
+ (`LenientParseModel`), JSON-stable via `model_dump(mode="json")`/`model_validate` on that boundary
2576
+ model; `RunObservation` stays a frozen dataclass; the dispatch record's domain object is `Dispatch`
2577
+ with `DispatchModel` (`LenientParseModel`) as the on-disk read boundary (below):
2388
2578
 
2389
2579
  - **`RunHandle`** — `runner` (the routed ref, `""` ⇒ default), `kind` (`"github-actions"`),
2390
2580
  `run_ref` (the runner-native run id — GitHub Actions' numeric id as a string), `url`. Stored
@@ -2392,13 +2582,34 @@ The value types (all frozen dataclasses, JSON-stable via `to_data`/`from_data`):
2392
2582
  `run_id` is the canonical, runner-agnostic correlation key; `run_ref` is the runner-side handle.
2393
2583
  - **`RunObservation`** — `status` (`"queued"|"in_progress"|"completed"|"unknown"`), `conclusion`
2394
2584
  (`"success"|"failure"|"cancelled"|…|None`), `url`.
2395
- - **`DispatchRecord`** — the durable linkage (below).
2396
-
2397
- ### The dispatch record (the supervisor's correlation source)
2398
-
2399
- `DispatchRecord` is persisted at **`.pi/workflow/scratch/runs/<run_id>/dispatch.json`** (the run's
2585
+ - **`ParsedRunName`** — `(stage, plan_id, run_id)`, the three fields the managed run-name embeds;
2586
+ `parse_run_name(title)` recovers them (`None` for a non-matching title or a non-ULID token).
2587
+ - **`DiscoveredRun`** `run_id`, `stage`, `plan_id` (the parsed run-name fields),
2588
+ `dispatched_at` (the run's `created_at` — the discovery-side dispatch time), `status`,
2589
+ `conclusion`, `handle` (a reconstructed `RunHandle`).
2590
+ - **`Dispatch`** — the durable linkage (below); its nested `plan_ref` (the unified `plan.PlanRef`,
2591
+ boundary `PlanRefModel`) and `run_handle` (a `RunHandle`, boundary `RunHandleModel`) are validated
2592
+ at the on-disk read boundary via `DispatchModel`.
2593
+
2594
+ ### The run-name: the canonical remote-run existence record
2595
+
2596
+ The managed workflow renders `run-name: "perk {stage} · plan #{plan} · {run_id}"` — the run title
2597
+ carries the stage, the plan id, and the perk `run_id` (a ULID). That rendered title, enumerable
2598
+ via the GHA run listing, **is the canonical record that a remote run exists**: any machine can
2599
+ reconstruct `run_id`/`stage`/`plan_id` + a `RunHandle` from it with zero local state. The
2600
+ workflow template (`workflow_artifacts.PERK_RUN_WORKFLOW`) and `parse_run_name` are **pinned to
2601
+ each other** (a template↔parser lockstep test renders the template and asserts the parser
2602
+ recovers the inputs) — a run-name change must ripple both.
2603
+
2604
+ ### The dispatch record (the local cache/correlation accelerator)
2605
+
2606
+ The `Dispatch` record is persisted at **`.perk/workflow/scratch/runs/<run_id>/dispatch.json`** (the run's
2400
2607
  scratch dir — `perk init` already creates `scratch/runs/` and `.gitignore` already excludes
2401
- `/.pi/workflow/scratch/`, so no layout/gitignore change). Shape:
2608
+ `/.perk/workflow/scratch/`, so no layout/gitignore change). It is a **cache**: for a successfully
2609
+ triggered run it accelerates/enriches what discovery can reconstruct (precise `dispatched_at`,
2610
+ the plan `url` + `objective_id` correlation, the routed `runner` ref) — and it is the **only
2611
+ durable trace of failed/never-triggered dispatches** (a run that never started has no run-name to
2612
+ discover). Shape:
2402
2613
 
2403
2614
  ```jsonc
2404
2615
  { "run_id": "<ULID>", // perk's canonical correlation key (authoritative on write)
@@ -2408,15 +2619,15 @@ scratch dir — `perk init` already creates `scratch/runs/` and `.gitignore` alr
2408
2619
  "kind": "github-actions",
2409
2620
  "status": "dispatching" | "dispatched" | "failed",
2410
2621
  "dispatched_at": "<ISO-8601 UTC>",
2411
- "run_handle": { /* RunHandle.to_data() */ } | null,
2622
+ "run_handle": { /* RunHandle model_dump */ } | null,
2412
2623
  "error": "<string>" | null }
2413
2624
  ```
2414
2625
 
2415
- The supervisor (Node 3.1) enumerates `scratch/runs/*/dispatch.json` to correlate
2416
- `run_id plan PR` (the `perk workflow run list` read surface, §8.17); that enumeration is its
2417
- work, not this node's. A **failed** record is kept
2626
+ The supervisor read surface (`perk workflow run list`, §8.17) merges the canonical discovery with
2627
+ these records (the record enriches a discovered run; a discovered run needs no record). A
2628
+ **failed** record is kept
2418
2629
  (not deleted) for that visibility — until the §8.1 age rule reclaims it. GC of dispatch records
2419
- rides the existing `.pi/workflow/` GC story (§8.1): records live *inside* `scratch/runs/<run_id>/`
2630
+ rides the existing `.perk/workflow/` GC story (§8.1): records live *inside* `scratch/runs/<run_id>/`
2420
2631
  and so are pruned wholesale with the run dir by `perk state prune` / the `cache-gc` check.
2421
2632
 
2422
2633
  ### Persist-then-trigger + read-back-verify (the establish-before-consume gate)
@@ -2430,7 +2641,7 @@ and so are pruned wholesale with the run dir by `perk state prune` / the `cache-
2430
2641
  silent).
2431
2642
  4. **`--dry-run` ⇒ a side-effect-free dispatch preview** (`success:true`, `dry_run:true`, an
2432
2643
  `inputs` preview; **no** persist, **no** trigger) — mirroring the local dry-run.
2433
- 5. **Persist** the `DispatchRecord` (`status:"dispatching"`) via `cache.write_dispatch`, then
2644
+ 5. **Persist** the `cache.DispatchCache` record (`status:"dispatching"`) via `cache.write_dispatch`, then
2434
2645
  **read it back** and assert `run_id` + `plan_ref.pr_id` round-tripped; a mismatch raises a
2435
2646
  **hard** `UserFacingCliError(dispatch_state_unverified)` (never a silent `pass`).
2436
2647
  6. **Trigger** via the selected runner's `dispatch`. On `RunnerError`/`GitHubError`: rewrite the
@@ -2486,8 +2697,17 @@ so `init` writes them and `doctor` verifies/repairs them through the one shared
2486
2697
  `drive` job validates required secrets — it fails fast when `PERK_GH_PAT` is missing **and** when
2487
2698
  **both** `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are empty (pre-empting the worker's late
2488
2699
  `no_model`) — checks out the plan branch (`plan-<plan>`), runs the composite setup, then `perk
2489
- run-worker`. An opt-out repo variable `PERK_ENABLED=false` disables the job without removing the
2490
- file. **Auth model:** checkout + push use the `PERK_GH_PAT` PAT, **not** `github.token` a
2700
+ run-worker`. The plan-branch checkout is **fetch-or-create**: when `origin/plan-<plan>` exists it
2701
+ is checked out and hard-reset to the remote tip; when it does not (a fresh, never-implemented
2702
+ plan — a remote dispatch positions nothing, §8.13), the step creates `plan-<plan>` from
2703
+ `origin/<base>` with a `::notice::`, so a fresh-plan remote `implement` needs no pre-existing
2704
+ branch. A final **`Upload run diagnostics`** step (`actions/upload-artifact@v4`) uploads
2705
+ `.perk/workflow/scratch/runs/<run_id>/` — the §8.12 durable run-event stream (`events.ndjson`
2706
+ and friends), which is otherwise written into the runner's checkout and lost at teardown — as
2707
+ artifact `perk-run-<run_id>` for **every real run, pass or fail**
2708
+ (`if: always() && inputs.smoke != 'true'`, `if-no-files-found: ignore`; smoke runs write nothing
2709
+ and upload nothing). An opt-out repo variable `PERK_ENABLED=false` disables the job without
2710
+ removing the file. **Auth model:** checkout + push use the `PERK_GH_PAT` PAT, **not** `github.token` — a
2491
2711
  PAT-pushed commit triggers downstream CI (the implement drive commits + `submit` pushes);
2492
2712
  `GITHUB_TOKEN`-pushed commits do not. This is a stated decision Node 2.4 inherited (the
2493
2713
  `runner-workflow-permissions` check is advisory `info` because of this PAT-push model — §8.16).
@@ -2667,10 +2887,10 @@ managed-artifact-present check and the live-spawn CI smoke. Node 2.4 adds checks
2667
2887
  ## §8.17 · The supervisor read surface (`perk workflow run list`, Node 3.1)
2668
2888
 
2669
2889
  The first command in the `perk workflow run` group: a deterministic, **read-only** supervisor
2670
- surface that enumerates the durable dispatch records (§8.13) and correlates each
2671
- `run_id ↔ plan ↔ PR`, overlaying live GitHub run state. It mutates nothing (no GitHub writes, no
2672
- `.pi/workflow/` writes). `cancel`/`retry` (the `run` subgroup's mutating siblings) **shipped** in
2673
- Node 3.2 — see §8.18.
2890
+ surface that enumerates runs from the **canonical GHA discovery** (§8.13's run-name record),
2891
+ merged with the local dispatch-record cache, correlating each `run_id ↔ plan ↔ PR`. It mutates
2892
+ nothing (no GitHub writes, no `.perk/workflow/` writes). `cancel`/`retry` (the `run` subgroup's
2893
+ mutating siblings) **shipped** in Node 3.2 — see §8.18.
2674
2894
 
2675
2895
  ### Command surface (`perk/cli/commands/workflow_cmd.py`)
2676
2896
 
@@ -2678,32 +2898,49 @@ Node 3.2 — see §8.18.
2678
2898
  group (alias `wf`) holds the `run` subgroup so Node 3.2 extends the same subgroup.
2679
2899
  - A dev/CI/supervisor surface (like `perk objective`/`perk state`), **not** an agent affordance.
2680
2900
  - `--json` → a stable machine report on **stdout**; the human table → **stderr** (the cli-vs-pi
2681
- §3.2 split). `--no-refresh` skips the live GitHub overlay; `--limit N` (default 50) caps the
2682
- newest-first list.
2683
-
2684
- ### Source of truth + correlation
2685
-
2686
- - **Local records are authoritative for *which* runs exist.** `cache.list_dispatch_records(root)`
2687
- enumerates `scratch/runs/*/dispatch.json` (§8.13), newest-first by `dispatched_at` (descending;
2688
- string ISO-8601 sort). A missing/unparseable/non-object record is skipped loud-but-non-fatal
2689
- (stderr warning), never fatal a corrupt record must not break the supervisor read; an absent
2690
- `scratch/runs/` yields `[]`. GitHub is **not** enumerated for run discovery.
2691
- - **Plan block** comes straight from the record's `plan_ref` (`pr_id`, `url`) — offline-safe, always
2692
- present. Note `plan_ref.pr_id` is the **plan issue** number, not a PR number.
2693
- - **PR correlation** derives the PR through `github.get_plan(number=int(pr_id)).pr` (memoized per
2694
- `pr_id`), since the draft PR is separate from the plan issue.
2695
- - **Run state** overlays via the `Runner.observe` contract (§8.13): when the record's `run_handle`
2696
- is non-null, `runner.select_runner(record.runner).observe(RunHandle.from_data(...))` yields the
2697
- `RunObservation` (`status`/`conclusion`/`url`). A null `run_handle` (records still
2698
- `dispatching`/`failed`) no GitHub call.
2901
+ §3.2 split). `--no-refresh` skips **all** GitHub reads (the cache-only view); `--limit N`
2902
+ (default 50) caps the newest-first list (applied **after** the merge).
2903
+
2904
+ ### Source of truth + the merge
2905
+
2906
+ - **GitHub's run enumeration is the existence source.** When refreshing (the default), the command
2907
+ fetches `discovery.discover_runs(root, limit=limit)` **once** (one enumeration replaces the old
2908
+ per-record `observe` calls) and merges it with `cache.list_dispatch_records(root)` by `run_id`.
2909
+ The single-page bound applies: runs older than the newest `per_page` page surface via local
2910
+ records only. Each row carries a `source` field:
2911
+ - **`"both"`** row fields from the local record (plan `url`, `objective_id` correlation, the
2912
+ precise `dispatched_at`, `error`); the `run` block from the `DiscoveredRun`
2913
+ (`run_ref`/`url`/`status`/`conclusion`) no `observe` call.
2914
+ - **`"local"`** — failed/`dispatching` records or runs past the discovery page: the record row,
2915
+ with the per-record `observe` overlay when a handle exists (an error continuation line for
2916
+ failed records).
2917
+ - **`"discovered"`** a run this clone never dispatched, reconstructed from the parsed
2918
+ run-name: `run_id`/`stage`/plan `pr_id` from the title; `runner: ""`,
2919
+ `kind: "github-actions"`, `dispatch_status: "dispatched"` (the run exists, so the dispatch
2920
+ evidently succeeded), `dispatched_at` = the run's `created_at`, `error: null`, plan
2921
+ `url: ""`.
2922
+ Merged rows sort **newest-first** by dispatch/created time (`datetime.fromisoformat`;
2923
+ unparseable sorts last).
2924
+ - `cache.list_dispatch_records(root)` enumerates `scratch/runs/*/dispatch.json` (§8.13),
2925
+ newest-first by `dispatched_at`. A missing/unparseable/non-object record is skipped
2926
+ loud-but-non-fatal (stderr warning), never fatal — a corrupt record must not break the
2927
+ supervisor read; an absent `scratch/runs/` yields `[]`.
2928
+ - **Plan block** comes from the record's `plan_ref` (`pr_id`, `url`) when one exists; a
2929
+ discovered-only row's `pr_id` is the parsed run-name plan id. Note `pr_id` is the **plan issue**
2930
+ id, not a PR number.
2931
+ - **PR correlation** derives the PR through the resolved issue backend's
2932
+ `get_plan(issue_id=pr_id).pr` (memoized per `pr_id`), since the draft PR is separate from the
2933
+ plan issue — it works unchanged off a parsed plan id.
2699
2934
 
2700
2935
  ### Fail-soft overlay discipline
2701
2936
 
2702
- The live overlay is **best-effort**: it does **not** call `require_github`; a missing/unauthed gh
2703
- simply yields no overlay (noted once on stderr). Each per-record read is wrapped a
2937
+ Every live read is **best-effort**: the command does **not** call `require_github`. A discovery
2938
+ `RunnerError` degrades to an empty enumeration with a one-line stderr notethe local-cache view
2939
+ (exactly the old behavior). Each per-record read on a `local` row is wrapped — a
2704
2940
  `runner.RunnerError` degrades the `run` block to `null`; a `github.GitHubError` degrades the `pr`
2705
2941
  block to `null` — with a one-line stderr note, never raising and never changing the exit code (this
2706
- is a read surface, not a gate). `--no-refresh` forces `pr`/`run` to `null` with zero GitHub reads.
2942
+ is a read surface, not a gate). `--no-refresh` is the **cache-only** view: zero GitHub reads, local
2943
+ records only (every row `source: "local"`, `pr`/`run` forced `null`).
2707
2944
 
2708
2945
  ### The `--json` payload (stdout, stable)
2709
2946
 
@@ -2715,10 +2952,12 @@ is a read surface, not a gate). `--no-refresh` forces `pr`/`run` to `null` with
2715
2952
  "plan": { "pr_id": "42", "url": "https://…/issues/42" },
2716
2953
  "pr": { "number": 51, "url": "https://…/pull/51", "state": "OPEN" } | null,
2717
2954
  "run": { "run_ref": "1234567", "url": "https://…/actions/runs/1234567",
2718
- "status": "completed", "conclusion": "success" } | null } ] }
2955
+ "status": "completed", "conclusion": "success" } | null,
2956
+ "source": "local" | "discovered" | "both" } ] }
2719
2957
  ```
2720
2958
 
2721
2959
  `refreshed = not no_refresh`; `pr`/`run` are `null` under `--no-refresh` or a failed/empty overlay.
2960
+ The top-level shape is unchanged from the pre-discovery surface; `source` is the per-row addition.
2722
2961
  `success` is always `true` for a successful enumeration (even zero runs); only `require_repo` failing
2723
2962
  (`not_a_repo`) routes through `_fail` (exit 2). No other error type is introduced.
2724
2963
 
@@ -2727,7 +2966,9 @@ is a read surface, not a gate). `--no-refresh` forces `pr`/`run` to `null` with
2727
2966
  Plain, manually-aligned, newest-first columns
2728
2967
  `RUN_ID STAGE DISPATCH RUN CONCLUSION PLAN PR AGE`. The full `run_id` (the supervisor copies
2729
2968
  it into Node 3.2's `cancel`/`retry`) is never truncated; the overlay columns show `-` when not
2730
- refreshed/unresolved; `AGE` is a compact relative age from `dispatched_at`. A `failed` record's
2969
+ refreshed/unresolved; `AGE` is a compact relative age from `dispatched_at`. Columns are unchanged
2970
+ by the merge — a discovered-only row simply renders what it knows (plan column still `#<pr_id>`).
2971
+ A `failed` record's
2731
2972
  `error` is surfaced on an indented continuation line (the §8.13 "failed records kept for visibility"
2732
2973
  rule). Empty state prints `No dispatched runs found`.
2733
2974
 
@@ -2747,13 +2988,25 @@ group/subgroup aliases (`wf`, `run`) apply; the commands themselves carry no ali
2747
2988
 
2748
2989
  `<RUN_ID>` is the **perk `run_id`** — the never-truncated `RUN_ID` the supervisor copies from
2749
2990
  `list` (§8.17). After `require_repo` + `require_github` (both commands *do* require auth — unlike
2750
- fail-soft `list`), the shared `_resolve_target` helper resolves it:
2751
-
2752
- 1. `record = cache.read_dispatch(root, run_id)`; `None` ⇒ `run_not_found` (exit 1).
2753
- 2. `record.run_handle` falsy (still `dispatching`/`failed`, never triggered) ⇒ `run_not_dispatched`
2754
- (exit 1) nothing to act on.
2755
- 3. otherwise reconstruct `RunHandle.from_data(...)` + `select_runner(record.runner)`; the runner op
2756
- acts on the runner-native `run_ref`.
2991
+ fail-soft `list`), the shared `resolve_target` helper resolves it via a **two-rung ladder** (the
2992
+ local record is the cache accelerator; discovery is the canonical source — so **any machine** can
2993
+ control a run it never dispatched):
2994
+
2995
+ 1. `record = cache.read_dispatch(root, run_id)`; a record **with** a `run_handle` ⇒ use it +
2996
+ `select_runner(record.runner)`.
2997
+ 2. Otherwise (no record, **or** a handle-less record whose §8.13 finalize write-back never
2998
+ landed): `discovery.find_discovered_run(root, run_id)` — exact match on the parsed `run_id`
2999
+ token. Found ⇒ use the reconstructed `DiscoveredRun.handle`; route
3000
+ `select_runner(record.runner)` when a local record exists, else `select_runner(handle.runner)`
3001
+ (the default). A discovery `RunnerError` degrades fail-soft (one stderr note) into the miss
3002
+ arm below.
3003
+ 3. Both missed ⇒ `run_not_found` (exit 1) when there was **no local record** — the message names
3004
+ both misses (no local dispatch record, and not among the newest discovered `perk-run.yml`
3005
+ runs); `run_not_dispatched` (exit 1) when a handle-less local record exists and discovery
3006
+ found nothing (the dispatch really never triggered).
3007
+
3008
+ The resolved tuple's `record` slot is nullable (`Dispatch | None` — `None` for a discovered-only
3009
+ run); `cancel`/`retry` act only on the handle. The error vocabulary is unchanged — no new type.
2757
3010
 
2758
3011
  ### No local mutation, no pre-gate (Corrections)
2759
3012
 
@@ -2762,7 +3015,7 @@ fail-soft `list`), the shared `_resolve_target` helper resolves it:
2762
3015
  `run_id ↔ plan ↔ PR` linkage stay valid. **No new ULID, no `cache.write_dispatch`.**
2763
3016
  - **Neither command mutates the dispatch record.** The record's `status` is the *dispatch-attempt*
2764
3017
  lifecycle; live run state is observed via `Runner.observe` (surfaced by `list`'s overlay). No
2765
- `.pi/workflow/` writes in this node.
3018
+ `.perk/workflow/` writes in this node.
2766
3019
  - **No pre-flight run-state gating.** The commands do not `observe` to decide cancellability/
2767
3020
  retryability — they pass through to gh and surface gh's own error (e.g. "cannot cancel a
2768
3021
  completed run") as a clean `cancel_failed`/`retry_failed`.
@@ -2817,8 +3070,11 @@ Flow: `require_repo` + `require_github`; run `workflow_checks` (rendered like `c
2817
3070
  verify-by-discovery would raise). PAT/model **warns do not block** — the live run is what verifies
2818
3071
  them. Then `dispatch_smoke` triggers the managed workflow **directly** (`trigger_workflow` with
2819
3072
  `stage=smoke`, `plan=smoke`, `smoke="true"`, ref/`base` = `default_branch` with a `"main"` fallback),
2820
- verifying by discovery on the minted `run_id`. It writes **no** `DispatchRecord` and creates **no**
2821
- GitHub artifacts (no branch/PR/issue), so `perk workflow run list` (§8.17) is unaffected and the smoke
3073
+ verifying by discovery on the minted `run_id`. It writes **no** dispatch record and creates **no**
3074
+ GitHub artifacts (no branch/PR/issue), so `perk workflow run list` (§8.17) is unaffected the
3075
+ no-record half of that claim covers the local cache, and the discovery half rests on the
3076
+ `stage == "smoke"` filter in `Runner.discover` (§8.13), which drops smoke runs from the canonical
3077
+ enumeration — and the smoke
2822
3078
  stays a pure doctor diagnostic. Without `--wait`: print the run URL, **exit 0**. With `--wait`:
2823
3079
  `poll_smoke` loops to `completed` or `POLL_TIMEOUT_S` (600s, every `POLL_INTERVAL_S`=15s) —
2824
3080
  `success` → exit 0; any other conclusion → exit 1; **timeout → `cancel_smoke` (best-effort
@@ -2851,7 +3107,7 @@ Error types + exits: `not_a_repo` → 2; `github_unauthed`, `runner_disabled`, `
2851
3107
 
2852
3108
  ## §8.20 · The capstone supervisor loop (`perk objective run`, Node 3.4)
2853
3109
 
2854
- The **scheduler** on top of the §8.13 dispatch-record substrate and the §8.17/§8.18 read/control
3110
+ The **scheduler** on top of the §8.13 runner/discovery substrate and the §8.17/§8.18 read/control
2855
3111
  siblings: a **deterministic, no-agentic-reasoning** supervisor that advances an active objective's
2856
3112
  backlog as far as is autonomously safe, then pauses at the human land gate. `perk objective run
2857
3113
  <NUMBER>` (alias `obj r`) is a supervisor surface (cli-vs-pi §3.2): `--json` → stdout, human text →
@@ -2887,10 +3143,19 @@ into interactive pi and never returns, which would destroy the loop.
2887
3143
  `cache.list_dispatch_records`, keep records whose `plan_ref.objective_id` canonicalizes
2888
3144
  (`str(...).lstrip("#")`) to NUMBER, sum each `run_report.read_outcome` `budget`
2889
3145
  (`turns`/`tokens`/`elapsed_ms`, missing ⇒ 0) → `{runs, turns, tokens, elapsed_ms}`. **Report-only:
2890
- no limits, no thresholds, no `budget_exhausted`.**
2891
- 4. **Active-run gate** (skipped under `--dry-run`): an objective run is in-flight when a kept record
2892
- has a `run_handle` and a live `observe` returns `queued`/`in_progress` (newest-first; observe
2893
- fail-soft treat as not-in-flight). Not `--wait` `awaiting_run`, exit 0. `--wait` → poll to
3146
+ no limits, no thresholds, no `budget_exhausted`.** The budget stays **local-cache-scoped by
3147
+ design**: run outcomes are local scratch artifacts, not reconstructable from the GHA
3148
+ enumeration a fresh clone undercounts (stated, not silently implied).
3149
+ 4. **Active-run gate** (skipped under `--dry-run`) **discovery-first**, so the gate works from a
3150
+ fresh clone (no double-dispatch on a machine that never dispatched): one
3151
+ `discovery.discover_runs(root, limit=100)` enumeration (replacing per-record `observe`s),
3152
+ keeping `queued`/`in_progress` runs whose parsed plan id (`#`-stripped) matches one of the
3153
+ objective's **node plan backlinks** (`node.pr`, computed from the already-fetched objective
3154
+ state — dispatch always happens after plan save, so the backlink exists for any dispatched
3155
+ node plan); the newest match gates. On a discovery `RunnerError`/`GitHubError`: one stderr
3156
+ note + the **legacy local-record loop** (a kept record with a `run_handle` whose fail-soft
3157
+ `observe` returns `queued`/`in_progress`, newest-first) — offline/degraded behavior unchanged.
3158
+ Not `--wait` → `awaiting_run`, exit 0. `--wait` → poll the (possibly reconstructed) handle to
2894
3159
  `completed` (or timeout → `awaiting_run` + `timed_out:true`, exit 0), then **re-fetch the
2895
3160
  objective state + rebuild the graph** (the settled run may have advanced GitHub) and re-evaluate
2896
3161
  selection once.
@@ -2903,27 +3168,28 @@ into interactive pi and never returns, which would destroy the loop.
2903
3168
  | `plannable` | a resumable node is ready | `plan_required` | emit node + remediation `perk objective-plan <NUMBER> --node <id>` (the supervisor cannot plan — `objective-plan` is `cold_remote:false`) |
2904
3169
  | `in_flight` | a committed plan exists | (stage resolution ↓) | |
2905
3170
 
2906
- ### In-flight stage resolution (`get_plan(node.pr)` → branch on `plan_state.pr`)
3171
+ ### In-flight stage resolution (`get_plan(node.pr)` → the shared §8.37 classifier)
3172
+
3173
+ Classification delegates to `resume.resolve_next_action` (§8.37); the verdict maps onto the
3174
+ supervisor's `action` vocabulary (unchanged) and is carried verbatim in the payload's
3175
+ `next_action` field:
2907
3176
 
2908
- | `plan_state.pr` | action | dispatch? |
2909
- |-----------------|--------|-----------|
2910
- | `None` (no PR yet) | `dispatched` `stage:"implement"` | yes (remote) |
2911
- | `MERGED` | `merged_pending_reconcile` | no |
2912
- | `CLOSED` (unmerged) | `pr_closed` (needs human) | no |
2913
- | `OPEN` + `is_draft` | `ready_for_review` | **no — never re-dispatch implement** |
2914
- | `OPEN` + not draft, `needs_address` true | `dispatched` `stage:"address"` | yes (remote) |
2915
- | `OPEN` + not draft, `needs_address` false | `awaiting_review` | no |
3177
+ | §8.37 verdict | action | dispatch? |
3178
+ |---------------|--------|-----------|
3179
+ | `implement` | `dispatched` `stage:"implement"` | yes (remote) |
3180
+ | `address` | `dispatched` `stage:"address"` | yes (remote) |
3181
+ | `ready_for_review` | `ready_for_review` | **no — never re-dispatch implement** |
3182
+ | `awaiting_review` | `awaiting_review` | no |
3183
+ | `learn` | `merged_pending_reconcile` + `remediation: "perk plan resume <plan-id>"` | no (learn is local-only) |
3184
+ | `done` | `merged_pending_reconcile` | no |
3185
+ | `pr_closed` | `pr_closed` (needs human) | no |
2916
3186
 
2917
3187
  A missing `node.pr` or a `None` `get_plan` falls back to `plan_required` (defensive). A draft PR means
2918
3188
  implement is **complete** — never re-dispatch `implement` from a draft.
2919
3189
 
2920
- ### The `needs_address` predicate (pure, offline-testable)
3190
+ ### The `needs_address` predicate
2921
3191
 
2922
- `needs_address(feedback: PrFeedback) -> bool` is **True** when either any `review_thread.is_resolved is
2923
- False`, **or** the **latest review per author** is `CHANGES_REQUESTED`. "Latest per author" = the
2924
- `Review` with the max `submitted_at` (ISO-8601 string compare; `None` sorts oldest). A `COMMENTED`/
2925
- `APPROVED` latest review does **not** trigger address; `discussion_comments` are never address triggers
2926
- (conversation, not change requests).
3192
+ Moved to the shared classifier module spec in §8.37 (canonical import path `perk.run.resume`).
2927
3193
 
2928
3194
  ### Remote dispatch mechanics
2929
3195
 
@@ -2949,9 +3215,10 @@ timeout is **inconclusive, not unhealthy** (`awaiting_run` + `timed_out:true`, e
2949
3215
  "budget": { "runs": 0, "turns": 0, "tokens": 0, "elapsed_ms": 0 },
2950
3216
  "action": "dispatched" | "ready_for_review" | "awaiting_review" | "awaiting_run"
2951
3217
  | "plan_required" | "blocked" | "completed" | "merged_pending_reconcile" | "pr_closed",
3218
+ "next_action": "<§8.37 verdict>" | null, // set on every in-flight arm
2952
3219
  "node": "<id>" | null, "stage": "implement" | "address" | null,
2953
3220
  "run_id": "<ULID>" | null, // present on dispatched
2954
- "remediation": "<cmd>" | null, // present on plan_required
3221
+ "remediation": "<cmd>" | null, // present on plan_required AND the merged-learn-pending arm
2955
3222
  "closed": false, // present on completed (+ "audit": [{node,status,pr}, …])
2956
3223
  "timed_out": false, // present on awaiting_run under --wait
2957
3224
  "dry_run": false }
@@ -2984,11 +3251,11 @@ team = "ENG" # the Linear team key — required when backend = "linear"
2984
3251
  ```
2985
3252
 
2986
3253
  **Committed-only read, both planes.** The selection (`backend` AND `team`) is read from committed
2987
- `.pi/perk.toml` **only** — never the `perk.local.toml` overlay (Python:
3254
+ `.perk/config.toml` **only** — never the `local.toml` overlay (Python:
2988
3255
  `load_committed_issues_backend` / `load_committed_issues_team`; TS: `resolveIssueBackendId` reads
2989
3256
  only the committed file). Rationale: the backend decides where canonical durable state
2990
3257
  (plan/learn/objective issues) is *written*; a per-user override would fragment the canonical
2991
- store. **`LINEAR_API_KEY` lives in the environment or the gitignored `.pi/perk.local.toml`
3258
+ store. **`LINEAR_API_KEY` lives in the environment or the gitignored `.perk/local.toml`
2992
3259
  `[linear] api_key`** (an exported env var wins over the config) — **never** in a committed file.
2993
3260
  The config read is local-file-only (`config.load_local_linear_api_key`, the inverse of the
2994
3261
  `load_committed_*` readers; fail-soft on malformed TOML — returns `None`, never raised). Two seams
@@ -2999,7 +3266,7 @@ inherit the session env) authenticate. The local file is read from the **main ch
2999
3266
  (the env dict is built before `os.chdir(worktree)`); because it is gitignored it is never copied
3000
3267
  into the linked worktree, so the env-seed is precisely the bridge that carries the key into the
3001
3268
  worktree-resident session and its cold-door workers — those consumers read it from the inherited
3002
- env, never from a `perk.local.toml` in the worktree. This is a deliberate, documented relaxation of the
3269
+ env, never from a `local.toml` in the worktree. This is a deliberate, documented relaxation of the
3003
3270
  "secrets in the environment only" rule: the secret may live in the gitignored local file, never a
3004
3271
  version-controlled one. **Python-plane-only** — the TS plane reads no Linear key, so there is no
3005
3272
  cross-plane TS mirror (the `launch_stage` env-seed is what carries the key into the TS session).
@@ -3017,7 +3284,7 @@ Raising (not falling back) is deliberate: a silent fallback would write canonica
3017
3284
  wrong tracker. `resolve_issue_backend(repo_root)` resolves the id and constructs the matching
3018
3285
  backend; every issue-tier consumer already routes `IssueBackendError` through its existing error
3019
3286
  boundary. The **linear construction arm** raises a typed `IssueBackendError` when either
3020
- requirement is missing: no committed `[issues] team` → remediation pointing at `.pi/perk.toml`;
3287
+ requirement is missing: no committed `[issues] team` → remediation pointing at `.perk/config.toml`;
3021
3288
  no/blank `LINEAR_API_KEY` → the hinted message from `client_from_env`. Construction is lazy (no
3022
3289
  network): the team key is bound and resolved to its UUID on first use.
3023
3290
 
@@ -3042,8 +3309,8 @@ user-owned config):
3042
3309
  | --- | --- | --- |
3043
3310
  | absent / `"github"` | `ok` | `issues backend: github` |
3044
3311
  | `"linear"` + committed `team` | `ok` | `issues backend: linear (team <key>)` |
3045
- | `"linear"` without `team` | `fail` | offline-decidable; remediate: set `[issues] team` in `.pi/perk.toml` |
3046
- | anything else | `fail` | `unknown issue backend '<x>'`; fix `.pi/perk.toml [issues]` |
3312
+ | `"linear"` without `team` | `fail` | offline-decidable; remediate: set `[issues] team` in `.perk/config.toml` |
3313
+ | anything else | `fail` | `unknown issue backend '<x>'`; fix `.perk/config.toml [issues]` |
3047
3314
  | malformed TOML | `warn` | selection not evaluated — defers to the config check (mirrors `providers`) |
3048
3315
 
3049
3316
  `fail` (not `warn`) for a bad selection is deliberate: unlike `[providers]` (graceful fallback →
@@ -3058,7 +3325,7 @@ init/doctor probe — report-shaped, never raises; phases short-circuit auth →
3058
3325
 
3059
3326
  - `linear-auth` — ok: `authenticated as <user>`; failure (or missing `LINEAR_API_KEY`): warn,
3060
3327
  remediation "export LINEAR_API_KEY (create a personal API key at linear.app Settings →
3061
- Security & access), or set [linear] api_key in .pi/perk.local.toml".
3328
+ Security & access), or set [linear] api_key in .perk/local.toml".
3062
3329
  - `linear-team` — ok: `team <key> found`; failure: warn with the error detail.
3063
3330
  - `linear-labels` — all five perk labels present (`perk:plan`, `perk:learn`, `perk:consolidated`,
3064
3331
  `perk:objective`, `perk:objective-node`): ok; otherwise warn listing the missing names,
@@ -3139,7 +3406,7 @@ Linear **Project URL** + the read-only `linear_get_issue` / `linear_list_comment
3139
3406
  `open <url>` fallback when the url is known; the indirect `run \`perk objective show <id>\` for its
3140
3407
  URL` form when it is not); `github` (and any non-linear) → `""` (the `perk objective show` step
3141
3408
  already covers GitHub — no churn). The warm plane resolves the backend from
3142
- `resolveIssueBackendId(ctx.cwd)` (committed `.pi/perk.toml` — authoritative since cross-backend
3409
+ `resolveIssueBackendId(ctx.cwd)` (committed `.perk/config.toml` — authoritative since cross-backend
3143
3410
  objectives are unsupported by policy) and fetches the Project URL via `perk objective show <id>
3144
3411
  --json` **only for `linear`** (github needs no clause → no fetch), **fail-open** (any fetch
3145
3412
  failure / missing url → the indirect form). The cold plane reads `store.backend_id` + `state.url`
@@ -3198,7 +3465,7 @@ there is no TS twin).
3198
3465
  personal-key requests keep the plain header byte-identically). Environment only — never
3199
3466
  config/committed files. No new config keys, no doctor check — the live smoke gate
3200
3467
  is the verification surface.
3201
- - **The file**: `.pi/workflow/agent-session.json` (cache tier, §8.1) —
3468
+ - **The file**: `.perk/workflow/agent-session.json` (cache tier, §8.1) —
3202
3469
  `{"session_id": str, "issue": str, "url": str | null}`, written at session create
3203
3470
  (`cache.write_agent_session`/`read_agent_session`). Absent at a follow-up hook → fail-soft
3204
3471
  skip with a stderr note (known consequence: a remote-run-created session is invisible to a
@@ -3265,6 +3532,33 @@ one-stop current shape.
3265
3532
  explicit values win outright (even one — never mixed), fail-open (a malformed carrier never
3266
3533
  blocks a save). `consumed_learn` rides the cold handoff (`_consumed_learn_from_handoff`).
3267
3534
 
3535
+ - **The implement-here exit (the no-save path).** A sanctioned, HUMAN-ONLY exit from plan
3536
+ authoring for changes too small to warrant the full lifecycle: the read-only gate comes off
3537
+ **without** an issue-backend save, and the model is instructed to implement the reviewed draft
3538
+ directly in the current session/checkout — edits only; git gestures (commit/branch/push) stay
3539
+ with the human. Two surfaces (`extension/factories/implementHere.ts` + the plan arm of
3540
+ `planReview.ts`), both machine-unreachable (no model tool exists — a verdict select or a
3541
+ human-run command; the model can never choose to skip the backend on its own):
3542
+ 1. the **4th first-party verdict** — the plan arm's `ctx.ui.select` offers
3543
+ "Implement here — no issue saved" between approve and deny; selecting it routes (before the
3544
+ generic outcome mapper, mirroring approved-first) through the `implementHereExit` seam (the
3545
+ gate-exit-WITHOUT-save sibling of `approvalSave`'s D1a arm) into a **non-terminating** tool
3546
+ result carrying the implement-now guidance — the model continues the turn and implements
3547
+ immediately. When the human edited the plan during review, the final reviewed bytes are
3548
+ inlined in that guidance (the draft write-back already happened pre-verdict).
3549
+ 2. the **`/implement-here` command** — the universal manual gesture: exits the gate through the
3550
+ same seam and injects the guidance (idle → an immediate turn; streaming → a followUp). With
3551
+ the gate already off it warns and does nothing (its meaning is *exiting plan mode without
3552
+ saving*).
3553
+
3554
+ Semantics: **no issue, no `cache.plan-ref`, no branch** — the PR-lifecycle doors
3555
+ (`/submit`/`/address`/`/land`) stay inapplicable; the plan-draft artifact is left intact, so
3556
+ `/plan-save` can still create the canonical issue afterwards. **Objective-node carve-out**: in a
3557
+ node-claimed planning session (`objective_node_claim` present) the verdict is suppressed (back
3558
+ to the 3-option select) and the command refuses — a node-linked plan must always save (the node
3559
+ advance and backlink depend on it). **Plannotator note**: the browser review's envelope returns
3560
+ only approve/deny — the verdict is unreachable there; the command is the surface.
3561
+
3268
3562
  §8.10's per-node Status blocks remain the historical record of how each piece landed; this section
3269
3563
  is the consolidated **current** contract.
3270
3564
 
@@ -3716,7 +4010,7 @@ a `test_engagement.py` byte-stability assert).
3716
4010
  **Cold-only injection (no warm door).** `replan` is a dedicated cold door (no registry stage, no
3717
4011
  `objectivePlan.ts`-style warm half). It reads engagement up front — **including on `--dry-run`**,
3718
4012
  which materializes the real artifact (replan's dry run is not offline) — and **appends** the
3719
- rendered block to the materialized `.pi/workflow/scratch/replan-<id>.md` after `</untrusted_plan>`
4013
+ rendered block to the materialized `.perk/workflow/scratch/replan-<id>.md` after `</untrusted_plan>`
3720
4014
  (the scratch-file-native home, vs §8.26's inline-seed injection — replan centers on the scratch
3721
4015
  file the session `read`s). The seed's step 1 points at the block only when present (empty → seed
3722
4016
  byte-unchanged).
@@ -3969,204 +4263,875 @@ command/verb).
3969
4263
 
3970
4264
  Two cross-plane **render seams** load prompt templates by explicit `name` (root-relative under
3971
4265
  `prompts/`, located via the node-1.1 resolvers `prompts_dir()` / `promptsDir()`) and render them
3972
- with a small, fixed feature surface — `{{ var }}` substitution, `{% include %}`, and (as of
3973
- Node 2.4) `{% if %}`/`{% elif %}`/`{% else %}` conditionals with string equality (`==`) and
3974
- `or`/`not` (no loops yet). Every later node in this objective rides on this mechanism; this node
3975
- proves it end-to-end on trivial fixture templates only no real prompt content moves here.
4266
+ with a small, fixed feature surface — `{{ var }}` substitution, `{% include %}`, and
4267
+ `{% if %}`/`{% elif %}`/`{% else %}` conditionals with string equality (`==`) and `and`/`or`/`not`
4268
+ (no loops). This surface is **frozen** as the canonical mini-jinja subset, cataloged exactly in
4269
+ "The frozen template-grammar subset" subsection below and enforced by a cross-plane conformance
4270
+ guard. Every later node in this objective rides on this mechanism.
4271
+
4272
+ **A template may be single-plane.** Two render seams exist (jinja2 on Python, vendored mini-jinja
4273
+ on TS), but a given *template* may be consumed in production by only one plane — e.g. a
4274
+ warm-door-only or cold-door-only injected seed/guidance prompt. `prompts/` is the canonical home
4275
+ for **every** externalized prompt string, single- or cross-plane; `live.yaml` renders **every**
4276
+ template on **both** engines and asserts byte-equality regardless of the production consumer, so a
4277
+ single-plane prompt still rides cross-engine parity for free (a portability guarantee that costs
4278
+ nothing, the subset being shared).
3976
4279
 
3977
4280
  - **Python:** `perk/prompts.py::render(name, variables)` over a module-level jinja2 `Environment`.
3978
- - **TS:** `extension/substrate/prompts.ts::render(name, vars)` over a module-level nunjucks
3979
- `Environment`. This module is imported **only** by its test in this node (no real prompt to render
3980
- until Phase 2; wiring it into `extension/index.ts` would be dead code).
4281
+ - **TS:** `extension/substrate/prompts.ts::render(name, vars)`, delegating to the vendored,
4282
+ zero-dependency `extension/substrate/miniJinja.ts` renderer (the frozen-subset engine that
4283
+ replaced nunjucks). The seam is LIVE on both planes: `render` is imported by the worker, the
4284
+ learn/address/learnDocs/lifecycleGates doors, the warm pr-review / submit / objective-save /
4285
+ objective-reconcile doors, the objective-plan factory, and — on the Python side — the cold
4286
+ plan-from / replan / objective-author / objective-replan doors.
3981
4287
 
3982
4288
  **Fail loudly on a missing var.** jinja2 uses `StrictUndefined` (raises `jinja2.UndefinedError`);
3983
- nunjucks uses `throwOnUndefined: true`. A missing required variable is an error, never an empty
3984
- string.
3985
-
3986
- **jinja2 is the reference engine.** The committed golden files under `prompts/_fixtures/golden/`
3987
- ARE jinja2's rendered output. The golden harness `prompts/_fixtures/cases.yaml` listing
3988
- `(template, vars, golden)` cases with committed golden-output files is the byte-parity proof:
3989
- `tests/test_prompts.py` asserts `jinja2-render == golden`, and
3990
- `extension/substrate/prompts.test.ts` asserts `nunjucks-render == golden`. The frozen subset is
3991
- "the jinja subset"; the future vendored TS renderer (node 4.2) must reproduce these same golden
3992
- bytes. Golden outputs are **separate committed files** (not inline multiline YAML) because the TS
3993
- harness reads `cases.yaml` through the vendored `miniYaml` reader, which throws on `|`/`>` block
3994
- scalars; fixture vars are strings only in this node (sidestepping jinja2-vs-nunjucks non-string
3995
- rendering divergence).
4289
+ the vendored `miniJinja` renderer matches it a referenced name that is **absent OR non-string**
4290
+ throws (`perk mini-jinja: …`). This deliberately tightens nunjucks's looser `throwOnUndefined` (and
4291
+ forbids a `String(value)` divergence): the render contract is string-only, so a missing required
4292
+ variable — or a boolean/number/null — is an error, never an empty or coerced string. **The
4293
+ string-only contract is enforced on BOTH planes:** the TS renderer throws lazily on a referenced
4294
+ non-string; `perk/prompts.py::render` validates the whole var map eagerly (raising `TypeError`)
4295
+ before delegating to jinja2.
4296
+
4297
+ **jinja2 is the reference engine verification is two decoupled tiers.** The cross-plane render
4298
+ seam is held in lockstep by two tiers that separate the frozen *contract* from real prompt *prose*:
4299
+
4300
+ - **Tier A contract snapshots (golden, sui generis).** `prompts/_fixtures/cases.yaml` lists
4301
+ `(template, vars, golden)` cases over a small catalog of purpose-built FIXTURE templates under
4302
+ `prompts/_fixtures/templates/`, each isolating one feature of the frozen render contract
4303
+ (variable substitution, `{% include %}`, `if`/`else`, `elif` chain, `==`/`and`/`or`/`not`,
4304
+ `trim_blocks` block-tag-on-own-line vs inline, trailing-newline preservation, no-trailing-newline
4305
+ fragment). The committed golden files under `prompts/_fixtures/golden/` ARE jinja2's rendered
4306
+ output for these fixtures; `tests/test_prompts.py` asserts `jinja2-render == golden` and
4307
+ `extension/substrate/prompts.test.ts` asserts the vendored mini-jinja render `== golden`. These
4308
+ goldens are stable — they change only when the render **contract** changes, never when a real
4309
+ prompt's prose changes. Golden outputs are **separate committed files** (not inline multiline
4310
+ YAML) because the TS harness reads `cases.yaml` through the vendored `miniYaml` reader, which
4311
+ throws on `|`/`>` block scalars.
4312
+ - **Tier B — live cross-engine equality (no goldens).** `prompts/_fixtures/live.yaml` lists every
4313
+ **real** template with representative vars and **no** `golden:` field. The Python-owned
4314
+ `tests/test_prompt_parity.py` renders each real template with jinja2 natively, shells out once to
4315
+ the dev-only node renderer `extension/testing/renderLive.ts` (which renders the same manifest with
4316
+ mini-jinja and prints a JSON array in manifest order), and asserts the two outputs are byte-equal
4317
+ per template — so editing a real prompt's prose touches **no** fixture. A coverage guard
4318
+ (`test_live_manifest_covers_every_real_template`) asserts every real template appears in
4319
+ `live.yaml`, so a newly-added prompt can't silently skip Tier B. The renderer lives under
4320
+ `extension/testing/` so it is excluded from the npm tarball yet still typechecked/linted, and is
4321
+ never picked up by `node --test` (it is not a `.test.ts`); the parity test **skips** when `node`
4322
+ is absent.
4323
+
4324
+ The frozen subset is "the jinja subset"; the vendored TS renderer reproduces jinja2's bytes for both
4325
+ tiers. Fixture and manifest vars are strings only (matching the string-only render contract, which
4326
+ also sidesteps any non-string rendering divergence); both `cases.yaml` and `live.yaml` are authored
4327
+ in the dual-parseable miniYaml subset (block maps/seqs, double-quoted strings, no `|`/`>` block
4328
+ scalars).
3996
4329
 
3997
4330
  **Environment-config parity baseline** (both engines): `autoescape` off (prompts are plain text,
3998
4331
  never HTML-escaped), `trim_blocks` **on** (as of Node 2.4) so a block tag on its own line emits no
3999
4332
  spurious newline — conditional templates keep their `{% %}` tags off the content lines while
4000
4333
  preserving the content's own indentation — `lstrip_blocks` off, and jinja2 `keep_trailing_newline`
4001
- on so jinja2 does not strip a trailing `\n` that nunjucks keeps — required for byte-parity.
4002
- (`trim_blocks` only affects block-tag templates; the only such templates are `stages/learn.md`
4003
- and the `with_include` fixture every real arm template uses `{{ var }}` only and is unaffected.)
4004
-
4005
- **Dependencies:** `jinja2` is a Python runtime dependency. `nunjucks` is a TS **runtime**
4006
- dependency (`@types/nunjucks` dev-only for typing) **until node 4.2** vendors a zero-dependency
4007
- renderer and removes it, restoring the bare-clone-loadable / zero-runtime-dependency invariant.
4008
-
4009
- **First prompt moved onto the seam — the plan-read instruction (Node 2.1).** The cross-plane
4010
- plan-read instruction (the "how do I read the saved plan" SSOT) is the first real (non-fixture)
4011
- consumer of the render seam. Its three arm templates live at
4012
- `prompts/common/plan-read/{github,linear,other}.md` one file per provider arm, no
4013
- conditionals/loops in the frozen subset. **Branching stays in code**: `perk/run/launch/prompts.py::
4014
- _plan_read_instruction` and `extension/doors/lifecycleGates.ts::planReadInstruction` keep their
4015
- `(provider, pr_id/prId, url)` signature and the same if/elif/else, each arm now a `render(...)` call
4016
- selecting its arm template (passing `{pr_id, url}`; jinja2/nunjucks ignore unused vars). The helpers
4017
- still branch on `cache.plan-ref.provider` only the **wording source** moved.
4018
-
4019
- The arm templates (and their golden files) carry **no trailing newline** — the helper returns
4020
- single-line strings embedded mid-prompt, so the render output must equal the prior literal exactly
4021
- (a deliberate departure from the fixture convention of trailing newlines). The three `plan-read-*`
4022
- golden cases in `cases.yaml` prove cross-plane byte-identity for each arm; a thin per-arm selection
4023
- test in each plane (`tests/test_worker_prompt_parity.py`, `extension/doors/lifecycleGates.test.ts`)
4024
- proves the code picks the right arm and `render()` is wired. This golden-fixture parity (plus the
4025
- selection tests) **replaces the prior dedicated substring parity** for plan-read; the
4026
- implement/learn prompt parity suites are untouched (they embed the byte-identical helper output, so
4027
- they keep passing the downstream prompts move in nodes 2.2/2.4).
4028
-
4029
- **Second prompt moved the implement primer (Node 2.2).** The implement-stage primer wording lives
4030
- at `prompts/stages/implement.md`, the second real consumer of the render seam. All three sites that
4031
- used to hand-duplicate it — cold `perk/run/launch/prompts.py::_implement_prompt`, worker
4032
- `extension/worker/worker.ts::initialPromptFor` (implement arm), and warm
4033
- `extension/doors/lifecycleGates.ts::implementHandoffPrompt` are now thin `render("stages/
4034
- implement.md", {provider, pr_id, url, read_cmd})` calls. The prior warm/cold variance (the warm
4035
- handoff omitting the "Progress markers:" tail) is reconciled by **unifying**: all three render the
4036
- one template with the same vars, so they are **byte-identical** and the warm handoff now carries the
4037
- progress markers too. `read_cmd` is the provider-selected plan-read instruction computed in code via
4038
- the Node-2.1 helper branching stays in code, no `{% if %}`/second template. The template and its
4039
- golden (`implement-github`) carry **no trailing newline** (matching the prior cold/worker literal).
4040
- One golden case proves the template renders identically in both planes; thin per-plane composition
4041
- tests (start-with / contains read_cmd / ends-with the progress tail) prove each helper wires the
4042
- right template + vars — together these **replace `IMPLEMENT_SUBSTRINGS`**. The pre-objective audit
4043
- `docs/design/prompt-language-audit.md` (still describing warm as a shorter near-copy) is left as a
4044
- frozen snapshot; this paragraph is the authoritative current-state note.
4045
-
4046
- **The address prompt moved onto the seam — converging three consumers (Node 2.3).** The
4047
- address-stage wording lives in two canonical templates `prompts/stages/address/{action,preview}.md`
4048
- (each a complete body, no template logic; vars `{{ provider }}`, `{{ pr_id }}`, `{{ url }}`,
4049
- `{{ model_clause }}`), rendered identically by **all three** address consumers via the shared
4050
- render seam: the cold `perk/run/launch/prompts.py::_address_prompt`, the worker
4051
- `extension/worker/worker.ts::initialPromptFor("address")`, and the warm
4052
- `extension/doors/address.ts::addressGuidance`. Before this node the warm `/address` loop used a
4053
- *different* wording; the three were **converged** onto one canonical body the cold/worker
4054
- structure (the PR-identity header a fresh headless worker needs) **plus** warm's Plan File Mode
4055
- step, which now upgrades the cold/worker path too; warm loses its divergent framing. This is a
4056
- deliberate wording change to all three surfaces; the *command/flag/config* surface of `/address`
4057
- and `perk pr address` is unchanged.
4058
-
4059
- **Branching stays in code** (the frozen subset has no conditionals): preview vs action is a
4060
- template *selection* (`preview.md` for `--preview`, which omits the action steps including Plan
4061
- File Mode; `action.md` otherwise), and the classifier present/absent split builds the
4062
- `model_clause` render var in code (empty string when no `[subagents] review-classifier` model)
4063
- the clause's own wording is deferred to node 3.3. The worker has **no preview path** (preview is a
4064
- warm/cold flag), so it always renders `action.md`.
4065
-
4066
- **The warm door is now ref-aware and null-guarded.** The converged body carries the PR identity, so
4067
- `addressGuidance` takes the active `PlanRef`; the `/address` handler resolves it via the same
4068
- helper `doors/learn.ts` uses (`readPlanRef(ctx.cwd)` fallback
4069
- `rebuildWorkflowState(branchOf(ctx)).active_plan_ref`). A null ref reports a `warning` (mirroring
4070
- the `/implement` guard) and sends no guidance a strict improvement, since `/address` cannot
4071
- function without a plan-ref regardless (the classifier child's `perk pr feedback` hard-errors
4072
- `no_plan_ref`).
4073
-
4074
- The two address templates (and their golden files) carry **no trailing newline** (the builders
4075
- return mid-prompt strings). Four `address-*` golden cases in `cases.yaml` (action/preview × model
4076
- present/absent) prove cross-plane byte-identity; thin per-plane selection tests prove each caller
4077
- picks the right template and injects/omits the model clause, and the warm null-ref guard is
4078
- covered. This golden-fixture parity **replaces the prior `ADDRESS_SUBSTRINGS` substring parity**.
4079
-
4080
- **The objective-read instruction moved onto the seam (Node 2.5).** The cross-plane objective-read
4081
- clause (the supplemental wording telling the model how to inspect a Linear-Project-backed
4082
- objective's node-issues) moved off its two hand-duplicated twins onto the render seam, mirroring the
4083
- plan-read move. The wording lives in a subdirectory at `prompts/common/objective-read/linear.md`
4084
- one arm file for the **linear** arm only (github and any non-linear backend return `""` directly in
4085
- code without rendering, since `perk objective show` already covers them). **Branching stays in
4086
- code**: `perk/cli/commands/objective/shared.py::objective_read_instruction` and
4087
- `extension/factories/objectivePlan.ts::objectiveReadInstruction` keep their `(backend,
4088
- objective_id/objectiveId, url)` signature and the `backend != "linear" → ""` early return; the
4089
- linear arm computes the two **url-presence** render vars `where`/`fallback` in code (the frozen
4090
- subset has no conditionals mirroring the `model_clause` precedent) and renders the one template.
4091
-
4092
- The template (and its golden files) carry **no trailing newline** — the helper returns a single-line
4093
- string embedded mid-prompt, so the render output must equal the prior literal exactly (the
4094
- `_seed_prompt`/`factoryGuidance`/`reconcileGuidance` composition tests embed it and keep passing).
4095
- Two `objective-read-*` golden cases in `cases.yaml` (the linear arm, both url sub-variants) prove
4096
- cross-plane byte-identity; the empty github/other arm stays code-only (no render no golden) and is
4097
- covered by the per-plane selection tests. Per-plane selection tests in each plane
4098
- (`tests/test_objective_prompt_parity.py`, `extension/factories/objectivePlan.test.ts`) prove the
4099
- code picks the right arm + computes where/fallback. This golden-fixture parity **replaces the prior
4100
- `OBJECTIVE_LINEAR_SUBSTRINGS` substring lockstep** (which remains only as a local constant for the
4101
- per-plane + seed-composition tests, no longer a cross-plane invariant). The `_seed_prompt` /
4102
- `factoryGuidance` / `reconcileGuidance` body moves are deferred to Node 2.6.
4103
-
4104
- **The learn primer moved onto the seam (Node 2.4).** The learn-stage primer wording moved off its
4105
- two hand-concatenated twins onto the render seam one canonical `prompts/stages/learn.md` rendered
4106
- byte-identical by cold `perk/run/launch/prompts.py::_learn_prompt` and warm
4107
- `extension/doors/learn.ts::learnGuidance` (learn has **no worker twin** only cold + warm). Cold
4108
- and warm are **unified onto the cold body**: warm `/learn` wording changed from its prior numbered
4109
- "perk /learn —" style to the cold bullet "You are in the learn step…" body, the `other` arm
4110
- collapsed to a single "Open the plan and its merged change" line (warm **lost** its prior `other`
4111
- merged-PR derivation — an accepted change for the effectively-unreachable provider arm), and warm's
4112
- no-plan-ref fallback folded into the same template. This node is the **first template to use
4113
- conditionals**: the `{% if pr_id %}` header split and the no-ref / github+linear / other structure
4114
- selection are the template's conditional on `provider` (+ `pr_id` presence); the provider read-line
4115
- text is supplied as the `read_cmd` var from the node-2.1 plan-read helper (`_plan_read_instruction`
4116
- / `planReadInstruction`), `read_cmd` passed always (empty string when absent) so it is defined. The
4117
- template keeps each `{% if %}`/`{% elif %}`/`{% else %}`/`{% endif %}` tag on its **own line** (off
4118
- the content lines) — enabled by the `trim_blocks` env flip above, which swallows the single newline
4119
- after each block tag so the indented bullet content renders intact (whitespace-control `{%- -%}`
4120
- markers alone could not — they also strip the bullets' leading indentation). The
4121
- template and all four golden files carry **no trailing newline** (matching the cold literal). Four
4122
- `learn-*` golden cases in `cases.yaml` (`learn-github`, `learn-linear`, `learn-other`,
4123
- `learn-no-ref`) prove cross-plane byte-identity and **replace the dedicated learn substring parity**;
4124
- thin per-plane selection/composition tests remain. nunjucks stays the TS engine the golden suite
4125
- is the byte-parity proof that jinja2 and nunjucks render the conditional template identically (the
4126
- tag-hugging whitespace discipline keeps them equal with `trim_blocks`/`lstrip_blocks` off).
4127
-
4128
- **The objective-plan factory seed + warm guidance moved onto the seam (Node 2.6).** The two
4129
- hand-built objective-plan-factory prompt bodies — the **cold** seed
4130
- (`perk/cli/commands/objective/plan_cmd.py::_seed_prompt`) and the **warm** guidance
4131
- (`extension/factories/objectivePlan.ts::factoryGuidance`) moved onto the render seam as the sixth
4132
- real consumer. Unlike the implement (2.2) / learn (2.4) moves, they are **NOT unified**: the cold
4133
- seed launches a *fresh* read-only session, so it **injects** the objective title + node description
4134
- (the `<untrusted_objective>` block) and the pre-planning node-engagement block as DATA, and its node
4135
- is already marked `planning` by the cold door; the warm guidance runs *in-session*, so it
4136
- **instructs** the model to fetch the objective + node engagement and to mark the node `planning`
4137
- itself. This **cold-injects / warm-instructs** asymmetry makes them genuinely different bodies, so
4138
- they become **two arm files in a subdirectory** `prompts/stages/objective-plan/{seed,guidance}.md`
4139
- (filenames mirror the function names) like 2.1/2.3/2.5 landed despite singular node titles. The
4140
- **branching moved INTO the templates** as `{% if %}` conditionals (the learn-2.4 pattern, enabled by
4141
- `trim_blocks`): block-level tags on their own lines (the cold engagement block, the warm
4142
- node-selection line) and inline tags mid-line (the read clause, the explorer/model clause). The
4143
- helpers now pass **raw** vars `node_engagement` (the rendered block, `""` when absent),
4144
- `read_clause` (the rendered linear clause, `""` for github/other), `model` (`""` when unset), and
4145
- (warm) `node` (`""` select-next) while the in-code arm SELECTION
4146
- (`objective_read_instruction` / `objectiveReadInstruction` backend logic) is unchanged. Both
4147
- templates and their golden files carry **no trailing newline** (the prior literals had none). Four
4148
- `objective-plan-*` golden cases in `cases.yaml` (seed/guidance × github/linear) prove cross-plane
4149
- byte-parity across both arms of every conditional. The per-plane composition tests are **retained**
4150
- (`OBJECTIVE_LINEAR_SUBSTRINGS` survives as a local constant feeding the per-plane selection +
4151
- seed-composition tests); no cross-plane substring lockstep existed between the two different prompts,
4152
- so none is removed.
4153
-
4154
- **The learned-docs factory seed + warm guidance moved onto the seam (Node 2.7).** The two
4155
- hand-built learned-docs-factory prompt bodies the **cold** seed
4156
- (`perk/cli/commands/learn/docs_cmd.py::_seed_prompt`) and the **warm** guidance
4157
- (`extension/doors/learnDocs.ts::learnDocsGuidance`)moved onto the render seam as the seventh real
4158
- consumer. **Unlike 2.6 they are UNIFIED** (the implement-2.2 / learn-2.4 pattern): the cold/warm
4159
- differences were all **superficial factory house-style** header wording, a header blank line,
4160
- step-number indentation, a cold-only "from this read-only session" qualifier, and the
4161
- closing-paragraph phrasing — none load-bearing, so they were **converged away** onto the **cold-seed
4162
- orientation form** rather than preserved behind conditionals. The warm guidance gained the "You are
4163
- running…" header + the standalone closing paragraph ("Judgment, user interaction, and durable writes
4164
- stay with you never delegate them."), and the cold seed lost the `" "` step indent + the "from
4165
- this read-only session" qualifier (the warm session is not read-only, so the qualifier was
4166
- cold-only-accurate anyway; the bare "NEVER write the docs directly" is correct in both planes). The
4167
- result is a single **flat** template `prompts/stages/learn-docs.md` with **zero `{% if %}`
4168
- conditionals**; both planes pass the same two vars (`inbox_path`, `num_list`). The template and its
4169
- golden carry **no trailing newline**. One `learn-docs` golden case in `cases.yaml` proves cross-plane
4170
- byte-parity. No cross-plane substring lockstep existed between cold and warm, so none is removed; the
4171
- per-plane composition tests are retained (one warm header assertion updated from `"perk /learn-docs"`
4172
- to `"learned-docs plan factory"`).
4334
+ on so jinja2 does not strip a trailing `\n` (the vendored TS renderer never strips one) — required
4335
+ for byte-parity. (`trim_blocks` only affects block-tag templates `stages/learn.md`,
4336
+ `stages/objective-plan/{seed,guidance}.md`, and the `with_include` fixture; the remaining arm
4337
+ templates use `{{ var }}` only and are unaffected.) The vendored renderer **bakes these in** — the
4338
+ subset is frozen, so there is no config object.
4339
+
4340
+ **Dependencies:** `jinja2` is the Python runtime dependency and the reference engine. The TS plane
4341
+ has **zero runtime dependencies**: the former lone runtime dep (`nunjucks`) is replaced by the
4342
+ vendored, zero-dependency `extension/substrate/miniJinja.ts` renderer, restoring the
4343
+ bare-clone-loadable / zero-runtime-dependency invariant. That invariant is durably guarded by
4344
+ `extension/bareImportGuard.test.ts` (no shipped source imports a bare npm package) and
4345
+ `tests/test_packaging.py::test_no_runtime_dependencies` (`package.json` declares no runtime
4346
+ `dependencies`).
4347
+
4348
+ **The frozen template-grammar subset (the node-4.2 renderer's input contract).** The construct
4349
+ surface actually used across every `prompts/` template is **frozen** as the canonical "mini-jinja"
4350
+ subset the input contract the vendored zero-dependency TS renderer (node 4.2) must implement
4351
+ exactly and throw loudly outside of. It is exactly four categories:
4352
+
4353
+ 1. **Variable substitution** `{{ <ident> }}` where `<ident>` matches `^[A-Za-z_][A-Za-z0-9_]*$`.
4354
+ Nothing else inside `{{ }}`: no filters (`|`), no dotted/attribute access, no parentheses, no
4355
+ literals, no operators.
4356
+ 2. **Include** `{% include "<path>" %}`, double-quoted root-relative path only.
4357
+ 3. **Conditionals** `{% if <cond> %}` / `{% elif <cond> %}` / `{% else %}` / `{% endif %}`,
4358
+ where `<cond>` is built only from bare identifiers (truthiness), double-quoted string literals,
4359
+ the `==` operator, and the keywords `and`, `or`, `not`. `and` is admitted for boolean
4360
+ completeness (and/or/not) even though only `or`/`not` appear in templates today.
4361
+ 4. **Whitespace control** — plain `{% %}` tags only. The `{%- … -%}` / `{{- … -}}` markers are
4362
+ **not** in the subset; tag-line stripping is achieved by the render-env `trim_blocks` flag
4363
+ (specified in the "Environment-config parity baseline" paragraph above, not restated here).
4364
+
4365
+ Everything outside (1)–(4) is **outside the subset** — `{% for %}`/`{% endfor %}`, `{% set %}`,
4366
+ `{% macro/block/extends/raw %}`, `{# #}` comments, filters, attribute access, `!=`/`<`/`>`,
4367
+ `in`, `is`, parentheses, numeric literals. The **conformance guard** enforces this in both planes
4368
+ with an allowlist posture (fail on any block matching no recognized construct):
4369
+ `tests/test_prompt_grammar.py` (Python) and `extension/substrate/promptGrammar.test.ts` (TS).
4370
+ `shared/contracts.md §8.31` is the SSOT for the shared scan algorithm; the two guards mirror it.
4371
+ The guard checks **construct membership only**, not if/endif nesting balance structural balance
4372
+ is already proven by the golden harness rendering every real template. Widening the subset later
4373
+ (e.g. a future template needing `in` or parentheses) is a deliberate decision that amends this
4374
+ subsection **and** both guards.
4375
+
4376
+ > **History.** The chronological per-node landing notes for this section (the seven
4377
+ > "prompt moved onto the seam" entries, Nodes 2.1–2.7) live in
4378
+ > [`contracts-history.md` §8.31](./contracts-history.md).
4379
+
4380
+ ## §8.32 · Objective replan the superseding re-author cold door (`objective replan`)
4381
+
4382
+ The objective analog of §8.27's plan-`replan`, but with a **different model**: where plan-`replan`
4383
+ rewrites the plan IN PLACE (`plan_save` is an upsert keyed on `run_id`), objective-`replan`
4384
+ **closes the old objective and creates a net-new one that supersedes it**. `create_objective` is
4385
+ find-then-return idempotent on `run_id` (NOT an upsert see §8.24's "objective_save is not an
4386
+ upsert" residual), so an in-place objective rewrite has no storage primitive; the close-old/
4387
+ create-new shape sidesteps that gap. The structural siblings are §8.27 (replan engagement) and
4388
+ §8.30 (in-place adoption).
4389
+
4390
+ **Surface.** `perk objective replan <N>` — a **dedicated cold door** (a launcher, not a registry
4391
+ stage) that *borrows* the `objective-author` stage for launch (exactly like `plan replan` borrows
4392
+ `plan` and `objective author --from` borrows `objective-author`). It mints a **fresh** `run_id`
4393
+ (the new objective is net-new no `run_id_override`), refuses `--remote` (objective-author is
4394
+ `cold_remote:false`), and refuses a not-found / already-superseded / non-OPEN (GitHub) objective
4395
+ (`objective_not_found` / `objective_not_open`). `("replan", ())` joins the `objective` group in the
4396
+ parity-smoke `EXPECTED_SURFACE`.
4397
+
4398
+ **The carry model.** Only the **unfinished** nodes carry forward (status ∈ {`pending`, `planning`,
4399
+ `in_progress`, `blocked`}); `done`/`skipped` nodes stay as **history on the closed old objective**
4400
+ (the new prose references the shipped phases). The cold door materializes the old objective's
4401
+ title + prose (`<untrusted_objective>`) and the unfinished nodes
4402
+ (`<untrusted_objective_unfinished_nodes>`) into a scratch file as DATA, seeds the unchanged
4403
+ `objective_draft plan_review objective_save` flow, and stashes `supersedes=<OLD>` in the run
4404
+ **handoff** so the link survives the save path (recovered by `_supersedes_from_handoff`, mirroring
4405
+ `_adopt_from_handoff`). Objective + node-issue engagement is read fail-soft (`render_objective_engagement`).
4406
+
4407
+ **The lineage fields.** `ObjectiveHeader` gains `supersedes` and `superseded_by` (both
4408
+ `str | None`, in `OBJECTIVE_HEADER_FIELDS` + `to_data()`): `supersedes=#<OLD>` on the NEW header,
4409
+ `superseded_by=#<NEW>` on the OLD header. Bidirectional by construction; both `None` for a
4410
+ normally-authored objective.
4411
+
4412
+ **The storage capability (`supersede_objective`).** A new `ObjectiveStore` method
4413
+ (keyword-only, returns `ObjectiveRef | None`) joins the no-op-family Protocol pattern (3
4414
+ implementers, ty-enforced; `None` = "this store doesn't support it", mirroring
4415
+ `adopt_source_as_objective`). Semantics: create a net-new objective (idempotent on `run_id`)
4416
+ carrying `supersedes`, then **close the old objective fail-open** (stamp `superseded_by`, post a
4417
+ best-effort status update create-new-first, close-old-last; a close failure never fails the
4418
+ create the §8.24 bookkeeping posture). `dry_run` `None` (resolving the old objective needs a
4419
+ network read; the cold door's `--dry-run` is offline); an empty `roadmap_nodes` raises.
4420
+
4421
+ **Backend-specific carry-forward.**
4422
+ - **GitHub** (a node is a row in one objective issue body): the new objective's roadmap rows are
4423
+ authored fresh; the old issue is closed. `carry_map` is ignored (no child issues).
4424
+ `objectives.supersede_objective_issue` extends `create_objective_issue` with a `supersedes`
4425
+ header field, then fail-open closes the old issue.
4426
+ - **Linear project store** (a node *is* a live issue): `carry_map` (new-node-id →
4427
+ existing-node-issue-id) **moves** each carried node-issue into the new project
4428
+ (`issueUpdate(input:{projectId})`), re-stamps its `objective-node` block to the new node id, and
4429
+ re-attaches it to the new phase milestone (identity / open PRs / discussion preserved);
4430
+ non-carried nodes mint fresh. The old project: `superseded_by` stamped, **every dropped
4431
+ (un-carried) still-open node-issue Canceled** (state type ∉ {completed, canceled} →
4432
+ `_workflow_state_id("canceled")`), then marked complete. `done` node-issues are left untouched.
4433
+ Flagged not-live-proven (verify at the Linear smoke gate).
4434
+ - **Issue-backed Linear store** (dormant): `supersede_objective → None` (the no-op-family signal).
4435
+
4436
+ **The dispatch carrier (`objective create --supersedes`).** Structurally symmetric to
4437
+ `--adopt-from`: a `--supersedes` worker flag (recovered from the handoff via
4438
+ `_supersedes_from_handoff`; explicit flag wins) parses the carry map via the reused
4439
+ `objective.parse_adopt_mapping(raw_roadmap)` (the node→issue side-map, interpreted as **move**
4440
+ semantics here) and calls `store.supersede_objective(...)`; a `None` return raises
4441
+ `supersede_unsupported`. `--supersedes` and `--adopt-from` are **mutually exclusive** (`invalid_input`).
4442
+
4443
+ **Binding + skill.** `command:objective-replan perk-objective-replan` (nudge) joins
4444
+ `shared/bindings.yaml` (mirroring `command:objective-reconcile`) and `DELIVERABLE_COMMAND_TARGETS`
4445
+ (it fires via the cold `binding_trigger="command:objective-replan"` override). The
4446
+ `perk-objective-replan` skill is the re-author judgment layer (carry-only-unfinished, the
4447
+ `adopt_issue` Linear move, the don't-churn rule), cross-referencing `perk-objective-author` for the
4448
+ draft→review→save mechanics. The warm plane is unchanged `objective_draft`/`objective_save`'s
4449
+ structured roadmap path already carries `adopt_issue` per node, and `supersedes` rides the handoff
4450
+ exactly as `adopt_from` does, so no TS schema edit is needed.
4451
+
4452
+ ## §8.33 · Local-file (and URL) seeding for the seed-from-source cold doors
4453
+
4454
+ *(`plan from` / `objective author --from` / `skills create --from`)*
4455
+
4456
+ Both adoption cold doors **also** accept a relative or absolute path to a local file. This is a
4457
+ distinct **seed-from-file** mode, NOT in-place adoption: a file has no canonical backend identity,
4458
+ so there is nothing to stamp perk's metadata into (the §8.29/§8.30 in-place model does not apply).
4459
+
4460
+ **Disambiguation (`seed_file.detect_seed_file`).** Both doors auto-detect an existing file
4461
+ **before** any id parsing / backend read: `Path(arg).expanduser()` (relative resolves against the
4462
+ invoking shell's cwd)if it `is_file()`, file mode wins (using the `.resolve()`d path); otherwise
4463
+ the arg falls through to the existing issue/source-id path **unchanged**. A non-existent path-like
4464
+ arg (slash or not) always falls through (no new path-shape heuristics): `parse_plan_id` rejects
4465
+ `/`-bearing ids as `invalid_input`, and a clean-but-unresolvable id errors `adopt_not_found` as
4466
+ today.
4467
+
4468
+ **Behavior.** The file is read as untrusted DATA (`seed_file.read_seed_file`) and materialized into
4469
+ a slash-free `seed-file-<safe-stem>-<hash8>.md` scratch (`seed_file.render_seed_file_scratch`; the
4470
+ absolute-path SHA1 hash keeps two same-named files in different dirs from colliding), wrapped in an
4471
+ `<untrusted_seed_file>` block. The read-only authoring session is primed with a file-mode seed
4472
+ prompt; saving mints a **fresh** `perk:plan` / `perk:objective` issue via the normal create path —
4473
+ **no `adopt_from` handoff, no `adopted_from` provenance, file untouched**.
4474
+
4475
+ **Surface.** File mode skips `require_github` (the only read is local; the backend write happens
4476
+ in-session at save time, mirroring the bare authoring path) but keeps `require_repo` /
4477
+ `require_config` (scratch dir + launch config) and the `--remote` rejection (local-only, same as the
4478
+ doors it extends). Errors: `seed_file_error` (non-UTF-8 / unreadable / empty file). Stable exits
4479
+ unchanged (`0` ok · `1` op-failure/refusal · `2` not-a-repo).
4480
+
4481
+ **Out of scope.** No in-place adoption of files (no backend identity), no change to `parse_plan_id`
4482
+ / the `adopt_from` handoff / `adopted_from` provenance / any §8.29/§8.30 machinery, no
4483
+ directory/glob support (a single file only), no write-back to the seed file.
4484
+
4485
+ **`skills create --from` (third consumer + URL sub-mode).** `perk skills create NAME --from <SOURCE>`
4486
+ reuses the same leaf. `SOURCE` is detected **URL-first, then file** (the disambiguation order
4487
+ diverges from the doors above, which detect file-first then fall through to an id): an http(s) URL
4488
+ (`seed_file.detect_seed_url` `urlsplit(arg).scheme` in `{http, https}`) takes the **URL sub-mode**;
4489
+ else an existing file (`detect_seed_file`) takes file mode; else a hard `seed_file_error` (no
4490
+ id/adoption fall-through a skill has no backend identity).
4491
+
4492
+ - **File mode** is identical to the doors above: materialized to an `<untrusted_seed_file>` scratch
4493
+ (written even on `--dry-run`, gitignored), the authoring session reads it as DATA, authoring a
4494
+ **fresh** skill.
4495
+ - **URL sub-mode** diverges: the URL is **not** materialized to a scratch and there is **no network
4496
+ in the Python command** (no `require_github`). The command only scheme-detects and hands the URL
4497
+ to the write-capable authoring session, which fetches the `SKILL.md` **and any sibling
4498
+ `references/`/`scripts/`/linked files in-session** (it has fetch/web tools read-write sessions
4499
+ are not tool-restricted), treats everything as DATA, and ports selectively. This keeps the door
4500
+ offline/fast and avoids GitHub-blob-HTML/raw-URL transforms in Python.
4501
+
4502
+ Both modes always produce a **fresh** skill (no in-place adoption / `adopt_from` / provenance a
4503
+ skill is not a backend object). `--dry-run` JSON adds `"from": <source>` and file mode only
4504
+ `"scratch_path"`. This is a Python-only change (no TS plane); the authoring judgment lives in the
4505
+ `perk-skill-author` skill.
4506
+
4507
+ ## §8.34 · Published JSON Schemas for the boundary models (Objective #943, Node 4.1)
4508
+
4509
+ perk's cross-plane machine surfaces are Pydantic boundary models (`perk/boundary.py`'s three roles).
4510
+ Their `model_json_schema()` is published as committed reference artifacts under `shared/schemas/`,
4511
+ so a consumer of perk's machine surfaces has a precise, reviewable contract.
4512
+
4513
+ **What is published (17 top-level models, three categories).**
4514
+
4515
+ - **Shared-YAML parse contracts** (`LenientParseModel`) → `shared/schemas/contracts/`:
4516
+ `registry.schema.json` (`RegistryFile`), `bindings.schema.json` (`BindingsFile`),
4517
+ `providers.schema.json` (`ProvidersFile`).
4518
+ - **Machine batch inputs** (`StrictInputModel` / `RootModel`) → `shared/schemas/inputs/`:
4519
+ `review-post-batch.schema.json` (`ReviewBatchInput`),
4520
+ `resolve-threads-batch.schema.json` (`ResolveThreadsBatch`),
4521
+ `handoff-arg.schema.json` (`HandoffArgInput`),
4522
+ `structured-roadmap-node.schema.json` (`StructuredRoadmapNode`).
4523
+ - **`--json` output envelopes** (`OutputModel`) → `shared/schemas/outputs/`: `plan-save`,
4524
+ `pr-submit`, `pr-ready`, `pr-land`, `pr-feedback`, `pr-review-context`, `learn-capture`,
4525
+ `learn-skip`, `init-report`, `doctor-report` (`.schema.json` each, for `PlanSaveOut` …
4526
+ `DoctorReportOut`).
4527
+
4528
+ **How they are generated.** `model_json_schema()` from the live boundary models. The mode is
4529
+ **per category** — parse/input contracts describe what perk **accepts**, so they use **validation
4530
+ mode** (the default); output envelopes describe what `--json` consumers **receive**, so they use
4531
+ **serialization mode**. Nested `*Out` / `*Entry` sub-models ride along in `$defs`.
4532
+
4533
+ **Cross-plane status.** The schemas are **published reference artifacts bundled into both planes**
4534
+ (`perk/_shared/schemas/` in the wheel, `shared/schemas/` in npm) — they are **not consumed at
4535
+ runtime** by either plane (TS still reads the YAML directly; Python validates via the live models).
4536
+
4537
+ **Drift discipline.** The committed files are regenerated only via
4538
+ `PERK_UPDATE_SCHEMAS=1 uv run pytest tests/test_contract_schemas.py`, and
4539
+ `tests/test_contract_schemas.py` fails CI on any un-regenerated drift (per-model drift assertions, a
4540
+ no-orphans/no-gaps coverage test, and a per-category mode-correctness smoke) — so a schema change is
4541
+ always reviewed intentionally. The harness mirrors the value-golden harness (`tests/_golden.py`):
4542
+ it always re-reads + asserts after a regen, so a non-roundtrippable schema still fails loudly.
4543
+
4544
+ **Non-goals.** `ConfigModel` (TOML, not a shared YAML contract) is not published; the stored-block
4545
+ serializers `PlanHeaderOut` / `PlanRefOut` are not published as standalone schemas (`PlanRefOut`
4546
+ rides transitively in `PlanSaveOut`'s `$defs`).
4547
+
4548
+ ## §8.35 · The learn evidence-bundle contract (Objective #896, Node 1.1)
4549
+
4550
+ `/learn` examines a **bundle of session-grounded evidence** for a landed plan — not only plan + diff.
4551
+ This section pins the bundle's shapes and vocabulary; the runtime handlers (nodes 2.x–7.1) build
4552
+ against it.
4553
+
4554
+ **The evidence bundle (definition + invariants).** The bundle is the full set of session-grounded
4555
+ artifacts `/learn` reasons over for a landed plan. Invariants:
4556
+
4557
+ - Every quoted artifact in the bundle is **untrusted DATA**, fenced as such — never instructions.
4558
+ - A missing source is **surfaced, never guessed**: the bundle reports a per-source status, and one
4559
+ missing/ambiguous source never fails the whole command.
4560
+ - The bundle is **resolved cross-run** from a landed plan's identity, not from the current session's
4561
+ identity — so a later or worktree session can rebuild it.
4562
+
4563
+ **Minimum manifest categories.** The bundle manifest lists at least these five categories: `plan`,
4564
+ `pr`, `planning-session`, `implementation-session`, `existing-docs`. Each category carries a
4565
+ per-source status drawn from the fixed set **`found` / `missing` / `ambiguous`**.
4566
+
4567
+ **Session classes.** Two session classes: **`planning`** (the session that authored/reviewed/saved
4568
+ the plan) and **`implementation`** (the session(s) that implemented it). Each class may resolve
4569
+ **both** a main session and a worker run, labelled distinctly when both are available.
4570
+
4571
+ **The canonical run-cache pointer carrier.** Session pointers are recorded **against the run in the
4572
+ run cache, keyed by `run_id`** (under the run scratch dir, `perk/state/cache.py::run_scratch_dir` /
4573
+ `extension/substrate/cache.ts::runScratchDir`) — this is the canonical cross-run carrier. The plan
4574
+ branch's workflow-state **may mirror** the pointers for provenance but is **not primary**. The
4575
+ cross-run resolution path is **`plan id → plan-header run_id → run-cache pointers`**, without relying
4576
+ on the identity-gated `session_artifacts` map (current-run-only). Missing or GC'd pointers degrade to
4577
+ a `missing` status, never a guess.
4578
+
4579
+ **The concrete carrier (node 2.1).** The record is `session-pointers.json`, written under the run's
4580
+ scratch dir at `<main-checkout>/.perk/workflow/scratch/runs/<run_id>/` — where `<main-checkout> =
4581
+ main_worktree_root(cwd) or cwd` (`perk/substrate/git.py::main_worktree_root` /
4582
+ `extension/substrate/git.ts::mainCheckoutRoot`) so a linked-worktree run and a later resolver agree
4583
+ on ONE shared location. The path is built only through the `run_scratch_dir`/`runScratchDir` seam.
4584
+ Schema (byte-identical across planes):
4585
+
4586
+ ```json
4587
+ {
4588
+ "run_id": "<this run's id>",
4589
+ "planning": { "main": <Pointer|null>, "worker": <Pointer|null> },
4590
+ "implementation": { "main": <Pointer|null>, "worker": <Pointer|null> }
4591
+ }
4592
+ ```
4593
+
4594
+ `Pointer = { "pi_session_id": str, "session_file": str, "parent_pi_session_id": str|null, "at":
4595
+ ISO-8601 }`. Each run is **self-keyed**: it writes its records ONLY under its OWN `run_id`, and
4596
+ fills only the slots it owns (planning runs → `planning.*`; implement runs → `implementation.*`).
4597
+ The four class/site slots are always present (null when unset) so a TS read-modify-write merges
4598
+ trivially. `pi_session_id` = the session-file basename (matches the `perk:workflow-state` stamp);
4599
+ `session_file` = the absolute path known at capture (informational); `parent_pi_session_id`
4600
+ preserves fork/replacement provenance (the inherited parent session, else null). `main` vs `worker`
4601
+ is distinguished by **capture site** (deterministic), not by inspection: the interior
4602
+ `session_start` writes `.main`, the headless `worker.driveStage` writes `.worker`.
4603
+
4604
+ **The plan-header linkage.** The planning `run_id` is already on the `plan-header`. The
4605
+ implementation run id(s) are stamped onto the header as `impl_run_ids: tuple[str, ...]`, a
4606
+ **submit-staged** field (null/empty at save, exactly like `branch`/`pr`) union-merged at `/submit`
4607
+ (`perk pr submit --run-id <run_id>` appends the current run id iff absent — dedup, order-preserving).
4608
+ The header is the canonical, GC-proof cross-run LINKAGE; the run cache is the primary POINTER store.
4609
+
4610
+ **Cross-run resolution (node 2.1, `perk/learn/sessions.py::resolve_plan_sessions`).** `plan_id →
4611
+ resolve_issue_backend(repo_root).get_plan(...).header → {run_id (planning), impl_run_ids
4612
+ (implementation)} → read each run's session-pointers record under the main checkout`. Per-role
4613
+ status is from this section's fixed set: `found` (the slot's pointer is present) / `missing`
4614
+ (plan/header/run_id absent, the record file GC'd/absent, or the slot is null). `ambiguous` is
4615
+ reserved for node 3.1's source-level manifest and is unused here. No user-facing command lands in
4616
+ this node — node 3.1 (`perk learn evidence`) is the first consumer.
4617
+
4618
+ **The session-export seam (node 2.2, `perk/learn/export.py::export_session_jsonl`).** Given a
4619
+ resolved pointer, materialize a current-branch JSONL artifact as a faithful **byte copy** of the
4620
+ pointer's `session_file`. Decision: **Option A — Python reads the on-disk session JSONL directly,
4621
+ on demand** (no Pi export primitive, no TS capture-time export). Rationale: the session file IS the
4622
+ JSONL (Pi persists each session as an append-only JSONL log — a header line + entry lines); the
4623
+ slot captures are all **mid-session** (the session keeps appending afterward, so a capture-time
4624
+ export would be a partial prefix); `/learn` runs **later** in a separate session, by when the
4625
+ planning + implementation sessions have finished writing, so the on-disk file is the COMPLETE
4626
+ transcript; and the files live under the home agent dir, so they **survive worktree deletion** (the
4627
+ captured absolute `session_file` stays valid — only Pi-side GC removes it → `missing`).
4628
+
4629
+ - Signature: `export_session_jsonl(pointer: SessionPointer | None, dest: Path) -> SessionExport`,
4630
+ with `SessionExport = { status: "found" | "missing", source: str | None, artifact: Path | None }`
4631
+ (a frozen `@dataclass`; `source`/`artifact` set only when `found`).
4632
+ - The copy is **faithful** (`shutil.copyfile`, not parse-and-reserialize) so the artifact preserves
4633
+ the raw JSONL exactly — the session header line, compaction/branch-summary entries, abandoned
4634
+ branches, unknown custom entries. Parsing/normalization is **node 3.2's** concern.
4635
+ - The stored absolute `session_file` is **authoritative**; the seam never re-derives the path from
4636
+ the capture cwd (a possibly-deleted worktree; the session dir is cwd-encoded).
4637
+ - It **never raises** (mirroring `read_session_pointers`): a `None` pointer, an empty
4638
+ `session_file`, a non-existent source, or any `OSError` (warned to stderr) → `missing`.
4639
+ - The export status **composes with** resolution: a `found` resolution **downgrades to `missing`**
4640
+ at export time if the source file is gone.
4641
+ - `dest` is **caller-composed** (dest-agnostic, the full target file path): the node-3.1 consumer
4642
+ composes the destination under the bundle scratch dir via the `run_scratch_dir`/`scratch_dir`
4643
+ seam; node 2.2 picks no naming convention. There is **no `--json` surface in this node** (so no
4644
+ `OutputModel` — the serialize-edge lands with node 3.1's manifest).
4645
+
4646
+ **The classification vocabulary (two distinct, related sets).**
4647
+
4648
+ - **The reconciled DECISION set** — the transient, in-session reconciliation output of `/learn`'s
4649
+ angle analysis. One of `CAPTURE_LEARN`, `SHOULD_BE_CODE`, `UPDATE_EXISTING_DOC`, `NEW_DOC`,
4650
+ `STALE_DOC`, `SKIP`, with one locked meaning each:
4651
+ - `CAPTURE_LEARN` — a durable cross-cutting learning → create a `perk:learn` issue.
4652
+ - `SHOULD_BE_CODE` — belongs in code/comment/docstring/schema/user-docs, not a learned doc
4653
+ (corresponds to the perk-learn-docs knowledge-placement hierarchy).
4654
+ - `UPDATE_EXISTING_DOC` — update an identified existing learned/user doc.
4655
+ - `NEW_DOC` — a new learned doc is warranted.
4656
+ - `STALE_DOC` — an existing doc is stale/duplicate and should be cleaned up.
4657
+ - `SKIP` — nothing durable; create no issue, clear the marker only.
4658
+ - **The durable CAPTURED metadata shape** — persisted on the `perk:learn` issue header (both
4659
+ backends). It is the DECISION set **minus `SKIP`** (a skip creates no issue) **plus an optional
4660
+ `target`**: `{ decision ∈ {CAPTURE_LEARN, SHOULD_BE_CODE, UPDATE_EXISTING_DOC, NEW_DOC,
4661
+ STALE_DOC}, target? }`. `target` is an optional routable pointer (e.g. an existing doc path) when
4662
+ the decision identifies one. The fields extend the existing `learn-header` metadata block (which
4663
+ already carries `{ run_id, created, plan }`) → `{ run_id, created, plan, decision, target? }`,
4664
+ rendered in both block styles (HTML on GitHub, `inline-code` on Linear) so it round-trips on both
4665
+ backends. Markdown stays the human payload. **Landed (node 4.2):** both backends render the
4666
+ header via the shared `perk/plan.py::render_learn_header(*, run_id, created, plan, decision,
4667
+ target, style)` helper (declaration order `run_id`, `created`, `plan`, then `decision`/`target`
4668
+ **only when present**) so the header is byte-identical in shape and the optional fields round-trip
4669
+ in either encoding; `decision` is a `plan.CapturedDecision` `StrEnum` (the five captured tokens).
4670
+ The `create_learn_issue` protocol + both adapters + `perk learn capture --decision/--target` thread
4671
+ the pair through; the `--json` capture envelope (`LearnCaptureOut`) is unchanged (the
4672
+ classification lives on the issue header, not the capture result). The typed read-back model is
4673
+ **Landed (node 7.1):** `plan.LearnHeaderModel` (`LenientParseModel`) → frozen `plan.LearnHeader`
4674
+ `@dataclass`, pinning all five fields (`run_id`, `created`, `plan`, `decision`, `target`). The
4675
+ never-raise reader `plan.parse_learn_header(body) -> LearnHeader | None` scans both block styles
4676
+ via `find_metadata_block`, returns `None` when the block is absent/malformed, and degrades an
4677
+ unknown/future `decision` token to `None` (a `before` field-validator) — it never raises. It is
4678
+ the gather-time classification route the learn factories read.
4679
+
4680
+ **Boundary-model discipline (forward-looking).** The new learn shapes are **boundary data** and,
4681
+ when their handlers land, follow perk's existing boundary-model convention (§8.34 / `perk/boundary.py`,
4682
+ the dignified-pydantic house style) — the contract pins *which role each shape serves*, leaving field
4683
+ lists to the handlers:
4684
+
4685
+ - The **bundle manifest** and the **session-normalization report** (`perk learn evidence --json`,
4686
+ nodes 3.1/3.2) are an **`OutputModel` serialize-edge** — the `--json` envelope is the contract a
4687
+ machine consumer receives.
4688
+ - Parsing **session JSONL** and reading the **`learn-header` captured metadata back off an issue**
4689
+ are **untrusted external data** → a **`LenientParseModel` read-edge** (`extra="ignore"`), converted
4690
+ to a **frozen `@dataclass`** domain object — never read raw dicts into domain logic.
4691
+ - The `decision` token is a **closed set** → modelled as a `StrEnum` (the five captured tokens; the
4692
+ transient reconciliation may also yield `SKIP`), never a free string; `target` is **omittable**
4693
+ (`str | None = None`), distinguishing "no target" from a present value.
4694
+ - `model_validate` for the untrusted edges; the constructor for trusted Python-shaped values (the
4695
+ `ty`-friendly habit, dignified-pydantic §38).
4696
+
4697
+ **Classification-aware hop-2 consolidation (node 7.1).** The typed `parse_learn_header` read-back
4698
+ (above) is the **gather-time default classification route** for the two learn plan factories. Both
4699
+ are read-only plan factories (author + save a `perk:plan`; never write docs/code directly) sharing
4700
+ `perk/cli/commands/learn/factory_common.py` (parameterized by a frozen `LearnFactoryKind`; the two
4701
+ thin click commands `docs_cmd.py`/`code_cmd.py` delegate to `run_factory`). The cold-door
4702
+ `gather` lists all open `perk:learn` issues and **partitions by `decision`**: a pre-stamped
4703
+ `SHOULD_BE_CODE` routes to the code factory; **every other classification — and any
4704
+ legacy/unclassified issue (absent/malformed header) — defaults to docs** (the catch-all).
4705
+
4706
+ - **`perk learn docs` / `/learn-docs`** consolidates the **doc-destined** subset into
4707
+ `docs/learned/`, cleanup-first, regenerating routing via `docs-sync` (never by hand). It stays a
4708
+ curator **AND verifier**: it applies the knowledge-placement hierarchy and **emits a
4709
+ `SHOULD_BE_CODE` follow-up step** when a doc-destined learning actually belongs in
4710
+ code/comment/docstring/schema/user-docs (the original node requirement; the verifier exit). Its
4711
+ inbox is **widened** with each learning's captured classification line (`decision` + optional
4712
+ `target`) and the node-5.1 existing-docs scan (`scan_existing_docs` inventory +
4713
+ `scan_docs_richly` findings) for cleanup-first + UPDATE-vs-NEW.
4714
+ - **`perk learn code` / `/learn-code`** *(new, additive)* is the dedicated sweep for the pre-stamped
4715
+ `SHOULD_BE_CODE` learnings, routing each into its real code home. Its inbox is **lean**
4716
+ (classification + `target` + the codebase it reads directly; no docs scan).
4717
+
4718
+ The partition is the *default* route, not the only path to a destination: `/learn-docs`'s verifier
4719
+ re-routes a doc-stamped item to code when warranted, and `/learn-code`'s skill may note an item
4720
+ better suited to a doc. Each factory **consumes its full filtered inbox** — whatever it places (a doc
4721
+ OR a verify-re-routed code step) stays in `consumed_learn` (carried through `launch_stage`'s
4722
+ `handoff_extra`); no per-item subsetting. The `--gather --json` envelope is unchanged
4723
+ (`{inbox_path, learn_numbers, launched}`). Parallel wiring SSOTs: `shared/bindings.yaml`
4724
+ (`command:learn-code` → `perk-learn-code` nudge), `bindings.py::DELIVERABLE_COMMAND_TARGETS`
4725
+ (`learn-code`), `init/skills.py::PERK_SKILLS` (`perk-learn-code`), the `learn` verb group, the warm
4726
+ `extension/doors/learnCode.ts`, and `prompts/_fixtures/live.yaml` (`stages/learn-code.md`).
4727
+
4728
+ **The non-empty `consumed_learn` discriminator.** A plan whose `plan-header` `consumed_learn` is
4729
+ **non-empty** *is* a learn-docs consolidation plan. `/learn` (and the future `perk learn evidence`
4730
+ command) detect this **up-front** and return a stable **no-op**: clear `pending-learn`, create no
4731
+ `perk:learn` issue, gather no bundle, spawn no children — reporting *"learn-docs plan; learn capture
4732
+ skipped"*.
4733
+
4734
+ **The bundle-manifest CLI (node 3.1, `perk learn evidence --json`).** The first consumer of the
4735
+ node-2.1 resolver + node-2.2 export seam. Reads the local `cache.plan-ref` (no positional arg,
4736
+ mirroring `perk learn capture`); gathers the bundle, materializes the artifacts under
4737
+ `cache.scratch_dir(repo_root) / "learn-evidence"`, and emits the manifest. Exit codes: `0` ok (skip
4738
+ OR gathered manifest) · `1` no plan-ref / invalid · `2` not-a-repo. `require_github` is **not**
4739
+ called — GitHub reads degrade per-source, so the manifest still gathers sessions + docs offline.
4740
+
4741
+ The `--json` envelope (`OutputModel` serialize edge, the contract a machine consumer receives):
4742
+
4743
+ ```
4744
+ EvidenceBundle = {
4745
+ success, error_type, message, # the standard envelope head
4746
+ skipped: bool, skip_reason: str|null,
4747
+ plan_id: str|null, bundle_dir: str|null, # bundle_dir relative to repo_root
4748
+ sources: EvidenceSource[],
4749
+ existing_docs: DocEntry[],
4750
+ docs_findings: DocFindings, # the node-5.1 rich scan (declared after existing_docs)
4751
+ }
4752
+ EvidenceSource = { category, label, status, artifact: str|null, detail: str|null }
4753
+ DocEntry = { kind, path, title: str|null, snippet: str|null }
4754
+ DocFindings = { stale_pointers: StalePointer[], broken_doc_paths: BrokenDocPath[],
4755
+ duplicate_groups: DuplicateGroup[] }
4756
+ StalePointer = { doc, pointer, reason } # reason ∈ {missing-file, missing-symbol}
4757
+ BrokenDocPath = { doc, target }
4758
+ DuplicateGroup = { basis, key, docs: str[] } # basis ∈ {title, read_when}
4759
+ ```
4760
+
4761
+ `status ∈ {found, missing, ambiguous}`. `artifact` paths are **relative to repo_root** (portable).
4762
+ The full shape is always serialized (no `exclude_unset`) so absent values render `null`.
4763
+
4764
+ **Category → source mapping.** `plan` (1; materializes `plan-body.md`), `pr` (1; materializes
4765
+ `pr.diff` when `found`), `planning-session` (2: `main`/`worker`), `implementation-session` (per
4766
+ `impl_run_ids` entry × `main`/`worker`, files `implementation-<i>-{main,worker}.jsonl`; **one
4767
+ `missing` entry labelled `(none)`** when there are no impl runs), `existing-docs` (1 roll-up:
4768
+ `found` when the inventory is non-empty, else `missing`; the detail rides the separate
4769
+ `existing_docs[]`).
4770
+
4771
+ **Skip detection — the plan-header, up front.** The plan is fetched once via the resolved issue
4772
+ backend; if `header["consumed_learn"]` is a **non-empty list** (LBYL: `isinstance(..., list)` +
4773
+ truthy) the command returns the stable skip (`skipped=true`, empty `sources`/`existing_docs`,
4774
+ `bundle_dir=null`) **before** any PR/session/docs gathering. A plan-**fetch** failure is **never** a
4775
+ skip signal — the command proceeds to gather with the `plan` source `missing`.
4776
+
4777
+ **Per-source degrade with a warning.** Each source gathers in its own try/except: *expected absence*
4778
+ (a `None` lookup / null slot / no impl runs) → `missing` **silently**; a *genuine error*
4779
+ (`IssueBackendError` / `GitHubError` / `OSError`) → `missing` + a `user_output("warning: …")` to
4780
+ stderr (loud-but-non-fatal). One missing/ambiguous source never fails the command (exit `0`).
4781
+
4782
+ **The first `ambiguous` producer — the multi-candidate PR rule.** `perk/github/prs.py` gains
4783
+ `list_prs_for_branch(*, branch, repo_root) -> tuple[PullRequest, ...]` (its own
4784
+ `head=<owner>:<branch>&state=all` list, all states; `find_pr_for_branch` is left unchanged). The PR
4785
+ source: `0` matches → `missing`; exactly one MERGED PR (even alongside closed/superseded PRs) →
4786
+ `found`; exactly one match (any state) → `found`; otherwise (`>1`, not exactly one merged) →
4787
+ **`ambiguous`** (no diff materialized).
4788
+
4789
+ **The existing-docs roots (node 3.1's basic inventory).** `scan_existing_docs(repo_root)` scans
4790
+ three conventional roots: `docs/learned/**/*.md` (frontmatter `title`/`read_when`),
4791
+ `docs/user-docs/**/*.md` (first `# ` heading + first paragraph), and `.perk/skills/*/SKILL.md`
4792
+ (frontmatter `name`/`description`). **Top-level `skills/` is deliberately excluded** — it is perk's
4793
+ own codebase, not the workflow-managed skill surface. Per entry: `DocEntry{kind, path, title,
4794
+ snippet}`, snippets bounded (`≈240` chars), sorted by path (deterministic); non-existent roots yield
4795
+ nothing.
4796
+
4797
+ **The rich existing-docs checker (node 5.1, `perk/learn/docs_scan.py::scan_docs_richly`).** A
4798
+ **deterministic, advisory** enrichment of the basic inventory: `scan_existing_docs` and the rich
4799
+ scan live together in a dependency-light pure leaf (`perk/learn/docs_scan.py`, imports only stdlib +
4800
+ `yaml` + `perk.boundary`) so node 6.1's `docs-check` reuses it without dragging in
4801
+ `github`/`backends`; `evidence.py` re-exports `DocEntry`/`scan_existing_docs` so existing call sites
4802
+ are byte-identical. `scan_docs_richly(repo_root) -> DocFindings` re-globs the same three roots,
4803
+ reads full bodies, is **deterministic** (sorted output, no wall-clock/random), **never raises**
4804
+ (per-doc try/except; `OSError` → skip), and is **bounded** (each doc read once; each finding family
4805
+ sorted **then** capped at `_MAX_FINDINGS = 200` — a pathological guard that never bites a normal
4806
+ corpus). It produces verifiable FACTS only; the de-dup **decision** is the analyst's (below).
4807
+
4808
+ - **`DocFindings`** (frozen dataclasses → `OutputModel` serialize edge): `stale_pointers:
4809
+ StalePointer[]`, `broken_doc_paths: BrokenDocPath[]`, `duplicate_groups: DuplicateGroup[]` (each
4810
+ always present, empty tuples when nothing found; empty `DocFindings()` on a skip bundle).
4811
+ - **Stale source pointers (phantoms) — the high-value check.** For each doc, inline-code spans
4812
+ (`` `([^`\n]+)` ``) whose *entire* content matches
4813
+ `^(?P<path>[\w./-]+\.(?:py|ts|tsx|js))(?:::(?P<symbol>[\w.]+))?$` **and** whose `path` first
4814
+ segment is in `_SOURCE_ROOTS = ("perk", "extension", "shared", "tests", "agents")` (the real
4815
+ source dirs — excludes example/third-party/runtime; `.md` is the broken-link rule). Verify:
4816
+ `repo_root/path` not a file → `StalePointer(reason="missing-file")`; file present **and** a
4817
+ `::symbol` present **and** `symbol.split(".")[-1]` **not** a substring of the file text →
4818
+ `StalePointer(reason="missing-symbol")`. Deduped per doc; sorted by `(doc, pointer)`.
4819
+ - **Broken doc paths — the routing-drift check.** Markdown links (`\[[^\]]*\]\(([^)]+)\)`); strip a
4820
+ trailing `#fragment`; keep **only** targets ending in `.md`; **skip** any target with whitespace
4821
+ or `|` (drops the validated false-positive code-snippet shapes `](cmd: C)` / `](scratch|runs)`)
4822
+ and `http(s)://`/`mailto:`. Resolve relative to the doc's parent dir (normalizing `..`, so
4823
+ cross-tree links resolve); a non-existent target → `BrokenDocPath`. Sorted by `(doc, target)`.
4824
+ (Catches stale `index.md` catalog links.)
4825
+ - **Duplicate / routing collisions — the cheap guard (rare by design).** Normalize =
4826
+ `" ".join(value.lower().split())`. Group **same-kind** docs by normalized non-empty `title`
4827
+ (≥2 → `basis="title"`); group **learned** docs by normalized non-empty `read_when` (≥2 →
4828
+ `basis="read_when"`). Sorted by `(basis, key)`; `docs` sorted within. Expected **empty** on a
4829
+ healthy curated corpus — it guards accidental literal duplication and is node 6.1's "duplicated
4830
+ read_when" substrate, **not** the dedup mechanism.
4831
+ - **De-dup is candidate-vs-corpus (the analyst, not Python).** The decision "does the learning being
4832
+ captured already live in an existing doc?" is the existing-docs angle's, made against the **full**
4833
+ `existing_docs[]` inventory **plus** these verified facts (it is never starved). The scan is
4834
+ corpus-wide and **high-recall** — learned docs carry historical pointers — so the analyst weighs
4835
+ findings by relevance to the candidate doc(s) for THIS capture (whole-corpus hygiene is node
4836
+ 6.1's `docs-check`). Within-corpus exact collision is only the guard above.
4837
+ - **`gather_evidence`** calls `scan_docs_richly(repo_root)` unconditionally on a non-skip bundle and
4838
+ stores the result on `EvidenceBundle.docs_findings`; the `--json` envelope (`EvidenceBundleOut`)
4839
+ serializes `docs_findings: DocFindings` declared **after `existing_docs`, before `render`**, so
4840
+ the `manifest.json` write carries it automatically. The human summary line gains a findings tail
4841
+ (`docs: N (stale-ptr: a, broken-link: b, dup-groups: c)`).
4842
+
4843
+ **The session-normalization render (node 3.2, `perk learn evidence --render`).** An opt-in `--render`
4844
+ flag projects the bundle's **found** session JSONLs into bounded, untrusted-DATA-fenced Markdown
4845
+ chunks through a deterministic, ordered normalization pipeline, and (with `--json`) emits a stable
4846
+ normalization report on the envelope's additive `render` field. The decisions are ported from erk's
4847
+ `preprocess_session.py` (its *driving decisions*, not its code/form): bound by **splitting at entry
4848
+ boundaries**, never by leaving chunks uncapped and never by eliding the middle — every entry survives
4849
+ in some chunk; the only lossy compression is per-payload. perk reimplements them in its idiom
4850
+ (lenient boundary model → frozen dataclass → typed pipeline → `OutputModel` report) and diverges
4851
+ where it must (Pi sessions are a `parentId` tree, so the pipeline adds branch selection; perk
4852
+ preserves Pi `compaction`/`branch_summary` entries with their `readFiles`/`modifiedFiles`).
4853
+
4854
+ - **Two pure leaves.** `perk/learn/session_jsonl.py` is the **JSONL grammar parser** — a lenient
4855
+ `SessionEntryModel` (`LenientParseModel`, `extra="ignore"`) → a frozen `SessionEntry`/`ToolCall`
4856
+ projection via `to_domain` + `parse_session_jsonl(path) -> ParsedSession`. `perk/learn/normalize.py`
4857
+ is the **ordered pipeline + renderer + budget splitter + report** (`normalize_session`, the XML-ish
4858
+ renderer + `escape_xml`, `split_to_chunks`, the `RenderReport`/`SessionReport`/`BoilerplateDigest`
4859
+ dataclasses, and `render_evidence`). `normalize.py` imports `session_jsonl`; neither imports
4860
+ `evidence.py` (the command bridges them — no cycle).
4861
+ - **The Pi session JSONL grammar.** A session file is an append-only JSONL log: **line 1 is the
4862
+ header** (`{type:"session", …}`), each later line is one entry; a `parentId` tree threads entries
4863
+ and the last entry is the active leaf (the active branch is the `parentId` walk from it to the
4864
+ root). Entry types: `message` (`role ∈ user/assistant/toolResult` + `bashExecution`), `compaction`
4865
+ (`summary`, `tokensBefore`, `details.readFiles`/`modifiedFiles`), `branch_summary` (`fromId`,
4866
+ `summary`), `custom`/`custom_message` (extension/injected state), plus session mechanics
4867
+ (`model_change`/`thinking_level_change`/`label`/`session_info`). **Unknown future `type` values
4868
+ exist** (real logs carry `active_long_running`/`needs_attention`) → the parse edge is **lenient**:
4869
+ a non-JSON / non-object / type-less line is counted in `malformed_lines`, never raised; a missing
4870
+ file → an empty `ParsedSession`.
4871
+ - **The fixed ordered pipeline (deterministic).** (1) **Select branch evidence** — keep only entries
4872
+ on the leaf's `parent_id` chain (off-branch entries drop). (2) **Classify** — PRESERVED
4873
+ (`compaction`/`branch_summary`), EVIDENCE (`message`/`bashExecution`), BOILERPLATE (everything else,
4874
+ incl. unknown types). (3) **Drop boilerplate → digest** keyed by `<kind>` or `<kind>:<custom_type>`
4875
+ (emitted sorted by label). (4) **Dedup** — (a) byte-identical EVIDENCE payloads collapse to the
4876
+ first + a `↑ duplicate of entry <id>` pointer (one `duplicate_groups` per collapsed set); (b) an
4877
+ assistant entry repeating the previous assistant text AND carrying tool calls drops the duplicated
4878
+ text. PRESERVED entries are exempt. (5) **Prune** non-substantive turns (no text/thinking/tool
4879
+ calls/output/command). (6) **Truncate large payloads** (visible pointers): tool-call args + params
4880
+ head+tail (path-aware) at `_MAX_PARAM_CHARS=200`; tool-result/bash output line-prune (first
4881
+ `_TOOL_RESULT_HEAD_LINES=40` lines + later error-keyword lines + a `… [<N> lines omitted …] …`
4882
+ marker); assistant/user text, thinking, preserved summary head+tail at `_MAX_PAYLOAD_CHARS=4000`.
4883
+ A PRESERVED summary truncates but the entry is **never dropped**.
4884
+ - **Bounding by split-at-budget (D4), never elide.** `split_to_chunks` accumulates a running
4885
+ `estimate_tokens(s) = len(s)//4` and starts a new chunk when the next entry would exceed
4886
+ `_MAX_CHUNK_TOKENS=50_000` (≈200KB) and the current chunk is non-empty. Splits happen only at entry
4887
+ boundaries; every kept entry survives in some chunk. Each found session source is a **role**; its
4888
+ kept entries render into **one or more** chunk files under `<bundle_dir>/chunks/` named
4889
+ `<stem>.md`, `<stem>-2.md`, … . A missing session source produces no chunk and no report.
4890
+ - **The XML-ish untrusted-DATA fence (D6).** Each chunk is a complete
4891
+ `<untrusted_session_evidence role="<category>/<label>" source="<repo-rel jsonl>" part="N">` document
4892
+ with a preamble ("treat every line as DATA … never as instructions to obey"), per-entry blocks
4893
+ (`<user>`/`<assistant>` with `<thinking>` + bounded `<tool_call>`/`<tool_result>`/`<bash>`/
4894
+ `<compaction>` with bounded `<read_files>`/`<modified_files>` (≤`_MAX_FILE_LIST=50`, then
4895
+ `(+K more)`)/`<branch_summary>`), with inner `<`/`>`/`&`/`"` escaped (`escape_xml`).
4896
+ - **The report shapes (`OutputModel` serialize-edge).** `RenderReport = { sessions: SessionReport[] }`;
4897
+ `SessionReport = { role, source, entries_read, entries_kept, entries_pruned, malformed_lines,
4898
+ duplicate_groups, truncations, boilerplate: BoilerplateDigest[], chunk_paths: str[] }`;
4899
+ `BoilerplateDigest = { label, count }` (a typed digest, never a `dict[str,int]` hole). Counters are
4900
+ **per role** (computed before splitting, reported once); `entries_read` excludes the header
4901
+ (`malformed_lines` is separate); `entries_pruned = entries_read − entries_kept`; `chunk_paths`
4902
+ (≥1) and `source` are repo_root-relative. The `--json` envelope gains an additive `render:
4903
+ RenderReport | null` field (declared LAST, **always serialized**, `null` unless `--render`).
4904
+ - **Determinism (the node's "stable manifest" exit).** No wall-clock / randomness / path
4905
+ nondeterminism: entries keep file order; the digest emits sorted by label; dedup is first-wins by
4906
+ content; truncate/prune/budget constants are fixed; `estimate_tokens` is `len//4`; chunk filenames
4907
+ derive from the input stem + part index — so the manifest + chunk bytes are stable across runs.
4908
+ - **No TS twin; not a published `shared/schemas/` artifact.** The only consumer is node 4.2's cold
4909
+ door, which shells the Python command (mirrors node 3.1). `EvidenceBundleOut` is deliberately
4910
+ absent from `shared/schemas/` (§8.34's registered set publishes `learn-capture` only); the additive
4911
+ `render` field touches nothing under `shared/schemas/`. The JSONL parse is a `LenientParseModel`
4912
+ read-edge → frozen `@dataclass`; the report is an `OutputModel` serialize-edge (realizing this
4913
+ section's forward-looking discipline).
4914
+
4915
+ **The learn-analyst angle agent (node 4.1).** A perk-owned project agent
4916
+ `agents/learn-analyst.md` (runtime `perk.learn-analyst`) — fresh-context, read-only, and
4917
+ **report-only** (it never captures learnings, never creates a `perk:learn` issue, never posts,
4918
+ never stages or writes files, never spawns subagents). Delivered like its siblings via `PERK_AGENTS`
4919
+ + the managed `.pi/agents/perk/` convergence. It is the cross-plane **output contract** node 4.2's
4920
+ warm `/learn` orchestrator parses; only the reconciliation logic is deferred.
4921
+
4922
+ - **Four angles** (one assigned per spawn, mirroring `pr-reviewer`): `plan-vs-implementation`
4923
+ (plan vs what shipped), `session-deviations` (course-corrections & durable gotchas),
4924
+ `validation-risk` (what stayed risky / under-tested), `existing-docs` (doc routing onto the
4925
+ manifest's `existing_docs[]` inventory **plus the node-5.1 `docs_findings`**: de-dup is
4926
+ candidate-vs-corpus — decide whether THIS capture's learning already lives in an existing doc,
4927
+ weighing `stale_pointers`/`broken_doc_paths`/`duplicate_groups` **by relevance** to the candidate
4928
+ doc(s); carries the two erk clauses — *VERIFY-not-HARMONIZE* (confirm both docs reference real,
4929
+ existing code before disambiguating) and *one-ghost-+-one-real → `STALE_DOC` the ghost*).
4930
+ - **Input.** The task prompt names (a) the assigned angle and (b) the absolute path to the
4931
+ `perk learn evidence --render --json` manifest (§8.35 above) plus the bundle dir. The child reads
4932
+ the shared bundle — manifest statuses, `existing_docs[]`, `render.sessions[].chunk_paths`,
4933
+ `plan-body.md`, `pr.diff` — and **never re-gathers** (the parent runs the gather once so all
4934
+ angles share one bundle). `missing`/`ambiguous` sources are surfaced in `fyi`, never guessed.
4935
+ - **Output (parsed by node 4.2).** A fenced JSON block `{angle, verdict, candidates[], fyi[]}`.
4936
+ `verdict ∈ {clean, actionable}` is **derived** — any candidate whose `decision` is not `SKIP` ⇒
4937
+ `actionable`, else `clean` (no default verdict — enumerate candidates first, then derive). Each
4938
+ candidate is `{decision ∈ the §8.35 DECISION set, summary, target: str|null, evidence}`. `SKIP`
4939
+ candidates may appear (a weighed-and-rejected item, for the parent's transparency); the durable
4940
+ CAPTURED metadata persists only non-`SKIP` decisions.
4941
+ - **Model** configurable via `[subagents] learn-analyst` (both planes; default
4942
+ `anthropic/claude-sonnet-4-5`, fallback `anthropic/claude-haiku-4-5`).
4943
+ - **Landed (node 4.2) — the warm `/learn` orchestrator.** Bare interactive `/learn` is a multi-angle
4944
+ orchestrator (`extension/doors/learn.ts`, mirroring `/pr-review`): **TS owns the deterministic
4945
+ spine** (gather + branch), **the model owns the judgment** (spawn / reconcile / capture).
4946
+ - **Gather once.** The parent runs `perk learn evidence --render --json` via `runColdDoor` (the
4947
+ single gather — §8.35 "the parent gathers once") and **also writes `<bundle_dir>/manifest.json`**
4948
+ (the full `EvidenceBundleOut` payload, the same as `--json` stdout incl. `render`) so the
4949
+ children can `read` the manifest (they cannot read the door's stdout). Written unconditionally on
4950
+ a materialized bundle (independent of `--json`), deterministic (no wall-clock); no write on a skip.
4951
+ - **Deterministic learn-docs short-circuit.** A success envelope `skipped:true` (a non-empty
4952
+ `consumed_learn` plan) → clear `pending-learn`, report *"learn-docs plan; learn capture skipped"*,
4953
+ inject **no** prompt, spawn **no** children, create **no** issue.
4954
+ - **Graceful degrade.** A gather failure (`!r.ok`) — or a success envelope with a null `bundle_dir`
4955
+ (defensive) — falls back to the prior simple `learnGuidance` injection (`/learn` is never a dead
4956
+ end). The evidence decode (`decodeEvidence`) is fully LENIENT — never null — so the `bad_output`
4957
+ arm is unreachable (mirrors `decodeLearnCapture`).
4958
+ - **Prompt-driven spawn/reconcile/capture.** Otherwise the parent injects the new warm-only
4959
+ `prompts/stages/learn-orchestrate.md` seed (rendered by `learnOrchestrateGuidance`), carrying the
4960
+ absolute manifest path + bundle dir + the configured `[subagents] learn-analyst` model (a per-call
4961
+ inline override on every spawn). The model spawns 2–4 fresh-context analysts (always incl.
4962
+ `session-deviations`, emphasizing off-track/dead-ends/wasted-effort), reconciles the per-angle
4963
+ `{angle, verdict, candidates[], fyi[]}` reports into ONE classified `decision` + a synthesized
4964
+ markdown body recording the per-angle nuance, then calls the `learn` tool to capture (with
4965
+ `decision`/`target`) or — on `SKIP`/nothing durable — with **no `summary`** (clears the marker, no
4966
+ issue). A missing/malformed child report is a skipped angle (noted, never fatal).
4967
+ - **The `learn` tool** gains `decision` (JSON-schema enum of the five captured tokens) + `target`
4968
+ (string) params; the tool-boundary decode mirrors the `summary` strictness (a present-but-mistyped
4969
+ or out-of-enum value ⇒ `bad_input`, marker NOT cleared; absent ⇒ the decision-less path).
4970
+ - **Unchanged paths.** Headless bare `/learn` stays the safe marker-clear (cannot drive a turn /
4971
+ spawn children); `/learn <text>` / `/learn skip` stay the verbatim-capture / marker-clear escape
4972
+ hatches (decision-less); cold `perk learn` launch stays the simple investigate+capture
4973
+ (`stages/learn.md` unchanged — the four `learn-*` golden cases + cold/warm parity preserved).
4974
+ **Deferred (out of scope):** cold-launch orchestration (the node is the warm orchestrator).
4975
+
4976
+ **Generated routing + on-demand checks (node 6.1) — `perk learn docs-sync` / `docs-check`.** The
4977
+ `docs/learned/` navigation is **generated from per-doc frontmatter** (the SSOT) and drift is
4978
+ **detectable on demand**, without wiring freshness into `just ci`/`just test`.
4979
+
4980
+ - **SSOT.** Every `docs/learned/**/*.md` doc **except `docs/learned/index.md`** carries `title` +
4981
+ `read_when` YAML frontmatter. The doc set's category = the posix relative dir under `docs/learned/`
4982
+ (e.g. `workflow`); slug = the filename stem; ordering is **alphabetical by `(category, slug)`** (no
4983
+ hardcoded category list). `read_learned_docs(repo_root)` (in `perk/learn/docs_scan.py`) is the
4984
+ shared, never-raising full-metadata reader (untruncated values; `OSError`/parse failure → `None`).
4985
+ - **Two generated artifacts** (`perk/learn/docs_sync.py`, a pure deterministic leaf importing only
4986
+ `docs_scan`):
4987
+ - **The terse routing block** → `.pi/APPEND_SYSTEM.md` (loaded ambiently into every session's
4988
+ system prompt): one line per doc, `- **<category>/<slug>** — <read_when>` (full, untruncated; no
4989
+ escaping — not a table).
4990
+ - **The per-doc catalog table** → `docs/learned/index.md`: a fixed header plus one
4991
+ `| <category> | [<slug>.md](<category>/<slug>.md) | <read_when, `|`-escaped> |` row per doc (links
4992
+ use the real filename; a table cell escapes `|`→`\|`).
4993
+ - **Markers + preamble.** Each artifact wraps its generated region in
4994
+ `<!-- BEGIN perk docs-sync … -->` / `<!-- END perk docs-sync -->`. `render_with_markers` replaces
4995
+ strictly **between** the markers when both are present (a hand-editable preamble outside them
4996
+ survives), else bootstraps `preamble + BEGIN + region + END`. Generation is **byte-for-byte
4997
+ deterministic** (sorted, fixed formatting, single trailing newline, no wall-clock/random) — re-running
4998
+ `docs-sync` on a converged tree is a no-op.
4999
+ - **`perk learn docs-sync`** (options `--json`, `--dry-run`; `require_repo` only — purely local) writes
5000
+ only artifacts whose content changed (`--dry-run` writes nothing) and reports `written`/`unchanged`.
5001
+ Exit `0` ok · `2` not-a-repo.
5002
+ - **`perk learn docs-check`** (option `--json`; `require_repo` only; read-only) splits **freshness**
5003
+ (each artifact's live marked region must match a fresh render — absent markers or a mismatch ⇒
5004
+ stale) from **advisory hygiene** (`missing_frontmatter`, `source_code_blocks`, plus the reused
5005
+ `docs_scan.scan_docs_richly` dup-`read_when`/stale-pointer/broken-link facts). **Freshness gates the
5006
+ exit; hygiene is advisory** (always printed, never changes a fresh exit): exit `0` fresh · `1` stale
5007
+ · `2` not-a-repo. **Not added to `[[ci]]`** (a deliberate follow-up — promoting freshness into CI is
5008
+ a one-line `[[ci]]` add gating only on freshness).
5009
+ - **The source-code-block heuristic.** A fenced block is flagged copied-source-looking only when its
5010
+ info-string is a source language (`py/python/ts/typescript/js/javascript/tsx/jsx/rust/rs/go`) **and**
5011
+ its body has `>= 10` non-blank lines (`_MAX_SOURCE_BLOCK_LINES`). Data-format/CLI fences
5012
+ (`json/yaml/toml/text/console/sh/bash/diff/ini`) and untagged fences are always allowed. Advisory only.
5013
+ - **Live-tree converge.** Node 6.1 ran `docs-sync` once and committed the regenerated
5014
+ `docs/learned/index.md` + `.pi/APPEND_SYSTEM.md`. A deliberate, documented consequence: the routing
5015
+ order is now `pi`, `toolchain`, `workflow` (alphabetical), the per-category mega-line blob is gone
5016
+ (replaced by per-doc lines), and the catalog's renamed-file links + row counts are corrected.
5017
+
5018
+ ## §8.36 · Canonical post-merge learn state (the plan-header `learn_state` field)
5019
+
5020
+ Post-merge learn state is **canonical in the issue backend**, not the local marker: the plan-header
5021
+ carries a land-staged `learn_state` field, so a merged-but-unlearned plan resolves identically from
5022
+ any machine, a fresh clone, or the main checkout. The local `pending-learn` marker (§ *The
5023
+ `pending-learn` semaphore* above) is **demoted to cache/friction-semaphore**: the in-worktree retry
5024
+ signal and the `worktree wipe` guard — never the source of truth.
5025
+
5026
+ **Vocabulary (`plan.LearnState`, a `StrEnum`; `"learn_state"` ∈ `PLAN_HEADER_FIELDS`).**
5027
+
5028
+ - `pending` — merged, learn not yet run.
5029
+ - `captured` — a `perk:learn` issue was created for this plan.
5030
+ - `skipped` — learn deliberately skipped (terminal; never reads as pending again).
5031
+ - **Absent** — a legacy (pre-field) plan or a failed stamp; resolution falls back to the local
5032
+ marker (exactly today's behavior — never worse).
5033
+
5034
+ The field is **land-staged**: never rendered at initial save (fresh headers stay byte-identical —
5035
+ no `learn_state: null` line; `PlanHeader`/`PlanHeaderOut` do NOT grow), written only through the
5036
+ existing `IssueBackend.update_plan_header` merge-write (both backends for free; unknown keys
5037
+ preserved on re-save).
5038
+
5039
+ **The three writers.**
5040
+
5041
+ 1. **`perk pr land`** (`_stamp_learn_state`, non-dry-run, after merge + `set_marker`): stamps
5042
+ `skipped` when `plan_ref.consumed_learn` is non-empty (a learn-docs consolidation plan skips its
5043
+ learn pass by design — it must never read forever-pending), else `pending`. **Never-downgrade
5044
+ guard**: an existing `captured`/`skipped` is kept (an idempotent re-land after `/learn` must not
5045
+ resurrect a done plan) and returned as the effective state. **Fail-open loud** (the on-land
5046
+ secondary-bookkeeping shape): never raises; a failure warns on stderr and the envelope carries
5047
+ `learn_state: null`. `PrLandOut.learn_state` is declared last (field byte-order preserved).
5048
+ 2. **`perk learn capture`**: stamps `captured` **strictly** (an `IssueBackendError` propagates,
5049
+ exit 1) and **before** `cache.clear_marker` — the local marker is cleared only once canonical
5050
+ state is terminal; a failed stamp leaves the marker set and the retry converges (capture is
5051
+ idempotent via the `run_id` finder). Capture always stamps `captured`: a capture after a skip is
5052
+ a legitimate upgrade.
5053
+ 3. **`perk learn skip`** (the cold skip door; `LearnSkipOut` envelope
5054
+ `{success, error_type, message, plan_issue, learn_state, pending_cleared, dry_run}`): stamps
5055
+ `skipped` strictly before the marker clear — **unless** the existing value is `captured` (then a
5056
+ no-op stamp; the envelope reports the kept `captured`; the marker is still cleared). `--dry-run`
5057
+ composes offline (no write, no marker change). Exit codes mirror `learn capture` (0/1/2). The
5058
+ warm no-summary `/learn` arm (the `learn` tool without `summary`, `/learn skip`, headless bare
5059
+ `/learn`) **delegates here** — a deliberate skip is never a TS-only marker-clear; on a failed
5060
+ delegation the warm door does NOT clear the marker (never silently close the cycle on
5061
+ uncertainty). The warm decode is fully lenient (render-only fields; `bad_output` unreachable).
5062
+ The learn-docs short-circuit in bare `/learn` stays a local marker-clear only — land already
5063
+ stamped `skipped` for a `consumed_learn` plan.
5064
+
5065
+ **The reader (`resume.resolve_next_action`'s MERGED arm, §8.37).**
5066
+
5067
+ | header `learn_state` | local marker | resolves to |
5068
+ | --- | --- | --- |
5069
+ | `pending` | (ignored) | `learn` |
5070
+ | `captured` / `skipped` | (ignored — even stale) | `done` |
5071
+ | absent / unrecognized | set | `learn` (the legacy fallback) |
5072
+ | absent / unrecognized | unset | `done` |
5073
+
5074
+ `has_pending_learn` stays a kwarg — it is now explicitly the legacy/cache **fallback** signal.
5075
+
5076
+ **Registry.** `land.writes` and `learn.writes` both include `github.plan` (the header stamp).
5077
+
5078
+ ---
5079
+
5080
+ ## §8.37 · Unified next-stage resolution (the shared classifier, Objective #1093 Node 1.2)
5081
+
5082
+ `perk plan resume` and `perk objective run` answer the same question — *given this plan's
5083
+ canonical state, what happens next?* — through **one shared pure function**,
5084
+ `resume.resolve_next_action(plan_state, *, has_pending_learn, get_feedback) -> NextAction`
5085
+ (`perk/run/resume.py`; pure, deterministic, no Click/subprocess/network), so the two surfaces
5086
+ provably agree.
5087
+
5088
+ ### The `NextAction` vocabulary (a `StrEnum`)
5089
+
5090
+ Seven verdicts: `implement` · `address` · `learn` (launchable — `NextAction.stage_id` returns the
5091
+ registry stage id) and `ready_for_review` · `awaiting_review` · `pr_closed` · `done`
5092
+ (gates/terminal — `stage_id` is `None`).
5093
+
5094
+ ### The classification matrix (arm order over the normalized PR vocabulary)
5095
+
5096
+ | plan state | verdict |
5097
+ |---|---|
5098
+ | `pr is None` (no PR yet) | `implement` |
5099
+ | `MERGED` + header `learn_state: pending` | `learn` |
5100
+ | `MERGED` + header `captured`/`skipped` | `done` (even with a stale marker) |
5101
+ | `MERGED`, field absent/unrecognized | `learn` iff `has_pending_learn`, else `done` |
5102
+ | `CLOSED` (unmerged) | `pr_closed` |
5103
+ | `is_draft` | `ready_for_review` (feedback is **never** fetched for a draft) |
5104
+ | OPEN non-draft (any unknown state is treated as open) | `address` if `needs_address(get_feedback(pr.number))`, else `awaiting_review` |
5105
+
5106
+ `get_feedback: Callable[[int], PrFeedback]` is the **lazy injected** feedback fetch — called only
5107
+ on the OPEN-non-draft arm (offline tests pass a raising stub for every other arm; the callers pass
5108
+ `github.get_pr_feedback`, which raises `GitHubError` on infra failure — translated at each Click
5109
+ boundary). `has_pending_learn` is the §8.36 legacy/cache **fallback** input (the local
5110
+ `pending-learn` marker); the canonical plan-header `learn_state` field wins whenever recognized.
5111
+
5112
+ ### The `needs_address` predicate (pure, offline-testable; moved here from §8.20)
5113
+
5114
+ `needs_address(feedback: PrFeedback) -> bool` — canonical import path `perk.run.resume` — is
5115
+ **True** when either any `review_thread.is_resolved is False`, **or** the **latest review per
5116
+ author** is `CHANGES_REQUESTED`. "Latest per author" = the `Review` with the max `submitted_at`
5117
+ (ISO-8601 string compare; `None` sorts oldest). A `COMMENTED`/`APPROVED` latest review does
5118
+ **not** trigger address; `discussion_comments` are never address triggers (conversation, not
5119
+ change requests).
5120
+
5121
+ ### The two consumers
5122
+
5123
+ - **`perk plan resume`** launches a launchable verdict's stage (dry-run previews it) and
5124
+ **reports** a gate/terminal verdict — gate arms never launch, in both real and dry-run modes
5125
+ (benign decisions, exit 0), naming the human gate instead of launching the wrong stage. There
5126
+ is **no `submit` resume target**: an open PR resolves to `address`, `awaiting_review`, or
5127
+ `ready_for_review`. Both resume payload shapes carry `next_action`; the launchable shape keeps
5128
+ `resumed_stage` (always equal to `next_action.stage_id`), gate shapes carry
5129
+ `{success: true, plan, next_action, resumed_stage: null, pr, message}`.
5130
+ - **`perk objective run`** maps the verdict onto its §8.20 `action` vocabulary (table there) and
5131
+ carries the verdict verbatim in the payload's `next_action` field.
5132
+
5133
+ ### The parity guarantee
5134
+
5135
+ For the same plan state, `perk plan resume <id> --dry-run --json` and
5136
+ `perk objective run <N> --dry-run --json` report the **same `next_action`**
5137
+ (`tests/test_next_action_parity.py`).