@mgiles/perk 1.0.1
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.
- package/README.md +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- package/shared/registry.yaml +199 -0
|
@@ -0,0 +1,4172 @@
|
|
|
1
|
+
# perk cross-plane contracts
|
|
2
|
+
|
|
3
|
+
The four language-neutral contracts both planes obey, authored once here and bundled into
|
|
4
|
+
each build artifact (`Q12`). These are **prose specs** (no parser): the Python CLI (`perk`)
|
|
5
|
+
and the TS extension (`@mgiles/perk`) each implement one side, against the exact names/paths/
|
|
6
|
+
fields pinned below. `perk doctor` (T6) verifies conformance.
|
|
7
|
+
|
|
8
|
+
There are now **three** parsed contracts (siblings of this file): `registry.yaml` — the stage
|
|
9
|
+
graph, whose `state_keys` block is the canonical vocabulary referenced throughout this
|
|
10
|
+
document — `bindings.yaml` — the skill-binding set (trigger→skill delivery), specified
|
|
11
|
+
in §8.9 — and `providers.yaml` — the provider-selection supported set, specified in §8.10.
|
|
12
|
+
|
|
13
|
+
Source decisions: `Q1` (workflow-state), `Q2` (layout + run_id), `Q3` (verified linkage),
|
|
14
|
+
`Q9`/`Q10` (gateway). Pi mechanics are cited against
|
|
15
|
+
[pi--best-practices.md](../docs/pi--best-practices.md).
|
|
16
|
+
|
|
17
|
+
> **History.** The chronological `Status (…)` landing-note changelog lives in the sibling
|
|
18
|
+
> [`contracts-history.md`](./contracts-history.md), grouped by `§N.M` anchor; this file is the
|
|
19
|
+
> compact current spec.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## §8.1 · `.pi/workflow/` layout (Q2)
|
|
24
|
+
|
|
25
|
+
The local cache tier — written and read by **both** the CLI (exterior) and the extension
|
|
26
|
+
(interior). Fixed layout:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
.pi/workflow/
|
|
30
|
+
├── plans/ # materialized plan cache (canonical copy stays in GitHub)
|
|
31
|
+
├── plan.md # cache.plan: the materialized plan body (transient per-worktree mirror)
|
|
32
|
+
├── plan-ref.json # cache.plan-ref: the active plan->branch ref pointer (local mirror)
|
|
33
|
+
├── scratch/runs/<run_id>/ # per-run inter-process workflow files (diffs, generated bodies)
|
|
34
|
+
│ └── data/ # the session data dir (Node 1.2): run-scoped session artifacts
|
|
35
|
+
├── handoff/<run_id>.json # pre-session CLI->extension cold-door state (claimed on session_start)
|
|
36
|
+
├── agent-session.json # cache.agent-session: the Linear AgentSession pointer (§8.22)
|
|
37
|
+
└── markers/ # existence-based friction semaphores (e.g. pending-learn)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- Keyed by the perk-owned **`run_id`** (a ULID — see §8.2), never the Pi session id (which
|
|
41
|
+
does not exist yet at cold-door launch time). The keying `run_id` may be **CLI-minted**
|
|
42
|
+
(cold launch, `perk/state/run_id.py`) or **extension-minted** (a warm session with no identity,
|
|
43
|
+
§8.2 — `extension/substrate/runId.ts`); handoff blobs remain cold-launch-only.
|
|
44
|
+
- **Handoff blob:** `{ run_id, stage, mode, consumed }` (+ `pi_session_id` once claimed). The
|
|
45
|
+
CLI's cold launch (`perk <stage>`, T4) writes it; the extension claims it on `session_start`
|
|
46
|
+
and sets `consumed: true` (§8.2). `stage` is the target stage id — the launched session's
|
|
47
|
+
interior *handler* acts on it (Phase 1); T4's extension reads only `mode`/`run_id`.
|
|
48
|
+
- **Session-data accessor seam (Objective #339 Node 1.2).** The session data dir is
|
|
49
|
+
`scratch/runs/<run_id>/data/` — a dedicated subdir so run-scoped session artifacts never
|
|
50
|
+
overlap perk machine records (`dispatch.json`, `events.ndjson`, `ci-*.md`) living directly in
|
|
51
|
+
the run dir — created lazily on first write (`session_start` stays artifact-free). All
|
|
52
|
+
scratch/session-data paths flow through one accessor per plane: `perk/state/cache.py` (exterior;
|
|
53
|
+
consumers hold an explicit `run_id`) and `extension/substrate/cache.ts` + `extension/substrate/sessionData.ts`
|
|
54
|
+
(interior; the ctx seam resolves the current `run_id` from `perk:workflow-state` and degrades
|
|
55
|
+
to `null` when the session has no identity — never a stamp `run_id`, contrast
|
|
56
|
+
`coldDoor.activeRunId`). Helpers degrade gracefully: absence and I/O failure → `None`/`null`
|
|
57
|
+
plus a stderr warning, never an exception. Manual construction of the `scratch`/`runs` path
|
|
58
|
+
segments outside the seam is forbidden and guard-tested in both planes
|
|
59
|
+
(`extension/cacheGuard.test.ts`, `tests/test_cache_guard.py`). The dedicated
|
|
60
|
+
`cache.session-data` state key is now real (Node 2.1): it names the run-scoped session data
|
|
61
|
+
dir artifacts and is declared in `writes` by the read-only authoring stages — `plan`,
|
|
62
|
+
`objective-plan`, and `objective-author` (`cache.scratch` still names the broader substrate).
|
|
63
|
+
|
|
64
|
+
**The plan-draft file tool (Node 2.1).** The tool `plan_draft` (interior-only; no Python
|
|
65
|
+
twin) is the first session-data producer: it writes the working plan during read-only plan
|
|
66
|
+
authoring. It is allowlisted in `READ_ONLY_TOOLS` (`extension/substrate/toolGating.ts`) as a **narrow
|
|
67
|
+
structural carve-out**: the tool has no path/name parameter — the artifact name is the fixed
|
|
68
|
+
constant `plan-draft.md` (`PLAN_DRAFT_ARTIFACT`, `extension/factories/planDraft.ts`) and the path is
|
|
69
|
+
derived exclusively through the accessor seam (`writeSessionArtifact`: file + provenance
|
|
70
|
+
pointer) — so the only bytes it can ever write are the one working-plan artifact in the
|
|
71
|
+
current run's data dir (gitignored scratch); the gate's `tool_call` `edit`/`write`/bash
|
|
72
|
+
blocking is unchanged. Semantics: full rewrite per call, non-terminating, NOT a save —
|
|
73
|
+
`plan_save`/`/plan-save` remain the canonical GitHub persist surface. Failure taxonomy (soft
|
|
74
|
+
results, never throws): mistyped params → `bad_input`; empty/whitespace plan →
|
|
75
|
+
`invalid_input`; no session `run_id` → `no_run_id`; file-or-pointer write failure →
|
|
76
|
+
`write_failed`. Consumers read the draft only via `readSessionArtifact` (digest-validated,
|
|
77
|
+
fail-open).
|
|
78
|
+
|
|
79
|
+
**File-first plan save (Node 2.2).** Both save surfaces resolve their plan through one shared
|
|
80
|
+
resolver (`resolvePlanSource`, `extension/factories/planSave.ts`), in order: (1) the validated
|
|
81
|
+
`plan-draft.md` artifact (`readSessionArtifact` — digest-validated, fail-open: no run_id / no
|
|
82
|
+
pointer / fork run_id mismatch / missing file / digest mismatch all fall through); (2) the
|
|
83
|
+
explicit `plan` param (tool only — now **optional** in the `plan_save` schema); (3) the
|
|
84
|
+
`extractPlanMarkdown` transcript scrape — the universal fail-open last resort for every save
|
|
85
|
+
surface; else the save refuses (`invalid_input` on the tool, a warning report on the command).
|
|
86
|
+
When the artifact wins over a differing non-blank `plan` param, the ignored param is **surfaced**
|
|
87
|
+
in the success message ("⚠ differing plan param ignored"), never silent and never a hard-fail.
|
|
88
|
+
Non-param sources are announced in the success message (`plan source: …`; param-path messages
|
|
89
|
+
stay byte-stable) and the machine-readable `plan_source` (`"plan-draft" | "param" |
|
|
90
|
+
"transcript" | null`) always lands in the tool's `details`.
|
|
91
|
+
|
|
92
|
+
**The objective-draft file tool (Objective #352 Node 2.1).** The tool `objective_draft`
|
|
93
|
+
(interior-only; no Python twin) is the objective-flavored twin of `plan_draft`: it writes the
|
|
94
|
+
working objective during read-only objective authoring. It is allowlisted in `READ_ONLY_TOOLS`
|
|
95
|
+
via the same structural carve-out argument (no path/name parameter; the artifact name is the
|
|
96
|
+
fixed constant `objective-draft.json` — `OBJECTIVE_DRAFT_ARTIFACT`,
|
|
97
|
+
`extension/factories/objectiveDraft.ts` — and the path derives exclusively through the accessor seam;
|
|
98
|
+
the gate's `edit`/`write`/bash blocking is unchanged). The artifact is a **single JSON file**
|
|
99
|
+
carrying `{schema_version: 1, title?, prose, roadmap}` — the structured roadmap rides
|
|
100
|
+
**verbatim** (node-shape validation stays with the Python plane at save time, the
|
|
101
|
+
`parse_structured_roadmap` path; an empty roadmap is allowed — only creation rejects
|
|
102
|
+
roadmap-free objectives). **The JSON is storage/transport only** — the human review surface
|
|
103
|
+
(node 2.2, Plannotator or the first-party editor) displays rendered markdown (the prose + a
|
|
104
|
+
markdown roadmap table) derived from the artifact, never raw JSON. Semantics: full rewrite per
|
|
105
|
+
call, non-terminating, NOT a save — `objective_save`/`/objective-save` remain the canonical
|
|
106
|
+
GitHub persist surface. Failure taxonomy (soft results, never throws): mistyped params →
|
|
107
|
+
`bad_input`; empty/whitespace prose → `invalid_input`; no session `run_id` → `no_run_id`;
|
|
108
|
+
file-or-pointer write failure → `write_failed`. Consumers read the draft only via
|
|
109
|
+
`readSessionArtifact` (digest-validated, fail-open). **The review surface (node 2.2, landed):**
|
|
110
|
+
`plan_review` in an objective-author session reviews the **rendered markdown** —
|
|
111
|
+
`readObjectiveDraft` (fail-open validation over the artifact: stderr warning + `null` on
|
|
112
|
+
malformed JSON / non-object payload / wrong `schema_version` / blank prose) +
|
|
113
|
+
`renderObjectiveDraft` (the prose plus a `## Roadmap` markdown table; a `Phase` column only
|
|
114
|
+
when some node carries one; cells sanitized) — **never raw JSON, never the `plan` param,
|
|
115
|
+
never the transcript**. No draft → soft-skip `reason: "no_objective_draft"` with an
|
|
116
|
+
`objective_draft` redirect. **The approval→save orchestration (node 2.3, landed):** an
|
|
117
|
+
APPROVED outcome wires into the `objectiveApprovalSave` seam (`extension/factories/objectiveSave.ts`,
|
|
118
|
+
the objective sibling of `approvalSave`): the seam **re-reads the structured artifact at save
|
|
119
|
+
time** (`readObjectiveDraft` — never the rendered markdown, never a param, never the
|
|
120
|
+
transcript) → `saveObjective` → D1a gate exit on a successful save (snapshot
|
|
121
|
+
`gating.isActive()` before the save) → a **terminating** result; a failed save is
|
|
122
|
+
non-terminating, the gate stays read-only, and the human `/objective-save` failsafe is
|
|
123
|
+
directed. Title precedence: an explicit title wins; else the draft's `title`; else the cold
|
|
124
|
+
door derives from the prose heading.
|
|
125
|
+
|
|
126
|
+
**Provenance (Node 1.3).** Session artifacts become *consumable* only via their
|
|
127
|
+
`session_artifacts` pointer in `perk:workflow-state` (§8.3) — a bare file on disk is never
|
|
128
|
+
trusted. The digest convention is `sha256:<hex>` of the bytes **read back** from disk after
|
|
129
|
+
the write. Validation derives the path from `run_id` + `name` through the accessor seam; the
|
|
130
|
+
recorded `path` is informational only and never dereferenced (workflow-state entries are
|
|
131
|
+
reconstructable from untrusted session history). Lifecycle: **rewind** ⇒ the rebuilt branch
|
|
132
|
+
carries an older pointer while disk holds newer bytes ⇒ digest mismatch ⇒ refusal; **fork /
|
|
133
|
+
concurrent sessions** ⇒ the pointer's `run_id` ≠ the active one ⇒ refusal (no inheritance —
|
|
134
|
+
a fork child's data dir starts empty); **reload / compaction** ⇒ same `run_id` ⇒ pointer and
|
|
135
|
+
dir persist through the LWW rebuild. Consumers fail open to their fallback when validation
|
|
136
|
+
refuses (the reader returns `null`; mismatched-run_id refusals are silent by design, broken
|
|
137
|
+
promises — missing file, digest mismatch — warn on stderr).
|
|
138
|
+
- **GC is perk-owned:** prune `scratch/runs/<id>/` + `handoff/<id>.json` per two rules —
|
|
139
|
+
**terminal-stage** (a *consumed* handoff whose `stage` has empty registry `successors`;
|
|
140
|
+
currently exactly `learn`, computed never hardcoded) ⇒ eligible regardless of age; and
|
|
141
|
+
**age** (older than `max_age_days`, default **14**) ⇒ eligible. The age is the run's ULID
|
|
142
|
+
self-date (`run_id` names self-date; fork suffixes strip via the base ULID), with the run
|
|
143
|
+
dir's / handoff file's `st_mtime` as the fallback for stray non-ULID names. Warm-minted run
|
|
144
|
+
dirs (no handoff ⇒ no stage) are age-pruned only. Current-run protection: a candidate whose
|
|
145
|
+
base ULID matches `$PERK_RUN_ID` (incl. its `<ulid>.<n>` fork children) is always kept.
|
|
146
|
+
Degrade-graceful: an unreadable handoff contributes no stage (age rule only — never
|
|
147
|
+
terminal-prune on a guess); a broken registry degrades the terminal set to empty (the age
|
|
148
|
+
rule still applies — GC never crashes on a broken install). Surfaces: the `cache-gc` `doctor`
|
|
149
|
+
check (a `warn` with remediation `perk state prune` whenever anything is prunable — **no
|
|
150
|
+
`--fix` arm**: deletion is *exclusively* `perk state prune`) and the `perk state prune`
|
|
151
|
+
command (alias `gc`; `--dry-run`/`--max-age-days`/`--json`). Policy home: `perk/state/gc.py`
|
|
152
|
+
(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).
|
|
159
|
+
- **`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
|
|
161
|
+
**Python cold door** (`perk plan-save`) writes it on a real save; the **extension** reads it
|
|
162
|
+
on `session_start` to reconcile `active_plan_ref` (§8.3). The cross-plane contract is the
|
|
163
|
+
*file* (`perk/state/cache.py` ↔ `extension/substrate/cache.ts`), not a shared module.
|
|
164
|
+
- **Selector vs binding duality (#43).** The file plays **two roles by checkout**. In the
|
|
165
|
+
**repo root** it is a mutable **selector** — "the plan a no-arg cold `perk implement`
|
|
166
|
+
consumes next" — written by `save`; the `worktree: none` stages (`plan`/`objective-plan`/
|
|
167
|
+
`save`) run here. In a **`plan-<N>` worktree** it is the durable **binding** — "this
|
|
168
|
+
worktree IS implementing plan #N" — materialized by the implement cold door; the worktree
|
|
169
|
+
stages (`implement`/`submit`/`address`/`land`/`learn`) run here. The selector is *not*
|
|
170
|
+
canonical history (GitHub is); it self-heals at the next `save`. The extension must never
|
|
171
|
+
let a stale **root selector** leak into a fresh planning session — hence the stage-gated
|
|
172
|
+
reconciliation in §8.3.
|
|
173
|
+
|
|
174
|
+
State keys (registry vocabulary): `cache.plan`, `cache.plan-ref`, `cache.scratch`,
|
|
175
|
+
`cache.handoff`, `cache.markers`, `cache.session-data`.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## §8.2 · The `PERK_RUN_ID` protocol (Q2)
|
|
180
|
+
|
|
181
|
+
`run_id` is a perk-minted **ULID** (time-sortable → trivial chronological ordering and
|
|
182
|
+
"GC older than N" queries). It is simultaneously the **launch token**, the **cache key**
|
|
183
|
+
(`scratch/runs/<run_id>/`, `handoff/<run_id>.json`), and the **correlation key** tying the
|
|
184
|
+
CLI launcher → handoff blob → the session's `perk:workflow-state` entry → scratch dir →
|
|
185
|
+
GitHub event blocks → worker logs.
|
|
186
|
+
|
|
187
|
+
**Channel — an env var (the only clean Pi launch channel).** Pi exposes no first-class
|
|
188
|
+
"pass control data to the extension at launch" flag. The CLI sets `PERK_RUN_ID=<ulid>` in the
|
|
189
|
+
environment before `exec pi`; an initial message or `@file` would pollute LLM context.
|
|
190
|
+
|
|
191
|
+
**Claim (on `session_start`)** — strict verified linkage (`Q3` establish-before-consume):
|
|
192
|
+
1. read `process.env.PERK_RUN_ID`;
|
|
193
|
+
2. load + verify `handoff/<run_id>.json` (read-back; on mismatch raise a hard, actionable
|
|
194
|
+
error — never a silent `pass`);
|
|
195
|
+
3. record `run_id` in `perk:workflow-state` (§8.3);
|
|
196
|
+
4. mark the handoff **consumed**.
|
|
197
|
+
|
|
198
|
+
**Optional handoff link context (`objective_id`/`node_id`, #78).** Beyond the claim fields, a
|
|
199
|
+
stage may stash extra keys in its handoff blob (the TS `Handoff` interface already carries
|
|
200
|
+
`[key: string]: unknown`). `objective-plan` writes the `objective_id`/`node_id` it just marked
|
|
201
|
+
`planning` so a later `perk plan-save` recovers the objective→node link **regardless of which save
|
|
202
|
+
surface the model used** — the `plan_save` *tool* passes the link explicitly, but an
|
|
203
|
+
approval-triggered `approvalSave` (and its `/plan-save` manual-failsafe invocation, which takes
|
|
204
|
+
only an optional title) carries no link params at all; the warm `objective_node_claim` carrier
|
|
205
|
+
(§8.3) covers those in-session, and this cold handoff carrier covers the relaunch/cold path
|
|
206
|
+
(→ §8.23). `plan-save` reads the
|
|
207
|
+
handoff and defaults `objective_id`/`node_id` from it only when neither flag was passed (explicit
|
|
208
|
+
flags always win; a non-objective handoff has no `objective_id`, so plain planning is unaffected).
|
|
209
|
+
|
|
210
|
+
The same carrier ferries `consumed_learn` (#102). `learn-docs` launches a **read-only** plan-mode
|
|
211
|
+
session, where the `plan_save` *tool* is gated out (`toolGating.ts`); the save lands review-first
|
|
212
|
+
through `approvalSave` (or the `/plan-save` failsafe), and only the `plan_save` tool's explicit
|
|
213
|
+
`consumed_learn` param can carry the numbers warm — the handoff carrier makes the consume
|
|
214
|
+
mechanism independent of which surface fired. The `learn-docs` cold door stashes them as
|
|
215
|
+
`handoff_extra={"consumed_learn": […]}`, and
|
|
216
|
+
`plan-save` recovers them (`_consumed_learn_from_handoff`) when `--consumed-learn` is absent
|
|
217
|
+
(explicit flag wins; a non-factory handoff has no key, so plain planning is unaffected). This makes
|
|
218
|
+
the consume mechanism independent of which save surface the model used.
|
|
219
|
+
|
|
220
|
+
**Fork ≠ branch (easy to get wrong).**
|
|
221
|
+
- A **fork** (`/fork`, `/clone`, `ctx.newSession({ parentSession })`, or a headless
|
|
222
|
+
`pi --fork`) creates a **new session file** that inherits the parent's entries — so the
|
|
223
|
+
parent's `perk:workflow-state` (hence its `run_id`) is present in the child's
|
|
224
|
+
`getBranch()`. **Detect a fork by the `run_id ↔ pi_session_id` mapping, not the
|
|
225
|
+
`session_start` reason:** a headless `pi --fork` arrives as `reason: "startup"` (not
|
|
226
|
+
`"fork"`) with no `previousSessionFile`, so reason-based detection is unreliable. On
|
|
227
|
+
`session_start`, compare the rebuilt entry's recorded `pi_session_id` to the **current**
|
|
228
|
+
session handle (the basename of `getSessionFile()`): **equal ⇒ reload** (keep the
|
|
229
|
+
`run_id`); **different ⇒ fork** — the `run_id` was inherited from another session, so
|
|
230
|
+
**derive a child-scoped id `<run_id>.<n>`**, record the parent as `predecessor`, and
|
|
231
|
+
isolate the child's scratch. Do **not** blindly inherit `PERK_RUN_ID` (that would hand the
|
|
232
|
+
parent's id to the child).
|
|
233
|
+
- `/tree` branches **in place** (same file / UUID / process), so `PERK_RUN_ID` in the env
|
|
234
|
+
survives and the `run_id` stays **stable**.
|
|
235
|
+
|
|
236
|
+
**Mint doctrine (three-way).** A warm in-session *stage transition* **keeps** the `run_id`
|
|
237
|
+
(matches the registry per-stage `run_id` policy); a *cold* relaunch **mints** a new `run_id`
|
|
238
|
+
in the **Python plane** (`perk/state/run_id.py`) that **records its predecessor**, so resume/relaunch
|
|
239
|
+
chains stay traceable; and a **warm session with no identity** (decideClaim's `none` arm — no
|
|
240
|
+
branch `run_id`, no `PERK_RUN_ID`: ad-hoc `pi`, `pi --plan`, spawned subagent children) **mints
|
|
241
|
+
its own ULID in the TS plane** (`extension/substrate/runId.ts`) on `session_start`, recording
|
|
242
|
+
`{run_id, pi_session_id}` via the strict append seam (§8.3) — **no predecessor, no handoff, no
|
|
243
|
+
disk artifacts**. A **failed cold claim never falls back to a mint** (`PERK_RUN_ID` set but the
|
|
244
|
+
handoff missing/mismatched stays a loud unclaimed error — minting would mask a launcher bug).
|
|
245
|
+
Under `PERK_SELFCHECK`, the T3 sentinel records a successful warm mint as `source: "mint"`.
|
|
246
|
+
|
|
247
|
+
The Pi session UUID is kept as a **secondary handle** (needed for `SessionManager.open` /
|
|
248
|
+
`continueRecent` on resume); the `run_id ↔ pi_session_id` mapping lives in `perk:workflow-state`.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## §8.3 · The `perk:workflow-state` schema (Q1)
|
|
253
|
+
|
|
254
|
+
The single namespaced session entry holding transient (tier-3) workflow state.
|
|
255
|
+
|
|
256
|
+
**Record (per-field last-write-wins):**
|
|
257
|
+
|
|
258
|
+
| field | type | meaning |
|
|
259
|
+
|---|---|---|
|
|
260
|
+
| `run_id` | string (ULID) | the perk run this session belongs to (§8.2) |
|
|
261
|
+
| `predecessor` | string \| null | the prior `run_id` this run forked from (or cold-relaunched after), §8.2; null for an original run |
|
|
262
|
+
| `pi_session_id` | string | the current session handle — the basename of Pi's session file; the **fork discriminator** (§8.2) and the key to resume via `SessionManager.open`/`continueRecent` |
|
|
263
|
+
| `mode` | string | the active registry stage `mode` (`read-only` / `read-write`) — **structurally gates tools** (P2.T1, see below) |
|
|
264
|
+
| `stage` | string | the registry stage id this run is acting on, recorded at cold **claim** from the handoff (P3.T2); lets the interior distinguish two read-only stages (e.g. `objective-author` vs `plan`) and inject the right authoring context |
|
|
265
|
+
| `active_plan_ref` | object \| null | the provider-agnostic plan ref (§8.4); null during early `plan` |
|
|
266
|
+
| `active_objective` | string \| null | the active objective id; **live since P2.T9** (`/objective <id>` sets it, `/objective clear` nulls it) |
|
|
267
|
+
| `last_review_batch` | object \| null | the last processed review batch (P2.T7): `{ pr, counts:{actionable,informational,praise,question}, resolved_thread_ids:[…], at:ISO }` |
|
|
268
|
+
| `session_artifacts` | object \| null | per-name session-artifact provenance pointers `{run_id, name, path, digest, at}` (Node 1.3, §8.1); appends carry the **whole merged map** (per-field LWW); strict-append tier |
|
|
269
|
+
| `objective_node_claim` | object \| null | the objective node this session has claimed `planning` (`{ objective, node }`, Node 2.3 of #339); written by the warm `objective_node` tool on a successful `planning` transition, cleared on a successful non-planning transition for the same node and after a successful node-linked plan save; best-effort tier (cheaply reconstructable; loud-but-non-fatal) |
|
|
270
|
+
| `conflict_resolution_attempts` | number | the bounded conflict-resolution re-drive counter (#556): incremented each time `/submit` drives the `perk.conflict-resolver` subagent on a definitively-unmergeable PR, reset to 0 on a clean submit; best-effort tier (cheaply reconstructable) |
|
|
271
|
+
|
|
272
|
+
**Persistence channel:** `pi.appendEntry("perk:workflow-state", data)`. (The *other* Pi
|
|
273
|
+
channel — tool-result `details` — is for state that *is* a tool's output; this is not that.)
|
|
274
|
+
|
|
275
|
+
**Rebuild (non-negotiable discipline, pi §4):** scan `ctx.sessionManager.getBranch()` for
|
|
276
|
+
`entry.type === "custom" && entry.customType === "perk:workflow-state"`, **on both
|
|
277
|
+
`session_start` AND `session_tree`** (skipping `session_tree` is the bug that makes state
|
|
278
|
+
stale after the user navigates the tree). Apply **per-field last-write-wins** so two tools
|
|
279
|
+
writing different fields in the same turn don't clobber each other.
|
|
280
|
+
|
|
281
|
+
**Subtlety borrowed from `plan-mode`:** when reconstructing state tied to a current
|
|
282
|
+
execution, only re-scan entries **after** the marker that began it, so stale fields from a
|
|
283
|
+
previous execution don't resurrect.
|
|
284
|
+
|
|
285
|
+
**Verified linkage tier (Q3):** the `run_id ↔ pi_session_id` mapping and `active_plan_ref`
|
|
286
|
+
are **strict** (durable/cross-process → read-back + correct ordering); purely transient
|
|
287
|
+
fields cheaply reconstructable on the next `session_start`/`session_tree` are
|
|
288
|
+
best-effort-with-logging (never silently swallowed).
|
|
289
|
+
|
|
290
|
+
**`active_plan_ref` reconciliation (T2b, stage-gated #43):** on `session_start`, after the
|
|
291
|
+
run_id claim, the extension reconciles `cache.plan-ref` into `active_plan_ref` — but **only
|
|
292
|
+
when the launched stage *consumes* the ref**, i.e. the stage's registry `requires`/`reads`
|
|
293
|
+
list `cache.plan-ref`. That is exactly the worktree binding stages
|
|
294
|
+
(`implement`/`submit`/`address`/`land`/`learn`); the root `worktree: none` stages
|
|
295
|
+
(`plan`/`objective-plan`/`save`) do **not** consume it, so a fresh planning session never
|
|
296
|
+
inherits the stale **root selector** (§8.1's duality). The launched stage is read from the
|
|
297
|
+
run's **handoff** blob (`stage`); only a settled run has one — `claim` (cold) reads it from
|
|
298
|
+
the claimed run, `keep` (reload) from the kept run, and `fork`/`none` carry **no launched
|
|
299
|
+
stage** (so they never re-read the file, relying on the LWW rebuild). When the stage does
|
|
300
|
+
consume the ref, the extension appends `active_plan_ref` **iff** the rebuilt value does not
|
|
301
|
+
already match the file — **idempotent by `(provider, pr_id)`** (so reloads don't duplicate
|
|
302
|
+
and a fork keeps the inherited ref), with a **strict read-back** (loud-but-non-fatal on
|
|
303
|
+
mismatch, headless-safe). When it does not consume the ref, an already-linked
|
|
304
|
+
`active_plan_ref` is still **preserved** via the LWW rebuild, but the file is never read.
|
|
305
|
+
`session_tree` re-reads nothing — the per-field LWW rebuild already restores
|
|
306
|
+
`active_plan_ref`, so branch navigation preserves it. The registry is the gate's source of
|
|
307
|
+
truth; if it fails to load, reconciliation stays **permissive** when a launched stage is
|
|
308
|
+
present (to preserve implement linkage). **No clearing** of the selector anywhere — gating
|
|
309
|
+
alone fixes the leak, and the Python plane is untouched.
|
|
310
|
+
|
|
311
|
+
**Warm `/plan-save` direct linkage (T3):** the in-session warm door appends `active_plan_ref`
|
|
312
|
+
**directly** after a successful save (same strict read-back, idempotent by `(provider, pr_id)`),
|
|
313
|
+
so the live session is linked without waiting for the next `session_start`. Both writers feed the
|
|
314
|
+
same LWW field; a warm append makes the next reload's reconciliation a no-op. This makes the warm
|
|
315
|
+
`save` stage a direct writer of `session.workflow-state`. The warm door also **surfaces the
|
|
316
|
+
objective node→plan link outcome** returned by `perk plan-save` (`objective_node`): a successful
|
|
317
|
+
advance shows `→ in_progress`, a failed one shows a visible `⚠ … NOT advanced — re-run /plan-save`
|
|
318
|
+
warning (§8.4 "The node↔plan link") — it is not silently swallowed. The warm door's decode of the
|
|
319
|
+
`perk plan-save --json` payload is strict **only** on `plan_ref` (the field appended to
|
|
320
|
+
workflow-state); the rendered issue id/url are derived from it (byte-identical by construction in
|
|
321
|
+
the cold door, which builds the ref from the issue), and `existed`/`objective_node` are advisory —
|
|
322
|
+
so a successful cold save can never be reported as a warm failure by render-only payload fields
|
|
323
|
+
(e.g. under CLI↔extension version skew, the #387/#390 incident).
|
|
324
|
+
|
|
325
|
+
**Approval→save orchestration seam (Node 2.3 of #339).** The exported `approvalSave` seam
|
|
326
|
+
(`extension/factories/planSave.ts`) is the shared APPROVED-review → save orchestration: artifact-first plan
|
|
327
|
+
resolution (`resolvePlanSource`) → `savePlan` → gate exit on success (the D1a pattern — snapshot
|
|
328
|
+
`gating.isActive()` before the save, `gating.exit` only on a successful save; a failed save leaves
|
|
329
|
+
the gate on). The `/plan-save` command is now the **manual failsafe** invocation of the same seam;
|
|
330
|
+
the `plan_review` door wires **two review backends** into it — plannotator's browser review
|
|
331
|
+
(Node 2.4) and the **first-party in-TUI editor review (Node 2.5)** — and those two backends cover
|
|
332
|
+
**every** selection (plannotator → the browser bridge; any other selection, tombell included, →
|
|
333
|
+
the first-party in-TUI review); all three authoring contexts (`PLAN_AUTHORING_CONTEXT`,
|
|
334
|
+
`PLAN_ADAPTER_PLANNOTATOR_CONTEXT`, `PLAN_ADAPTER_TOMBELL_CONTEXT`) now speak review-first, and
|
|
335
|
+
APPROVED outcomes run this seam. No resolvable plan source → a `no-plan` outcome, nothing saved, gate untouched
|
|
336
|
+
(fail-open; callers render their own fallback). **Warm node-link recovery:** when a save reaches
|
|
337
|
+
`savePlan` with **both** `objectiveId` and `nodeId` absent (an approval-triggered save carries no
|
|
338
|
+
model params), the link is recovered **both-or-neither** from the rebuilt `objective_node_claim`;
|
|
339
|
+
any explicit value (even one) wins outright — never mixed; a malformed/missing claim never blocks
|
|
340
|
+
the save. The cold handoff recovery (`perk plan-save` `_link_from_handoff`, #78) is unchanged
|
|
341
|
+
underneath — if both carriers exist the recovered values match, and Python's explicit-flags-win
|
|
342
|
+
ordering is preserved. A successful node-linked save clears the matching claim (best-effort).
|
|
343
|
+
|
|
344
|
+
**Plan-issue title (#129).** The warm door now **actually forwards** an explicit `title` to
|
|
345
|
+
`perk plan-save --title` (it was previously accepted by `savePlan` but silently dropped). When no
|
|
346
|
+
explicit `title` is given, it **best-effort generates one** via the session model
|
|
347
|
+
(`extension/factories/planTitle.ts` → `extension/substrate/structuredOutput.ts`, a reusable structured-output substrate
|
|
348
|
+
over `@earendil-works/pi-ai` tool-calling) and forwards that. Every failure mode (no model,
|
|
349
|
+
unresolved auth, a model error, no tool call, schema-invalid args, an empty sanitized title) and the
|
|
350
|
+
`PERK_NO_LLM` offline gate (set by the test harness, never by the production CLI) yield **no**
|
|
351
|
+
`--title`, so the cold door's deterministic `plan.derive_title` fallback takes over — a save is never
|
|
352
|
+
blocked. The cold door's `--title`/`derive_title` contract is unchanged.
|
|
353
|
+
|
|
354
|
+
State key (registry vocabulary): `session.workflow-state`.
|
|
355
|
+
|
|
356
|
+
**Objective budget + compaction (P2.T9).** With `active_objective` now live, the TS substrate
|
|
357
|
+
(`extension/factories/objective.ts`, `registerObjective`) adds three pieces, all **inert when no objective
|
|
358
|
+
is active** and **never throwing** (logged-not-thrown, like checkpoints):
|
|
359
|
+
- **`/objective [<id>|clear]`** — `<id>` appends `{ active_objective: <id> }` to
|
|
360
|
+
`perk:workflow-state` (LWW field) **and** seeds a dedicated `perk:objective-budget` activation
|
|
361
|
+
marker `{ objective_id, activated_at: <ISO> }`; `clear` appends `{ active_objective: null }`; no
|
|
362
|
+
arg shows the current objective + budget line. The dedicated `perk:objective-budget` entry keeps
|
|
363
|
+
high-churn budget data **off** the shared `perk:workflow-state` record (mirrors checkpoints'
|
|
364
|
+
dedicated entry).
|
|
365
|
+
- **Budget accounting** — a stateless rebuild (the `goal.ts` pattern): scan the branch for
|
|
366
|
+
`role === "assistant"` messages **after** the latest `perk:objective-budget` marker, summing
|
|
367
|
+
`max(0, usage.input) + max(0, usage.output)`; elapsed = `now − activated_at`. Surfaced as the
|
|
368
|
+
**objective segment of the single composed `perk` status slot** (segments ordered objective →
|
|
369
|
+
checkpoints per charter D2, joined with two spaces, composed by `surfaces.ts createPerkStatus` —
|
|
370
|
+
headless calls are full no-ops); the `perk-objective` **widget is retired** (node 2.3) — the
|
|
371
|
+
status segment carries id + tokens + elapsed (`🎯 <id> · <tokens> tok · <elapsed>`). In TUI mode
|
|
372
|
+
the segment renders inside the **perk-owned footer** (node 3.1, see the checkpoints block below);
|
|
373
|
+
the composed `perk` status slot keeps publishing and is the RPC-visible surface. Rebuilt on
|
|
374
|
+
`session_start`, `session_tree`, **and** `agent_end` (survives reload/branch/compaction for
|
|
375
|
+
free). Pure helpers
|
|
376
|
+
(`sumAssistantTokens` / `formatBudgetLine` / `findBudgetMarker` / `rebuildBudget`) are
|
|
377
|
+
offline-tested.
|
|
378
|
+
- **Threshold-triggered compaction** (the `trigger-compact.ts` pattern) — on `turn_end`, **only
|
|
379
|
+
when `active_objective != null`**, read `ctx.getContextUsage()` and call `ctx.compact({…})` when
|
|
380
|
+
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
|
|
382
|
+
TOML subset reads only strings). The decision is the pure `shouldCompact(usage, threshold)`;
|
|
383
|
+
compaction is best-effort (`onError` logs and continues). The custom cheaper-model
|
|
384
|
+
`session_before_compact` summary is **deferred** — T9 ships the simpler `ctx.compact` trigger.
|
|
385
|
+
|
|
386
|
+
No model-facing bounded transition tools are added here — the `objective-plan` stage, the plan
|
|
387
|
+
factory, and the "fire only when…" tools are **T10**.
|
|
388
|
+
|
|
389
|
+
**Objective authoring loop (P3.T2).** Objective *creation* is now a first-class read-only → save
|
|
390
|
+
loop, the mirror of the `plan → save` spine. Two new registry stages precede `objective-plan` as
|
|
391
|
+
the new single initial: `objective-author -> objective-save -> objective-plan -> plan -> …`.
|
|
392
|
+
- **`perk objective-author`** (a dedicated seeded cold door, like `objective-plan`) opens a
|
|
393
|
+
**read-only** authoring session, seeded with the objective-authoring guidance. Its handoff records
|
|
394
|
+
`stage: objective-author`, claimed into `perk:workflow-state.stage`.
|
|
395
|
+
- **Coupling break (the `stage` field).** `extension/factories/planMode.ts` previously injected its
|
|
396
|
+
plan-authoring context on *any* read-only gate. An `objective-author` session is **also**
|
|
397
|
+
read-only, so plan mode now **defers** when `stage === "objective-author"`, and
|
|
398
|
+
`extension/factories/objectiveAuthor.ts` injects its own `perk:objective-author-context` instead (keyed off
|
|
399
|
+
read-only gate **AND** the stage; stripped from `context` when no longer authoring — the same
|
|
400
|
+
hygiene plan mode applies). Exactly one authoring context is present. The injected
|
|
401
|
+
objective-authoring context is optionally extended by the **same** `[workflow] plan_authoring`
|
|
402
|
+
addendum the plan-authoring injection consumes (read per-event via `extension/substrate/config.ts`'s
|
|
403
|
+
`loadPerkConfig`) — verbatim reuse, no new config key.
|
|
404
|
+
- **`objective_save` warm door** (`extension/factories/objectiveSave.ts`, the mirror of `planSave.ts`). The
|
|
405
|
+
`objective_save` **tool** takes `prose` + a **structured `roadmap`** (a JSON array of nodes —
|
|
406
|
+
never hand-written YAML) and delegates the write to `perk objective create --body <file> --roadmap
|
|
407
|
+
<json> --run-id <rid> --json` (canonical mutation in Python, idempotent on the run_id). On success
|
|
408
|
+
it links the live session: appends `active_objective` **and** seeds a fresh `perk:objective-budget`
|
|
409
|
+
activation marker (mirrors `/objective <id>`), so budget tracking starts immediately; it
|
|
410
|
+
**terminates** the turn. The `/objective-save` **command is the artifact-first manual
|
|
411
|
+
failsafe** (#352 Node 2.3): it invokes the shared `objectiveApprovalSave` seam (re-read the
|
|
412
|
+
structured `objective-draft.json` artifact → `saveObjective` → D1a gate exit on success) and
|
|
413
|
+
relays the save message (`error` severity on a failed save — the gate stays read-only). Only
|
|
414
|
+
when **no draft exists** does it fall back to the legacy drive-the-session behavior: exit the
|
|
415
|
+
read-only gate (so the `objective_save` tool becomes reachable) and inject guidance via
|
|
416
|
+
`pi.sendUserMessage` instructing the model to call `objective_save` with `prose` + the
|
|
417
|
+
structured `roadmap` (mirrors `/address`, `/objective-plan`) — objectives have no transcript
|
|
418
|
+
scrape by design (a roadmap is structured data, unscrapeable), so a draftless session still
|
|
419
|
+
needs the driven save path. The tool is structurally unreachable while read-only and remains
|
|
420
|
+
the post-gate-exit direct failsafe.
|
|
421
|
+
- **Structured roadmap (never hand-written YAML).** `create_objective_issue` gains an optional
|
|
422
|
+
`roadmap_nodes`; `perk objective create` gains `--roadmap <json>` (parsed via
|
|
423
|
+
`objective.parse_structured_roadmap`, where per-node `status` is optional and defaults to
|
|
424
|
+
`pending`). When `--roadmap`/`roadmap_nodes` is given the body is pure prose; otherwise the legacy
|
|
425
|
+
body-embedded roadmap parse still applies (the cold-CLI path). **Creation requires ≥1 roadmap
|
|
426
|
+
node**: `perk objective create` rejects an empty roadmap with `error_type: empty_roadmap` (exit 1)
|
|
427
|
+
and `create_objective_issue` raises `GitHubError` — the parse/read layer stays lenient (existing
|
|
428
|
+
node-less issues remain readable/closable). The judgment layer lives in the `perk-objective-author`
|
|
429
|
+
skill, which now speaks the review-first discipline (draft via `objective_draft` → `plan_review`
|
|
430
|
+
→ approval auto-save; `/objective-save` is the artifact-first failsafe) — #352 Node 3.2.
|
|
431
|
+
|
|
432
|
+
**Objective plan factory + transition tools (P2.T10).** The objective **transition** surface on top
|
|
433
|
+
of T9's mechanics (`extension/factories/objectivePlan.ts`, `registerObjectivePlan`):
|
|
434
|
+
- **`/objective-plan [<number>] [--node ID]`** — the warm entry: resolve the objective (arg, else
|
|
435
|
+
`active_objective` from the rebuilt `perk:workflow-state`) and `pi.sendUserMessage(...)` the
|
|
436
|
+
factory guidance to start the loop (mirrors `/address`). Headless-safe. On invocation it ALSO
|
|
437
|
+
**enters the read-only gate** when it is off (skip-if-active: no duplicate `mode` append or
|
|
438
|
+
announce when already read-only): appends `mode: "read-only"` to `perk:workflow-state` via
|
|
439
|
+
`gating.enter` and reports a dedicated announce line — parity with the cold door's registry
|
|
440
|
+
`mode: read-only` handoff claim. Gate **exit** remains owned by `plan_save` (D1a, approval
|
|
441
|
+
auto-save included) / `/plan` off; the no-objective warning path never enters the gate.
|
|
442
|
+
(Objective #352 Node 1.2.) As of #352 Node 3.1 the injected factory guidance (warm
|
|
443
|
+
`factoryGuidance`; mirrored by the cold `_seed_prompt`, which adds handoff claim recovery and
|
|
444
|
+
drops the mark step) instructs the **file-first loop**: the **unconditional** `planning` mark
|
|
445
|
+
(the successful transition records the `objective_node_claim`), `plan_draft`/`plan_review`,
|
|
446
|
+
the approval-driven save with both-or-neither link recovery from the claim, and
|
|
447
|
+
`plan_save`-with-both-ids as the manual failsafe.
|
|
448
|
+
- **`objective_node` tool** — the BOUNDED model-facing transition. It **delegates** the mutation to
|
|
449
|
+
the Python cold door (`perk objective node`, canonical mutations in Python) and **never throws**
|
|
450
|
+
(soft `details.ok`, mirrors `resolve_review_threads`). Params `{ objective, node, status?, pr?,
|
|
451
|
+
audit? }`; exec args are built **conditionally** (matching T9's optional `--status`/`--pr` —
|
|
452
|
+
`--status ""` is a Click error, so it is omitted when no status change): a **`pr`-only backlink**
|
|
453
|
+
(`pr` present, `status` absent) → `["objective","node",N,"--node",id,"--pr",pr,"--json"]` (no
|
|
454
|
+
`--status`, no audit); a **status change** adds `["--status",status]` (and `--pr` only if also
|
|
455
|
+
given). A call with **neither `status` nor `pr`** is refused (`bad_input`, no exec).
|
|
456
|
+
- **Completion-audit gate (model-path-only).** When `status === "done"` the tool requires a
|
|
457
|
+
**non-trivial `audit`** and refuses otherwise (`audit_required`, **no exec**). Non-trivial **iff**
|
|
458
|
+
`audit` is a string whose value **after `.trim()` is ≥ 40 characters**. This is a property of the
|
|
459
|
+
**model-facing boundary**, NOT an invariant on the node-`done` state: the canonical cold CLI
|
|
460
|
+
(`perk objective node --status done`, human/CI) has **no** audit gate, and **T11's auto-on-merge
|
|
461
|
+
node-done deliberately sets `done` without an audit**. Both are intentional non-audited paths — the
|
|
462
|
+
refusal protects the model's path only. The "are we done?" judgment text (prompt-to-artifact
|
|
463
|
+
checklist; treat uncertainty as not-done) lives in the `perk-objective-plan` skill.
|
|
464
|
+
- **The node↔plan link.** plan→objective is carried by the plan header/ref `objective_id` (threaded
|
|
465
|
+
through `perk plan-save --objective-id` + the `plan_save` tool's `objective_id` param). The
|
|
466
|
+
objective→plan backlink (`node.pr`) **and** the `planning → in_progress` advance are now set
|
|
467
|
+
**atomically by `plan-save`** when invoked with `--objective-id` + `--node-id` (warm `plan_save`
|
|
468
|
+
tool params `objective_id` + `node_id`) — a single `update_objective_node(status=in_progress,
|
|
469
|
+
pr="#<issue>")` write, **fail-open + non-fatal + idempotent on re-save** (the plan already exists
|
|
470
|
+
so a link failure is non-fatal and surfaces `objective_node.error`; the same `run_id` re-links on
|
|
471
|
+
a retried save). On a failed advance, the **warm `/plan-save` door surfaces the outcome to the
|
|
472
|
+
user** — it appends a `⚠ objective node <id> NOT advanced — re-run /plan-save to retry` note to
|
|
473
|
+
the save-result text (rendered by both the `plan_save` tool and the `/plan-save` command) and
|
|
474
|
+
notifies at **`warning`** severity (mirrored to stderr in headless runs), not merely a Python
|
|
475
|
+
stderr line. Re-running `/plan-save` with no further arguments retries the advance idempotently.
|
|
476
|
+
The standalone `objective_node` `pr`-only shape remains for **manual
|
|
477
|
+
repair** but is no longer part of the factory loop. T11's reconciliation-on-land consumes both
|
|
478
|
+
directions.
|
|
479
|
+
- **Node lifecycle = a resumable lease (factory selection).** `planning` is a **resumable claim**
|
|
480
|
+
(intent to plan; no saved plan yet — `objective-plan` re-selects it, an abandoned claim self-heals;
|
|
481
|
+
the eager mark is idempotent). `in_progress` is a **committed plan** (saved, node→plan backlinked,
|
|
482
|
+
awaiting land). `done` is set by the land path (`nodes_for_pr`) or the audited tool. Factory
|
|
483
|
+
selection lives in `objective.DependencyGraph`: `plannable_nodes()` (membership: unblocked ∧
|
|
484
|
+
(`pending`, or `planning` with **no** `pr`), position order — feeds the explicit `--node` lookup);
|
|
485
|
+
a `planning` node **with** a `pr` and any `in_progress` node are `in_flight_nodes()`;
|
|
486
|
+
`resumable_claims()` is the unblocked `planning`-with-no-`pr` subset (the "live or abandoned
|
|
487
|
+
claim" set the surfaces report). `next_plannable()` — the single implicit-selection method (so
|
|
488
|
+
`objective next`/`show` resume a claim; the `--json` field name stays `next_node`) — is
|
|
489
|
+
**pending-first**: the first unblocked `pending` node by position, then the first resumable claim
|
|
490
|
+
by position. Rationale: a claim cannot be distinguished from a session actively planning in
|
|
491
|
+
another terminal, so implicit selection never steals/duplicates a possibly-live claim while safe
|
|
492
|
+
pending work exists; self-healing of abandoned claims is preserved as the fallback (and via
|
|
493
|
+
explicit `--node`). This makes **parallel `objective-plan` launches** on independent nodes the
|
|
494
|
+
supported behavior: the first launch marks its node `planning` (removing it from the pending
|
|
495
|
+
set), the second launch selects the next unblocked pending node. The cold door surfaces the
|
|
496
|
+
skipped-claim set (a stderr `note:` line on non-JSON-payload paths + a `skipped_claims` array in
|
|
497
|
+
the `--dry-run --json` payload), and `objective show --json` carries `resumable_claims` (full
|
|
498
|
+
node dicts) for multi-terminal coordination.
|
|
499
|
+
**Accepted backlink race:** concurrent `update_objective_node` writes (two parallel `plan_save`s,
|
|
500
|
+
or a save racing a second door's `planning` mark) are read-modify-write on the issue body, so a
|
|
501
|
+
simultaneous write can drop one node's update. Accepted, not fixed (erk shipped the same as a
|
|
502
|
+
tripwire): the loser is recoverable — `/plan-save` re-save is idempotent and retries the link,
|
|
503
|
+
and `perk objective node` is the manual repair. No optimistic-concurrency machinery.
|
|
504
|
+
`classify_for_planning()` returns
|
|
505
|
+
`plannable`/`in_flight`/`blocked`/`complete` and drives the cold door's honest errors
|
|
506
|
+
(`objective_in_flight` is a new `error_type`, exit 1, in place of the old misleading "all blocked
|
|
507
|
+
or complete"). `objective show --json` gains `selection_kind`.
|
|
508
|
+
|
|
509
|
+
**Objective reconciliation after landing (P2.T11).** When a PR linked to an objective node merges,
|
|
510
|
+
the roadmap reconciles against what actually landed — two seams matching the D9 Mechanical/
|
|
511
|
+
Reconcilable/Immutable typing:
|
|
512
|
+
- **Mechanical (on land).** The land path auto-marks the backlinked node(s) `done` — fail-open and
|
|
513
|
+
non-audited (the audit gate is the model-tool boundary only). The warm `/land` then **auto-drives**
|
|
514
|
+
the reconcile pass: it injects the same `reconcileGuidance` message `/objective-reconcile` injects
|
|
515
|
+
(`deliverAs: "followUp"` from the terminating `land` tool, an immediate turn from the idle `/land`
|
|
516
|
+
command) instead of printing a manual nudge.
|
|
517
|
+
- **Reconcilable (warm, post-merge).** `/objective-reconcile [<number>]` resolves the objective via
|
|
518
|
+
a **three-tier** lookup — arg → `active_objective` → `readPlanRef(cwd).objective_id` (the
|
|
519
|
+
just-landed objective sitting in the plan-ref, so the post-land path works even when the user
|
|
520
|
+
never ran `/objective`) — then `pi.sendUserMessage(...)` the reconcile guidance (mirrors
|
|
521
|
+
`/objective-plan`; headless-safe). The `reconcile_objective` tool (`{ objective, prose }`) writes
|
|
522
|
+
the prose to a run-scoped scratch file and delegates to `perk objective reconcile … --body <path>`
|
|
523
|
+
(never throws); it rewrites ONLY the marker-bounded Reconcilable prose region (the roadmap table +
|
|
524
|
+
Immutable notes are structurally never touched). The `objective_node` tool gains a `description?`
|
|
525
|
+
param (node scope/naming reconciliation) — `buildObjectiveNodeArgs` relaxes its structural refusal
|
|
526
|
+
so a `description`-only call is valid; the `status:"done"` audit gate is unchanged. The judgment
|
|
527
|
+
text lives in the `perk-objective-reconcile` skill.
|
|
528
|
+
|
|
529
|
+
**Session-lifecycle gates (T4b).** The interior guards `session_before_switch` /
|
|
530
|
+
`session_before_fork` with a **dirty-repo check** (`git status --porcelain` via `pi.exec`),
|
|
531
|
+
**scoped to active perk workflows** (`active_plan_ref != null` — perk never interferes with
|
|
532
|
+
non-perk forks/switches). A dirty tree in an active workflow returns `{ cancel: true }` with a
|
|
533
|
+
loud message (notify if UI, else stderr) — **fail-safe-headless** (it cancels in both modes; there
|
|
534
|
+
is no proceed-anyway in Phase 1). A clean tree, or any transition outside a workflow, is allowed
|
|
535
|
+
(returns `undefined`); if `git status` itself fails (e.g. not a repo) the gate allows (it is a
|
|
536
|
+
hygiene guard, not a repo validator). The warm `/implement` command
|
|
537
|
+
*enforces* `implement.doors.warm: false` for the **cross-worktree** transition: outside an impl
|
|
538
|
+
context it refuses and points to the cold door `perk implement`. The proceed-anyway confirm dialog
|
|
539
|
+
+ `git-checkpoint` stash-on-turn are Phase 2.
|
|
540
|
+
|
|
541
|
+
**Warm `/implement` in-worktree handoff (P2.T2b).** `implement.doors.warm` stays **`false`** — the
|
|
542
|
+
plan→implement *stage transition* is cold-only because **no extension-reachable session API can
|
|
543
|
+
change cwd** (the `ExtensionCommandContext` surface exposes `newSession`/`switchSession`, neither of
|
|
544
|
+
which takes a cwd; `cwdOverride` lives only on the lower `SessionManager.open`, out of reach
|
|
545
|
+
in-session — D2). What T2b adds is the in-process twin of the cold door usable **inside** an active
|
|
546
|
+
impl worktree (same cwd): when `/implement` runs in an impl context (read-write + a linked
|
|
547
|
+
`active_plan_ref`), it offers a lossless `ctx.newSession` fresh-context handoff seeded (via
|
|
548
|
+
`withSession` → `sendUserMessage`) with the plan-read priming (`implementHandoffPrompt`, the
|
|
549
|
+
in-session twin of `perk/run/launch/prompts.py`'s `_initial_prompt`: read the plan from its canonical source,
|
|
550
|
+
implement, `/submit` — carry the plan forward, never summarize it). Model-visible output is capped
|
|
551
|
+
(a single short confirmation; the durable state is the worktree's materialized plan-ref + the plan
|
|
552
|
+
issue). Dirty-tree hygiene is gated **manually** in the handler (a `newSession` session-replace may
|
|
553
|
+
bypass the `session_before_*` gate, so the handler re-checks `git status --porcelain` and refuses on
|
|
554
|
+
a dirty tree), fail-safe-headless. This is a **context refresh, not a stage transition** — the
|
|
555
|
+
registry's `implement.doors.warm: false` is unchanged.
|
|
556
|
+
|
|
557
|
+
**Checkpoints (P2.T2c).** Implementation progress is tracked in a **dedicated `perk:checkpoint`**
|
|
558
|
+
session entry (D3) — kept OFF the `perk:workflow-state` record because progress is high-churn (an
|
|
559
|
+
append every advancing `turn_end`), and a separate entry avoids LWW-append smell on the shared
|
|
560
|
+
record. The interior (`extension/checkpoints/checkpoints.ts`) seeds an ordered step list from the plan body's
|
|
561
|
+
`## Steps` numbered list (read from the `cache.plan` body cache) on `session_start` — **only** in an
|
|
562
|
+
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**:
|
|
564
|
+
`perk implement` (`launch._materialize_plan_body`) fetches the plan body from GitHub
|
|
565
|
+
(`github.get_plan_body` → the `plan-body` block in the issue's first comment, parsed by
|
|
566
|
+
`plan.extract_plan_body`) and writes it into the worktree alongside the plan-ref + handoff
|
|
567
|
+
(best-effort + loud-but-non-fatal — an unreachable body just yields inert checkpoints, never a failed
|
|
568
|
+
launch). The cold door also **mirrors `repo_root/.agents/skills/*` into the worktree** as per-skill
|
|
569
|
+
symlinks (`launch.materialize_skills`): a linked worktree never carries the gitignored
|
|
570
|
+
`.agents/skills/` tree and pi discovers skills only up to the worktree's own git root, so without the
|
|
571
|
+
mirror a worktree session sees zero skills (ENOENT on `perk-implement/SKILL.md`). Best-effort +
|
|
572
|
+
loud-but-non-fatal (a missing source set warns; doctor's fail-level `skills-delivery` check owns the
|
|
573
|
+
hard gate); idempotent on resume (an already-correct symlink is left untouched, a real non-symlink
|
|
574
|
+
entry is never clobbered). **After** materialization (and only when the cold door **freshly
|
|
575
|
+
created** the worktree, never on idempotent reuse/dry-run), the cold door runs the project's
|
|
576
|
+
`[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
|
|
578
|
+
inherited stdio, **aborting the launch** (a `UserFacingCliError`) on any non-zero exit / timeout /
|
|
579
|
+
missing `bash` (a half-built environment is worse than a clear failure; the worktree is left for a
|
|
580
|
+
fixed re-run). This is **Python-plane-only** (no TS twin — the extension never creates worktrees);
|
|
581
|
+
the manual `perk worktree create` runs the same hook, and the remote runner's `position_worktree`
|
|
582
|
+
deliberately does **not** (CI environment setup belongs to the GHA composite action). It is
|
|
583
|
+
**opt-in + inert-by-default (D4)**: perk plans are prose, so when no `## Steps` list is
|
|
584
|
+
present the checkpoint degrades to inert (no entry, no crash); the `perk-plan` skill documents the
|
|
585
|
+
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
|
|
587
|
+
`session_compact`** (the `session_compact` re-render — rebuild + render only, NO re-seed, mirroring
|
|
588
|
+
`session_tree` — was adapted from `@juicesharp/rpiv-todo`; its `catch` arm swallows the pi-core
|
|
589
|
+
stale-`ctx` compaction race silently — the proxy `/stale after session replacement/` error fired
|
|
590
|
+
when pi replaces the running session out from under the in-flight handler — while logging genuine
|
|
591
|
+
replay failures); `turn_end` scans the assistant message for `[DONE:n]` and, when a step advances,
|
|
592
|
+
appends a new `perk:checkpoint` marker carrying completion forward. The rebuild uses the
|
|
593
|
+
**scan-after-marker** discipline: the latest `perk:checkpoint` entry is the marker, and `[DONE:n]`/
|
|
594
|
+
`[WIP:n]` are re-folded only from assistant messages **after** it (stale markers from a previous
|
|
595
|
+
execution cannot resurrect a step). An **in-progress (`current`) step** is derived (not persisted):
|
|
596
|
+
the latest live `[WIP:n]` after the marker whose step exists and is incomplete, falling back to the
|
|
597
|
+
lowest incomplete step, else `null`; completion always wins (`▸` never renders on a completed step).
|
|
598
|
+
The `📋 done/total` (plus ` · ▸n` when current) text renders as the **checkpoints segment of the
|
|
599
|
+
single composed `perk` status slot** (ordered objective → checkpoints per charter D2, two-space
|
|
600
|
+
join, composed by `surfaces.ts createPerkStatus` — node 2.3 retired the per-feature
|
|
601
|
+
`perk-checkpoints`/`perk-objective` status slots). The widget keeps its own `perk-checkpoints`
|
|
602
|
+
slot and is a **themed component factory** (`(tui, theme) => { render, invalidate }`, stateless render per charter D10 — themed
|
|
603
|
+
lines are computed inside `render()` per call, never cached) placed **`belowEditor`** (D4); lines
|
|
604
|
+
are `✓/▸/○ <n>. <text>` colored per the charter §5 table (`success`/`accent`/`dim`) with
|
|
605
|
+
completed-step text muted, **windowed to ≤ 4 step lines** (D1: a sliding window anchored on the
|
|
606
|
+
current step sitting second when possible; `… +N earlier` / `… +N later` dim elision markers
|
|
607
|
+
render *in addition* to the step lines, ≤ 6 rendered lines worst case), and every line is
|
|
608
|
+
width-truncated via pi-tui's `truncateToWidth` (D9). `/checkpoints` notifies a **single line**
|
|
609
|
+
(D8): `done/total · ▸n <current step text>` (the ` · ▸n <text>` tail drops when no step is
|
|
610
|
+
current). **Accepted RPC caveat:** pi drops component-factory widgets in RPC mode (only string
|
|
611
|
+
arrays forward), so the checkpoints widget is invisible to RPC clients — the status (now arriving
|
|
612
|
+
under the composed slot `perk`) and `/checkpoints` remain the RPC-visible surfaces. **Footer
|
|
613
|
+
ownership (node 3.1, charter D2):** in TUI mode perk **owns the footer by default** via
|
|
614
|
+
`ctx.ui.setFooter` (`surfaces.ts perkFooter`/`installPerkFooter` — installed once per session on
|
|
615
|
+
`session_start`, headful only) — **unless** a foreign `[providers] footer` provider is selected, in
|
|
616
|
+
which case perk **vacates `installPerkFooter`** (install-site runtime vacating keyed off `ctx.cwd`,
|
|
617
|
+
fail-safe to install; see §8.10's footer interface-seam note) and the foreign footer is the sole
|
|
618
|
+
footer surface. perk's default-owned footer composes one line, in charter order, perk identity
|
|
619
|
+
(`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
|
|
623
|
+
**remains published** (the `createPerkStatus` dual-publish is deliberate) and is the RPC-visible
|
|
624
|
+
surface — `setFooter` is an RPC no-op. The `v<version> loaded` startup notify is **retired**
|
|
625
|
+
(charter D7: identity is standing footer state, not a transition) — `session_start` no longer
|
|
626
|
+
emits a startup notify or its headless stderr mirror; the `PERK_SELFCHECK` `.perk-loaded` sentinel
|
|
627
|
+
is unchanged. D5 (branded working indicator) is **rescinded**: perk never calls
|
|
628
|
+
`setWorkingIndicator`. The **marker protocol is taught to the implement session**
|
|
629
|
+
via `_implement_prompt` (the launch prompt) + the **`perk-implement` skill**, so the implementer
|
|
630
|
+
knows to emit `[WIP:n]`/`[DONE:n]`. **Coarse fallback (P2.T15):** when no `## Steps` checklist exists
|
|
631
|
+
but a plan is active, the status bar shows `📋 <stage>` (the stage label from the handoff,
|
|
632
|
+
`readHandoff(cwd, run_id).stage`, falling back to `"active"`) with a single dim widget line (the
|
|
633
|
+
same themed-factory path, `belowEditor`) noting the plan is prose — so an active plan never goes
|
|
634
|
+
dark; with no active plan, the segment and widget clear. All surfaces are headless-safe (the
|
|
635
|
+
composed-status handle and `setStandingWidget` no-op without UI — headless never touches rich
|
|
636
|
+
UI); `/checkpoints` lists progress (notify when UI, else stderr). State key: a transient tier-3 session entry (not in the registry vocabulary, like
|
|
637
|
+
`perk:workflow-state`'s sibling execution/todo entries). `@juicesharp/rpiv-todo` **is** retired in
|
|
638
|
+
P2.T12 (removed from `init.py`'s `BORROWED_PACKAGES` and `.pi/settings.json`): perk now owns the
|
|
639
|
+
implement-progress overlay via this perk-owned `perk:checkpoint` seam. `@tombell/pi-status` is
|
|
640
|
+
likewise **retired** from `BORROWED_PACKAGES`: `ctx.ui.setFooter` is a single last-wins slot, and
|
|
641
|
+
pi-status's `session_start` footer install replaced perk's footer — a *borrowed* package must never
|
|
642
|
+
own the footer. (Distinct from a *selected* `footer` provider, which legitimately does: the footer
|
|
643
|
+
seam is the sanctioned way to hand the footer to a foreign package — perk vacates `installPerkFooter`
|
|
644
|
+
so there is no last-wins clobber. See §8.10's footer interface-seam note.) **`@tombell/pi-status` is
|
|
645
|
+
now ALSO a selectable footer provider** (`pi-status-footer`, #670): selecting it via `[providers]
|
|
646
|
+
footer` makes `perk init` converge `npm:@tombell/pi-status` into `packages` (object form) and perk
|
|
647
|
+
vacates `installPerkFooter` — the machine-governed way to get pi-status's footer, replacing the
|
|
648
|
+
unmanaged settings.json hand-edit. Unlike `powerline-footer`/`pi-bar-footer`, pi-status does **not**
|
|
649
|
+
render extension statuses, so perk's objective/checkpoints progress is **not shown** under it (an
|
|
650
|
+
accepted limitation, no status-bridge adapter). A sibling `pi-default` provider (`package: null`)
|
|
651
|
+
adds **no** footer package and vacates perk's install gate, leaving pi's stock built-in footer.
|
|
652
|
+
|
|
653
|
+
**Rejected `@juicesharp/rpiv-todo` ideas (deliberate non-adoptions).** A survey of rpiv-todo's
|
|
654
|
+
model-driven todo design against perk's passive, plan-derived, linear checkpoints (see
|
|
655
|
+
`docs/design/checkpoints-rpiv-todo-comparison.md`) adopted only the `session_compact` stale-`ctx`
|
|
656
|
+
robustness above. Rejected with rationale: (1) the **model-callable `todo` tool / `blockedBy`
|
|
657
|
+
dependency graph / dynamic create-update-delete** — reverses the P2.T2c charter that separates a
|
|
658
|
+
read-only plan from a linear, marker-driven, never-model-mutated checklist; (2) the **`activeForm`
|
|
659
|
+
present-continuous label** — there is no channel for the model to supply one (markers are
|
|
660
|
+
`[WIP:n]`/`[DONE:n]`) and the step *text* already serves as the in-progress label (`▸n <text>`);
|
|
661
|
+
adopting it would expand the marker grammar (a protocol change, not polish); (3) the
|
|
662
|
+
**completed-fall-away overlay** — `windowProgress` already does richer overflow handling (a sliding
|
|
663
|
+
window with `… +N earlier`/`… +N later` elision); rpiv's drop-after-next-turn is a different
|
|
664
|
+
philosophy, not clearly better for an ordered linear checklist.
|
|
665
|
+
|
|
666
|
+
**Generated checkpoint steps for prose plans (#342).** When the implement-session `session_start`
|
|
667
|
+
seeding finds a **materialized plan body with no usable `## Steps`** (`extractSteps` → `[]` covers
|
|
668
|
+
both a missing and a malformed section), checkpoints **generate** the step list on the fly via the
|
|
669
|
+
structured-output substrate (`extension/checkpoints/planSteps.ts`, the `planTitle.ts` idiom: a single
|
|
670
|
+
`set_plan_steps` tool call, TypeBox-validated, 2–12 steps sanitized to ≤200 chars each). Trigger
|
|
671
|
+
conditions (ALL required): the perk-checkpoints reference is the selected todo provider; no
|
|
672
|
+
existing `perk:checkpoint` entry (seed-once); an active workflow (`active_plan_ref != null`); a
|
|
673
|
+
non-null plan body whose `extractSteps` is empty; and the **launched stage is `implement`** (the
|
|
674
|
+
handoff's `stage` — address/learn/plan sessions never generate). **Artifact reuse first**: the
|
|
675
|
+
generated list persists as the session artifact `plan-steps.json`
|
|
676
|
+
(`{ plan_id, plan_body_digest, steps }`) written through the §8.1 session-data accessor with a
|
|
677
|
+
§8.3 provenance pointer, and is trusted only when the pointer validates AND its stored
|
|
678
|
+
`plan_body_digest` (the §8.1 `sha256:` convention over the current `plan.md` bytes) matches — a
|
|
679
|
+
replan/rematerialized body invalidates the cache and regenerates. On success the seed is
|
|
680
|
+
byte-identical to the explicit-`## Steps` path (same `perk:checkpoint` entry shape — no schema
|
|
681
|
+
change; rebuild/advance/render untouched); generated-ness is **recomputed, never stored**
|
|
682
|
+
(non-inert AND the current plan body parses to no explicit steps). A once-only
|
|
683
|
+
**`perk:steps-context`** hidden context message (injected at `before_agent_start`, dedup-guarded by
|
|
684
|
+
the branch already carrying the type; **no strip handler** — the checklist never goes stale within
|
|
685
|
+
the session) teaches the model the exact step numbers for `[WIP:n]`/`[DONE:n]`. `/checkpoints`
|
|
686
|
+
appends ` (generated)` when generated-ness recomputes true. **Fail-safe ladder**: the `PERK_NO_LLM`
|
|
687
|
+
offline gate, no model/auth, a model error, schema-invalid args, an unusable sanitized list, or a
|
|
688
|
+
missing session-data substrate each fall back to the coarse prose behavior (byte-identical widget
|
|
689
|
+
text) — never a failed session start. The plan issue is never mutated (generated steps are
|
|
690
|
+
cache-tier, session-local state).
|
|
691
|
+
|
|
692
|
+
**Surfaces discipline (Objective #251, node 4.1).** Every interior rich-UI call — `ctx.ui.notify`,
|
|
693
|
+
`setStatus`, `setWidget`, `setFooter`, `setWorkingMessage` — lives in the surfaces module
|
|
694
|
+
(`extension/surfaces/surfaces.ts` + `extension/surfaces/report.ts`); every other extension module
|
|
695
|
+
reaches the UI only through the seams (`report()`, `createPerkStatus`, `setStandingWidget`,
|
|
696
|
+
`installPerkFooter`, `setWorkingMessage`). `setWorkingIndicator` is never called anywhere (D5
|
|
697
|
+
rescinded); the distinct **`setWorkingMessage`** call (text-only label on pi's default spinner,
|
|
698
|
+
headless-no-op) **is** permitted (it was never declined) and is routed through the
|
|
699
|
+
`setWorkingMessage` surfaces seam — `whimsical` flavors the spinner label through it. **`ctx.ui.custom`
|
|
700
|
+
stays declined for all workflow surfaces** (charter §6 D6); the sole sanctioned exception is **`/btw`**,
|
|
701
|
+
a human-only side-chat popover that is `hasUI`-gated, exposes no model tool, and is not a stage/door —
|
|
702
|
+
so it is never machine-reachable and cannot threaten the machine-executability the decline protects.
|
|
703
|
+
Enforced by the source-scan guard `extension/surfacesGuard.test.ts` (node:test, runs in
|
|
704
|
+
`just test`/`just ci`).
|
|
705
|
+
|
|
706
|
+
**Tool-gating (P2.T1).** The `mode` field **structurally gates tools** — enforcement, not
|
|
707
|
+
prompting. When `mode == "read-only"` the interior (`extension/substrate/toolGating.ts`):
|
|
708
|
+
(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**
|
|
710
|
+
of all provider tool names: `web_search`/`code_search`/`fetch_content`/`get_search_content`
|
|
711
|
+
(`pi-web-access`, the default), `ollama_web_search`/`ollama_web_fetch` (`@ollama/pi-web-search`),
|
|
712
|
+
and `web_fetch` (`@juicesharp/rpiv-web-tools`); foreign tool names are inert
|
|
713
|
+
when their package is absent) via `pi.setActiveTools`, **snapshot-then-restore** (snapshot `pi.getActiveTools()` on the off→on
|
|
714
|
+
transition; restore it on on→off, falling back to the **full** configured tool set
|
|
715
|
+
`pi.getAllTools()` if no snapshot exists — never a hardcoded list, so perk's custom tools survive);
|
|
716
|
+
(2) blocks `edit`/`write`
|
|
717
|
+
and non-allowlisted `bash` commands at `tool_call` with `{ block: true, reason }` (a perk-owned
|
|
718
|
+
copy of plan-mode's destructive/safe regex tables; the bash allowlist additionally includes
|
|
719
|
+
read-only `gh` query subcommands — `gh issue|pr|repo|run|release|label view|list|diff|status|checks`,
|
|
720
|
+
`gh search …`, `gh auth status` — while `gh api` and all mutating `gh` subcommands stay blocked, plus
|
|
721
|
+
the command-keyed `agent-browser` / `npx agent-browser` entries (the browser-automation skill,
|
|
722
|
+
command-keyed like `ast-grep` — its own output flags can write files outside the gate, an accepted
|
|
723
|
+
leniency like `curl`/`fetch_content`); (3) injects a hidden `[READ-ONLY MODE]`
|
|
724
|
+
context at `before_agent_start` and **strips** that marker from `context` when off. The allowlist
|
|
725
|
+
is **restored on both `session_start` and `session_tree`** (re-sync from the rebuilt `mode`).
|
|
726
|
+
**Fail-closed:** the in-memory gate flag drives `tool_call`; a failed state-rebuild never opens the
|
|
727
|
+
gate (the sync is skipped), and `tool_call` blocks on any internal error. `mode` writes are
|
|
728
|
+
best-effort transient (no strict read-back). The `enter(ctx?)`/`exit(ctx?)` surface
|
|
729
|
+
(append `mode` + flip the gate) is the API the perk-owned plan mode (T2) and the read-only CI
|
|
730
|
+
executor (T5) consume; this primitive ships no `/plan` ownership and adds no registry stage.
|
|
731
|
+
|
|
732
|
+
**Perk-owned plan mode (P2.T2a).** `mode` is now perk-owned **end-to-end** — the borrowed
|
|
733
|
+
`@tombell/pi-plan` package is retired (removed from `init.py`'s `BORROWED_PACKAGES` and
|
|
734
|
+
`.pi/settings.json`). The interior (`extension/factories/planMode.ts`) owns the toggle surface over T1's gate:
|
|
735
|
+
a `/plan` command, a `Ctrl+Alt+P` shortcut, and a `--plan` flag all flip `gating.enter`/`exit`
|
|
736
|
+
(perk adds **no** parallel enforcement — T1 is the single read-only authority). It also injects a
|
|
737
|
+
hidden plan-authoring prompt layer under its own `perk:plan-context` customType (keyed off the
|
|
738
|
+
read-only gate; stripped from `context` when off — the same hygiene T1 applies to
|
|
739
|
+
`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
|
|
741
|
+
overlay). `isPlanModeActive` (in `extension/factories/planSave.ts`) now reads perk's own `mode == "read-only"`
|
|
742
|
+
(the P1.T3b `plan-mode-state` soft coupling is gone). The `plan_save` **tool** is structurally
|
|
743
|
+
unreachable while read-only (T1's allowlist excludes it), so there is no auto-exit on the tool path;
|
|
744
|
+
the `/plan-save` **command** *can* run while read-only and, on a successful save, calls
|
|
745
|
+
`gating.exit()` — save marks the read-only → read-write boundary in one gesture (D1a). perk does
|
|
746
|
+
**not** adopt plan-mode's in-session "execution mode" flip: it separates plan (read-only session)
|
|
747
|
+
from implement (cold-door fresh worktree session); `[DONE:n]` checkpoints live in the implement
|
|
748
|
+
session (T2c). The `plan` registry stage now records `writes: [session.workflow-state]` (the
|
|
749
|
+
`/plan` enter/exit `mode` append).
|
|
750
|
+
|
|
751
|
+
**Plan-provider deferral (Node 2.2).** `planMode` now *consumes* the resolved `[providers] plan`
|
|
752
|
+
selection: it reads `loadPerkConfig(ctx.cwd).providers` through `extension/substrate/providers.ts`'s
|
|
753
|
+
`resolveProviders` per-event (`resolvedPlanProviderId(cwd)` / `isPerkPlanReferenceSelected(cwd)`,
|
|
754
|
+
fail-safe to `perk-plan` on any load failure) and **steps its authoring surface aside** when the
|
|
755
|
+
resolved plan provider ≠ `perk-plan` — the `/plan` toggle announces the deferral headless-safe and
|
|
756
|
+
returns, `Ctrl+Alt+P` routes through the same `toggle`, `--plan` defers **silently** (no gate
|
|
757
|
+
entry), and the `perk:plan-context` injection is suppressed (a second defer condition alongside the
|
|
758
|
+
objective-author one). The `context`-strip is unchanged. `savePlan`/the `plan_save` tool/`/plan-save`
|
|
759
|
+
/the read-only gate are the **seam-shared substrate** the Node 2.3 adapter bridges to — they are
|
|
760
|
+
always-registered and never defer (only perk's own authoring surface does).
|
|
761
|
+
|
|
762
|
+
**Todo-provider deferral (Node 3.1).** `checkpoints` (perk's reference todo provider,
|
|
763
|
+
`perk-checkpoints`) now *consumes* the resolved `[providers] todo` selection — the todo-seam mirror
|
|
764
|
+
of the plan-seam deferral above. It reads `loadPerkConfig(ctx.cwd).providers` through
|
|
765
|
+
`extension/substrate/providers.ts`'s `resolveProviders` per-event (`resolvedTodoProviderId(cwd)` /
|
|
766
|
+
`isPerkCheckpointsReferenceSelected(cwd)`, fail-safe to `perk-checkpoints` on any load failure) and
|
|
767
|
+
**steps its progress surface aside** when the resolved todo provider ≠ `perk-checkpoints`: the
|
|
768
|
+
`session_start` / `session_tree` / `turn_end` handlers early-return **silently** (no seed, no
|
|
769
|
+
advance, no `setStatus`/`setWidget` render — the foreign provider owns the surface uncontested) and
|
|
770
|
+
`/checkpoints` **announces** the deferral headless-safe and returns. The pure checkpoint helpers, the
|
|
771
|
+
`perk:checkpoint` session entry, and the `## Steps` seeding are the seam-shared substrate (untouched).
|
|
772
|
+
Fail-safe to the reference: any config-read error → treated as `perk-checkpoints` → everything runs
|
|
773
|
+
exactly as today (the default path is the hard guarantee, zero behavior change).
|
|
774
|
+
|
|
775
|
+
**The `@juicesharp/rpiv-todo` adapter (Node 3.2).** `juicesharp-todo` is now a **real, selectable**
|
|
776
|
+
todo provider (no longer illustrative); the todo seam is **behavior-complete**. The perk-owned shim
|
|
777
|
+
`extension/adapters/todoAdapterJuicesharp.ts` (`registerTodoAdapterJuicesharp`, always registered, wired right
|
|
778
|
+
after `registerCheckpoints`) is an **injection-only** bridge, inert unless `[providers] todo =
|
|
779
|
+
"juicesharp-todo"` **and** the session is an active workflow (`active_plan_ref != null`). When both
|
|
780
|
+
hold it injects a hidden (`display:false`) `perk:todo-adapter-juicesharp` context that carries perk's
|
|
781
|
+
implement-progress **discipline** onto the foreign checklist overlay (seed from `## Steps`, mark each
|
|
782
|
+
item complete in order); a `context` handler strips the stale `[TODO ADAPTER: JUICESHARP]` marker
|
|
783
|
+
once deselected. Two seam asymmetries this node resolves, both deliberate deviations from the Node
|
|
784
|
+
3.1 forward-assumption that "registration-time vacating is the concrete adapter's concern":
|
|
785
|
+
- **(a) NO registration-time vacating** for the todo seam. The plan seam needed it purely because
|
|
786
|
+
perk and `@tombell/pi-plan` both register `/plan` (Pi suffixes duplicate command names). The todo
|
|
787
|
+
seam has **no command-name collision** — perk registers `/checkpoints`, the foreign overlay
|
|
788
|
+
registers its own differently-named command(s) — so Node 3.1's runtime deferral is already
|
|
789
|
+
sufficient and the shim adds none.
|
|
790
|
+
- **(b) The bridge is injection-only + active-workflow-gated** and does **NOT** write
|
|
791
|
+
`perk:checkpoint` or revive the deferred marker scanner (Correction 2). Unlike `cache.plan-ref`
|
|
792
|
+
(a durable cross-plane artifact downstream stages read, so a foreign plan *must* be bridged into
|
|
793
|
+
it), `perk:checkpoint` is a transient TS-only overlay nothing downstream consumes and perk's
|
|
794
|
+
render + scanner are already deferred — re-populating it would be dead duplication. The foreign
|
|
795
|
+
overlay is the sole, uncontested progress surface.
|
|
796
|
+
|
|
797
|
+
The shim **never** owns the read-only gate, **never** `setActiveTools`, and **never** restamps any
|
|
798
|
+
provider field (the todo-provider id lives only in `[providers] todo`). Validation record:
|
|
799
|
+
`docs/design/provider-smoke-juicesharp-todo.md`.
|
|
800
|
+
|
|
801
|
+
**In-process read-only child sessions (P2.T4).** The first context-isolation primitive: a
|
|
802
|
+
deterministic, fully-isolated read-only child spun at the SDK level (`extension/worker/readOnlySession.ts`,
|
|
803
|
+
interior/TS-only). This is the **shared handoff contract** both context-isolation primitives honor
|
|
804
|
+
(T4 in-process here; T6 the spawned shape later), so its shape is locked now and T6 conforms.
|
|
805
|
+
|
|
806
|
+
- **SDK read-only via `createReadOnlySession`.** The child's allowlist is
|
|
807
|
+
`SDK_READ_ONLY_TOOLS = ["read", "grep", "find", "ls"]` — **no `bash`**, stricter than T1's
|
|
808
|
+
in-session `READ_ONLY_TOOLS` (a separate constant, not a reuse). T5 composes its own allowlist
|
|
809
|
+
when it needs a gated test-runner command.
|
|
810
|
+
- **Isolation = `DefaultResourceLoader` `no*` flags + the tools allowlist** — **not**
|
|
811
|
+
`extensionFactories: []` (that is already the default and controls only inline factories; it does
|
|
812
|
+
**not** stop `loader.reload()` from resolving the project's `.pi/settings.json` packages and
|
|
813
|
+
loading perk's own extension into the child). The child loader sets
|
|
814
|
+
`noExtensions/noSkills/noPromptTemplates/noThemes/noContextFiles`, so **no perk machinery loads
|
|
815
|
+
into the child** and the path stays offline/deterministic. A custom loader is **reloaded by the
|
|
816
|
+
caller** (`await loader.reload()` before `createAgentSession`); `agentDir` is a throwaway temp dir
|
|
817
|
+
(a locked-down child loads nothing from it). The read-only guarantee is **structural** —
|
|
818
|
+
provable offline via `getActiveToolNames()` with no `prompt()`.
|
|
819
|
+
- **The handoff contract (`runReadOnlyChild`).** Cap the **model-visible** output
|
|
820
|
+
(`DEFAULT_MODEL_VISIBLE_CAP = 50 KiB`, UTF-8-byte-safe, overridable), keep the **full** result in
|
|
821
|
+
a **verified** scratch file (`write → verify → pass-path`), and return **double-delivery**: compact
|
|
822
|
+
`prose` for the human + a `structured` block for the orchestrator (which T5 places in a tool's
|
|
823
|
+
forking-safe `details`). **Route-don't-relay** is enforced structurally — the raw output never
|
|
824
|
+
enters the parent; only a path/summary does (`scratchPath`). **Fail loud + fail closed:** never
|
|
825
|
+
throws to the parent — on any error (session-create/task throw, failed scratch-verify, or abort)
|
|
826
|
+
it returns `{ success: false, scratchPath: null }` with the error in **both** `prose` and
|
|
827
|
+
`structured.error`. Offline-testability is a hard requirement: the session-running step is behind
|
|
828
|
+
an injectable `runTask` dependency so the cap/scratch/verify/double-delivery machinery is exercised
|
|
829
|
+
with no model turn.
|
|
830
|
+
- **Substrate only.** No registry stage, no door change, no cross-CLI behavior. The consumer is the
|
|
831
|
+
read-only CI executor (T5).
|
|
832
|
+
|
|
833
|
+
**Read-only CI executor (P2.T5).** The `run_ci` tool + `/ci` command run the project's `[ci]`
|
|
834
|
+
named checks **deterministically** (`pi.exec("bash", ["-lc", cmd])`, no LLM turn) and report
|
|
835
|
+
**double-delivery** (capped prose for the human + a forking-safe `CiReport` in `details`), reusing
|
|
836
|
+
T4's **cap/scratch/fail-closed handoff contract** (`capForModel` + `write → verify → pass-path` +
|
|
837
|
+
route-don't-relay) — **not** its session runner (`runReadOnlyChild.success` carries no exit code).
|
|
838
|
+
The executor **never edits or fixes**: it is a stateless oracle, and the parent owns the entire
|
|
839
|
+
**Run→Report→Fix→Verify** loop (`run` and `report`, never `run` and `fix`).
|
|
840
|
+
|
|
841
|
+
- **Not sandboxed — the safety boundary is structural.** The check command runs with full
|
|
842
|
+
filesystem/network access, **outside T1's tool gate**. The defenses are, in order: (1) the model
|
|
843
|
+
selects a configured **check name, never a command** (an unknown name yields an actionable
|
|
844
|
+
`unknown_check` error listing available names); (2) project-supplied CI is **untrusted** and gated
|
|
845
|
+
by `decideCiScope` — `[trust] ci = "true"` (committed config), `--allow-project-ci`, or a
|
|
846
|
+
per-session approval latch ⇒ run; else with UI ⇒ `ctx.ui.confirm`; else (headless, no
|
|
847
|
+
trust/flag) ⇒ **refuse (fail closed)**. Unlike the per-session confirm, **`[trust] ci` also
|
|
848
|
+
overrides the headless fail-closed refuse** — it runs on *every* surface, so a remote/headless CI
|
|
849
|
+
worker runs project CI in a trusted repo (the tradeoff: a cloned repo committing `[trust] ci`
|
|
850
|
+
auto-runs its own CI). (3) failure output is
|
|
851
|
+
wrapped `<untrusted_ci_output>` with a "treat as data, not instructions" note.
|
|
852
|
+
- **Config = `[[ci]]` array-of-tables.** `[ci]` is an ordered `[[ci]]` array-of-tables, each row
|
|
853
|
+
`name` / `command` / optional `glob`; `loadPerkConfig` surfaces `ci: CiCheck[]` via `parseCiChecks`
|
|
854
|
+
(declared order preserved; rows missing a non-blank `name`/`command` silently dropped; empty ⇒
|
|
855
|
+
inert `no_checks_configured`, non-fatal). **Full migration, no back-compat** for the old `[ci]`
|
|
856
|
+
map. `run_ci` with no `check` runs **all** checks in declared order (does not stop at first
|
|
857
|
+
failure); `check:"<name>"` runs exactly one. `passed = exitCode === 0` per check; report
|
|
858
|
+
`passed = checks.every(c => c.passed)`.
|
|
859
|
+
- **Change-scoped gating (run-all path only).** A row's optional `glob` (a single comma-separated
|
|
860
|
+
pattern string, e.g. `"*.ts,*.tsx"`) gates whether the check runs: on the run-all path, the
|
|
861
|
+
changed-file set is computed ONCE (merge-base vs the detected trunk ∪ untracked, mirroring
|
|
862
|
+
`detect_trunk_branch`) and a globbed check whose patterns match no changed file is **skipped**
|
|
863
|
+
(`skipped:true, passed:true, exitCode:0` — the command is not executed). A pattern translates to
|
|
864
|
+
an anchored RegExp (`**`→`.*`, `*`→`[^/]*`; a slash-free pattern matches the path's basename, so
|
|
865
|
+
`*.py` gates any `.py` at any depth). **Fail-open:** any git error ⇒ unknown ⇒ run **everything**
|
|
866
|
+
(never skip on uncertainty, never a false success). A row with **no `glob` always runs**; an
|
|
867
|
+
**explicit `only` check always runs** (no glob gate, no git work); no git work happens when no
|
|
868
|
+
selected row is globbed. An all-skip run is `passed:true`; skipped rows contribute no
|
|
869
|
+
`<untrusted_ci_output>` block.
|
|
870
|
+
- **Interior/TS-only.** No registry stage, no door change (`doors.cold_remote` unchanged). Python
|
|
871
|
+
never reads `[ci]`.
|
|
872
|
+
|
|
873
|
+
**Spawned delegation engine seam (P2.T6).** perk's *second* context-isolation shape is a **spawned**
|
|
874
|
+
read-only child engine, stood up by **borrowing the `pi-subagents` engine** behind a thin seam rather
|
|
875
|
+
than building a spawn primitive. T6 is substrate only (no registry stage, no in-session TS consumer,
|
|
876
|
+
no perk-authored agent definitions, no roster/model-tier config — those land with the first consumer,
|
|
877
|
+
T7 `/address`).
|
|
878
|
+
|
|
879
|
+
- **Borrow boundary.** perk borrows the `pi-subagents` *engine* (its `subagent` tool + spawn/handoff
|
|
880
|
+
machinery); perk **owns** the agent definitions, chains, and acceptance wiring. perk authors **no**
|
|
881
|
+
`subagent` tool of its own — the "one `subagent` tool" is the borrowed one.
|
|
882
|
+
- **Defs location.** perk-owned agent definitions live in **`.pi/agents/`** (committed; scaffolded by
|
|
883
|
+
`perk init` with a `.gitkeep`, *not* gitignored — perk owns and commits its defs). `pi-subagents`
|
|
884
|
+
discovers them as project agents (`agentScope` default `both`).
|
|
885
|
+
- **Handoff reuse.** Spawned children honor the **same handoff contract as the P2.T4 amendment above**
|
|
886
|
+
(cap-model-visible-output, full result in a verified scratch file, double-delivery of compact prose
|
|
887
|
+
+ a structured block, route-don't-relay, fail-closed) — the shared contract both context-isolation
|
|
888
|
+
primitives honor (T4 in-process; T6 spawned).
|
|
889
|
+
- **Never-delegate boundaries** (`erk-subagent-usage.md`): judgment, user interaction, and
|
|
890
|
+
durable-state writes stay with the parent; spawned children do bounded, ideally read-only,
|
|
891
|
+
mechanical work.
|
|
892
|
+
- **Model tiering convention (locked, value deferred to T7).** perk agent defs set a **cheap model** in
|
|
893
|
+
frontmatter for mechanical child work; the parent keeps the top-tier model.
|
|
894
|
+
- **Standing signal vs spike vs live smoke.** `perk doctor`'s `settings-wiring` (the `npm:pi-subagents`
|
|
895
|
+
package entry) + `subagent-agents` (the `.pi/agents/` defs dir) own drift; the **informational**
|
|
896
|
+
`subagent-engine` check is a constant pointer carrying the seam shape and never re-derives that
|
|
897
|
+
drift. The **open-#6 spike** (recorded in the turn outcomes) settles "runs cleanly headlessly"; the
|
|
898
|
+
**live "runs under the worker" smoke is deferred to Phase 3 `doctor workflow`**.
|
|
899
|
+
- **Roster control deferred to T7.** `subagents.disableBuiltins` + the `.agents/`-recursion-collision
|
|
900
|
+
mitigation (perk's `.agents/skills/*/SKILL.md` would otherwise be discovered as stray agents) land
|
|
901
|
+
with the first agent.
|
|
902
|
+
**Review loop (`/address`, P2.T7).** perk's review-handling stage is **classify-then-act**, and the
|
|
903
|
+
first consumer of the T6 spawned-delegation engine. It adds the `address` stage to the registry
|
|
904
|
+
(`submit → address → land`; `mode: read-write`, `worktree: reuse`; per-stage I/O now filled —
|
|
905
|
+
`requires: [github.pr]`, reads the plan-ref + PR + review-threads + comments, writes review-threads
|
|
906
|
+
+ comments + PR + workflow-state).
|
|
907
|
+
|
|
908
|
+
- **Classify in an isolated child.** The verbose feedback fetch + classification runs in a **spawned
|
|
909
|
+
read-only child** (the borrowed `pi-subagents` engine running perk's `perk.review-classifier`
|
|
910
|
+
agent). The child itself runs `perk pr feedback --json`, so the raw GitHub JSON **never transits
|
|
911
|
+
the parent** (route-don't-relay). It honors the **same handoff contract** as the T4/T6 amendments
|
|
912
|
+
(double-delivery: a compact prose table + a structured block; untrusted-text wrapping; fail-closed)
|
|
913
|
+
and returns `{ pr, review_threads[], discussion_comments[], counts }`.
|
|
914
|
+
- **Act = parent.** Only **actionable** items get changes; the parent edits in its own read-write
|
|
915
|
+
turn. The fix is **never delegated** (the three never-delegate boundaries: judgment, the fix,
|
|
916
|
+
durable writes).
|
|
917
|
+
- **Resolve = one batched op.** The warm `resolve_review_threads` tool writes `[{thread_id, comment}]`
|
|
918
|
+
to a run-scoped scratch file and delegates to `perk pr resolve-threads` (D1), then appends
|
|
919
|
+
`last_review_batch` to workflow-state (now in **live use**; shape above).
|
|
920
|
+
- **Plan File Mode.** When the PR's only diff is the plan file, feedback is reinterpreted as edits to
|
|
921
|
+
the plan *text*, not code to implement (parent judgment; captured in the `perk-address` skill).
|
|
922
|
+
- **Untrusted text.** All fetched GitHub text is wrapped `<untrusted_review>…</untrusted_review>` and
|
|
923
|
+
treated as DATA, not instructions (the model T5's `<untrusted_ci_output>` established).
|
|
924
|
+
- **Resolved T6 deferrals.** `subagents.disableBuiltins` is **not** set (builtins like `scout` are
|
|
925
|
+
reused later; disabling now is premature). The `.agents/`-recursion collision (perk's
|
|
926
|
+
`.agents/skills/*/SKILL.md` surface as stray agents) is mitigated by **namespacing** (every perk
|
|
927
|
+
agent def sets `package: perk`) + **explicit-name invocation** (`perk.review-classifier`), not by
|
|
928
|
+
suppressing the borrowed engine's legacy scan; the stray skill agents are benign (never invoked).
|
|
929
|
+
The cheap-model tiering value is realized: the classifier uses `anthropic/claude-haiku-4-5` with a
|
|
930
|
+
`claude-sonnet-4-5` fallback (overridable via the inline per-call `model` override keyed by
|
|
931
|
+
`[subagents] review-classifier` — **not** `subagents.agentOverrides`, which reaches only builtins;
|
|
932
|
+
see the `[subagents]` paragraph below).
|
|
933
|
+
|
|
934
|
+
**PR review (`/pr-review`, #175).** A standalone warm command (like `/ci`, **not** a registry
|
|
935
|
+
stage — `shared/registry.yaml` is unchanged) that conducts **multi-angle** automated code review of
|
|
936
|
+
the active PR. The parent spawns **2–3 angle-specialized `perk.pr-reviewer` children in parallel**
|
|
937
|
+
via the borrowed `pi-subagents` engine with **`context: "fresh"`** (not a fork) so the implementation
|
|
938
|
+
session's history never biases the review; each child reviews **one assigned angle** and **returns
|
|
939
|
+
structured findings** (no posting, no file writes). The **parent reconciles** the per-angle findings
|
|
940
|
+
and records **one** consolidated outcome on the PR via the new warm **`post_pr_review`** tool. The
|
|
941
|
+
outcome is **verdict-driven**: the review lands **as comments on the PR only on an `actionable`
|
|
942
|
+
verdict**; a `clean` verdict posts a single 👍 reaction to the PR description and nothing else —
|
|
943
|
+
comments and `/address` are reserved for actionable feedback, and a clean verdict unambiguously
|
|
944
|
+
routes to `/land`.
|
|
945
|
+
|
|
946
|
+
- **Verdict-driven batch.** The review batch requires a `verdict` of exactly `"clean"` or
|
|
947
|
+
`"actionable"` (a clean verdict with non-empty `comments` is a `bad_batch`). The optional
|
|
948
|
+
`fyi: string[]` field carries borderline notes that are validated and echoed **in-session only**
|
|
949
|
+
— it is structurally never part of any GitHub payload. The clean path's 👍 reaction
|
|
950
|
+
(`add_pr_reaction`, the issues-reactions endpoint — idempotent on rerun) is a **hard error** on
|
|
951
|
+
failure (mutations raise; no fallback ladder — nothing review-shaped is lost).
|
|
952
|
+
|
|
953
|
+
- **Follows the read-only-child convention (multi-angle classify-then-act, #658).** Like `/address`,
|
|
954
|
+
the reviewer children are **read-only and report-only** — they classify their assigned angle and
|
|
955
|
+
**return** findings; the **parent** reconciles and posts. The parent always spawns the **Plan
|
|
956
|
+
fidelity & completeness** reviewer plus **1–2** of: **Correctness & regressions** (security/edge
|
|
957
|
+
cases), **Tests & validation adequacy**, **Code quality, simplicity & docs/contracts accuracy** —
|
|
958
|
+
chosen to fit the change (2–3 reviewers total), with the angle passed per-call in the spawn `task`
|
|
959
|
+
(one parameterized agent, no new defs). Each child returns a fenced JSON block
|
|
960
|
+
`{angle, verdict, findings:[{path,line,body}], fyi}` with inline findings **already anchored to
|
|
961
|
+
diff lines**; the parent **unions + dedupes** across angles (same `path`+`line` → merge bodies),
|
|
962
|
+
**derives the overall verdict** (`actionable` if **any** reviewer is actionable, else `clean`), and
|
|
963
|
+
passes the findings straight into `post_pr_review`'s `comments[]` — the parent **never re-anchors**
|
|
964
|
+
(the raw diff never enters the parent; each child runs its own `review-context`). D1 is still
|
|
965
|
+
honored — the GitHub mutation stays canonical in the **Python gateway**: `post_pr_review` delegates
|
|
966
|
+
to `perk pr review-post` (the existing cold door) via `runColdDoor` (stdin `--batch`). The review is
|
|
967
|
+
**advisory `COMMENT` only** — `event` is hardcoded `COMMENT` in the gateway, so the parent can never
|
|
968
|
+
approve/request-changes.
|
|
969
|
+
- **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).
|
|
974
|
+
Each configured value is injected as a **per-call inline `model` override** on that agent's
|
|
975
|
+
`subagent` spawn (the agent's frontmatter `model` stays the default when the key is unset). This
|
|
976
|
+
is wired at the authored spawn sites: the warm TS doors (`prReviewGuidance`,
|
|
977
|
+
`addressGuidance`, `factoryGuidance`, `conflictResolutionGuidance`), the cold Python prompts
|
|
978
|
+
(`_address_prompt`, `_seed_prompt`), and the headless worker (`initialPromptFor`). The earlier
|
|
979
|
+
`[pr-review] model` key is removed
|
|
980
|
+
outright (clean break, no alias — perk `0.0.1` pre-release, init converges forward). Unknown/typo'd
|
|
981
|
+
agent keys are silently ignored (mirrors `_parse_providers_selection`); no doctor validation.
|
|
982
|
+
**Correction to the T7 note above:** `subagents.agentOverrides` does **not** reach project agents
|
|
983
|
+
— `pi-subagents`' `applyBuiltinOverrides` applies overrides only to **builtin** agents — so the
|
|
984
|
+
inline per-call override (not an override map) is the configuration mechanism for project agents
|
|
985
|
+
like `perk.review-classifier` and `perk.pr-reviewer`.
|
|
986
|
+
- **Workflow-state record (`last_pr_review`, #658).** The `post_pr_review` parent tool turn appends
|
|
987
|
+
a compact `last_pr_review` (`{pr, verdict, angles, comment_count, mode, at}`) to
|
|
988
|
+
`perk:workflow-state`, best-effort / non-fatal (mirrors `resolve_review_threads`'s
|
|
989
|
+
`last_review_batch`). The PR comment stays the canonical record; this is the in-session twin
|
|
990
|
+
(the earlier deferral is delivered).
|
|
991
|
+
- **Still a warm command, not a `DriveStage`.** `/pr-review` remains human-invoked — the registry is
|
|
992
|
+
unchanged and `DriveStage = implement | address` (the headless worker drives only those two). But
|
|
993
|
+
the new `post_pr_review` tool turn + `last_pr_review` append make it **structurally symmetric with
|
|
994
|
+
`address`** (an ok tool result + an appended workflow-state field is exactly the terminal signal
|
|
995
|
+
the worker's `applyEvent`/`evaluateTerminal` latches onto), so a future promotion to a
|
|
996
|
+
headless-drivable stage is a clean follow-up (deferred — not built here).
|
|
997
|
+
- **Agent-def delivery.** perk's agent **sources** live at top-level `agents/<name>.md` (no leading
|
|
998
|
+
dot, so pi never discovers them in the source tree) and are bundled into the wheel as `perk/_agents`
|
|
999
|
+
(hatchling `force-include`) + the sdist `only-include`. `perk init` materializes them into the
|
|
1000
|
+
consumer-owned **`.pi/agents/perk/`** subdir as a **committed managed convergence** (the
|
|
1001
|
+
`subagent-agents` capability): each `<name>.md` is written byte-for-byte from its source, strays
|
|
1002
|
+
inside `perk/` are pruned, and drift is `doctor --fix`-repaired. The agent frontmatter (`name`,
|
|
1003
|
+
`package: perk`, …) is unchanged, so the runtime names stay `perk.*` and the spawn sites need no
|
|
1004
|
+
edits. perk owns ONLY the `.pi/agents/perk/` subdir — **custom user agents** live at
|
|
1005
|
+
`.pi/agents/<name>.md` (top-level or any non-`perk/` subdir), set their model/tools in frontmatter,
|
|
1006
|
+
and are invoked via pi's native `subagent` tool (the fixed-key `[subagents]` table configures only
|
|
1007
|
+
perk's own agents). Linked worktrees inherit the delivered defs via git checkout (no worktree
|
|
1008
|
+
mirror).
|
|
1009
|
+
|
|
1010
|
+
**Conflict resolution (`/submit`, #556).** After `/submit` opens the draft PR, the Python
|
|
1011
|
+
`perk pr submit` cold door probes the PR's mergeability against the base branch with a deterministic
|
|
1012
|
+
local `git merge-tree` probe and surfaces `base` / `mergeable` (bool \| null) / `conflicts[]` in its
|
|
1013
|
+
`--json` (see §8.4). When the probe is a definitive `mergeable: false` with conflicts, the warm
|
|
1014
|
+
`submit` door (shared by the `/submit` command and the headless worker — both route through the same
|
|
1015
|
+
`submit` tool) drives the perk-owned **`perk.conflict-resolver`** agent via the borrowed
|
|
1016
|
+
`pi-subagents` engine with **`context: "fresh"`** (not a fork). Unlike the read-only
|
|
1017
|
+
classifier/reviewer, the conflict-resolver is **write-capable** and **inherits project context +
|
|
1018
|
+
skills** (resolving conflicts correctly requires understanding the code and running the repo's
|
|
1019
|
+
checks); like the reviewer it **fetches its own plan + PR context** read-only via
|
|
1020
|
+
`perk pr review-context` (the verbatim `plan_body` + `diff` are what let it resolve *correctly*, not
|
|
1021
|
+
merely cleanly), then rebases onto `base_ref`, resolves every conflict, verifies, and force-pushes —
|
|
1022
|
+
the parent then re-runs `/submit` to confirm. The re-drive is **bounded** by
|
|
1023
|
+
`CONFLICT_RESOLUTION_ATTEMPT_CAP = 2` via the `conflict_resolution_attempts` workflow-state field
|
|
1024
|
+
(§8.3; reset to 0 on a clean submit); past the cap the unresolved conflict is surfaced loudly
|
|
1025
|
+
instead of looping. The probe is **fail-open**: an undetermined probe (`mergeable: null`) never
|
|
1026
|
+
blocks submit. Configurable model via `[subagents] conflict-resolver`.
|
|
1027
|
+
|
|
1028
|
+
- **Filing note (deferral).** This §8.3 cluster (T1/T2a/T2b/T2c/T4/T5/T6/T7) has outgrown "the
|
|
1029
|
+
workflow-state schema"; promoting the context-isolation/handoff paragraphs (T4/T5/T6) into a
|
|
1030
|
+
dedicated "context-isolation" section is a **deferred** doc refactor — T6 files as a sibling here to
|
|
1031
|
+
preserve cohesion now.
|
|
1032
|
+
|
|
1033
|
+
---
|
|
1034
|
+
|
|
1035
|
+
## §8.4 · The GitHub gateway contract (Q9/Q10)
|
|
1036
|
+
|
|
1037
|
+
**One contract, implemented once per plane** (no shared module, no in-process coupling):
|
|
1038
|
+
a `gh`-shelling gateway in the Python CLI (`init`/worker) and a `gh`-shelling gateway in the
|
|
1039
|
+
TS extension (in-session mutations). Both conform to the **same operation names + payload
|
|
1040
|
+
shapes**, so either can later swap `gh`-shell → API-backed independently, and `doctor` can
|
|
1041
|
+
verify both.
|
|
1042
|
+
|
|
1043
|
+
### Verification-only operations (Phase 0 — authored now, **no mutation**)
|
|
1044
|
+
|
|
1045
|
+
These are all `init`/`doctor` needs in Phase 0 (`Q9`: verification-only; the first label is
|
|
1046
|
+
created lazily by `/plan-save` in Phase 1). **Implemented in the Python plane (T5):**
|
|
1047
|
+
`perk/github/` (typed dataclasses mirroring these shapes); the TS plane follows in Phase 1.
|
|
1048
|
+
|
|
1049
|
+
```
|
|
1050
|
+
check_auth() -> { ok: bool, user: string|null, scopes: string[], error: string|null }
|
|
1051
|
+
# `gh auth status` (+ `gh api user`); never mutates.
|
|
1052
|
+
check_repo_access() -> { ok: bool, repo: string|null, can_push: bool, error: string|null }
|
|
1053
|
+
# `gh repo view`; can_push from viewerPermission ∈ {WRITE,MAINTAIN,ADMIN}.
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
`require_github(ctx)` is the **strict DI binding** for Phase-1+ commands (raises
|
|
1057
|
+
`UserFacingCliError` / `error_type: github_unauthed` when unauthed); `init`/`doctor` call the
|
|
1058
|
+
`check_*` ops directly to *report* (non-fatal — see §8.5).
|
|
1059
|
+
|
|
1060
|
+
### Mutation operations
|
|
1061
|
+
|
|
1062
|
+
**Authored (P1.T2a — the plan write).** REST `gh api`; mutations **raise** on failure (the
|
|
1063
|
+
command boundary maps to `UserFacingCliError`), lookups return `… | null`:
|
|
1064
|
+
|
|
1065
|
+
```
|
|
1066
|
+
create_label{ name, color, description } -> Label{ name, created }
|
|
1067
|
+
# POST repos/{o}/{r}/labels; HTTP 422 ⇒ created:false (idempotent)
|
|
1068
|
+
create_plan_issue{ title, body, labels[], run_id } -> PlanIssue{ number, url, existed }
|
|
1069
|
+
# POST repos/{o}/{r}/issues (-F body=@file); idempotent on run_id
|
|
1070
|
+
add_issue_comment{ issue, body } -> CommentResult{ posted }
|
|
1071
|
+
# POST repos/{o}/{r}/issues/{n}/comments (the plan-body first comment)
|
|
1072
|
+
find_plan_issue{ run_id } -> PlanIssue | null
|
|
1073
|
+
# GET repos/{o}/{r}/issues?labels=perk:plan&state=open + header run_id match
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
- **Idempotency** is keyed on the header `run_id`, discovered via the **list** endpoint (not
|
|
1077
|
+
the eventually-consistent search index), create-then-return (`Q3` establish-before-record).
|
|
1078
|
+
- **`perk:plan` label** is created lazily on first save.
|
|
1079
|
+
- **`perk plan-save` is an upsert keyed on `run_id` (P2.T13).** The *first* save with a `run_id`
|
|
1080
|
+
creates the issue and posts the `plan-body` comment; a *re-save* with the same `run_id` updates
|
|
1081
|
+
the existing issue **in place** instead of no-opping — `create_plan_issue` still dedups (never a
|
|
1082
|
+
second issue per `run_id`), then `update_plan_issue{ number, title, body_comment }` PATCHes the
|
|
1083
|
+
`plan-body` comment with the revised markdown and PATCHes the issue **title** from the (possibly
|
|
1084
|
+
revised) plan H1. The comment is found by marker (REST comment list → first body containing the
|
|
1085
|
+
`plan-body` block; perk stores no comment id), which also repairs legacy plan issues; a missing
|
|
1086
|
+
comment falls back to a fresh POST so the body is never stranded. The anti-duplicate guarantee is
|
|
1087
|
+
preserved. Because `update_plan_issue` rewrites only the `plan-body` comment + the title (never
|
|
1088
|
+
the `plan-header`), a re-save **additionally** merges the planning header fields (`objective_id`,
|
|
1089
|
+
`consumed_learn`) back into the existing `plan-header` via `update_plan_header` when provided —
|
|
1090
|
+
additive, so an omitted field is left intact (no clobber of a previously linked objective/learn
|
|
1091
|
+
set, no reset of the submit-populated `branch`/`pr`/`lifecycle_stage`). This keeps the canonical
|
|
1092
|
+
header (the source `reconstruct_plan_ref` and the on-land `consumed_learn` consume read from)
|
|
1093
|
+
current on every save, not just the first create; the header write is fail-loud (a failure raises
|
|
1094
|
+
`GitHubError` → `github_error`, since this is the canonical save). `--json` carries a top-level
|
|
1095
|
+
`updated` (true on re-save, false on fresh create);
|
|
1096
|
+
`cached` stays true on every real save. The warm `/plan-save` surfaces `details.updated` and an
|
|
1097
|
+
"Updated plan #N" message on the re-save path.
|
|
1098
|
+
|
|
1099
|
+
```
|
|
1100
|
+
update_plan_issue{ number, title, body_comment } -> PlanUpdate{ number, body_updated, title_updated, dry_run }
|
|
1101
|
+
# find the plan-body comment by marker -> PATCH .../issues/comments/{id} (-F body=@file)
|
|
1102
|
+
# (fallback: POST a fresh comment, body_updated:false) ; PATCH .../issues/{n} (-f title=)
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
**Authored (P1.T5a — the submit path).** REST `gh api`; idempotent via the list endpoint:
|
|
1106
|
+
|
|
1107
|
+
```
|
|
1108
|
+
default_branch() -> string
|
|
1109
|
+
# gh repo view --json defaultBranchRef (the PR base)
|
|
1110
|
+
find_pr_for_branch{ branch } -> PullRequest | null
|
|
1111
|
+
# GET .../pulls?head=<owner>:<branch>&state=all (prefers an open PR)
|
|
1112
|
+
create_pr{ head, base, title, body, draft } -> PullRequest{ number, url, is_draft, state, existed }
|
|
1113
|
+
# POST .../pulls (-F body=@file); idempotent on head (find-then-create)
|
|
1114
|
+
update_plan_header{ issue, fields } -> PlanHeaderUpdate{ fields_updated[], dry_run }
|
|
1115
|
+
# GET issue body -> merge fields into the plan-header block -> PATCH .../issues/{n}
|
|
1116
|
+
# rejects unknown header keys (LBYL on the schema); submit sets branch/pr/lifecycle_stage=impl
|
|
1117
|
+
prepend_plan_callout{ issue, callout, command } -> bool (#664)
|
|
1118
|
+
# GET issue body -> plan.prepend_callout(body, callout, command=) -> PATCH .../issues/{n}
|
|
1119
|
+
# idempotent on `command`; True iff a write occurred (False when already present / dry-run)
|
|
1120
|
+
get_plan{ number } -> PlanState{ number, url, title, header, pr, state } | null
|
|
1121
|
+
# gh issue view --json (+ pulls/{n} when the header carries pr); the `perk resume` read (T5c).
|
|
1122
|
+
# `state` is the issue's OPEN/CLOSED state (the `replan` OPEN guard reads it).
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
- **`perk replan <plan>` re-authors an OPEN plan *in place*.** A **dedicated cold door** (not a
|
|
1126
|
+
registry stage): it borrows the `plan` stage descriptor (`mode: read-only`, `worktree: none`) and
|
|
1127
|
+
re-launches it with `run_id_override` = the target plan's **original `run_id`** (a deliberate,
|
|
1128
|
+
documented exception to the registry's "cold mints" `run_id` policy — the override re-enters an
|
|
1129
|
+
existing plan's run). Because the warm `plan_save` is an upsert keyed on `run_id` (above), the
|
|
1130
|
+
re-save **updates the same plan issue in place** rather than creating a new one — preserving the
|
|
1131
|
+
`plan-header` and thus the plan→objective link (`objective_id`) and the node→plan backlink. The
|
|
1132
|
+
cold door performs every GitHub read up front (read-only `gh` query subcommands are
|
|
1133
|
+
allowlisted, but the cold door still materializes every GitHub read up front — deterministic
|
|
1134
|
+
and token-cheap) and
|
|
1135
|
+
materializes the prior plan body into a `<untrusted_plan>` scratch file the session reads. It
|
|
1136
|
+
**refuses** a non-OPEN plan (`plan_not_open` — a closed plan would silently create a new issue),
|
|
1137
|
+
a missing plan (`plan_not_found`), a header without `run_id` (`no_run_id`), or an empty body
|
|
1138
|
+
(`no_plan_body`). **No extension change is required** (the interior sees an ordinary read-only
|
|
1139
|
+
`plan`-stage session). **Single-plan only** — erk's multi-plan consolidation (`erk-consolidated`)
|
|
1140
|
+
is deliberately deferred.
|
|
1141
|
+
|
|
1142
|
+
- **PR body (P1.T5a, minimal):** `Closes #<issue>` (so the squash-merge closes the plan) + a
|
|
1143
|
+
`Plan: #<issue>` link + a **plain-text** `` `gh pr checkout <n>` `` footer (no HTML — erk's
|
|
1144
|
+
tripwire). Full-plan re-embedding + AI body craft are Phase 2.
|
|
1145
|
+
|
|
1146
|
+
**Authored (P1.T5b — the land path).** Idempotent; the caller checks PR state before merging:
|
|
1147
|
+
|
|
1148
|
+
```
|
|
1149
|
+
mark_pr_ready{ number } -> void
|
|
1150
|
+
# gh pr ready <n> — the ONE non-REST op (draft->ready is GraphQL-only); called only on a draft
|
|
1151
|
+
merge_pr{ number, commit_message? } -> PullRequest (state MERGED)
|
|
1152
|
+
# PUT .../pulls/{n}/merge (merge_method=squash); idempotent ("already merged" ⇒ success)
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
- **`Closes #<issue>`** rides in the PR body (T5a) so the squash-merge closes the plan issue;
|
|
1156
|
+
`commit_message` repeats it belt-and-suspenders. Post-merge state is **derived from PR**, never
|
|
1157
|
+
stored (Q8).
|
|
1158
|
+
|
|
1159
|
+
**Authored (P2.T8b — deep `/land` + `/learn`).** Land deepens the squash commit message; learn
|
|
1160
|
+
graduates from a thin marker-clear into a real knowledge-capture pass:
|
|
1161
|
+
|
|
1162
|
+
```
|
|
1163
|
+
find_learn_issue{ run_id } -> PlanIssue | null
|
|
1164
|
+
# GET .../issues?labels=perk:learn&state=open + learn-header run_id match. LABEL-SCOPED to
|
|
1165
|
+
# perk:learn (+ the learn-header block) so it CANNOT return the plan issue, which shares the
|
|
1166
|
+
# plan's run_id under the warm:keep learn stage. Implemented by parameterizing find_plan_issue
|
|
1167
|
+
# with label/header_key (the perk:plan/plan-header defaults preserved — no caller changes).
|
|
1168
|
+
create_learn_issue{ title, body, run_id, plan_number } -> PlanIssue{ number, url, existed }
|
|
1169
|
+
# lazy create_label("perk:learn"); idempotent via find_learn_issue (NOT find_plan_issue);
|
|
1170
|
+
# renders a learn-header block { run_id, created, plan } into the body so the finder matches.
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
**Authored (hop-2 — the learned-docs consumer).** The factory cold door gathers + lands the
|
|
1174
|
+
consume; both ops follow the established conventions (REST `gh api`, LIST endpoint, lazy label,
|
|
1175
|
+
mutations raise / lookups never mask infra failure):
|
|
1176
|
+
|
|
1177
|
+
```
|
|
1178
|
+
list_learn_issues{} -> LearnIssueSummary[]{ number, title, url, body }
|
|
1179
|
+
# GET .../issues?labels=perk:learn&state=open (the find_plan_issue list call, label-scoped to
|
|
1180
|
+
# perk:learn). Returns every open learn issue's full body for the inbox; raises on infra
|
|
1181
|
+
# failure (never masks as empty); skips non-dict / pull_request entries.
|
|
1182
|
+
close_and_label_consolidated{ issue } -> bool
|
|
1183
|
+
# lazy create_label("perk:consolidated"); POST .../issues/{n}/labels (-f labels[]=perk:consolidated,
|
|
1184
|
+
# ADD not replace) THEN PATCH .../issues/{n} (-f state=closed). Idempotent (re-closing /
|
|
1185
|
+
# re-labelling is success). Raises GitHubError on infra failure.
|
|
1186
|
+
```
|
|
1187
|
+
|
|
1188
|
+
- **Deepened squash commit message (D8).** Land now passes `merge_pr(commit_message=)` =
|
|
1189
|
+
plain `"<plan title>\n\nCloses #<issue>"` (`get_plan(...).title`, fallback `Closes #<issue>` on an
|
|
1190
|
+
empty title). Plain text only — the second of the **two PR targets** (the GitHub HTML body, T8a,
|
|
1191
|
+
is the other); HTML never leaks into `git log`.
|
|
1192
|
+
- **`/learn` (D10).** The `learn capture` worker (`perk learn capture --json --body <file>`) reads
|
|
1193
|
+
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
|
|
1195
|
+
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
|
|
1198
|
+
`[cache.markers, github.learn, github.comments]` (the `github.learn` vocabulary key is new).
|
|
1199
|
+
The warm door's `learn_issue` decode is **lenient** (render-only field): a `success: true`
|
|
1200
|
+
envelope yields the captured-ok terminating result and mirrors the marker-clear even when the
|
|
1201
|
+
sub-object is undecodable (e.g. under CLI↔extension version skew); the generic decode-null
|
|
1202
|
+
`bad_output` message across doors now names probable version skew while keeping the
|
|
1203
|
+
`unexpected payload` substring.
|
|
1204
|
+
|
|
1205
|
+
**P2.T17 — learn is now ACTIVE (primed launch + guided warm door).** The capture mechanism above
|
|
1206
|
+
is unchanged; what's added is the *driver*. The `learn` cold launch is **primed** (`launch/prompts.py`
|
|
1207
|
+
`_learn_prompt`): the session opens already investigating the landed change (read the plan +
|
|
1208
|
+
derive the merged PR from the `plan-<pr_id>` head branch) and is told to call the `learn` tool
|
|
1209
|
+
with synthesized learnings. The warm **bare `/learn`** (interactive) **injects `perk-learn`
|
|
1210
|
+
guidance** via `pi.sendUserMessage` instead of silently clearing the marker (the agent clears it
|
|
1211
|
+
by calling the `learn` tool); **`/learn skip`** preserves the pure marker-clear and **`/learn
|
|
1212
|
+
<text>`** still captures verbatim; **headless** bare `/learn` stays the safe marker-clear
|
|
1213
|
+
(can't drive a turn). The **`perk-learn` skill** is the judgment layer both surfaces point at.
|
|
1214
|
+
No new gateway op — the existing `learn` tool / `learn capture` worker remain the durable-write
|
|
1215
|
+
path. **Tier 3 update (hop-2):** the **`docs/learned/*.md` documentation-plan loop is now BUILT**
|
|
1216
|
+
(see the *Learned-docs consumer (hop-2)* subsection below). The remaining Tier-3 pieces
|
|
1217
|
+
(session-material bundling on land, multi-agent session/diff/docs analysis) stay **deferred** —
|
|
1218
|
+
perk's already-synthesized `perk:learn` records are the materials, replacing erk's session
|
|
1219
|
+
preprocessing.
|
|
1220
|
+
- **Reconciliation typing (D9 — vocabulary established; Reconcilable + objective reconciliation
|
|
1221
|
+
implemented in P2.T11).** Three section types on land: **Mechanical** (command-updated,
|
|
1222
|
+
deterministic — T8b: `pending-learn` + the plain squash commit message; **P2.T11a**: the
|
|
1223
|
+
auto-on-merge node-done); **Reconcilable** (LLM-updated post-merge — **implemented in P2.T11**,
|
|
1224
|
+
see the P2.T11 subsection below); **Immutable** (never touched). The merged state is **PR-derived
|
|
1225
|
+
and not stored** (Q8), so land authors no new stored field. Objective-node reconciliation is
|
|
1226
|
+
**implemented in P2.T11** (the auto-on-merge node-done + the warm `/objective-reconcile` pass).
|
|
1227
|
+
|
|
1228
|
+
**Authored (P2.T7 — the `/address` review loop).** Review threads + their resolution are
|
|
1229
|
+
**GraphQL-only** (REST has no `isResolved`, no `resolveReviewThread`/`addPullRequestReviewThreadReply`);
|
|
1230
|
+
discussion comments stay REST. The GraphQL shapes are verbatim from erk (the durable prior art). The
|
|
1231
|
+
read **raises** on infra failure; the resolve captures **per-item** failures into its result (one bad
|
|
1232
|
+
thread does not sink the batch) but still raises on a hard infra failure (gh missing / timeout):
|
|
1233
|
+
|
|
1234
|
+
```
|
|
1235
|
+
get_pr_feedback{ pr_number } -> PrFeedback{ pr_number, review_threads[], discussion_comments[], reviews[] }
|
|
1236
|
+
# review threads + PR-level reviews via `gh api graphql`; discussion comments via REST
|
|
1237
|
+
# GET .../issues/{n}/comments. The three sources are kept SEPARATE (counted apart) — review
|
|
1238
|
+
# threads (inline, with a resolvable thread_id) are a distinct API from discussion comments.
|
|
1239
|
+
# Read-only; what the spawned `perk.review-classifier` child runs (via `perk pr feedback`).
|
|
1240
|
+
resolve_review_threads{ batch:[{thread_id, comment?}] } -> BatchResolveResult{ success, results[] }
|
|
1241
|
+
# for each item: optional reply (addPullRequestReviewThreadReply) THEN resolveReviewThread,
|
|
1242
|
+
# both GraphQL. results[] is per-item {thread_id, success, comment_added, error}; top-level
|
|
1243
|
+
# success = all resolved. An already-resolved thread re-resolves to success (idempotent).
|
|
1244
|
+
# The warm TS twin writes the batch to a run-scoped scratch file (pi.exec has no stdin) and
|
|
1245
|
+
# delegates via `perk pr resolve-threads --json --batch <path>`.
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
- **Batch shape (PRIOR_ART §5/§11):** `[{ thread_id, comment }]` (objects, not a flat list).
|
|
1249
|
+
|
|
1250
|
+
**Authored (#175 — the `/pr-review` automated-review door).** The read gathers everything the
|
|
1251
|
+
fresh-context `perk.pr-reviewer` child needs to review the active PR; the mutation submits the
|
|
1252
|
+
child's review back. `event` is **hardcoded `COMMENT`** (the agent can never approve/block).
|
|
1253
|
+
Resilience: if the inline-anchored review submission fails (e.g. a `line` not present in the diff),
|
|
1254
|
+
`post_pr_review` falls back to posting the summary (+ rendered findings) as a single discussion
|
|
1255
|
+
comment, so a review **always** lands on the PR:
|
|
1256
|
+
|
|
1257
|
+
```
|
|
1258
|
+
get_pr_review_context{ pr_number, branch, plan_body } -> PrReviewContext{ pr_number, base_ref, head_ref, title, body, diff, plan_body }
|
|
1259
|
+
# Read-only. PR meta via `gh api pulls/{n}`, diff via `gh pr diff {n}`. The gateway no longer
|
|
1260
|
+
# reads plan/issue state: `plan_body` is resolved backend-neutrally by the consumer
|
|
1261
|
+
# (`perk pr review-context`) — the materialized `cache.plan` mirror first, else
|
|
1262
|
+
# `IssueBackend.get_plan_body` via the resolver (GitHub numeric ids AND Linear `ENG-123`) —
|
|
1263
|
+
# and passed straight in (best-effort; null lets the review run from the diff). What the
|
|
1264
|
+
# spawned child runs (Objective #746 Node 2.2 hoist: the gateway is pure PR/CI/auth/review).
|
|
1265
|
+
post_pr_review{ pr_number, summary, comments:[{path,line,body}] } -> ReviewPostResult{ ok, mode, pr_number, comment_count }
|
|
1266
|
+
# ONE review via POST .../pulls/{n}/reviews with event=COMMENT (hardcoded) + inline comments[]
|
|
1267
|
+
# (path, line, side=RIGHT). mode ∈ {"review" (inline-anchored), "comment_fallback" (discussion
|
|
1268
|
+
# comment when the review submission fails)}. The warm twin is `/pr-review`'s parent-side
|
|
1269
|
+
# `post_pr_review` tool (#658), which delegates via `perk pr review-post --json --batch <path>`
|
|
1270
|
+
# (the reviewer children no longer call it directly — they report findings to the parent).
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
**Authored (P2.T8a — PR-body craft + the deliberate review gate).** The submit body is composed
|
|
1274
|
+
in `perk pr submit` via **create-then-update** (the checkout footer needs the PR number, unknown
|
|
1275
|
+
until `create_pr` returns), which also fixes a latent correctness bug (the Phase-1 footer carried
|
|
1276
|
+
the **issue** number, not the PR's — erk's single most common agent mistake):
|
|
1277
|
+
|
|
1278
|
+
```
|
|
1279
|
+
update_pr_body{ number, body } -> PrBodyUpdate{ number, dry_run }
|
|
1280
|
+
# PATCH .../pulls/{n} (-F body=@file); mirrors update_plan_header (PR body, not issue body).
|
|
1281
|
+
# Re-writes the full body WITH the plain-backtick `gh pr checkout <pr_number>` footer once the
|
|
1282
|
+
# PR number is known. Idempotent (overwrites).
|
|
1283
|
+
get_pr_body{ number } -> string | null
|
|
1284
|
+
# GET .../pulls/{n} --jq .body; the read `perk pr check` re-validates against.
|
|
1285
|
+
validate_pr_body(body, *, pr_number) -> string[] (empty == valid)
|
|
1286
|
+
# PURE (no gh). Footer-scoped ONLY (the <details> embed is explicitly fine): the footer must be
|
|
1287
|
+
# present, plain-backtick (not HTML-wrapped), and carry the PR number (word-boundary: #12 ≠
|
|
1288
|
+
# …checkout 123). This is the self-check that catches the issue-numbered-footer bug.
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
- **The two-target split (D4).** The HTML-enhanced body — a best-effort `<details>` embed of the
|
|
1292
|
+
verbatim plan (via `get_plan_body`; `None` → no embed, no raise) + the checkout footer — goes
|
|
1293
|
+
**only** into the GitHub PR body (`update_pr_body`). The squash **commit message** is the OTHER
|
|
1294
|
+
target: plain text, set at land (T8b) so HTML never leaks into `git log`.
|
|
1295
|
+
- **Mergeability probe (#556).** **After** the PR is created + the body validated, `perk pr submit`
|
|
1296
|
+
runs a deterministic **local** `git merge-tree --write-tree origin/<base> <branch>` probe (no
|
|
1297
|
+
GitHub round-trip, no reliance on GitHub's eventually-consistent `mergeable` field) and surfaces
|
|
1298
|
+
three new `--json` fields: `base` (the target branch), `mergeable` (`true` clean / `false`
|
|
1299
|
+
conflicts present / `null` undetermined), and `conflicts[]` (the conflicted paths). The probe is
|
|
1300
|
+
**fail-open**: a best-effort `git fetch origin <base>`, an unresolvable base, or any `merge-tree`
|
|
1301
|
+
exit other than 0/1 (e.g. old git lacking `--write-tree`) yields `mergeable: null` and never
|
|
1302
|
+
changes submit's exit code — the gate (the warm-door conflict-resolver drive, §8.3) fires only on
|
|
1303
|
+
a **definitive** `mergeable: false`. `--dry-run` stays fully offline (`base: ""`, `mergeable:
|
|
1304
|
+
null`, no probe). The submit still **succeeds mechanically** (exit 0) when conflicts are present —
|
|
1305
|
+
mergeability is reported separately, not an op failure.
|
|
1306
|
+
- **`pr check` (D5).** `perk pr submit` runs `validate_pr_body` as a **post-write self-check** and
|
|
1307
|
+
**raises** (`error_type: pr_check_failed`) on failure. A thin `perk pr check --json` (active
|
|
1308
|
+
plan-ref → find PR → `get_pr_body` → `validate_pr_body`) is the supervisor surface (exit 0 valid /
|
|
1309
|
+
1 invalid·op-failure / 2 not-a-repo).
|
|
1310
|
+
- **Draft → ready is a deliberate gesture (D6).** Submit keeps the PR **draft**; perk does **not**
|
|
1311
|
+
auto-publish (unlike erk's `finalize_pr`). The new `perk pr ready` (warm `/ready`, `extension/
|
|
1312
|
+
ready.ts`) is the explicit review gate — `mark_pr_ready` if draft, idempotent. Land's
|
|
1313
|
+
mark-ready-if-draft stays a safety net. **Correction:** perk plans are GitHub *issues*, not repo
|
|
1314
|
+
files, so erk's plan-file-diff completion heuristic does **not** map — the explicit draft→ready
|
|
1315
|
+
transition is the gate, and no plan-file-diff detector is built (never infer completion from PR
|
|
1316
|
+
open/closed state alone).
|
|
1317
|
+
- **Re-submit on rewritten history (P2.T8a follow-up).** `perk pr submit` **force-pushes the
|
|
1318
|
+
perk-owned plan branch with `--force-with-lease`** (auto-force; a no-op on the first push). Plan
|
|
1319
|
+
branches (`plan-<n>`) are single-author and expected to diverge after amend/squash/rebase, so a
|
|
1320
|
+
plain push would be rejected non-fast-forward on every re-submit after a history rewrite. The
|
|
1321
|
+
lease still rejects an *unexpected* origin move (teammate safety) — no `git fetch` is needed
|
|
1322
|
+
because only this worktree pushes this branch. Two stable error surfaces front this:
|
|
1323
|
+
- **`error_type: dirty_tree`** — submit refuses on a dirty worktree (commit-first guard, fired
|
|
1324
|
+
before the push) because uncommitted work isn't pushed and would silently fail to update the PR.
|
|
1325
|
+
- **`error_type: push_rejected`** — a non-fast-forward / lease failure maps to an actionable
|
|
1326
|
+
"remote moved unexpectedly; fetch/rebase and re-submit" message instead of raw git stderr
|
|
1327
|
+
(`error_type: git_error` remains the fallback for other git failures).
|
|
1328
|
+
- **Phase-2 caveat:** a fresh-clone resume (remote branch with no local remote-tracking ref) may
|
|
1329
|
+
hit a `stale info` lease failure and need a targeted `git fetch origin <branch>` before the
|
|
1330
|
+
lease; deferred with remote-branch resume (Phase 2).
|
|
1331
|
+
|
|
1332
|
+
### Plan-ref payload (provider-agnostic; full schema → Phase 1)
|
|
1333
|
+
|
|
1334
|
+
`active_plan_ref` / `cache.plan-ref` is **provider-agnostic** from day one (PRIOR_ART §2 —
|
|
1335
|
+
erk migrated away from GitHub-specific refs and issue-numbers-in-branch-names):
|
|
1336
|
+
|
|
1337
|
+
```
|
|
1338
|
+
{ provider: string, # the resolved issue backend ("github" today — §8.21)
|
|
1339
|
+
pr_id: string, # STRING (allows non-numeric ids like Jira "PROJ-123")
|
|
1340
|
+
url: string, # during planning: the plan issue url/id; branch/pr staged null
|
|
1341
|
+
labels: string[], # ["perk:plan"]
|
|
1342
|
+
objective_id: string|null, # Phase 2
|
|
1343
|
+
consumed_learn: string[], # hop-2: perk:learn issue ids a docs plan consolidates (closed on
|
|
1344
|
+
# land) — opaque strings (§8.21; Node 4.1)
|
|
1345
|
+
base: string|null } # #633: the pinned PR merge target / worktree start-point branch;
|
|
1346
|
+
# null ⇒ fall back to the GitHub default branch
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
**Plan-header block (P1.T2a — the queryable metadata in the issue *body*).** The minimal
|
|
1350
|
+
observably-distinct set; rendered as a `perk:metadata-block:plan-header` collapsible YAML
|
|
1351
|
+
block; the full plan markdown lives in the `plan-body` first comment:
|
|
1352
|
+
|
|
1353
|
+
```
|
|
1354
|
+
{ run_id: string, # the §8.2 run that created the plan (idempotency key)
|
|
1355
|
+
lifecycle_stage: string, # "planned" (Q8: collapses planned→impl; post-states from PR)
|
|
1356
|
+
branch: string|null, # staged — populated at submit
|
|
1357
|
+
pr: string|null, # staged — populated at submit
|
|
1358
|
+
created: string, # ISO-8601 UTC
|
|
1359
|
+
objective_id: string|null, # Phase 2
|
|
1360
|
+
consumed_learn: string[], # hop-2: perk:learn issue ids (opaque strings — §8.21; Node 4.1)
|
|
1361
|
+
base: string|null } # #633: the pinned PR merge target / worktree start-point branch;
|
|
1362
|
+
# null ⇒ fall back to the GitHub default branch
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
**The copyable command callout (#664).** A freshly-created plan issue's **body/description** (which
|
|
1366
|
+
otherwise holds only the hidden `plan-header` block) now **leads with a visible, copyable command
|
|
1367
|
+
callout** — a bold label, a bare fenced ` ```perk impl <id>``` ` block (GitHub/Linear render a
|
|
1368
|
+
one-click copy button), and an italic hint. It is injected on the **fresh standalone-create** path
|
|
1369
|
+
of `plan save` (in `_plan_save_impl`, via the new `IssueBackend.prepend_plan_callout`) with the
|
|
1370
|
+
**server-assigned** id (`issue.id`), since that id is only known post-create. `<id>` is the
|
|
1371
|
+
artifact's own ref id (GitHub number, Linear `ENG-N`, or — for project-backed objectives — the raw
|
|
1372
|
+
project UUID), all already accepted by `parse_plan_id`/`parse_objective_id`. The callout is pure
|
|
1373
|
+
portable Markdown (no HTML/`<details>`/perk sentinels), so `to_linear_markdown` passes it through
|
|
1374
|
+
unchanged. It is **idempotent** (keyed on the literal command string — no duplicate on re-save) and
|
|
1375
|
+
sits **structurally above** the `plan-header` block, so `extract_run_id`/header parsing and the
|
|
1376
|
+
submit-time `update_plan_header` rewrite (which touches only the header block) are unaffected.
|
|
1377
|
+
Forward-only: artifacts created before #664 are not retro-fitted. For the Linear **project node↔plan
|
|
1378
|
+
unified** plan the same `perk impl <ENG-N>` callout is folded into the node-issue description by
|
|
1379
|
+
`save_node_plan` (no extra write).
|
|
1380
|
+
|
|
1381
|
+
**The pinned base (`base`, #633).** A plan or objective can declare a **non-default target
|
|
1382
|
+
branch**. `perk plan save` resolves the effective base **once** — the linked objective's own
|
|
1383
|
+
`base` (the `objective-header` `base`, the source of truth for its node plans) → the repo's
|
|
1384
|
+
`[workflow] base` config → `None` — and pins it into BOTH the `plan-header.base` and the
|
|
1385
|
+
`cache.plan-ref.base`. Three consumers read it: `create_pr` (the PR merge target), the worktree
|
|
1386
|
+
start-point (`launch.resolve_base` bases the `plan-<id>` branch off `origin/<base>` instead of the
|
|
1387
|
+
detected trunk), and the `/submit` merge-conflict probe. The submit base-resolution chain is
|
|
1388
|
+
`cache.plan-ref.base` → `plan-header.base` → `default_branch()`; when `base` is absent everywhere
|
|
1389
|
+
the behavior is byte-identical to pre-#633 (fall back to the GitHub default / `detect_trunk_branch`).
|
|
1390
|
+
The explicit `implement`/`run-worker` `--base` flag (a one-off git start-point override for
|
|
1391
|
+
stacking) still wins the start-point verbatim. `reconstruct_plan_ref` carries `base` from the
|
|
1392
|
+
`plan-header` so `implement`/`resume`/the remote `run-worker` recover the pinned value when the
|
|
1393
|
+
local `cache.plan-ref` is absent.
|
|
1394
|
+
|
|
1395
|
+
**Label taxonomy (minimal, PRIOR_ART §2/§6):** `perk:plan` (green `1f883d`), `perk:learn` (purple
|
|
1396
|
+
`8250df`), `perk:objective` (indigo `5319e7`, description "perk objective issue", since P2.T9),
|
|
1397
|
+
`perk:objective-node` (indigo `5319e7`, on Linear project-backed roadmap node-issues; #669), and
|
|
1398
|
+
— since hop-2 — `perk:consolidated` (gray `6e7781`, description "perk learn issue consolidated into
|
|
1399
|
+
docs/learned"), each **lazily created** by its gateway create-op on first use (perk never seeds
|
|
1400
|
+
labels in `init`). Query by a **single** label — GitHub label filters are AND-semantics. (On
|
|
1401
|
+
Linear, `perk init` / `doctor --fix` proactively ensure the five `perk:*` labels at **workspace**
|
|
1402
|
+
scope — §8.21.)
|
|
1403
|
+
|
|
1404
|
+
**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`
|
|
1406
|
+
sets it** (after a successful merge), **`learn` clears it**. While present it signals the
|
|
1407
|
+
land→learn cycle is open and the worktree is not yet releasable (a future `worktree remove` /
|
|
1408
|
+
`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.
|
|
1410
|
+
|
|
1411
|
+
### Authored (P2.T9 — objective storage + mechanics)
|
|
1412
|
+
|
|
1413
|
+
> **Forward pointer (Objective #548).** The objective methods described here as living on
|
|
1414
|
+
> `IssueBackend` have since been **extracted into the objective-storage tier** (`ObjectiveStore`,
|
|
1415
|
+
> §8.24) — the issue tier and the objective tier are now distinct seams sharing the `[issues]`
|
|
1416
|
+
> selection. The objective substrate ops listed below are unchanged: they remain
|
|
1417
|
+
> `GitHubObjectiveStore`'s delegation target (the equivalence lock) and now live in the GitHub
|
|
1418
|
+
> backend package at `perk/backends/github/objectives.py` (moved out of the `perk/github/` forge
|
|
1419
|
+
> gateway in Objective #746, Node 2.2). The historical record below is left intact per the
|
|
1420
|
+
> keep-and-annotate discipline.
|
|
1421
|
+
|
|
1422
|
+
The **objective layer's deterministic foundation** — a long-running goal that *generates* bounded
|
|
1423
|
+
plans (PRIOR_ART §3). The pure mechanics live in the `perk/objective/` package (the `plan.py` twin,
|
|
1424
|
+
reusing its block engine); the GitHub writes live in `perk/backends/github/objectives.py`; the cold-door workers are the
|
|
1425
|
+
`perk objective` group. **No registry stage and no model-facing tools** — those are T10.
|
|
1426
|
+
|
|
1427
|
+
**Storage blocks (perk-namespaced, schema 1).** An objective is an issue + first comment:
|
|
1428
|
+
- `objective-header` (issue body) — compact, queryable: `{ run_id, created,
|
|
1429
|
+
objective_comment_id, status, base }` (`status` is the explicit objective-level rollup, e.g.
|
|
1430
|
+
`"active"`; `objective_comment_id` is backfilled in the two-step create; `base` (#633) is the
|
|
1431
|
+
objective's target branch, inherited by every node plan, `null` when unset).
|
|
1432
|
+
- `objective-roadmap` (issue body) — the **canonical** flat-node YAML frontmatter:
|
|
1433
|
+
`{ schema_version: "1", nodes: [ { id, slug, description, status, pr, depends_on?, comment? } ] }`.
|
|
1434
|
+
Phase membership is derived from the **ID prefix** (`"1.2" → phase 1`, `"2A.1" → phase 2A`); phase
|
|
1435
|
+
*names* are not stored (extracted from `### Phase N: name` headers when rendering). `depends_on`
|
|
1436
|
+
is `null`/absent (infer sequential deps) vs `[]` (explicitly none). The `depends_on`/`comment`
|
|
1437
|
+
columns are omitted from the serialization unless some node specifies them.
|
|
1438
|
+
- `objective-body` (first comment) — the human-readable rendered roadmap table (marker-bounded by
|
|
1439
|
+
`<!-- perk:roadmap-table -->`, deterministically re-rendered from the frontmatter) + prose.
|
|
1440
|
+
|
|
1441
|
+
**The copyable command callout (#664).** An objective's human-readable surface — the `objective-body`
|
|
1442
|
+
comment (issue-backed) / the project **overview** (Linear project-backed) — now **leads with a
|
|
1443
|
+
visible, copyable ` ```perk objective plan <id>``` ` callout** (bold label + fenced block + italic
|
|
1444
|
+
hint), the objective sibling of the plan callout. For an issue-backed objective the callout is folded
|
|
1445
|
+
into the `objective-body` comment at compose time (the `created.number`/`created.id` is known before
|
|
1446
|
+
the comment is posted — **no extra write**); for a Linear project-backed objective it is written into
|
|
1447
|
+
the overview with one post-create `update_project_content` (the project UUID is only known after
|
|
1448
|
+
`create_project`). It is idempotent (keyed on the command string), pure portable Markdown, and sits
|
|
1449
|
+
**above** every metadata/marker block, so the table re-render and the §8.4 reconcile splice (which
|
|
1450
|
+
work strictly between markers) preserve it.
|
|
1451
|
+
|
|
1452
|
+
**Explicit-status-only (foundation open #3).** A node's `status` is **never inferred from a PR
|
|
1453
|
+
column** — `update_node` takes `status` verbatim or preserves it; setting `pr` never changes
|
|
1454
|
+
`status`. This is the deliberate departure from erk's two-tier infer-from-PR model.
|
|
1455
|
+
|
|
1456
|
+
**Gateway ops (canonical Python plane; same idempotency + two-step pattern as plan/learn):**
|
|
1457
|
+
- `find_objective_issue(*, run_id, repo_root) -> ObjectiveIssue | None` — label-scoped to
|
|
1458
|
+
`perk:objective` + the `objective-header` block (delegates to the parameterized `find_plan_issue`).
|
|
1459
|
+
- `create_objective_issue(*, title, body, repo_root, run_id, status="active", base=None,
|
|
1460
|
+
dry_run=False) -> ObjectiveIssue` — the **two-step** create (`base` (#633) persists into the
|
|
1461
|
+
`objective-header`): idempotency check → lazy `perk:objective` label →
|
|
1462
|
+
compose body (`objective-header` with `objective_comment_id: null` + `objective-roadmap`) → POST
|
|
1463
|
+
issue → POST `objective-body` comment (capturing its id) → **backfill** `objective_comment_id`
|
|
1464
|
+
into the header.
|
|
1465
|
+
- `get_objective(*, number, repo_root) -> ObjectiveState | None` — parse header + roadmap nodes;
|
|
1466
|
+
`None` if absent, raises on infra failure / invalid roadmap.
|
|
1467
|
+
- `update_objective_node(*, number, node_id, status=None, pr=None, description=None, repo_root,
|
|
1468
|
+
dry_run=False) -> ObjectiveNodeUpdate` — re-render the authoritative `objective-roadmap` block in
|
|
1469
|
+
the issue body **and** the rendered table in the `objective-body` comment (best-effort); raises if
|
|
1470
|
+
the node is not found.
|
|
1471
|
+
- `add_objective_node(*, number, phase, description, status=PENDING, slug=None, depends_on=None,
|
|
1472
|
+
comment=None, repo_root, dry_run=False) -> ObjectiveNodeAdd` — insert a new node into `phase`
|
|
1473
|
+
(auto-assigned `<phase>.<n>`, appended after that phase's last node) with the same re-render
|
|
1474
|
+
discipline; raises on an id collision. The rare node-insertion surface for reconciliation
|
|
1475
|
+
(prose-guarded, no audit gate — like the other workers).
|
|
1476
|
+
- `update_objective_header(*, number, fields, repo_root, dry_run=False) -> ObjectiveHeaderUpdate` —
|
|
1477
|
+
the `update_plan_header` twin (read-merge-PATCH), rejecting unknown keys (LBYL on
|
|
1478
|
+
`OBJECTIVE_HEADER_FIELDS`).
|
|
1479
|
+
|
|
1480
|
+
**Cold-door workers (`perk objective …` — a dev/CI/T10 surface, not an agent affordance):**
|
|
1481
|
+
`create --body @FILE [--title]`, `show NUMBER`, `node NUMBER --node ID [--status][--pr][--description]`,
|
|
1482
|
+
`node-add NUMBER --phase N --description STR [--status][--slug][--depends-on …][--comment]`,
|
|
1483
|
+
`next NUMBER` (the dependency-graph `build_graph(nodes).next_plannable()` selection T10's
|
|
1484
|
+
`/objective-plan` consumes). All supervisor surfaces (`--json` → stdout, human → stderr, exit
|
|
1485
|
+
`0`/`1`/`2`). The objective issues are pure REST (issues + comments), no GraphQL.
|
|
1486
|
+
|
|
1487
|
+
State key (registry vocabulary): `github.objective` (live since P2.T9 storage; its **stage** —
|
|
1488
|
+
`objective-plan` — exists since P2.T10).
|
|
1489
|
+
|
|
1490
|
+
### Authored (P2.T10 — the objective plan factory)
|
|
1491
|
+
|
|
1492
|
+
The objective **transition** layer on top of T9's mechanics — the plan factory + the node↔plan link.
|
|
1493
|
+
|
|
1494
|
+
- **`objective-plan` registry stage + cold door.** A new stage (`mode: read-only`, `worktree:
|
|
1495
|
+
none`, `doors.cold_remote: false`) inserted as the **single initial** before `plan`
|
|
1496
|
+
(`objective-plan → plan`); `requires/reads: [github.objective]`, `writes: [github.objective,
|
|
1497
|
+
session.workflow-state]`. Its cold door is a **dedicated** command (`DEDICATED_STAGES`),
|
|
1498
|
+
`perk objective-plan [NUMBER] [--node ID]` (the generic launcher cannot select a node): it
|
|
1499
|
+
requires an explicit NUMBER (a cold session has no `active_objective`), selects the next actionable
|
|
1500
|
+
node (pending-first dependency-graph order — unblocked `pending` nodes by position, then resumable
|
|
1501
|
+
`planning`-no-`pr` claims; or `--node`), marks it `planning` (`update_objective_node`), and launches
|
|
1502
|
+
a read-only
|
|
1503
|
+
plan-mode session seeded with the node (via `launch_stage(prompt_override=…)`). Supervisor surface
|
|
1504
|
+
(`--json`/exits `0`/`1`/`2`); error types `objective_required`/`objective_not_found`/
|
|
1505
|
+
`no_actionable_node`/`remote_blocked`.
|
|
1506
|
+
- **`launch_stage(prompt_override=…)`.** A minimal seam: when given, the override is the seeded
|
|
1507
|
+
initial prompt instead of the stage-derived `_initial_prompt` (objective-plan has no plan-ref, so
|
|
1508
|
+
`_initial_prompt` returns `None`). All existing callers pass `None`, unaffected.
|
|
1509
|
+
- **`--objective-id` thread.** `perk plan-save --objective-id N` (and the warm `plan_save` tool's
|
|
1510
|
+
`objective_id` param) populate `plan.PlanHeader.objective_id` + `plan.PlanRef.objective_id` (both
|
|
1511
|
+
fields already existed). This persists the plan→objective direction; non-objective plans omit it.
|
|
1512
|
+
- **Node mutations stay canonical Python.** The `objective_node` model tool delegates to
|
|
1513
|
+
`perk objective node` — there is **no audit gate at the CLI layer** (the audit refusal is the
|
|
1514
|
+
model-facing tool boundary only, §8.3). Whole-objective rollup-to-`done` (`update_objective_header`
|
|
1515
|
+
via a CLI) is **deferred** (T10's completion-audit unit is the node); auto-on-merge node-done is
|
|
1516
|
+
**T11**.
|
|
1517
|
+
|
|
1518
|
+
### Authored (P2.T11 — objective reconciliation after landing)
|
|
1519
|
+
|
|
1520
|
+
Close the objective loop: when a PR linked to an objective node merges, the roadmap reconciles
|
|
1521
|
+
against what was *actually* built. Two seams (PRIOR_ART §3), matching the D9 section-boundary typing:
|
|
1522
|
+
|
|
1523
|
+
**T11a — Mechanical (deterministic, on land).** The cold land path (`perk pr land`) auto-marks the
|
|
1524
|
+
objective node(s) backlinked to the just-merged plan `done` — **fail-open** (the merge already
|
|
1525
|
+
succeeded; objective tracking must never block landing) and **deliberately non-audited** (per the
|
|
1526
|
+
T10 §8.3 note, the audit gate protects the model-facing tool path only).
|
|
1527
|
+
- `objective.nodes_for_pr(nodes, pr_number) -> [ObjectiveNode]` (pure) — returns nodes whose `pr`
|
|
1528
|
+
backlink matches `pr_number` canonicalized to `"#<n>"` (`"#6"` / `6` / `"6"` interchangeably).
|
|
1529
|
+
- `pr_land_cmd._reconcile_objective_on_land(*, plan_ref, repo_root) -> ObjectiveLandUpdate`
|
|
1530
|
+
(`{ objective, nodes_marked, skipped_reason, closed }`) — best-effort, **never raises**: it parses
|
|
1531
|
+
`plan_ref.objective_id` (`skipped_reason` ∈ `no_objective_link` / `bad_objective_id` /
|
|
1532
|
+
`objective_not_found` / `no_linked_node`, or `error: <exc>` on any failure, logged loud-but-non-fatal
|
|
1533
|
+
to stderr), then `update_objective_node(... status=DONE)` for each non-terminal matched node. Called
|
|
1534
|
+
in `_pr_land_impl`'s **non-dry-run** branch only, **after** `set_marker(PENDING_LEARN)`; the
|
|
1535
|
+
dry-run branch sets an inert `ObjectiveLandUpdate(None, (), "dry_run")` and stays fully offline.
|
|
1536
|
+
`_result_to_dict` always emits `"objective": { id, nodes_marked, skipped_reason, closed }`
|
|
1537
|
+
(`id` an opaque string objective id — §8.21; Node 4.1);
|
|
1538
|
+
`_render_human` adds an `objective #N: marked node(s) X done` line when non-empty (and an
|
|
1539
|
+
`objective #N complete — closed` line when `closed`).
|
|
1540
|
+
- **Close-on-complete.** After the marking loop (targets non-empty only — the early-return skips
|
|
1541
|
+
above never reach it), the land path checks completeness **locally** over the post-mark node
|
|
1542
|
+
list (every backlinked target counts as terminal, all other nodes as fetched — the same
|
|
1543
|
+
all-terminal predicate as `DependencyGraph.is_complete`, no re-fetch, no graph construction).
|
|
1544
|
+
When complete it calls `github.close_issue(number=...)` — idempotent REST PATCH, **no closing
|
|
1545
|
+
comment** (symmetric with the §8.20 supervisor close) — and sets `closed=True`. The check runs
|
|
1546
|
+
even when zero nodes were marked (all targets already terminal), so a **re-land is idempotent**:
|
|
1547
|
+
re-landing the final PR still converges the objective to closed. The close is wrapped in its own
|
|
1548
|
+
**isolated fail-open** handler: a close failure preserves the already-marked `nodes_marked`,
|
|
1549
|
+
logs loud-but-non-fatal to stderr, and reports `skipped_reason = "close_failed: <exc>"` with
|
|
1550
|
+
`closed=False` — the land result is never affected.
|
|
1551
|
+
- The warm `extension/doors/land.ts` surfaces `objective.nodes_marked` and **auto-drives** the reconcile
|
|
1552
|
+
pass via `driveReconcileAfterLand`, which injects
|
|
1553
|
+
`reconcileGuidance(...) + bindingSuffix(..., "command:objective-reconcile")` — byte-for-byte the
|
|
1554
|
+
message `/objective-reconcile` injects — when the land succeeded with a node marked done.
|
|
1555
|
+
Delivery branches on `ctx.isIdle()`: the streaming `land` tool path uses
|
|
1556
|
+
`deliverAs: "followUp"` (delivered after the terminating batch), the idle `/land` command path an
|
|
1557
|
+
immediate turn. `land` stays **terminating** because `terminate` only skips the *automatic*
|
|
1558
|
+
follow-up LLM call — an injected `followUp` user message is a separate deliberate new turn, so the
|
|
1559
|
+
two compose. The success text reports the auto-reconciliation rather than a copy-pasteable nudge;
|
|
1560
|
+
the merge itself is unchanged. `land.ts` decodes `objective.closed` **leniently** (missing or
|
|
1561
|
+
non-boolean → `false`, sub-object kept — advisory display detail) and adds an
|
|
1562
|
+
`Objective #N complete — closed.` success line when `closed`; `driveReconcileAfterLand` is
|
|
1563
|
+
unchanged — the reconcile pass still auto-drives after a closing land (a closed issue's
|
|
1564
|
+
body/comments remain editable).
|
|
1565
|
+
- The `land` stage I/O gains `github.objective` in both `reads` (the node lookup) and `writes` (the
|
|
1566
|
+
mechanical node-done).
|
|
1567
|
+
|
|
1568
|
+
**T11b — Reconcilable (LLM judgment, post-merge, warm).** A `/objective-reconcile` surface +
|
|
1569
|
+
`perk-objective-reconcile` skill drive the model to reconcile stale objective **prose** (and node
|
|
1570
|
+
descriptions) against the real diff. The objective-body prose is a marker-bounded **Reconcilable**
|
|
1571
|
+
region; everything outside it (the Mechanical roadmap table, any Immutable notes below) is
|
|
1572
|
+
**structurally** protected.
|
|
1573
|
+
- `objective.OBJECTIVE_RECONCILABLE_MARKER_START/_END` + `replace_reconcilable_section(comment_body,
|
|
1574
|
+
new_prose) -> str | None` (pure; splices between the markers, preserving the table block above +
|
|
1575
|
+
Immutable notes below; `None` when markers absent). `render_body_comment(nodes, *, prose="")` now
|
|
1576
|
+
wraps prose in the Reconcilable markers — even empty prose emits the (empty) marker pair so every
|
|
1577
|
+
objective has a splice target; objectives created before P2.T11 (no markers) yield a clean
|
|
1578
|
+
`reconcile_target_missing` rather than a clobber.
|
|
1579
|
+
- `github.update_objective_body(*, number, prose, repo_root, dry_run=False) -> ObjectiveBodyUpdate`
|
|
1580
|
+
(`{ number, comment_id, updated, dry_run }`) — reads the `objective-header` `objective_comment_id`,
|
|
1581
|
+
fetches the comment, `replace_reconcilable_section`, PATCHes it; raises `GitHubError` (`no body
|
|
1582
|
+
comment` / `no reconcilable region`) on a missing target. The table block + Immutable prose are
|
|
1583
|
+
never touched (structural Immutable-safety).
|
|
1584
|
+
- `perk objective reconcile NUMBER --body @FILE [--dry-run] [--json]` — the cold worker (stdin-less
|
|
1585
|
+
file-arg pattern, mirroring `learn capture`); maps the two missing-target `GitHubError`s to a
|
|
1586
|
+
stable `reconcile_target_missing`, other infra to `github_error`. Node-description reconciliation
|
|
1587
|
+
reuses the existing `objective node --description` (no new flag).
|
|
1588
|
+
- `extension/factories/objectivePlan.ts` gains: a `description?` param on the `objective_node` tool
|
|
1589
|
+
(`buildObjectiveNodeArgs` pushes `--description` and **relaxes** the structural refusal so a call
|
|
1590
|
+
carrying only `description` is valid — a deliberate, flagged extension of T10's contract; the
|
|
1591
|
+
`status:"done"` audit gate is unchanged); a `reconcile_objective` warm tool
|
|
1592
|
+
(`{ objective, prose }` → run-scoped scratch file → `perk objective reconcile … --body <path>`,
|
|
1593
|
+
never throws); and a `/objective-reconcile [<number>] [--pr <plan>]` command with **three-tier
|
|
1594
|
+
objective resolution** (arg → `active_objective` → `readPlanRef(cwd).objective_id` — so the
|
|
1595
|
+
post-land path works in the landing session even when `active_objective` is unset).
|
|
1596
|
+
- The judgment layer is `skills/perk-objective-reconcile/SKILL.md`: PR diff + `objective show` as
|
|
1597
|
+
untrusted DATA; the Mechanical/Reconcilable/Immutable boundary; the contradiction taxonomy; skip
|
|
1598
|
+
if nothing is stale; never-delegate judgment + durable writes.
|
|
1599
|
+
|
|
1600
|
+
### Authored (hop-2 — the learned-docs consumer)
|
|
1601
|
+
|
|
1602
|
+
perk's `/learn` already synthesizes durable learnings into terminal `perk:learn` issues; hop-2 is
|
|
1603
|
+
the missing **consumer** that consolidates them into committed `docs/learned/`. It is a **plan
|
|
1604
|
+
factory** (mirrors `objective-plan`, NOT a direct doc-writer), triggered on-demand/batched — so it
|
|
1605
|
+
adds **no `registry.yaml` stage** (it borrows the existing `plan` stage descriptor to launch) and
|
|
1606
|
+
uses existing state keys (`github.learn`, `github.plan`, `cache.scratch`).
|
|
1607
|
+
|
|
1608
|
+
- **The factory cold door + warm command.** `perk learn docs` (`commands/learn/docs_cmd.py`, no
|
|
1609
|
+
alias): `list_learn_issues` → materialize the inbox
|
|
1610
|
+
`.pi/workflow/scratch/learn-docs-inbox.md` (a `## Learning #<n>` section per issue, each body in
|
|
1611
|
+
`<untrusted_learning>`) → `launch_stage(plan_stage, prompt_override=<seed>)` (a read-only
|
|
1612
|
+
plan-mode session). `--gather` materializes the inbox + emits `{ inbox_path, learn_numbers }`
|
|
1613
|
+
with no launch (the warm path + tests consume this); `--dry-run` gathers + prints; `--remote` is
|
|
1614
|
+
rejected (`remote_blocked`, the `plan` stage is `cold_remote:false`); no open learn issues →
|
|
1615
|
+
exit 1 `no_learn_issues`. The warm `/learn-docs` (`extension/doors/learnDocs.ts`) delegates to
|
|
1616
|
+
`perk learn docs --gather --json` (gate-safe — extension `pi.exec` is not subject to the
|
|
1617
|
+
read-only bash gate), then `pi.sendUserMessage`s the factory guidance pointing at the
|
|
1618
|
+
`perk-learn-docs` skill. **Headless-safe** (the inbox is still materialized; no turn is driven).
|
|
1619
|
+
- **`learn` is a hybrid group (Node 2.2).** `perk learn` is a hand-written default-dispatch group
|
|
1620
|
+
(`commands/learn/`): a bare/non-verb invocation falls through to a hidden launcher built from
|
|
1621
|
+
the generic registry factory (byte-identical to the generated `learn` stage launcher), while
|
|
1622
|
+
`capture` and `docs` are the cold workers (no aliases). Warm ids (`/learn`, `/learn-docs`,
|
|
1623
|
+
`command:learn-docs`, the inbox artifact) are unchanged — they key off warm command ids, not
|
|
1624
|
+
cold CLI spellings.
|
|
1625
|
+
- **The factory discipline is inbox-over-gh.** The seeded factory session reads the materialized
|
|
1626
|
+
inbox via the `read` tool as its canonical input. Read-only `gh` query subcommands are now
|
|
1627
|
+
allowlisted in the read-only bash gate (`extension/substrate/toolGating.ts`), so ad-hoc GitHub reads are
|
|
1628
|
+
*possible* — but the cold door remains the canonical gatherer (deterministic, token-cheap), and
|
|
1629
|
+
factory sessions should not re-fetch the inbox's contents via `gh`.
|
|
1630
|
+
- **The `consumed_learn` thread.** `perk plan-save --consumed-learn "45,50"` (and the warm
|
|
1631
|
+
`plan_save` tool's `consumed_learn` array param) populate `plan.PlanHeader.consumed_learn` +
|
|
1632
|
+
`plan.PlanRef.consumed_learn` (parsed to a sorted unique `tuple[str, ...]` of opaque string ids
|
|
1633
|
+
— §8.21; only empty tokens are dropped — there is no int parse). The warm param decode
|
|
1634
|
+
(`idArrayParam`) accepts strings and coerces bare numbers via `String()` (the learn-docs
|
|
1635
|
+
guidance renders bare numeric ids on GitHub). This persists which `perk:learn` issues the docs
|
|
1636
|
+
plan consolidates; non-factory
|
|
1637
|
+
plans omit it. Because the read-only factory saves via the `/plan-save` *command* (which forwards
|
|
1638
|
+
only `{plan, title}`), `plan-save` also recovers `consumed_learn` from the run's handoff
|
|
1639
|
+
(`_consumed_learn_from_handoff`, #102) when the flag is absent — see §8.2's handoff-carrier note.
|
|
1640
|
+
- **On-land consume (Mechanical, deterministic).** `pr_land_cmd._consume_learn_on_land(*, plan_ref,
|
|
1641
|
+
repo_root) -> LearnConsumeUpdate{ closed, skipped_reason }` reads `plan_ref.consumed_learn` and
|
|
1642
|
+
`close_and_label_consolidated` for each issue — **fail-open, never raises, never changes the land
|
|
1643
|
+
result** (mirrors `_reconcile_objective_on_land`). Each issue is closed **independently** (#102
|
|
1644
|
+
per-issue isolation): one bad issue (already-deleted / transient infra error) is logged
|
|
1645
|
+
loud-but-non-fatal and rolled into a `failed: #a, #b` `skipped_reason` while the rest still close.
|
|
1646
|
+
`skipped_reason` ∈ `no_consumed_learn` / `bad_consumed_learn` / `failed: …` / `error: <exc>`.
|
|
1647
|
+
Called in `_pr_land_impl`'s non-dry-run branch after `set_marker(PENDING_LEARN)` and the objective
|
|
1648
|
+
reconcile; the dry-run branch sets an inert `LearnConsumeUpdate((), "dry_run")`. `_result_to_dict`
|
|
1649
|
+
emits `"learn": { closed, skipped_reason }`; `_render_human` adds a `consolidated learn issue(s) X
|
|
1650
|
+
into docs/learned` line when non-empty, plus a `⚠ learn consume incomplete: <reason>` line for any
|
|
1651
|
+
non-benign skip (everything except `no_consumed_learn`/`dry_run`). The warm `extension/doors/land.ts`
|
|
1652
|
+
surfaces `learn.closed` in a `Closed N learn issue(s) … into docs/learned` line and a
|
|
1653
|
+
`Warning: learn consume incomplete — <reason>` line for the same non-benign skips. Closing already excludes a consumed issue from the next `state=open` gather;
|
|
1654
|
+
the `perk:consolidated` label is the durable/queryable record.
|
|
1655
|
+
- **The docs surface (plan-maintained, never `init`-managed).** `docs/learned/<category>/*.md`
|
|
1656
|
+
carries light frontmatter (`title` + `read_when`); `docs/learned/index.md` is the standalone full
|
|
1657
|
+
catalog; `.pi/APPEND_SYSTEM.md` (Pi's project-scoped system-prompt append, ambient on every
|
|
1658
|
+
session) holds the **compressed** routing index — the realization of the PRIOR_ART §6
|
|
1659
|
+
"compressed index must be ambient" finding (a retrieval-tier index is too brittle). Both index
|
|
1660
|
+
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.
|
|
1663
|
+
- **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).
|
|
1667
|
+
|
|
1668
|
+
## §8.5 · The `init` machine surface (T5; cli-vs-pi §3.2)
|
|
1669
|
+
|
|
1670
|
+
`perk init` is a **supervisor surface**: human text → stderr, `--json` → stdout (one object),
|
|
1671
|
+
stable exit codes. The agent never parses it (it calls extension tools); the consumer is a
|
|
1672
|
+
process orchestrating sessions.
|
|
1673
|
+
|
|
1674
|
+
**Exit codes.** `0` converged · `1` invalid input (`invalid_settings` / `invalid_config`) ·
|
|
1675
|
+
`2` environment-not-ready (`not_a_repo` / `missing_tool` / `skills_conflict` /
|
|
1676
|
+
`skills_sync_failed` — see the skills-delivery substrate clause in §8.9). GitHub-unauthed is
|
|
1677
|
+
**non-fatal** in `init` (reported, exit 0); `github_unauthed` is reserved for the strict
|
|
1678
|
+
`require_github` path. On `skills_sync_failed` the report **preserves `changes`** (convergence
|
|
1679
|
+
already happened before the sync); `skills_conflict` short-circuits before any convergence
|
|
1680
|
+
(`changes` is `[]`).
|
|
1681
|
+
|
|
1682
|
+
**`--json` object.**
|
|
1683
|
+
```
|
|
1684
|
+
{ success: bool, mode: "self"|"consumer"|"unknown", error_type: string|null, message: string|null,
|
|
1685
|
+
env: [ { name, ok, detail, remediation, optional } ], # tooling checks; `optional:true` entries
|
|
1686
|
+
# (e.g. ast-grep) are non-fatal — present-or-
|
|
1687
|
+
# absent, never a `missing_tool` exit-2
|
|
1688
|
+
github: { auth: { ok, user, scopes[], error }, # null when env-not-ready / verify skipped
|
|
1689
|
+
repo: { ok, repo, can_push, error } },
|
|
1690
|
+
linear: { ok, team, error, # null unless verify ran AND the committed
|
|
1691
|
+
readiness: { auth_ok, user, team_ok, # [issues] backend is "linear" (§8.21);
|
|
1692
|
+
missing_labels[], created_labels[], error } | null, # non-fatal like github
|
|
1693
|
+
project: { projects_ok, projects_error, # project-backed objective readiness (Node 4.2);
|
|
1694
|
+
missing_state_types[], states_error } | null }, # null unless auth_ok && team_ok; non-fatal
|
|
1695
|
+
capabilities: string[], # the managed inventory (perk/convergence/capabilities.py)
|
|
1696
|
+
changes: string[], # converged/seeded pieces ([] ⇒ already converged)
|
|
1697
|
+
handoff: string|null } # path to the post-init markdown on-ramp
|
|
1698
|
+
```
|
|
1699
|
+
|
|
1700
|
+
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
|
|
1702
|
+
machine run-handoff JSON. It is the Phase-0 dogfood on-ramp.
|
|
1703
|
+
|
|
1704
|
+
**Capability inventory.** `perk/convergence/capabilities.py` is the declared SSOT of what `init` manages
|
|
1705
|
+
(required-vs-optional + self-vs-consumer scope). Phase 0 ships an all-required set; `doctor`
|
|
1706
|
+
**(T6, implemented)** reuses it for health-check filtering (the inventory's `verify()` side). The
|
|
1707
|
+
installed-optional state file + `Capability` ABC are deferred until the first *optional*
|
|
1708
|
+
capability exists.
|
|
1709
|
+
|
|
1710
|
+
---
|
|
1711
|
+
|
|
1712
|
+
## §8.6 · The `doctor` machine surface (T6; cli-vs-pi §3.2)
|
|
1713
|
+
|
|
1714
|
+
`perk doctor` is the **second** supervisor surface (the agent never parses it). It is `init`'s
|
|
1715
|
+
diagnostic twin: `init` converges *forward*, `doctor` **reports** coherence and `--fix` **repairs**
|
|
1716
|
+
drift. Managed-piece checks reuse `init`'s convergence helpers in **dry-run** (`apply=False`) — so
|
|
1717
|
+
init and doctor share one desired-state SSOT — and `--fix` runs the same helpers with `apply=True`.
|
|
1718
|
+
Shipped as a Click **group** (`invoke_without_command=True`) so the Phase-3 `doctor workflow`
|
|
1719
|
+
subgroup slots in without a breaking change.
|
|
1720
|
+
|
|
1721
|
+
**Exit codes (report-don't-refuse, D5).** `0` healthy (warnings allowed) · `1` unhealthy (≥1
|
|
1722
|
+
failing check) · `2` `not_a_repo`. A **missing required tool is a failing check (exit 1)**, *not*
|
|
1723
|
+
exit 2 — doctor's job is to report tool problems, not refuse to run; only `not_a_repo` blocks.
|
|
1724
|
+
GitHub readiness is **non-fatal** (`warn`, never `fail`); doctor **never mutates** GitHub.
|
|
1725
|
+
|
|
1726
|
+
**No silent pass.** A check that cannot be evaluated (a shell raised, a file is unreadable) reports
|
|
1727
|
+
`warn`/`info` with the reason in `detail` — never a silent `ok`.
|
|
1728
|
+
|
|
1729
|
+
**`--json` object.**
|
|
1730
|
+
```
|
|
1731
|
+
{ success: bool, # the command ran (false only on not_a_repo)
|
|
1732
|
+
healthy: bool, # no failing checks
|
|
1733
|
+
self_repo: bool, # self (perk's own repo) vs consumer dual-mode
|
|
1734
|
+
error_type: string|null, # "not_a_repo" on the exit-2 path
|
|
1735
|
+
message: string|null,
|
|
1736
|
+
checks: [ { name, group, status, message, detail, remediation } ], # status ∈ ok|warn|info|fail
|
|
1737
|
+
summary: { passed: int, warnings: int, failed: int },
|
|
1738
|
+
fixed: string[], # repairs applied by --fix ([] otherwise)
|
|
1739
|
+
fix_errors: string[] } # --fix repairs that FAILED (e.g. a skills sync error;
|
|
1740
|
+
# rendered loudly; the post-fix re-verify keeps the
|
|
1741
|
+
# failing check, so the exit code stays honest)
|
|
1742
|
+
```
|
|
1743
|
+
|
|
1744
|
+
**Groups.** `environment` (tools; required tools missing = `fail`; optional tools (e.g. ast-grep)
|
|
1745
|
+
missing = `warn`) · `github` (auth/access; non-fatal `warn`) ·
|
|
1746
|
+
`linear` (verify-gated Linear readiness — auth/team/labels; present only when the committed
|
|
1747
|
+
`[issues] backend` is `"linear"`; warn-level, the github D3 mirror; `--fix` ensures the five perk
|
|
1748
|
+
labels — §8.21) · `runner` (remote-runner prereqs; report-only, non-fatal — §8.16) ·
|
|
1749
|
+
`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) ·
|
|
1751
|
+
`repository` (gitignore/agents blocks + config present/valid) ·
|
|
1752
|
+
`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
|
|
1754
|
+
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
|
|
1757
|
+
always run. Human render (stderr) follows the three-way condensed rule per group (collapse a clean
|
|
1758
|
+
group; else expand only its failures/warnings); `--verbose` expands every check.
|
|
1759
|
+
|
|
1760
|
+
### §8.6a · perk-package ref reconcile + the npm-install extension (#635/#639/#812)
|
|
1761
|
+
|
|
1762
|
+
Keeping a consumer's pi-loaded perk extension runnable rests on two invariants:
|
|
1763
|
+
|
|
1764
|
+
- **perk's own extension is wired as an exact version-pinned `npm:@mgiles/perk` entry, reconciled
|
|
1765
|
+
*forward*** (no longer purely append-only). `_desired_packages` emits `npm:@mgiles/perk@{__version__}`
|
|
1766
|
+
for a consumer (`_perk_npm_entry()`, mirroring the PyPI install pin SSOT in
|
|
1767
|
+
`workflow_artifacts.py`); the self-repo still wires `..`. `_merge_static_packages` rewrites perk's
|
|
1768
|
+
own `packages` entry **in place** (list position preserved) when its `@mgiles/perk` identity already
|
|
1769
|
+
exists but the full spec differs from the desired pin — so a stale `npm:@mgiles/perk@0.0.0` is
|
|
1770
|
+
reconciled to `@{__version__}` (extra string duplicates of that identity collapse to one). Only
|
|
1771
|
+
perk's own npm identity is version-reconciled; the borrowed npm packages stay unpinned/append-only
|
|
1772
|
+
(distinguished by `_npm_name` identity vs `_npm_name(NPM_PACKAGE)`), and a user's other packages
|
|
1773
|
+
are never in the desired set so they stay untouched/append-only. The in-body migration strips a
|
|
1774
|
+
repo's legacy **`git:` perk** entry (any ref, by `_git_identity == GIT_PACKAGE`) so the flip from
|
|
1775
|
+
the old git wiring converges; a user's unrelated `git:` packages are preserved. **String-form
|
|
1776
|
+
only** (perk never writes object-form for its own package — Invariant 2; a hand-written
|
|
1777
|
+
object-form perk entry is a documented limitation). This rides the existing `settings-wiring`
|
|
1778
|
+
`ManagedConvergence` — version-pin drift becomes a `settings-wiring` **fail** that `--fix`
|
|
1779
|
+
repairs, with **no new doctor wiring**.
|
|
1780
|
+
- **perk owns the `@mgiles/perk` *npm install*, superseding pi's `git:`-clone extension lifecycle
|
|
1781
|
+
(#812).**
|
|
1782
|
+
Node 2.2 flipped perk's own extension to a pinned `npm:@mgiles/perk@{__version__}` settings entry; this
|
|
1783
|
+
bullet makes init/doctor/launch **physically install** that pin. pi installs a missing
|
|
1784
|
+
project-scope `npm:` package lazily and **unlocked** at launch (`resolvePackageSources`) — a
|
|
1785
|
+
missing/half-materialized race for `npm:` packages. The npm install path now **fully supersedes**
|
|
1786
|
+
pi's `git:`-clone extension lifecycle, which is retired: the clone status/lock/materialize
|
|
1787
|
+
primitives, the `extension-clone` doctor check, and the launch warm-clone are all removed. A
|
|
1788
|
+
`doctor --fix` **migration** (`_remove_orphaned_git_clone`, in the `_MIGRATIONS` seam) carries a
|
|
1789
|
+
former git-clone consumer forward by `rmtree`-ing the orphaned `.pi/git/<host>/<path>` clone
|
|
1790
|
+
(filesystem-only, gitignored path; idempotent — a no-op once absent; a failed removal lands on
|
|
1791
|
+
`fix_errors`, never swallowed). perk now owns the install end-to-end:
|
|
1792
|
+
`materialize_extension_install` (init/doctor) reconciles the install **forward** —
|
|
1793
|
+
install-if-`absent` / reinstall-if-version-`mismatch` (the pinned `@mgiles/perk@{__version__}`,
|
|
1794
|
+
`npm install <pin> --prefix .pi/npm --legacy-peer-deps`, additive — borrowed entries untouched) —
|
|
1795
|
+
and `ensure_extension_install_present` warms it **pre-launch** in `launch_stage` (presence-only, a
|
|
1796
|
+
cheap `is_dir()` no-op once present, so the launch hot path stays network-free). Both run under an
|
|
1797
|
+
exclusive `fcntl.flock` on `<repo_root>/.pi/npm/.perk-npm-install.lock` (the lock lives in the
|
|
1798
|
+
install **root** `.pi/npm/` — already managed-gitignored — so a `node_modules` wipe never drops it;
|
|
1799
|
+
degrades to a no-op lock on non-POSIX), so concurrent launches **serialize** and a double-checked
|
|
1800
|
+
`is_dir()` installs exactly once. All npm work is best-effort + **non-fatal** (an `NpmError` —
|
|
1801
|
+
flaky network / not-yet-published pin — is swallowed, never raised); the self-repo (`..` package)
|
|
1802
|
+
is exempt. The verify-gated `extension-install` doctor check (group `package`) reports it:
|
|
1803
|
+
`absent`/`mismatch` → **fail** (+`perk doctor --fix`, which install/reinstalls — perk init/doctor
|
|
1804
|
+
*own installing*), `present` → `ok`, `unverifiable` → `warn`, `self` → `info`.
|
|
1805
|
+
This is **install ownership**: presence + the *install-vs-pin* version comparison.
|
|
1806
|
+
- **Version-parity enforcement is complete (#838).** The *wired* pin is enforced by the
|
|
1807
|
+
`settings-wiring` check (`_perk_npm_entry()` reconciled forward to `npm:@mgiles/perk@{__version__}`,
|
|
1808
|
+
above) and the *installed* version by the `extension-install` check (install-vs-`__version__`
|
|
1809
|
+
`mismatch` → fail), both against the running CLI's `perk.__version__` SSOT — **no third
|
|
1810
|
+
`version-parity` doctor check** is added (it would only duplicate these). The only version perk
|
|
1811
|
+
cannot *statically* check is the **live loaded** extension at launch: pi can lazy-install / load a
|
|
1812
|
+
stale `npm:@mgiles/perk`, so the `@mgiles/perk` actually running may differ from the CLI that launched it.
|
|
1813
|
+
That runtime skew is surfaced by a **soft `session_start` drift signal**: the local launch seam
|
|
1814
|
+
(`launch_stage`) injects `PERK_CLI_VERSION = __version__` into the exec env (a second informational
|
|
1815
|
+
launch env var beside `PERK_RUN_ID` — §8.2 — but *not* run-control data: the extension only reads
|
|
1816
|
+
it to compare versions), and the extension's `session_start` handler compares it against its own
|
|
1817
|
+
`perkVersion()`. When both are present and differ, it emits a **soft, non-fatal `warning`** via
|
|
1818
|
+
`report()` (headless-safe; UI notify or stderr) pointing at `perk doctor --fix`. No once-guard
|
|
1819
|
+
(it may re-emit on reload — acceptable for a soft warning); silent for ad-hoc `pi` (no env) and the
|
|
1820
|
+
self-repo (versions equal). Injected at the **local launch only** (the operator-facing path); the
|
|
1821
|
+
remote worker loads from the same pinned install and is headless, so it is deliberately out of
|
|
1822
|
+
scope. `tests/test_packaging.py` now also guards the **wired + install pin lockstep** against the
|
|
1823
|
+
version SSOT (`test_npm_pin_lockstep`: `_perk_npm_entry()` and `_pinned_spec()` both track
|
|
1824
|
+
`_pyproject_version()`), beyond the existing `__version__` `test_version_lockstep`.
|
|
1825
|
+
|
|
1826
|
+
---
|
|
1827
|
+
|
|
1828
|
+
## §8.7 · Cross-plane session-context markers (the selfcheck verifier)
|
|
1829
|
+
|
|
1830
|
+
Two pieces of session context are converged by one plane and **read back** by the other, so the
|
|
1831
|
+
literal markers are a cross-plane contract:
|
|
1832
|
+
|
|
1833
|
+
- **`<!-- BEGIN perk managed -->`** — the managed `AGENTS.md` block. `perk init` (Python plane)
|
|
1834
|
+
writes it; Pi loads `AGENTS.md` into `contextFiles`; the extension's `/perk-selfcheck` (TS plane,
|
|
1835
|
+
`extension/doors/selfcheck.ts`) reads `getSystemPromptOptions().contextFiles` and confirms some file
|
|
1836
|
+
carries this marker. Changing the literal in `perk/convergence/init/blocks.py` **must** update
|
|
1837
|
+
`MANAGED_AGENTS_MARKER` in `extension/doors/selfcheck.ts` in the same turn.
|
|
1838
|
+
- **`.pi/APPEND_SYSTEM.md`** — the ambient routing index (maintained by `/learn-docs`, never
|
|
1839
|
+
`init`). Pi joins it into `appendSystemPrompt`; selfcheck confirms the on-disk content reached the
|
|
1840
|
+
prompt verbatim (a trimmed-substring probe).
|
|
1841
|
+
|
|
1842
|
+
The division of labor: **`perk doctor` checks the disk** (files converged); **`/perk-selfcheck`
|
|
1843
|
+
checks the prompt** (the converged context actually reached the model via Pi's
|
|
1844
|
+
`getSystemPromptOptions()`, available only on a command context). selfcheck logs only derived
|
|
1845
|
+
booleans/counts — never the raw prompt text (the options expose the full system prompt).
|
|
1846
|
+
|
|
1847
|
+
The `.pi/workflow/.perk-t3.json` diagnostics sentinel additionally records **`run_mode`** — Pi's
|
|
1848
|
+
`ctx.mode` (`tui`/`rpc`/`json`/`print`) — distinct from the workflow **`mode`** (`read-only`/
|
|
1849
|
+
`read-write`) that drives tool gating. `run_mode` is observability `ctx.hasUI` (a binary) can't
|
|
1850
|
+
express; it is written from `ctx.mode` on both `session_start` and `session_tree`.
|
|
1851
|
+
|
|
1852
|
+
---
|
|
1853
|
+
|
|
1854
|
+
## §8.9 · Skill bindings (the trigger→skill delivery contract)
|
|
1855
|
+
|
|
1856
|
+
The **second parsed cross-plane contract**, `shared/bindings.yaml` (sibling of `registry.yaml`),
|
|
1857
|
+
maps a **trigger** to a **skill** plus a per-binding delivery **mode**. It is bundled automatically
|
|
1858
|
+
via the `shared/` force-include (wheel → `perk/_shared/`, npm tarball → `shared/`) and read by both
|
|
1859
|
+
planes through independent readers: **`perk/substrate/bindings.py`** (`load_bindings` / `validate`, returning
|
|
1860
|
+
`BindingSet`/`Binding` + the shared `Issue`/`FindingSeverity` findings, raising `BindingsError` only for
|
|
1861
|
+
structural failures) and **`extension/substrate/bindings.ts`** (`loadDefaultBindings`, a thin structural
|
|
1862
|
+
parse). The Python plane is the authoritative validator.
|
|
1863
|
+
|
|
1864
|
+
**Trigger vocabulary — one `"<kind>:<id>"` string, kind ∈ {`stage`, `command`}:**
|
|
1865
|
+
- `stage:<id>` — `<id>` is a **registry stage id** (e.g. `stage:implement`). Fires at that stage's
|
|
1866
|
+
launch / session entry.
|
|
1867
|
+
- `command:<id>` — `<id>` is a perk command / slash-command that is **not** a registry stage (e.g.
|
|
1868
|
+
`command:learn-docs`). Fires when that command runs.
|
|
1869
|
+
|
|
1870
|
+
**Kind-selection rule:** when a command corresponds 1:1 to a registry stage of the same name, bind
|
|
1871
|
+
to `stage:<id>` (the canonical trigger — the delivery layer fires it across both the cold launch and
|
|
1872
|
+
the warm slash-command of that name). Use `command:<id>` **only** for commands with no registry
|
|
1873
|
+
stage. This keeps the default set free of redundant stage+command pairs for one skill.
|
|
1874
|
+
|
|
1875
|
+
**Binding model — `{ trigger, skill, mode }`:** `trigger` is the `<kind>:<id>` string; `skill` is a
|
|
1876
|
+
skill name (a `skills/*/` dir name today); `mode ∈ {nudge, transclude}` is **per-binding** —
|
|
1877
|
+
`nudge` delivers a short pointer to follow the named skill (the skill body stays ambient /
|
|
1878
|
+
Pi-discovered), `transclude` inlines the skill body. The same skill may be a nudge at one trigger
|
|
1879
|
+
and a transclude at another.
|
|
1880
|
+
|
|
1881
|
+
**Shipped default set (all 9 shipped bindings, all `nudge` — perk's own skills are ambient package
|
|
1882
|
+
skills, so a pointer suffices; `transclude` exists for the user-binding case):**
|
|
1883
|
+
|
|
1884
|
+
| trigger | skill | mode |
|
|
1885
|
+
|---|---|---|
|
|
1886
|
+
| `stage:plan` | `perk-plan` | `nudge` |
|
|
1887
|
+
| `stage:objective-author` | `perk-objective-author` | `nudge` |
|
|
1888
|
+
| `stage:objective-plan` | `perk-objective-plan` | `nudge` |
|
|
1889
|
+
| `stage:implement` | `perk-implement` | `nudge` |
|
|
1890
|
+
| `stage:address` | `perk-address` | `nudge` |
|
|
1891
|
+
| `stage:learn` | `perk-learn` | `nudge` |
|
|
1892
|
+
| `command:objective-reconcile` | `perk-objective-reconcile` | `nudge` |
|
|
1893
|
+
| `command:learn-docs` | `perk-learn-docs` | `nudge` |
|
|
1894
|
+
| `command:pr-review` | `perk-pr-review` | `nudge` |
|
|
1895
|
+
|
|
1896
|
+
**Validation depth (shape-only, registry-free):** the loaders/validators check that
|
|
1897
|
+
`schema_version == 1` (else a structural load error), each binding has a non-empty `skill`, a
|
|
1898
|
+
`mode ∈ {nudge, transclude}`, and a `trigger` that parses as `<kind>:<id>` with a known `kind` and a
|
|
1899
|
+
non-empty `<id>`, and that no `trigger` repeats. They do **not** check that a `stage:`/`command:`
|
|
1900
|
+
target actually exists — that cross-contract, target-existence validation is **`doctor`**'s job.
|
|
1901
|
+
|
|
1902
|
+
**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**
|
|
1905
|
+
(local wins — the local array supersedes the committed one entirely, never merged element-wise,
|
|
1906
|
+
mirroring the leaf-replace overlay for scalars). Both planes parse this into the same binding shape
|
|
1907
|
+
(`perk/substrate/config.py` → `Config.user_bindings`; `extension/substrate/config.ts` → `PerkConfig.bindings`) and
|
|
1908
|
+
resolve it against the shipped defaults through a **pure free function** —
|
|
1909
|
+
`perk.substrate.bindings.resolve_bindings(user_bindings, defaults=load_bindings().bindings)` /
|
|
1910
|
+
`extension/substrate/bindings.ts resolveBindings(userBindings, defaults=loadDefaultBindings())` — each
|
|
1911
|
+
returning a `ResolvedBindings { bindings, issues }`. The override is **trigger-keyed**: starting from
|
|
1912
|
+
the defaults (order preserved), each *applied* user binding **replaces in place** the entry with the
|
|
1913
|
+
same trigger or **appends** at a new trigger, so the resolved set has **unique triggers by
|
|
1914
|
+
construction**. A user binding is applied iff it is **shape-valid** (same shape-only checks above)
|
|
1915
|
+
AND its trigger was not already applied by an earlier user binding; otherwise it is dropped and its
|
|
1916
|
+
shape/`duplicate` `Issue` recorded in `issues` for loud-but-non-fatal surfacing. **Defaults are
|
|
1917
|
+
trusted** (not re-validated). The resolver remains registry-free: target-existence is still
|
|
1918
|
+
**`doctor`** (Node 3.1), never the resolver. No removal/disable syntax and no multi-skill-per-trigger
|
|
1919
|
+
co-delivery are defined yet.
|
|
1920
|
+
|
|
1921
|
+
**Cold-door delivery (Node 2.3, Python plane):** `perk/substrate/binding_delivery.py`
|
|
1922
|
+
(`render_cold_bindings(user_bindings, repo_root, trigger)`) renders the **full resolved** bindings
|
|
1923
|
+
(shipped defaults ⊕ the user overlay) whose trigger matches the launch — Node 2.3 deleted perk's
|
|
1924
|
+
hardcoded "Follow the … skill" strings, so the mechanism is now the **single delivery path** for
|
|
1925
|
+
perk's own nudges and the defaults are **no longer subtracted**. `launch_stage` appends that
|
|
1926
|
+
fragment to the initial prompt **only when there is one to augment** (D2): an **idle** launch (a
|
|
1927
|
+
stage with no `_initial_prompt` — today only `plan`) stays idle, so a binding **never synthesizes** a
|
|
1928
|
+
whole prompt and never auto-starts a turn; the idle stage's pointer is delivered **warm** by
|
|
1929
|
+
Mechanism A instead. The launch trigger is `stage:<stage.id>` by default; the `learn-docs` cold door
|
|
1930
|
+
(which borrows the `plan` stage) overrides it to `command:learn-docs` via `launch_stage`'s
|
|
1931
|
+
`binding_trigger` parameter, so it never fires `stage:plan`. `objective-reconcile` is a non-launching
|
|
1932
|
+
**worker** (it rewrites the objective body, no initial prompt), so `command:objective-reconcile` has
|
|
1933
|
+
**no cold delivery surface** — it fires only at the warm door. `nudge` renders a ``Follow the
|
|
1934
|
+
`<skill>` skill.`` pointer line; `transclude` inlines `.agents/skills/<skill>/SKILL.md` with its YAML
|
|
1935
|
+
frontmatter stripped, degrading to the nudge pointer with a **loud-but-non-fatal** warning when the
|
|
1936
|
+
file is absent/unreadable. Resolver `issues` and delivery `warnings` are surfaced loud-but-non-fatal
|
|
1937
|
+
on every launch and never block it. Target-existence remains **`doctor`** (Node 3.1).
|
|
1938
|
+
|
|
1939
|
+
**Warm-door delivery (Node 2.2/2.3, TS extension):** `extension/substrate/bindingDelivery.ts` is the in-session
|
|
1940
|
+
twin of the cold door. `resolvedBindings(cwd)` is the TS mirror of cold's `resolve_bindings(...)
|
|
1941
|
+
.bindings` — the **full resolved** overlay (defaults ⊕ user, no subtraction — Node 2.3), and
|
|
1942
|
+
`renderBindings(cwd, trigger)` / `bindingSuffix(cwd, trigger)` render exactly as the cold door does.
|
|
1943
|
+
It delivers at two **warm surfaces**: **Mechanism A** — a `before_agent_start` handler injects the
|
|
1944
|
+
launched **`stage:<id>`** bindings as a hidden (`display:false`) `perk:binding-context` message
|
|
1945
|
+
(mirroring `planMode.ts` / `objectiveAuthor.ts`). This is the delivery path for **`stage:plan`**'s
|
|
1946
|
+
`perk-plan` pointer (D6): a cold `perk plan` launches **idle** (no prompt to augment), so the one
|
|
1947
|
+
previously-ambient `plan` skill is now made **explicit** here. **Mechanism B** — `bindingSuffix` is
|
|
1948
|
+
appended into the guidance of **every** perk warm slash-command so each **self-delivers** its pointer
|
|
1949
|
+
(D5): `/address`→`stage:address`, `/learn`→`stage:learn`, `/objective-plan`→`stage:objective-plan`
|
|
1950
|
+
(a warm `/objective-plan` run *outside* a `stage:objective-plan` session would otherwise get none
|
|
1951
|
+
from Mechanism A), `/objective-reconcile`→`command:objective-reconcile`, `/learn-docs`→
|
|
1952
|
+
`command:learn-docs`. Delivery is the **single path** for perk's own nudges (Node 2.3 deleted the
|
|
1953
|
+
hardcoded strings) and **never double-delivers**.
|
|
1954
|
+
|
|
1955
|
+
The **cross-plane dedup marker is the render header itself** — `BINDING_HEADER` (TS) is pinned
|
|
1956
|
+
byte-for-byte to the cold `_HEADER` (Python) by a literal test in **both** planes. The cold door
|
|
1957
|
+
already puts `stage:<id>` bindings in a cold-launched session's **initial prompt**, and
|
|
1958
|
+
`before_agent_start` fires for that same session, so Mechanism A injects **iff** a launched `stage`
|
|
1959
|
+
exists, the resolved render is non-empty, **and** no entry on `ctx.sessionManager.getBranch()`
|
|
1960
|
+
already carries `BINDING_HEADER` (the cold prompt OR a prior warm inject). The injected custom and the
|
|
1961
|
+
cold prompt both carry the header → idempotent across turns/reloads; after compaction drops the
|
|
1962
|
+
original the header disappears and it **re-delivers** (its ongoing value). Mechanism B is a one-shot
|
|
1963
|
+
`sendUserMessage` suffix at an invocation distinct from any cold launch, so it cannot auto-double. A
|
|
1964
|
+
narrower-than-`planMode` `context` strip removes a **stale** `perk:binding-context` custom (stage
|
|
1965
|
+
changed / overlay removed) while **never** stripping a user message that carries the header (a cold
|
|
1966
|
+
prompt legitimately does). Resolver shape `issues` are **not** surfaced warm (the cold launch + doctor
|
|
1967
|
+
own them); only the delivery `warnings` are loud-but-non-fatal: Mechanism A `console.error`s them,
|
|
1968
|
+
and **`bindingSuffix` (Mechanism B) now `console.error`s them too** (Node 3.1) — previously it
|
|
1969
|
+
degraded silently. The injection-time mirror is **skill-presence only** (the trigger is fixed at
|
|
1970
|
+
injection): the **`nudge`** path now warns when its skill is not installed under
|
|
1971
|
+
`.agents/skills/<name>/SKILL.md` (mirroring the long-standing `transclude` warning), so every
|
|
1972
|
+
delivered binding whose skill is missing yields **exactly one** warning, in both planes — never
|
|
1973
|
+
silently delivered. Injection checks only user-originated skills (installed under `.agents/skills`),
|
|
1974
|
+
so it uses that path **only** (no self-repo fallback).
|
|
1975
|
+
|
|
1976
|
+
**Validation (`doctor`, Node 3.1):** `perk doctor` adds one rolled-up, non-fatal **`bindings`**
|
|
1977
|
+
check (`perk/convergence/doctor/checks.py::_bindings_check`) over the **full resolved set** (`resolve_bindings(user,
|
|
1978
|
+
defaults=load_bindings().bindings)`). It surfaces the resolver's dropped-user-binding `issues` plus,
|
|
1979
|
+
per delivered binding: **skill-presence** — the skill is installed under `.agents/skills/<name>/
|
|
1980
|
+
SKILL.md`, with a self-repo `skills/<name>/SKILL.md` *pre-sync safety net* fallback
|
|
1981
|
+
(`bindings.is_skill_installed(root, skill, *, self_repo)`, D4). perk's own `perk-*` skills are
|
|
1982
|
+
delivered into `.agents/skills/` by the `skills` CLI in **both** self-repo and consumer trees (the
|
|
1983
|
+
Pi package no longer declares `pi.skills`, so Pi never discovers the package `skills/` dir); the
|
|
1984
|
+
`skills/<name>` fallback covers only the window before `skills update --sync` has run — and
|
|
1985
|
+
**target-existence**
|
|
1986
|
+
— `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
|
|
1988
|
+
delivery layer fires; a `command:<id>` outside it never fires). Every binding finding is a **`warn`**
|
|
1989
|
+
(loud-but-non-fatal, D1): `perk doctor` stays exit-0 over a binding misconfiguration — the
|
|
1990
|
+
`bindings` check owns user-binding *config* only. The delivery **substrate** (perk's own skills
|
|
1991
|
+
actually reaching `.agents/skills/`) is load-bearing and owned by the fail-level
|
|
1992
|
+
**`skills-delivery`** check below, not by `bindings`. A `BindingsError` on the *bundled* file is a `fail`
|
|
1993
|
+
("Reinstall perk"; cannot occur in a healthy install). A `RegistryError`/bad-TOML during the check
|
|
1994
|
+
degrades to a warn note rather than failing (the registry/config checks own those failures). The
|
|
1995
|
+
check is report-only — no `--fix` for bindings.
|
|
1996
|
+
|
|
1997
|
+
**Skills-delivery substrate (load-bearing; #289).** Skills delivery via the `skills` CLI is
|
|
1998
|
+
**load-bearing**, not best-effort: a consumer where perk's skills cannot be materialized is a
|
|
1999
|
+
broken environment, surfaced at `init`/`doctor` time (never first at `perk plan` via the warm
|
|
2000
|
+
dangling-pointer warning, which stays a last-resort signal).
|
|
2001
|
+
|
|
2002
|
+
- **`perk init` pre-flight:** before any convergence, `init` probes the five skills-CLI managed
|
|
2003
|
+
runtime pathspecs (`SKILLS_MANAGED_PATHSPECS` = `.agents/state.yaml`, `.agents/local.yaml`,
|
|
2004
|
+
`.agents/skills`, `.claude/skills`, `.agents/cache` — duplicated by value from the skills CLI's
|
|
2005
|
+
`internal/project/project.go`) for **tracked Git content** (`git ls-files`). Any hit
|
|
2006
|
+
short-circuits exit 2 (`skills_conflict`) with a migrate-then-rerun remediation; perk never
|
|
2007
|
+
auto-untracks (the migration is a human, per-repo task). A `GitError` during the probe degrades
|
|
2008
|
+
to *no* short-circuit — the fatal sync below fails loudly instead.
|
|
2009
|
+
- **Fatal sync:** any `skills init --cache=local` / `skills update --sync` failure (non-zero exit,
|
|
2010
|
+
missing CLI, OSError, timeout) is fatal — `init` returns `skills_sync_failed` (exit 2) with the
|
|
2011
|
+
failing command + first stderr lines in `message`, **preserving `changes`** (convergence already
|
|
2012
|
+
happened). After a successful sync, every `MANAGED_SKILL_NAMES` name must pass
|
|
2013
|
+
`bindings.is_skill_installed` — a sync that delivers nothing (e.g. an outdated `skills` CLI) is
|
|
2014
|
+
the same fatal failure, never a silent pass. `MANAGED_SKILL_NAMES` is the verified set:
|
|
2015
|
+
perk-authored skills (source `perk`) **plus** a set of required external skills. The managed
|
|
2016
|
+
fragment now declares **multiple sources** — perk's own (`PERK_SKILL_SOURCE`) plus the required
|
|
2017
|
+
external sources (`REQUIRED_SKILL_SOURCES`: `astral`, `dagster`, `mattpocock`) — promoting those
|
|
2018
|
+
external skills from repo-specific to managed/required.
|
|
2019
|
+
- **`doctor` check:** a fail-level **`skills-delivery`** check (group `skills`, evaluated under
|
|
2020
|
+
`verify` only — it shells git + validates external-CLI outcomes). Fail conditions, first match
|
|
2021
|
+
wins: (a) tracked content under the managed pathspecs (a `GitError` degrades to `warn`, no
|
|
2022
|
+
silent pass); (b) the perk fragment (`.agents/manifest.d/perk.yaml`) exists but
|
|
2023
|
+
`.agents/manifest.yaml` does not (`skills init` failed or never ran, so `skills update --sync`
|
|
2024
|
+
can never run); (c) any `MANAGED_SKILL_NAMES` name (perk-authored + the required external
|
|
2025
|
+
skills) not installed per `bindings.is_skill_installed`.
|
|
2026
|
+
- **`doctor --fix`:** the repair-gesture sync's failure message is carried on
|
|
2027
|
+
`DoctorReport.fix_errors` (rendered loudly; `fix_errors` in the `--json` report — §8.6); the
|
|
2028
|
+
post-fix re-verify keeps the failing `skills-delivery` check so the exit code reflects the
|
|
2029
|
+
still-broken state.
|
|
2030
|
+
|
|
2031
|
+
## §8.10 · Provider selection (the supported-set registry + the `[providers]` selection)
|
|
2032
|
+
|
|
2033
|
+
The **third parsed cross-plane contract**, `shared/providers.yaml` (sibling of `registry.yaml`
|
|
2034
|
+
and `bindings.yaml`), is the **supported set** — the catalog of plan/todo/askuser/footer/web *providers* perk
|
|
2035
|
+
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
|
|
2037
|
+
`shared/` force-include (wheel → `perk/_shared/`, npm tarball → `shared/`) and read by both planes
|
|
2038
|
+
through independent readers: **`perk/substrate/providers.py`** (`load_providers` / `validate` /
|
|
2039
|
+
`resolve_providers`, returning `ProviderSet`/`Provider` + the shared `Issue`/`FindingSeverity` findings,
|
|
2040
|
+
raising `ProvidersError` only for structural failures) and **`extension/substrate/providers.ts`**
|
|
2041
|
+
(`loadProviders` + the pure `resolveProviders`, returning `ResolvedProviders { plan, todo, askuser, footer, web, issues }`
|
|
2042
|
+
with `issues` as **`string[]`** — the TS plane has no `Issue`/`FindingSeverity`). The Python plane is the
|
|
2043
|
+
authoritative validator. The
|
|
2044
|
+
design is locked in `docs/design/adapter-architecture.md` (Node 1.3), over
|
|
2045
|
+
`docs/design/provider-contract.md` (the seven dimensions) and `docs/design/pluggability-taxonomy.md` (the C3 behavior-preserving
|
|
2046
|
+
default).
|
|
2047
|
+
|
|
2048
|
+
**Provider entry shape — `{ id, seam, package, adapter, default, package_filter? }`:** `id` is the
|
|
2049
|
+
stable provider id (it is **not** the `cache.plan-ref` `provider` string — see the
|
|
2050
|
+
“`cache.plan-ref.provider` is the issue backend, not the seam id” paragraph below); `seam ∈
|
|
2051
|
+
{plan, todo, askuser, footer, web}`; `package` is the foreign Pi package spec added to `.pi/settings.json` `packages`
|
|
2052
|
+
(`null` for perk's own bundled reference provider — nothing to add; **not universal** — the `web`
|
|
2053
|
+
seam's reference provider `pi-web-access` carries a **non-null** `package` because perk owns no
|
|
2054
|
+
native web implementation, the documented exception); `adapter` is the perk-owned
|
|
2055
|
+
shim module bridging a foreign surface to the artifact boundary (`null` for the reference
|
|
2056
|
+
provider); `default` is a bool — **exactly one `true` per seam**, the behavior-preserving no-config
|
|
2057
|
+
pick; `package_filter` is an optional Pi object-form filter (`extensions`/`skills`/… arrays) merged
|
|
2058
|
+
into a foreign package's object-form `packages` entry. Because both planes read this with their
|
|
2059
|
+
full YAML readers, it can carry the nested `package_filter` object that the narrow-TOML config
|
|
2060
|
+
reader cannot.
|
|
2061
|
+
|
|
2062
|
+
**Shipped set (Node 2.1 → 3.2 + askuser):** the three reference entries `perk-plan` (seam `plan`),
|
|
2063
|
+
`perk-checkpoints` (seam `todo`), and `perk-ask-user` (seam `askuser`), all `package: null` /
|
|
2064
|
+
`adapter: null` / `default: true`, plus a **real** foreign entry per seam. `tombell-plan` (→ `npm:@tombell/pi-plan`, `adapter:
|
|
2065
|
+
planAdapterTombell`) is a real, selectable plan provider (Node 2.3); `juicesharp-todo`
|
|
2066
|
+
(→ `npm:@juicesharp/rpiv-todo`, `adapter: todoAdapterJuicesharp`) is now a real, selectable **todo**
|
|
2067
|
+
provider (Node 3.2) — neither is illustrative any longer. **Both seams are behavior-complete:** the
|
|
2068
|
+
**plan** seam (perk vacates its surface at registration time + the adapter bridges the foreign one —
|
|
2069
|
+
see the Node 2.3 status note in contracts-history.md §8.10) and the **todo** seam (perk's `checkpoints` **defers at runtime** under
|
|
2070
|
+
a foreign `[providers] todo` selection — Node 3.1 — with **no** registration-time vacating, because
|
|
2071
|
+
the todo seam has no command-name collision; the `todoAdapterJuicesharp` shim carries perk's
|
|
2072
|
+
progress discipline onto the foreign overlay — see the Node 3.2 status note in contracts-history.md §8.10). The **askuser** seam is an **interface seam** — see the askuser status
|
|
2073
|
+
note in [`contracts-history.md`](./contracts-history.md) §8.10. A fourth reference entry `perk-footer` (seam `footer`, `package: null` / `adapter: null` /
|
|
2074
|
+
`default: true`) plus **four** foreign/null footer providers — `powerline-footer` (→ `npm:pi-powerline-footer`),
|
|
2075
|
+
`pi-bar-footer` (→ `npm:pi-bar`), `pi-status-footer` (→ `npm:@tombell/pi-status`, #670), and
|
|
2076
|
+
`pi-default` (`package: null`, #670 — "install nothing / pi stock footer") — make the **footer** seam
|
|
2077
|
+
a **second interface seam** (vacate-only, `adapter: null`). `pi-status-footer` does **not** render
|
|
2078
|
+
extension statuses, so perk progress is not shown under it (accepted limitation). With these the
|
|
2079
|
+
footer is governed **exclusively** by `[providers] footer` — no footer outcome needs a manual
|
|
2080
|
+
`packages` edit. See the footer status note in contracts-history.md §8.10. A fifth reference entry `pi-web-access` (seam
|
|
2081
|
+
`web`, **`package: "npm:pi-web-access"`** — the first non-null-package default — / `adapter: null` /
|
|
2082
|
+
`default: true`) plus two **real** foreign web providers `ollama-web-search` (→ `npm:@ollama/pi-web-search`)
|
|
2083
|
+
and `juicesharp-web-tools` (→ `npm:@juicesharp/rpiv-web-tools`) make the **web** seam a **third interface
|
|
2084
|
+
seam** (vacate-only, `adapter: null`) — see the web status note in contracts-history.md §8.10. The **default** path (the reference providers) is unaffected and is the hard guarantee.
|
|
2085
|
+
|
|
2086
|
+
**`cache.plan-ref.provider` is the issue backend, not the seam id.** Despite
|
|
2087
|
+
`docs/design/provider-contract.md` framing the `cache.plan-ref` `provider` field as the plan
|
|
2088
|
+
provider id, today it is the **issue backend** (`"github"`) — `perk/run/launch/prompts.py` branches on
|
|
2089
|
+
`provider == "github"`. The stamp sites (`plan_save_cmd.py` / `resume.py`'s
|
|
2090
|
+
`reconstruct_plan_ref` callers) no longer hardcode the `"github"` literal: the field is stamped
|
|
2091
|
+
from the **resolved issue backend's `backend_id`** (§8.21) — still the issue backend, still ≠
|
|
2092
|
+
the seam id. That "id == provider field" equivalence is aspirational; Node 2.2 does **not**
|
|
2093
|
+
restamp it (restamping would break `launch`'s backend branching). `cache.plan-ref` is
|
|
2094
|
+
untouched by the plan-seam deferral.
|
|
2095
|
+
|
|
2096
|
+
**Validation depth (shape-only, repo-free):** the loaders/validators check that
|
|
2097
|
+
`schema_version == 1` (else a structural load error), each provider has a non-empty unique `id`, a
|
|
2098
|
+
`seam ∈ {plan, todo, askuser, footer, web}`, and that **exactly one `default: true`** exists per seam. They do **not**
|
|
2099
|
+
check that any repo *selection* names a real provider — that cross-file validation is **`doctor`**'s
|
|
2100
|
+
job (mirroring how bindings target-existence lives in doctor, not the loaders).
|
|
2101
|
+
|
|
2102
|
+
**The `[providers]` selection — flat string table in `.pi/perk.toml`:** a per-repo selection with
|
|
2103
|
+
one key per seam (`plan` / `todo` / `askuser` / `footer` / `web`), values are **bare provider-id strings** (the TS narrow-TOML
|
|
2104
|
+
reader `parseTomlSubset` reads string values only; richer structure lives in `providers.yaml`).
|
|
2105
|
+
Both planes parse it raw (`perk/substrate/config.py` → `Config.providers`; `extension/substrate/config.ts` →
|
|
2106
|
+
`PerkConfig.providers`); resolution against the supported set is `init`/`doctor` in Python and the
|
|
2107
|
+
`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
|
|
2109
|
+
wins (standard local-override precedence). The pure resolver
|
|
2110
|
+
`perk.substrate.providers.resolve_providers(selection, providers)` returns `ResolvedProviders { plan, todo,
|
|
2111
|
+
askuser, footer, web, issues }`: an absent key falls back to the default **silently**; an unknown id or a seam mismatch
|
|
2112
|
+
falls back to the default and records a **loud-but-non-fatal** `Issue`.
|
|
2113
|
+
|
|
2114
|
+
**`perk init` two-directional settings wiring:** provider wiring composes on top of the static
|
|
2115
|
+
`_desired_packages` (perk + `BORROWED_PACKAGES`: `npm:@tombell/pi-diff`,
|
|
2116
|
+
`npm:pi-subagents`) layer within the same `_converge_settings` body — `npm:pi-web-access` is **no
|
|
2117
|
+
longer borrowed** (#529): it is the `web` seam's `default: true` provider, converged via the
|
|
2118
|
+
provider path (see the web status note in contracts-history.md §8.10), so a default repo still installs it but deselecting `web`
|
|
2119
|
+
removes it like any provider package —
|
|
2120
|
+
so it stays inside the `settings-wiring` `ManagedConvergence` (one desired-state SSOT — `doctor`
|
|
2121
|
+
dry-runs/fixes it for free). The **whole supported set** gives the *provider-managed identity set*
|
|
2122
|
+
(every non-null `package`'s npm/git identity) — the discriminator separating provider packages from
|
|
2123
|
+
borrowed and user-hand-added packages. The resolved selection gives the *desired foreign packages*.
|
|
2124
|
+
Unlike today's append-only convergence, provider wiring is **two-directional**: it **removes** any
|
|
2125
|
+
existing `packages` entry whose identity is provider-managed but **not** desired (a deselect), and
|
|
2126
|
+
**adds** each desired foreign package in **object form** (`{ "source": <spec>, **package_filter }`,
|
|
2127
|
+
omitting the filter keys when absent). Entries outside the managed set (perk's own, borrowed, user)
|
|
2128
|
+
are never touched. **perk's own package is never filtered, never object-form** (Invariant 2: perk
|
|
2129
|
+
defers at runtime, it is not filtered). **Resolved ambiguity (Node 1.3 step 4):** any `packages`
|
|
2130
|
+
entry whose identity matches a provider's `package` is treated as **provider-managed** (removable
|
|
2131
|
+
when deselected); hand-adding a provider's package *without* selecting it is unsupported — a user
|
|
2132
|
+
who wants that package selects the provider via `[providers]`. The retired `@tombell/pi-plan` /
|
|
2133
|
+
`@juicesharp/rpiv-todo` re-enter `packages` **only** when a selection names them.
|
|
2134
|
+
|
|
2135
|
+
**Validation (`doctor`):** `perk doctor` adds one **`providers`** check (`perk/convergence/doctor/checks.py::
|
|
2136
|
+
_providers_check`). A `ProvidersError` on the *bundled* file is a `fail` (cannot occur in a healthy
|
|
2137
|
+
install; "Reinstall perk"); an `ERROR` shape `Issue` on the bundled file is a `fail`. The repo
|
|
2138
|
+
selection is resolved against the supported set and any resolver `issue` (unknown id / seam
|
|
2139
|
+
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
|
|
2141
|
+
package-wired / orphan check — that drift is owned by the `settings-wiring` managed convergence
|
|
2142
|
+
(which `doctor` already dry-runs); `_providers_check` owns only what convergence cannot repair (an
|
|
2143
|
+
invalid bundled file, a selection naming a non-existent / wrong-seam provider).
|
|
2144
|
+
|
|
2145
|
+
**`[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>`
|
|
2147
|
+
sessions by converging into the committed `.pi/settings.json` `compaction` object (pi reads that
|
|
2148
|
+
natively at session boot). It is **Python-plane-only** — the extension never reads it (pi consumes
|
|
2149
|
+
`settings.json` itself), so `extension/substrate/config.ts` is untouched. Three snake_case keys map to pi's
|
|
2150
|
+
camelCase `settings.json` keys: `enabled`→`enabled`, `reserve_tokens`→`reserveTokens`,
|
|
2151
|
+
`keep_recent_tokens`→`keepRecentTokens`. Validation is LBYL silent-omit (mirrors `[providers]`):
|
|
2152
|
+
`enabled` kept only if a real `bool`; the token keys kept only if `int` (not `bool`) and `> 0`;
|
|
2153
|
+
ill-typed/absent keys are dropped (pi fills defaults). The convergence composes inside
|
|
2154
|
+
`_converge_settings` (`perk/substrate/config.py::parse_compaction_table` + `load_committed_compaction`,
|
|
2155
|
+
`perk/convergence/init/settings.py::_converge_compaction`), so it stays in the `settings-wiring` `ManagedConvergence` —
|
|
2156
|
+
`doctor` dry-runs/fixes it for free, **no** new check. **Committed-only read** (the deliberate
|
|
2157
|
+
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`
|
|
2159
|
+
stays a deterministic function of committed config (no stray per-user git diff). Per-user overrides
|
|
2160
|
+
belong in pi's native global `~/.pi/agent/settings.json` (pi merges it under project settings).
|
|
2161
|
+
**Write semantics are non-destructive write-when-present / leave-when-absent:** when `[compaction]`
|
|
2162
|
+
is present, its mapped keys merge over any existing `settings.json` `compaction` dict (perk keys
|
|
2163
|
+
win; unrelated hand-added keys survive; unspecified keys are left to pi's defaults); when
|
|
2164
|
+
**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
|
|
2166
|
+
up by hand). A malformed-TOML error defers to the config check (treated as empty here, mirroring
|
|
2167
|
+
`_converge_provider_packages`). perk's headless worker (`compaction: { enabled: false }`) and the
|
|
2168
|
+
objective threshold compaction (`[objective] compact_threshold`) are orthogonal and unaffected.
|
|
2169
|
+
|
|
2170
|
+
> **Interactive save discipline (as of Node 2.5 the present + `/plan-save` flow is
|
|
2171
|
+
> FALLBACK-ONLY on every interactive path — perk-plan included):** the prior
|
|
2172
|
+
> `PLAN_AUTHORING_CONTEXT` ending ("disable plan mode (/plan off), then call the plan_save
|
|
2173
|
+
> tool") was structurally broken — `/plan` is a user command the model cannot run, and the
|
|
2174
|
+
> `plan_save` tool is excluded from `READ_ONLY_TOOLS` (hidden while the gate is on). The
|
|
2175
|
+
> review-first discipline, now spoken by `PLAN_AUTHORING_CONTEXT`,
|
|
2176
|
+
> `PLAN_ADAPTER_PLANNOTATOR_CONTEXT`, `OBJECTIVE_AUTHORING_CONTEXT`, the objective-plan factory
|
|
2177
|
+
> guidance on both planes (warm `factoryGuidance` / cold `_seed_prompt` — #352 Node 3.1),
|
|
2178
|
+
> `skills/perk-plan/SKILL.md`, `skills/perk-objective-author/SKILL.md`, and
|
|
2179
|
+
> `skills/perk-objective-plan/SKILL.md` (#352 Node 3.2): keep the working draft
|
|
2180
|
+
> current with `plan_draft`, call `plan_review` when decision-complete, and an approval
|
|
2181
|
+
> **auto-saves** via `approvalSave`. Only when `plan_review` reports **skipped or unavailable**
|
|
2182
|
+
> (headless, dismissed, no surface) does the model **present the complete plan as its final
|
|
2183
|
+
> message and never attempt to save**; the **human** runs `/plan-save` (its
|
|
2184
|
+
> `extractPlanMarkdown` scrape is reliable by construction — the final message is the clean
|
|
2185
|
+
> plan; as of Node 2.2 `/plan-save` prefers the validated plan-draft artifact when one exists,
|
|
2186
|
+
> and the scrape is the demoted universal fallback). `PLAN_ADAPTER_TOMBELL_CONTEXT` (Node 2.6)
|
|
2187
|
+
> now joins `PLAN_AUTHORING_CONTEXT` / `PLAN_ADAPTER_PLANNOTATOR_CONTEXT` in the review-first
|
|
2188
|
+
> list; the present + `/plan-save` (artifact-preferred, scrape-fallback) flow remains its
|
|
2189
|
+
> explicit **fail-open** arm — including when `@tombell/pi-plan`'s own interactive `/plan`
|
|
2190
|
+
> `setActiveTools` restriction hides `plan_draft`/`plan_review` from the tool set.
|
|
2191
|
+
> `savePlan()` / the `plan_save` tool / `/plan-save` are **untouched**. The orchestrated
|
|
2192
|
+
> **factory flows** that still instruct an autonomous `plan_save` tool call narrow to
|
|
2193
|
+
> **learn-docs and replan**; **objective-plan** is review-first as of #352 Node 3.1 — the
|
|
2194
|
+
> approval-driven save recovers the node link from the `objective_node_claim` carrier, with
|
|
2195
|
+
> `plan_save`-with-both-ids demoted to the manual failsafe.
|
|
2196
|
+
|
|
2197
|
+
## §8.11 · The headless stage-drive worker contract (Node 1.2)
|
|
2198
|
+
|
|
2199
|
+
The **stage-drive primitive** (`extension/worker/worker.ts` `driveStage`) drives ONE read-write stage
|
|
2200
|
+
(`implement`/`address`) end-to-end on an **already-prepared** worktree, in-process via the SDK
|
|
2201
|
+
runtime factory, running the **same** `@mgiles/perk` extension package. It is the substrate Node 1.3
|
|
2202
|
+
(the structured event stream) and Node 4.1 (the e2e harness) consume. This section locks the
|
|
2203
|
+
worker's inputs, determinism invariants, terminal-signal definition, and outcome shape (the full
|
|
2204
|
+
audit is `docs/design/headless-worker.md`, Node 1.1). The worker makes **no GitHub mutation of its
|
|
2205
|
+
own** — the stage's own tools (`submit`, `resolve_review_threads`) delegate to the Python gateway
|
|
2206
|
+
exactly as in a warm session (§8.4).
|
|
2207
|
+
|
|
2208
|
+
### Inputs (the prepared-worktree contract)
|
|
2209
|
+
|
|
2210
|
+
| input | shape | source |
|
|
2211
|
+
|---|---|---|
|
|
2212
|
+
| `worktree` | absolute path, already positioned | the cold-door/runner positioning (`perk/run/launch/__init__.py`), **not** the worker (Gap 7) |
|
|
2213
|
+
| `stage` | `"implement" \| "address"` | the only `doors.cold_remote: true` read-write stages (`shared/registry.yaml`) |
|
|
2214
|
+
| `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 |
|
|
2216
|
+
| `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
|
+
| `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
|
+
| `budget` | `{ maxTurns, maxTokens, wallClockMs }` | worker input; the watchdog that drives abort (Gap 2) |
|
|
2219
|
+
| `signal` | `AbortSignal` | external cancellation; OR'd with the budget watchdog |
|
|
2220
|
+
|
|
2221
|
+
### Determinism invariants (fixed by the worker; not caller-tunable)
|
|
2222
|
+
|
|
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.
|
|
2233
|
+
- **`ctx.hasUI === false`** (Gap 6): the session binds with `{ uiContext: undefined, mode: "json" }`,
|
|
2234
|
+
so every perk UI surface takes its headless `console.error` fallback.
|
|
2235
|
+
- **Rebind defensiveness** (Gap 1): the worker is built on `createAgentSessionRuntime` (the
|
|
2236
|
+
services/from-services factory), and a `bindAndSubscribe`/`rebind` helper re-binds the extension
|
|
2237
|
+
and re-attaches the terminal/budget listener after any runtime replacement — but `bindExtensions`
|
|
2238
|
+
is **still called explicitly** at startup (the factory only *loads* extensions; binding emits
|
|
2239
|
+
`session_start` and runs perk's claim path). A mid-drive replacement is **not expected** on the
|
|
2240
|
+
happy path (the prompt instructs `/submit`, never `/implement`; `lifecycleGates.newSession` is
|
|
2241
|
+
`hasUI`-guarded; objective compaction is inert) — so an observed replacement is a **loud
|
|
2242
|
+
structured-log error** before the listener is kept alive.
|
|
2243
|
+
|
|
2244
|
+
### Terminal-signal definition
|
|
2245
|
+
|
|
2246
|
+
The drive terminates on the **first** of:
|
|
2247
|
+
|
|
2248
|
+
1. **Terminating-tool success** (the primary signal), observed via the `tool_execution_end`
|
|
2249
|
+
`result.details` captured by the subscribe listener: for `implement`, a successful `submit`
|
|
2250
|
+
carrying a `pr` **AND `mergeable !== false`** (#556 — a definitively-unmergeable PR with
|
|
2251
|
+
unresolved merge conflicts is NOT complete; `mergeable: true`/`null`/absent all allow completion,
|
|
2252
|
+
fail-open) → `completed`/`submit_tool`; for `address`, `resolve_review_threads` ok **and**
|
|
2253
|
+
`perk:workflow-state.last_review_batch` appended → `completed`/`address_resolved`. The resolver
|
|
2254
|
+
re-drive (§8.3) runs as follow-up turns inside the same `prompt()` drive; the final clean
|
|
2255
|
+
re-`submit` overwrites the captured details with `mergeable: true`, so natural-idle then passes.
|
|
2256
|
+
2. **Driving `prompt()` resolved (agent idle), verified against the success predicate.** Idle is
|
|
2257
|
+
**not** itself success — if the predicate does not hold, → `failed`/`agent_idle_incomplete`.
|
|
2258
|
+
3. **Budget / timeout / external abort** → `session.abort()` (hard; propagates into the in-flight
|
|
2259
|
+
`ctx.signal`-aware shelled tools `submit`/`resolve_review_threads`/`run_ci`): the watchdog →
|
|
2260
|
+
`budget_exhausted`/`budget`; the external `signal` → `aborted`/`external_abort`.
|
|
2261
|
+
4. **Post-acceptance model error** (with retry off, an assistant `message_end` with
|
|
2262
|
+
`stopReason:"error"`) → `failed`/`model_error`.
|
|
2263
|
+
|
|
2264
|
+
### Outcome shape (frozen; **additive-stable** — 1.3 may add fields, existing fields keep meaning)
|
|
2265
|
+
|
|
2266
|
+
```jsonc
|
|
2267
|
+
{
|
|
2268
|
+
"run_id": "<ULID>",
|
|
2269
|
+
"stage": "implement" | "address",
|
|
2270
|
+
"status": "completed" | "failed" | "aborted" | "budget_exhausted",
|
|
2271
|
+
"terminal_signal": "submit_tool" | "address_resolved" | "agent_idle_incomplete"
|
|
2272
|
+
| "budget" | "external_abort" | "model_error",
|
|
2273
|
+
"pr": { "number": 0, "url": "" } | null, // populated on a completed implement; from SubmitDetails.pr
|
|
2274
|
+
"budget": { "turns": 0, "tokens": 0, "elapsed_ms": 0 },
|
|
2275
|
+
"error": { "type": "string", "message": "string", "summary": "string" } | null
|
|
2276
|
+
}
|
|
2277
|
+
```
|
|
2278
|
+
|
|
2279
|
+
`error.summary` is a short, model-free synthesis capped via the `route-don't-relay`/double-delivery
|
|
2280
|
+
discipline (`capForModel`); the PR is extracted **directly from the captured terminal tool event**,
|
|
2281
|
+
not a new Python `find-pr-for-branch` JSON command. Node 1.3 surfaces this outcome as the run-event
|
|
2282
|
+
stream's terminal `run_finished` event (§8.12) — the same frozen object, carried in the structured
|
|
2283
|
+
channel.
|
|
2284
|
+
|
|
2285
|
+
> **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
|
|
2287
|
+
> 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`
|
|
2289
|
+
> override byte-identical to `_address_prompt`'s parity twin. The **subagent-under-worker live
|
|
2290
|
+
> smoke** stays the open-#6 dependency (§8.3, T6) **deferred to the Phase-3 `doctor workflow`**;
|
|
2291
|
+
> Node 1.2 does not prove it.
|
|
2292
|
+
|
|
2293
|
+
## §8.12 · The structured run-event stream (Node 1.3)
|
|
2294
|
+
|
|
2295
|
+
The headless stage-drive worker (§8.11) emits a **structured run-event stream** while it drives one
|
|
2296
|
+
`implement`/`address` stage to terminal. The stream is the *substrate* Node 2.3 (GitHub
|
|
2297
|
+
progress/terminal reporting) and Node 4.1 (the e2e worker harness) consume: it carries full ordered
|
|
2298
|
+
run detail in a **structured channel**, while the surfaced `RunOutcome` (§8.11) stays bounded — the
|
|
2299
|
+
`route-don't-relay`/double-delivery discipline. This node is **purely additive** to §8.11: the
|
|
2300
|
+
`RunOutcome` shape is unchanged, and every surface is opt-in/fail-soft.
|
|
2301
|
+
|
|
2302
|
+
### The `RunEvent` union (additive-stable; keyed on `kind`)
|
|
2303
|
+
|
|
2304
|
+
A small, JSON-serializable, **additive-stable** discriminated union. Every event carries a monotonic
|
|
2305
|
+
`seq` (0-based, +1 per emit) and `t` (elapsed ms from the drive's injected clock — the SAME basis as
|
|
2306
|
+
`RunOutcome.budget.elapsed_ms`). Future nodes may add variants/fields; existing ones keep meaning.
|
|
2307
|
+
|
|
2308
|
+
```jsonc
|
|
2309
|
+
{ "kind": "run_started", "seq": 0, "t": 0, "run_id": "<ULID>", "stage": "implement" | "address" }
|
|
2310
|
+
{ "kind": "step_marker", "seq": 1, "t": 0, "marker": "wip" | "done", "step": 1 }
|
|
2311
|
+
{ "kind": "tool_outcome", "seq": 2, "t": 0, "tool": "submit", "ok": true, "summary": null }
|
|
2312
|
+
{ "kind": "run_finished", "seq": 3, "t": 0, "outcome": { /* the frozen §8.11 RunOutcome */ } }
|
|
2313
|
+
```
|
|
2314
|
+
|
|
2315
|
+
- **`run_started`** — emitted once at drive start (after a successful bind, before `session.prompt`).
|
|
2316
|
+
- **`step_marker`** — one per `[WIP:n]`/`[DONE:n]` in an assistant turn's text, in **textual
|
|
2317
|
+
appearance order** (`turn_end` fires once per turn, so each turn's markers emit exactly once).
|
|
2318
|
+
- **`tool_outcome`** — one per `tool_execution_end`. `ok` = `details.ok === true` when the result
|
|
2319
|
+
carries a `details.ok` boolean, else `!isError`. `summary` is `null` on success and, on failure, a
|
|
2320
|
+
**capped** synthesis (`capForModel(message, EVENT_SUMMARY_CAP=2KiB).shown`) — never the raw result.
|
|
2321
|
+
- **`run_finished`** — emitted **exactly once** at every terminal exit (natural-idle/verdict,
|
|
2322
|
+
budget/abort, drive-error catch, AND the `no_model` early return), carrying the full frozen
|
|
2323
|
+
`RunOutcome` (terminal status + `error.summary` = the terminal failure summary). The stream's
|
|
2324
|
+
"terminal status" event. A zero-turn run still emits a `run_started` + `run_finished` pair.
|
|
2325
|
+
|
|
2326
|
+
### Dual delivery (the injectable sink seam)
|
|
2327
|
+
|
|
2328
|
+
`RunEventSink = (event: RunEvent) => void`, injectable via `DriveStageDeps.eventSink`. This satisfies
|
|
2329
|
+
both consumers: Node 4.1 asserts events in-process via an injected array sink; Node 2.3 reads the
|
|
2330
|
+
durable file out-of-process.
|
|
2331
|
+
|
|
2332
|
+
- **Default sink** (when `eventSink` is absent) = a run-scoped NDJSON **file** sink built from
|
|
2333
|
+
`opts.worktree` + the resolved `run_id` (`env.PERK_RUN_ID`, the same source `assembleOutcome`
|
|
2334
|
+
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.
|
|
2337
|
+
- **No-op when `run_id` is empty** — keeps the offline drive tests (which set no `PERK_RUN_ID`)
|
|
2338
|
+
write-free; `workerMain` always has `PERK_RUN_ID`, so a real run always writes the file.
|
|
2339
|
+
- **Fail-soft** — each append (and the emitter's `sink(...)` call) is try/caught and swallowed with a
|
|
2340
|
+
structured-log line; a broken/throwing sink never aborts or fails the drive.
|
|
2341
|
+
|
|
2342
|
+
### Cap (route-don't-relay)
|
|
2343
|
+
|
|
2344
|
+
The structured channel carries the *narrative* (which tools ran + ok/fail, step progress, terminal
|
|
2345
|
+
outcome), **not** raw tool payloads (those already live in the session transcript). Per-event free
|
|
2346
|
+
text is capped at `EVENT_SUMMARY_CAP = 2 KiB`. The surfaced `RunOutcome` is unchanged and already
|
|
2347
|
+
bounded. The worker only *writes* the structured channel — **no GitHub mutation** here; surfacing it
|
|
2348
|
+
as PR comments/checks from the runner is Node 2.3 (Phase 2).
|
|
2349
|
+
|
|
2350
|
+
---
|
|
2351
|
+
|
|
2352
|
+
## §8.13 · Remote dispatch: the `Runner` contract + the dispatch record (Node 2.1)
|
|
2353
|
+
|
|
2354
|
+
A `--remote` launch of a drivable stage (`implement`/`address`, the `doors.cold_remote:true`
|
|
2355
|
+
stages) is a **real drive** (it was `remote_not_driven` through P2.T8c). The Python plane mints a
|
|
2356
|
+
perk `run_id`, **persists the `run_id → plan` linkage**, reads it back to verify, then **triggers**
|
|
2357
|
+
a runner that is discovered + matched back to the `run_id`. This node builds the dispatch driver
|
|
2358
|
+
(`perk/run/launch/remote.py` `_drive_remote_target`) + the runner library (`perk/run/runner.py`); the GitHub
|
|
2359
|
+
Actions workflow YAML it triggers is **Node 2.2** (named below, built there).
|
|
2360
|
+
|
|
2361
|
+
### The `Runner` contract (`perk/run/runner.py`)
|
|
2362
|
+
|
|
2363
|
+
A runner-agnostic `typing.Protocol`. GitHub Actions is the first (and currently only)
|
|
2364
|
+
implementation; `select_runner(ref)` returns a `GitHubActionsRunner(ref)` for any ref today (the
|
|
2365
|
+
"keep future runners open" seam — the ref is recorded but not yet mapped to a runner *kind*).
|
|
2366
|
+
|
|
2367
|
+
```python
|
|
2368
|
+
class Runner(Protocol):
|
|
2369
|
+
kind: str
|
|
2370
|
+
def dispatch(self, *, stage, plan_ref, run_id, base, repo_root) -> RunHandle: ...
|
|
2371
|
+
def observe(self, handle: RunHandle, *, repo_root) -> RunObservation: ...
|
|
2372
|
+
def cancel(self, handle: RunHandle, *, repo_root) -> None: ...
|
|
2373
|
+
def retry(self, handle: RunHandle, *, failed_only, repo_root) -> None: ...
|
|
2374
|
+
```
|
|
2375
|
+
|
|
2376
|
+
- **`dispatch`** triggers the run and returns the **verified** handle (verified = the runner-side
|
|
2377
|
+
run was discovered and matched to `run_id`); it raises `RunnerError` on a trigger/discovery
|
|
2378
|
+
failure.
|
|
2379
|
+
- **`observe`/`cancel`/`retry`** operate on a previously-returned `RunHandle`. They are implemented (not
|
|
2380
|
+
stubbed) so the contract is validated end-to-end and the supervisor nodes (3.1/3.2) consume
|
|
2381
|
+
settled shapes — but the **supervisor command surfaces** (`perk workflow run list/cancel/retry`,
|
|
2382
|
+
tables, correlation) are those later nodes' work, not this one (`list` is §8.17;
|
|
2383
|
+
`cancel`/`retry` are §8.18). `retry` re-runs the existing run (same `run_ref`); `failed_only`
|
|
2384
|
+
re-runs only the failed jobs. `GitHubActionsRunner.retry` shells `github.rerun_workflow_run`
|
|
2385
|
+
(`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`):
|
|
2388
|
+
|
|
2389
|
+
- **`RunHandle`** — `runner` (the routed ref, `""` ⇒ default), `kind` (`"github-actions"`),
|
|
2390
|
+
`run_ref` (the runner-native run id — GitHub Actions' numeric id as a string), `url`. Stored
|
|
2391
|
+
inside the dispatch record. **Do not conflate** `run_ref` with the perk `run_id`: the perk
|
|
2392
|
+
`run_id` is the canonical, runner-agnostic correlation key; `run_ref` is the runner-side handle.
|
|
2393
|
+
- **`RunObservation`** — `status` (`"queued"|"in_progress"|"completed"|"unknown"`), `conclusion`
|
|
2394
|
+
(`"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
|
|
2400
|
+
scratch dir — `perk init` already creates `scratch/runs/` and `.gitignore` already excludes
|
|
2401
|
+
`/.pi/workflow/scratch/`, so no layout/gitignore change). Shape:
|
|
2402
|
+
|
|
2403
|
+
```jsonc
|
|
2404
|
+
{ "run_id": "<ULID>", // perk's canonical correlation key (authoritative on write)
|
|
2405
|
+
"stage": "implement",
|
|
2406
|
+
"plan_ref": { /* the cache.plan-ref blob */ },
|
|
2407
|
+
"runner": "", // the routed runner ref ("" => default)
|
|
2408
|
+
"kind": "github-actions",
|
|
2409
|
+
"status": "dispatching" | "dispatched" | "failed",
|
|
2410
|
+
"dispatched_at": "<ISO-8601 UTC>",
|
|
2411
|
+
"run_handle": { /* RunHandle.to_data() */ } | null,
|
|
2412
|
+
"error": "<string>" | null }
|
|
2413
|
+
```
|
|
2414
|
+
|
|
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
|
|
2418
|
+
(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>/`
|
|
2420
|
+
and so are pruned wholesale with the run dir by `perk state prune` / the `cache-gc` check.
|
|
2421
|
+
|
|
2422
|
+
### Persist-then-trigger + read-back-verify (the establish-before-consume gate)
|
|
2423
|
+
|
|
2424
|
+
`_drive_remote_target` ordering (the establish-before-consume discipline, cross-referencing §8.2):
|
|
2425
|
+
|
|
2426
|
+
1. Resolve the plan from `cache.plan-ref`; **no plan ⇒** `UserFacingCliError(no_plan_ref)` (a
|
|
2427
|
+
remote drive must not invent a plan).
|
|
2428
|
+
2. Mint `run_id` (a cold dispatch is a cold launch ⇒ mints).
|
|
2429
|
+
3. Resolve `base` = the default branch (best-effort; loud fallback to `"main"` on failure — never
|
|
2430
|
+
silent).
|
|
2431
|
+
4. **`--dry-run` ⇒ a side-effect-free dispatch preview** (`success:true`, `dry_run:true`, an
|
|
2432
|
+
`inputs` preview; **no** persist, **no** trigger) — mirroring the local dry-run.
|
|
2433
|
+
5. **Persist** the `DispatchRecord` (`status:"dispatching"`) via `cache.write_dispatch`, then
|
|
2434
|
+
**read it back** and assert `run_id` + `plan_ref.pr_id` round-tripped; a mismatch raises a
|
|
2435
|
+
**hard** `UserFacingCliError(dispatch_state_unverified)` (never a silent `pass`).
|
|
2436
|
+
6. **Trigger** via the selected runner's `dispatch`. On `RunnerError`/`GitHubError`: rewrite the
|
|
2437
|
+
record `status:"failed"` + `error`, then raise `UserFacingCliError(dispatch_failed)`.
|
|
2438
|
+
7. **Finalize** the record `status:"dispatched"` + `run_handle` (read-back is best-effort here —
|
|
2439
|
+
the critical verified linkage is step 5's). Surface a human line + a `--json`
|
|
2440
|
+
`{success, stage, run_id, runner, run_handle}` payload. Exit 0.
|
|
2441
|
+
|
|
2442
|
+
The **error types**: `remote_not_driven` is **retired**; the new types are `no_plan_ref`,
|
|
2443
|
+
`dispatch_state_unverified`, `dispatch_failed`.
|
|
2444
|
+
|
|
2445
|
+
### The `workflow_dispatch` input contract (the Node 2.2 dependency)
|
|
2446
|
+
|
|
2447
|
+
`GitHubActionsRunner.dispatch` triggers a `workflow_dispatch` and then **verifies** the run by
|
|
2448
|
+
polling `repos/{owner}/{repo}/actions/workflows/<workflow>/runs` and matching the run whose
|
|
2449
|
+
`display_title`/`name` **contains the perk `run_id`** (exponential backoff `min(2**attempt, 8)`,
|
|
2450
|
+
`max_attempts=11`; a matched `skipped`/`cancelled` run or exhaustion ⇒ `GitHubError`). So Node 2.2
|
|
2451
|
+
**must** ship:
|
|
2452
|
+
|
|
2453
|
+
- a workflow file named **`perk-run.yml`** (`runner.GITHUB_ACTIONS_WORKFLOW`);
|
|
2454
|
+
- typed `workflow_dispatch` inputs **`run_id`, `stage`, `plan`, `base`**;
|
|
2455
|
+
- a `run-name` that **embeds `${{ inputs.run_id }}`** so the dispatcher can verify-by-discovery
|
|
2456
|
+
(the perk `run_id` unifies erk's separate `distinct_id`);
|
|
2457
|
+
- a per-plan `concurrency` group is recommended (mirroring erk's `implement-plan-${{ … }}`).
|
|
2458
|
+
|
|
2459
|
+
Until 2.2 lands, a real `--remote` dispatch surfaces a clean `gh`-sourced "workflow not found"
|
|
2460
|
+
`dispatch_failed` (an honest failure, not a crash). The CI-side positioning (the worktree/handoff
|
|
2461
|
+
the worker consumes) is also Node 2.2's workflow; this node positions **nothing** locally.
|
|
2462
|
+
|
|
2463
|
+
## §8.14 · The GitHub Actions runner artifact + the CI worker entrypoint (Node 2.2)
|
|
2464
|
+
|
|
2465
|
+
The runner side of §8.13's cold remote door: the **managed** GitHub Actions workflow the dispatcher
|
|
2466
|
+
triggers, plus the `perk run-worker` positioning entrypoint that workflow invokes. Both are built in
|
|
2467
|
+
this node; §8.13's "until 2.2 lands" caveat is reconciled by it.
|
|
2468
|
+
|
|
2469
|
+
### The managed artifact (`perk/run/workflow_artifacts.py`)
|
|
2470
|
+
|
|
2471
|
+
Two perk-owned files, **managed by `perk init` and repaired by `perk doctor --fix`** (a
|
|
2472
|
+
`ManagedConvergence` in `init.managed_convergences()`, covering the `runner-workflow` capability —
|
|
2473
|
+
so `init` writes them and `doctor` verifies/repairs them through the one shared SSOT):
|
|
2474
|
+
|
|
2475
|
+
- **`.github/workflows/perk-run.yml`** — the runner workflow. It honors §8.13's `workflow_dispatch`
|
|
2476
|
+
input contract: a `run-name` embedding **`${{ inputs.run_id }}`** (verify-by-discovery); typed
|
|
2477
|
+
inputs **`run_id`, `stage`, `plan`, `base`** (`base` is `required: true` with no default — the
|
|
2478
|
+
dispatcher always sends it); a per-plan `concurrency` group `perk-run-${{ inputs.plan }}`. An
|
|
2479
|
+
additive **`smoke`** input (`required: false`, `default: "false"`, `type: string`) drives the
|
|
2480
|
+
doctor smoke short-circuit (§8.19): when `smoke == 'true'` the `drive` job runs only `Validate
|
|
2481
|
+
required secrets` + a `Smoke check` echo step and exits **success** — every subsequent step
|
|
2482
|
+
(`actions/checkout`, the composite setup `uses:`, `Check out the plan branch`, `Drive the stage
|
|
2483
|
+
headlessly`) carries `if: inputs.smoke != 'true'`, so a smoke run does no plan checkout, no setup,
|
|
2484
|
+
no worker drive, and spends no model budget. Real dispatches omit `smoke` and inherit the
|
|
2485
|
+
`"false"` default (backward-compatible). The
|
|
2486
|
+
`drive` job validates required secrets — it fails fast when `PERK_GH_PAT` is missing **and** when
|
|
2487
|
+
**both** `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are empty (pre-empting the worker's late
|
|
2488
|
+
`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
|
|
2491
|
+
PAT-pushed commit triggers downstream CI (the implement drive commits + `submit` pushes);
|
|
2492
|
+
`GITHUB_TOKEN`-pushed commits do not. This is a stated decision Node 2.4 inherited (the
|
|
2493
|
+
`runner-workflow-permissions` check is advisory `info` because of this PAT-push model — §8.16).
|
|
2494
|
+
- **`.github/actions/perk-remote-setup/action.yml`** — the composite setup action: the two pinned
|
|
2495
|
+
toolchains (uv + Node 22), then perk (the exterior CLI — `--from . perk` for the self-repo,
|
|
2496
|
+
an exact-version-pinned PyPI install `uv tool install perk=={__version__}` for a consumer,
|
|
2497
|
+
baked in at `perk init` time so the runner reproduces the wiring perk version), pi (the interior the
|
|
2498
|
+
worker drives), the Node worker's peer deps, and a final **git-identity** step (`perk[bot]`,
|
|
2499
|
+
`--global`) so the worker's commits succeed on a fresh runner. The worker-deps step is repo-kind
|
|
2500
|
+
aware: **self** uses `npm ci` (the self-repo has the `package.json`/lockfile/devDeps the worker
|
|
2501
|
+
resolves); **consumer** installs the pinned `@mgiles/perk`
|
|
2502
|
+
(`npm install @mgiles/perk@{__version__} --prefix .pi/npm --legacy-peer-deps`, baked in at `perk init`
|
|
2503
|
+
time so the runner reproduces the wiring perk version) — landing `@mgiles/perk` *and its runtime deps*
|
|
2504
|
+
under `.pi/npm/node_modules/`, so the `consumer-npm` worker entry and its peer imports resolve.
|
|
2505
|
+
|
|
2506
|
+
Full-file managed (like the settings/gitignore/AGENTS blocks): a hand-edited file reads as drift and
|
|
2507
|
+
is converged back to the template. The templates are authored as code (string constants), not
|
|
2508
|
+
packaged data, so there is no wheel-data surface to guard.
|
|
2509
|
+
|
|
2510
|
+
### `perk run-worker` (the CI positioning + drive entrypoint, `perk/run/run_worker.py`)
|
|
2511
|
+
|
|
2512
|
+
`perk run-worker --run-id --stage --plan [--base]` is the runner's positioning job (Gap 7), invoked
|
|
2513
|
+
by the workflow **after** it checks out the plan branch (so cwd = the checkout = the worktree):
|
|
2514
|
+
|
|
2515
|
+
1. Resolve a remotely-drivable stage (a `doors.cold_remote: true` stage) from the registry; else
|
|
2516
|
+
`UserFacingCliError(stage_not_drivable)`.
|
|
2517
|
+
2. Reconstruct the `cache.plan-ref` from the plan's GitHub state (`github.get_plan` +
|
|
2518
|
+
`resume.reconstruct_plan_ref`); a missing plan ⇒ `plan_not_found`.
|
|
2519
|
+
3. **Position** the worktree (mirroring `launch.launch_stage`): `cache.ensure_layout`,
|
|
2520
|
+
`write_handoff({stage, mode})`, `write_plan_ref`, then materialize the plan body. The worker
|
|
2521
|
+
inherits the prepared worktree and never re-writes it (the §B inputs table).
|
|
2522
|
+
4. Resolve the Node worker entrypoint — `PERK_WORKER_ENTRY` override (`env`), else the self-repo
|
|
2523
|
+
`extension/workerMain.ts` (`self`), else the consumer npm install under
|
|
2524
|
+
`.pi/npm/node_modules/@mgiles/perk/extension/workerMain.ts` (`consumer-npm`); a miss ⇒
|
|
2525
|
+
`worker_entry_missing`.
|
|
2526
|
+
5. **Spawn** `node <entry> <stage> --worktree <repo_root>` with `PERK_RUN_ID=<run_id>` in the env
|
|
2527
|
+
(inherited stdio — the worker owns stdout/the `RunOutcome` JSON), and **exit with the worker's
|
|
2528
|
+
exit code** so the workflow step reflects the drive outcome.
|
|
2529
|
+
|
|
2530
|
+
`run-worker` is a deterministic exterior command (no agentic reasoning): it positions and drives;
|
|
2531
|
+
model/auth resolution is the Node worker's job (env-var key resolution, Gap 5). `--base` is part of
|
|
2532
|
+
the §8.13 input contract and is carried for parity, but the plan branch is already checked out by the
|
|
2533
|
+
workflow, so it is not consumed here. Reporting run progress/terminal status back into GitHub is
|
|
2534
|
+
**Node 2.3**; the runner's secrets/health checks (the `PERK_GH_PAT`/model-credential prereqs) are
|
|
2535
|
+
**Node 2.4** — the `perk doctor` `runner` check group (§8.16).
|
|
2536
|
+
|
|
2537
|
+
---
|
|
2538
|
+
|
|
2539
|
+
## §8.15 · Remote run reporting back into GitHub (Node 2.3)
|
|
2540
|
+
|
|
2541
|
+
The **runner-side** consumer of the §8.12 structured run-event stream + the §8.11 `RunOutcome`: when
|
|
2542
|
+
`perk run-worker` drives a stage remotely, it makes that run **observable on GitHub**. The worker
|
|
2543
|
+
itself never mutates GitHub (§8.12 is explicit — surfacing the stream is this node); the reporter is
|
|
2544
|
+
a deterministic exterior task (no agentic reasoning) living in the Python plane (`perk/run/run_report.py`)
|
|
2545
|
+
and wired into `perk run-worker` (`perk/run/run_worker.py`).
|
|
2546
|
+
|
|
2547
|
+
### The two reporting points (fail-soft, exit-code-neutral)
|
|
2548
|
+
|
|
2549
|
+
Two calls bracket the worker spawn in `run_worker(...)`:
|
|
2550
|
+
|
|
2551
|
+
- **started** — `report_started(...)` after the worker entry resolves and **before** `_spawn_worker`
|
|
2552
|
+
(the truest "drive is starting" point; positioning failures already raise loudly before this).
|
|
2553
|
+
- **terminal** — `report_terminal(...)` after `_spawn_worker` returns the exit code and **before**
|
|
2554
|
+
`run-worker` returns it.
|
|
2555
|
+
|
|
2556
|
+
Both are **fully fail-soft**: any exception inside reporting is caught, logged via `user_output` to
|
|
2557
|
+
stderr, and swallowed. Reporting must never change the worker's exit code or crash the runner —
|
|
2558
|
+
observability is best-effort (mirrors the worker's fail-soft event sink).
|
|
2559
|
+
|
|
2560
|
+
### The surfaces
|
|
2561
|
+
|
|
2562
|
+
- **One marker-keyed plan-issue comment per `run_id`.** The target is the **plan issue** (the
|
|
2563
|
+
issue-canonical model — a perk plan *is* a GitHub issue; the implementation PR is referenced by
|
|
2564
|
+
URL when known). A single comment carrying the marker `<!-- perk:run-report:<run_id> -->` is
|
|
2565
|
+
**upserted** started → terminal (`github.upsert_marked_comment` →
|
|
2566
|
+
`find_comment_id_by_marker` PATCH-if-found, else POST), so the started note evolves into the
|
|
2567
|
+
terminal note (no two-comment spam; reruns are distinct `run_id`s). The plan issue is the only
|
|
2568
|
+
correlation anchor known at *started* time (for `implement` the PR does not exist until mid-drive).
|
|
2569
|
+
- **The GitHub Actions job summary** (`$GITHUB_STEP_SUMMARY`) is the "checks"/run-page half: the
|
|
2570
|
+
terminal step appends a self-contained `## perk remote <stage>` summary (status + budget + the
|
|
2571
|
+
failure summary on non-success) when the env var is set (skipped silently when unset — local/test).
|
|
2572
|
+
|
|
2573
|
+
### Inputs the reporter derives
|
|
2574
|
+
|
|
2575
|
+
- The terminal `RunOutcome` is read from the **durable events file** out-of-process
|
|
2576
|
+
(`cache.read_scratch(repo_root, run_id, "events.ndjson")` → the last `run_finished` event's
|
|
2577
|
+
`outcome`), because `_spawn_worker` inherits stdio and does not capture the worker's stdout. A
|
|
2578
|
+
missing/empty/malformed events file ⇒ a clearly-labelled **degraded** terminal note derived from
|
|
2579
|
+
the worker exit code alone (so a terminal note always posts).
|
|
2580
|
+
- The run URL is derived from standard GitHub Actions env
|
|
2581
|
+
(`GITHUB_SERVER_URL`/`GITHUB_REPOSITORY`/`GITHUB_RUN_ID` →
|
|
2582
|
+
`{server}/{repo}/actions/runs/{run_id}`); absent ⇒ notes post without the link.
|
|
2583
|
+
- `outcome.pr` is present only for a successful `implement` drive (the worker captures the PR from
|
|
2584
|
+
`submit`); for `address`/failures it is `null`, and the report omits the PR line.
|
|
2585
|
+
|
|
2586
|
+
### Untrusted-data discipline (route-don't-relay end-to-end)
|
|
2587
|
+
|
|
2588
|
+
The reporter quotes **no** GitHub-sourced prose (no plan title, no fetched GitHub text is
|
|
2589
|
+
interpolated into the bodies). The only free text it surfaces is the worker's own `error.summary`,
|
|
2590
|
+
which is worker-generated and already capped at 2 KiB (§8.12) — never re-expanded. This preserves
|
|
2591
|
+
route-don't-relay from the worker's structured channel all the way into the GitHub surfaces.
|
|
2592
|
+
|
|
2593
|
+
No change to `.github/workflows/perk-run.yml` or `perk/run/workflow_artifacts.py`: reporting hooks into
|
|
2594
|
+
`run-worker` itself, so the managed artifact (and its convergence/doctor tests) stay untouched.
|
|
2595
|
+
|
|
2596
|
+
---
|
|
2597
|
+
|
|
2598
|
+
## §8.16 · Remote-runner prerequisites: credential + permission health-checks (Node 2.4)
|
|
2599
|
+
|
|
2600
|
+
The **pre-flight** twin of §8.14's execution-time `Validate required secrets` step: `perk doctor`'s
|
|
2601
|
+
diagnostic side surfaces a mis-configured runner (missing checkout/push PAT, missing model
|
|
2602
|
+
credential, restrictive workflow-permissions) **before** a `--remote` drive reaches CI, instead of
|
|
2603
|
+
letting the CI job fail at its validate step. This is perk's analogue of erk's
|
|
2604
|
+
`erk-queue-pat-secret` / `anthropic-api-secret` / `workflow-permissions` doctor checks, adapted to
|
|
2605
|
+
Pi (multi-provider model keys) and perk's `{owner}/{repo}` gateway convention.
|
|
2606
|
+
|
|
2607
|
+
**Division of labor.** `init` *manages* the runner credentials by writing the managed workflow whose
|
|
2608
|
+
`Validate required secrets` step is the execution-time gate (§8.14, Node 2.2); `doctor` *health-checks*
|
|
2609
|
+
them ahead of time. perk init/doctor **never mutate** GitHub (Decision D2 — there is no
|
|
2610
|
+
secret-setting command); each actionable finding instead carries an exact `gh` remediation string in
|
|
2611
|
+
`Check.remediation` (e.g. `gh secret set PERK_GH_PAT`).
|
|
2612
|
+
|
|
2613
|
+
### The three verification-only gateway reads (`perk/github/workflows.py`)
|
|
2614
|
+
|
|
2615
|
+
All shell `gh` via `_run` with `cwd=repo_root` + gh's `{owner}/{repo}` placeholder auto-fill (no
|
|
2616
|
+
remote-URL parsing); none mutate; a gh-missing/timeout raises `GitHubError`:
|
|
2617
|
+
|
|
2618
|
+
- `secret_exists(*, name, repo_root) -> bool | None` — `GET .../actions/secrets/{name}`: present →
|
|
2619
|
+
`True`, 404 → `False`, any other non-zero (e.g. 403) → `None` (unknown). Never reads the value.
|
|
2620
|
+
- `get_workflow_permissions(*, repo_root) -> WorkflowPermissions | None` —
|
|
2621
|
+
`GET .../actions/permissions/workflow`; the frozen `WorkflowPermissions` carries
|
|
2622
|
+
`default_workflow_permissions: str` + `can_approve_pull_request_reviews: bool`. Non-zero → `None`;
|
|
2623
|
+
unparseable JSON → `GitHubError`.
|
|
2624
|
+
- `get_repo_variable(*, name, repo_root) -> str | None` — `GET .../actions/variables/{name}`
|
|
2625
|
+
(`--jq .value`): value on returncode 0, `None` on 404/non-zero/empty. Used to read `PERK_ENABLED`.
|
|
2626
|
+
|
|
2627
|
+
### The report-only `runner` check group (`perk/convergence/doctor/github_checks.py::_runner_checks`)
|
|
2628
|
+
|
|
2629
|
+
A **report-only** check group (no `--fix` side — `_apply_fixes` is untouched), wired into
|
|
2630
|
+
`_build_checks` **inside the `if verify:` block** after `_github_checks` (it shells `gh`), wrapped in
|
|
2631
|
+
`try/except GitHubError` → a single `info` `runner-prereqs` degrade (no silent pass, never a crash).
|
|
2632
|
+
**Non-fatal posture:** present → `ok`; actionable-absent → `warn`; unverifiable → `info`; **never
|
|
2633
|
+
`fail`** — so a `warn` keeps exit 0 (`report.healthy` keys off `fail` only), matching §8.6's
|
|
2634
|
+
GitHub-non-fatal rule. Order:
|
|
2635
|
+
|
|
2636
|
+
1. **Auth gate** — re-probe `check_auth()`; unauthed → a single `runner-prereqs` `info`, no further
|
|
2637
|
+
`gh` calls.
|
|
2638
|
+
2. **`runner-enabled`** (always emitted) — reads `PERK_ENABLED` (`RUNNER_ENABLED_VAR`): `info`
|
|
2639
|
+
reporting unset→default-on / `=<value>` / `=false`→disabled.
|
|
2640
|
+
3. **`PERK_ENABLED=false` → stop** (skip the three probes — don't nag about a deliberately-disabled
|
|
2641
|
+
runner). This is "check only what's enabled".
|
|
2642
|
+
4. Otherwise the three probes (all group `"runner"`; names from `workflow_artifacts`):
|
|
2643
|
+
- **`runner-pat-secret`** ← `secret_exists(RUNNER_PAT_SECRET)`: `True`→`ok`; `False`→`warn`
|
|
2644
|
+
(remediation `gh secret set PERK_GH_PAT`); `None`→`info`.
|
|
2645
|
+
- **`runner-model-secret`** ← `secret_exists` for **both** `ANTHROPIC_API_KEY` and
|
|
2646
|
+
`OPENAI_API_KEY` (the workflow's "either" logic): either present→`ok`; both absent→`warn`;
|
|
2647
|
+
else→`info`.
|
|
2648
|
+
- **`runner-workflow-permissions`** ← `get_workflow_permissions`: **`info` in all non-error
|
|
2649
|
+
cases** (advisory — perk pushes with a PAT, not `github.token`, so it does not block the
|
|
2650
|
+
runner); `can_approve_pull_request_reviews` false carries the PUT remediation; `None`→`info`.
|
|
2651
|
+
|
|
2652
|
+
**Self-vs-consumer dual mode (D6).** The check *set* is identical for both repo kinds (the
|
|
2653
|
+
`runner-workflow` capability is `scope="both"`); only the actionable-absent `detail` wording adapts —
|
|
2654
|
+
self: "expected on perk's own repo (perk dogfoods `--remote` drives)"; consumer: "required only if
|
|
2655
|
+
you use `perk … --remote` drives". No new capability is added (report-only), so the
|
|
2656
|
+
`test_every_required_capability_has_a_doctor_check` coherence guard is unaffected.
|
|
2657
|
+
|
|
2658
|
+
**Human render.** `runner` is added to `doctor_cmd._GROUP_ORDER` (after `github`) — a group absent
|
|
2659
|
+
from that tuple is invisible in human text (the `_GROUP_ORDER` trap); `--json` and the exit code
|
|
2660
|
+
surface it regardless.
|
|
2661
|
+
|
|
2662
|
+
**Node 3.3 reuse.** `_runner_checks` is a free function (not inlined) and the three reads are the
|
|
2663
|
+
**static** prereq layer that `perk doctor workflow` (Node 3.3) will compose with the
|
|
2664
|
+
managed-artifact-present check and the live-spawn CI smoke. Node 2.4 adds checks to the **bare**
|
|
2665
|
+
`perk doctor` run only; the `doctor workflow` subcommand is not built here.
|
|
2666
|
+
|
|
2667
|
+
## §8.17 · The supervisor read surface (`perk workflow run list`, Node 3.1)
|
|
2668
|
+
|
|
2669
|
+
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.
|
|
2674
|
+
|
|
2675
|
+
### Command surface (`perk/cli/commands/workflow_cmd.py`)
|
|
2676
|
+
|
|
2677
|
+
- `perk workflow run list` (aliases `perk workflow run ls`, `perk wf run list`). The `workflow`
|
|
2678
|
+
group (alias `wf`) holds the `run` subgroup so Node 3.2 extends the same subgroup.
|
|
2679
|
+
- A dev/CI/supervisor surface (like `perk objective`/`perk state`), **not** an agent affordance.
|
|
2680
|
+
- `--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.
|
|
2699
|
+
|
|
2700
|
+
### Fail-soft overlay discipline
|
|
2701
|
+
|
|
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
|
|
2704
|
+
`runner.RunnerError` degrades the `run` block to `null`; a `github.GitHubError` degrades the `pr`
|
|
2705
|
+
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.
|
|
2707
|
+
|
|
2708
|
+
### The `--json` payload (stdout, stable)
|
|
2709
|
+
|
|
2710
|
+
```jsonc
|
|
2711
|
+
{ "success": true, "error_type": null, "refreshed": true, "count": 1,
|
|
2712
|
+
"runs": [
|
|
2713
|
+
{ "run_id": "01J…", "stage": "implement", "runner": "", "kind": "github-actions",
|
|
2714
|
+
"dispatch_status": "dispatched", "dispatched_at": "<ISO-8601 UTC>", "error": null,
|
|
2715
|
+
"plan": { "pr_id": "42", "url": "https://…/issues/42" },
|
|
2716
|
+
"pr": { "number": 51, "url": "https://…/pull/51", "state": "OPEN" } | null,
|
|
2717
|
+
"run": { "run_ref": "1234567", "url": "https://…/actions/runs/1234567",
|
|
2718
|
+
"status": "completed", "conclusion": "success" } | null } ] }
|
|
2719
|
+
```
|
|
2720
|
+
|
|
2721
|
+
`refreshed = not no_refresh`; `pr`/`run` are `null` under `--no-refresh` or a failed/empty overlay.
|
|
2722
|
+
`success` is always `true` for a successful enumeration (even zero runs); only `require_repo` failing
|
|
2723
|
+
(`not_a_repo`) routes through `_fail` (exit 2). No other error type is introduced.
|
|
2724
|
+
|
|
2725
|
+
### Human table (stderr)
|
|
2726
|
+
|
|
2727
|
+
Plain, manually-aligned, newest-first columns
|
|
2728
|
+
`RUN_ID STAGE DISPATCH RUN CONCLUSION PLAN PR AGE`. The full `run_id` (the supervisor copies
|
|
2729
|
+
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
|
|
2731
|
+
`error` is surfaced on an indented continuation line (the §8.13 "failed records kept for visibility"
|
|
2732
|
+
rule). Empty state prints `No dispatched runs found`.
|
|
2733
|
+
|
|
2734
|
+
## §8.18 · The supervisor control surface (`perk workflow run cancel`/`retry`, Node 3.2)
|
|
2735
|
+
|
|
2736
|
+
The mutating control siblings of `list` (§8.17) in the same `perk workflow run` subgroup:
|
|
2737
|
+
deterministic, **no agentic reasoning** dev/CI/supervisor commands (not agent affordances).
|
|
2738
|
+
|
|
2739
|
+
- `perk workflow run cancel <RUN_ID>` — cancel an in-flight (queued/in_progress) run.
|
|
2740
|
+
- `perk workflow run retry <RUN_ID> [--failed]` — re-run a completed/failed run; `--failed` re-runs
|
|
2741
|
+
only the failed jobs.
|
|
2742
|
+
|
|
2743
|
+
Both take `--json` (stable machine report on **stdout**; human confirmation on **stderr**). The
|
|
2744
|
+
group/subgroup aliases (`wf`, `run`) apply; the commands themselves carry no aliases.
|
|
2745
|
+
|
|
2746
|
+
### `<RUN_ID>` resolution (D1)
|
|
2747
|
+
|
|
2748
|
+
`<RUN_ID>` is the **perk `run_id`** — the never-truncated `RUN_ID` the supervisor copies from
|
|
2749
|
+
`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`.
|
|
2757
|
+
|
|
2758
|
+
### No local mutation, no pre-gate (Corrections)
|
|
2759
|
+
|
|
2760
|
+
- **Retry reuses the SAME run** — `gh run rerun` re-runs the existing run (same `run_ref`,
|
|
2761
|
+
preserving the `run-name` that embeds the perk `run_id`), so the dispatch record and its
|
|
2762
|
+
`run_id ↔ plan ↔ PR` linkage stay valid. **No new ULID, no `cache.write_dispatch`.**
|
|
2763
|
+
- **Neither command mutates the dispatch record.** The record's `status` is the *dispatch-attempt*
|
|
2764
|
+
lifecycle; live run state is observed via `Runner.observe` (surfaced by `list`'s overlay). No
|
|
2765
|
+
`.pi/workflow/` writes in this node.
|
|
2766
|
+
- **No pre-flight run-state gating.** The commands do not `observe` to decide cancellability/
|
|
2767
|
+
retryability — they pass through to gh and surface gh's own error (e.g. "cannot cancel a
|
|
2768
|
+
completed run") as a clean `cancel_failed`/`retry_failed`.
|
|
2769
|
+
|
|
2770
|
+
### `--json` payload (stdout, stable)
|
|
2771
|
+
|
|
2772
|
+
```jsonc
|
|
2773
|
+
// success (cancel)
|
|
2774
|
+
{ "success": true, "error_type": null, "action": "cancel",
|
|
2775
|
+
"run_id": "01J…", "run_ref": "1234567", "runner": "", "kind": "github-actions",
|
|
2776
|
+
"url": "https://…/actions/runs/1234567" }
|
|
2777
|
+
// success (retry) — adds: "failed_only": false
|
|
2778
|
+
// failure → the shared _fail shape:
|
|
2779
|
+
{ "success": false, "error_type": "<type>", "message": "<gh's own error>" }
|
|
2780
|
+
```
|
|
2781
|
+
|
|
2782
|
+
`run_ref`/`runner`/`kind`/`url` come from the reconstructed `RunHandle`. Error types + exits:
|
|
2783
|
+
`not_a_repo` → 2; `github_unauthed`, `run_not_found`, `run_not_dispatched`, `cancel_failed`,
|
|
2784
|
+
`retry_failed`, `invalid_input` → 1. The only free text in a failure payload is gh's own error
|
|
2785
|
+
string (wrapped via `RunnerError`) — no model-authored interpretation.
|
|
2786
|
+
|
|
2787
|
+
---
|
|
2788
|
+
|
|
2789
|
+
## §8.19 · `perk doctor workflow` — static prereq checks + a live CI smoke (Node 3.3)
|
|
2790
|
+
|
|
2791
|
+
The workflow-focused diagnostic twin: a Click **subgroup** on the `doctor` group (the reserved
|
|
2792
|
+
`invoked_subcommand` hook §8.6 left open). A dev/CI/supervisor surface, not an agent affordance. Bare
|
|
2793
|
+
`perk doctor workflow` prints help; the two commands are `check` and `smoke-test [--wait]`. Both take
|
|
2794
|
+
`-v/--verbose` + `--json` (stable machine report on **stdout**; grouped human render to **stderr**).
|
|
2795
|
+
|
|
2796
|
+
### `check` — the static layer (`doctor.workflow_checks`)
|
|
2797
|
+
|
|
2798
|
+
Composes the **same builders** as bare `perk doctor` (doctor's SSOT — no duplication): `_github_checks`
|
|
2799
|
+
(GitHub readiness) ⊕ `_runner_checks` (the §8.16 remote-runner prereqs; a `GitHubError` degrades to a
|
|
2800
|
+
single `info` `runner-prereqs`) — both under `verify=True` — ⊕ the **`runner-workflow`
|
|
2801
|
+
managed-artifact-present** check (always): locate the `runner-workflow` `ManagedConvergence`, dry-run
|
|
2802
|
+
it, and emit `ok` (converged) / `fail` (drift — detail = joined drift, remediation `perk doctor
|
|
2803
|
+
--fix`; or unverifiable). Rendered grouped over `("github", "runner", "repository")`. Exit codes
|
|
2804
|
+
mirror §8.6: **1** if any `fail`, else **0** (warns allowed); **2** only on not-a-repo.
|
|
2805
|
+
|
|
2806
|
+
### `smoke-test [--wait]` — the live proof (`perk/run/workflow_smoke.py`)
|
|
2807
|
+
|
|
2808
|
+
Proves the genuinely CI-only prerequisites a static check cannot: that the managed workflow is
|
|
2809
|
+
**dispatchable**, the runner actually **starts a job**, and the secrets are **readable in the Actions
|
|
2810
|
+
context** (environment-protection rules can hide an existing secret). It does **not** exercise the
|
|
2811
|
+
composite setup or the worker/model drive — the `smoke=true` short-circuit keeps
|
|
2812
|
+
it universal and ~0-cost. A future "full" smoke is out of scope.
|
|
2813
|
+
|
|
2814
|
+
Flow: `require_repo` + `require_github`; run `workflow_checks` (rendered like `check`). **Gate (refuse
|
|
2815
|
+
→ exit 1):** if the `github-auth` check is not `ok` → `github_unauthed`; if
|
|
2816
|
+
`get_repo_variable(PERK_ENABLED) == "false"` → `runner_disabled` (the job would be skipped and
|
|
2817
|
+
verify-by-discovery would raise). PAT/model **warns do not block** — the live run is what verifies
|
|
2818
|
+
them. Then `dispatch_smoke` triggers the managed workflow **directly** (`trigger_workflow` with
|
|
2819
|
+
`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
|
|
2822
|
+
stays a pure doctor diagnostic. Without `--wait`: print the run URL, **exit 0**. With `--wait`:
|
|
2823
|
+
`poll_smoke` loops to `completed` or `POLL_TIMEOUT_S` (600s, every `POLL_INTERVAL_S`=15s) —
|
|
2824
|
+
`success` → exit 0; any other conclusion → exit 1; **timeout → `cancel_smoke` (best-effort
|
|
2825
|
+
self-cancel) + exit 0** (inconclusive, not unhealthy).
|
|
2826
|
+
|
|
2827
|
+
### No `cleanup` command (deviation from erk)
|
|
2828
|
+
|
|
2829
|
+
erk's `doctor_workflow` ships `check`/`smoke-test`/`cleanup` because its smoke opens a one-shot PR.
|
|
2830
|
+
perk's smoke creates nothing durable, so a `cleanup` would be fiction — only `check` + `smoke-test`
|
|
2831
|
+
are built; `smoke-test --wait` self-cancels its own in-flight run on a poll timeout (the sole real
|
|
2832
|
+
leftover).
|
|
2833
|
+
|
|
2834
|
+
### `--json` payloads (stdout, stable)
|
|
2835
|
+
|
|
2836
|
+
```jsonc
|
|
2837
|
+
// check
|
|
2838
|
+
{ "success": true, "healthy": true, "self_repo": false,
|
|
2839
|
+
"checks": [ { "name": "runner-workflow", "group": "repository", "status": "ok", … } ],
|
|
2840
|
+
"summary": { "passed": 1, "warnings": 0, "failed": 0 } }
|
|
2841
|
+
// smoke-test (dispatch)
|
|
2842
|
+
{ "success": true, "action": "smoke-test", "run_id": "01J…", "run_ref": "555",
|
|
2843
|
+
"url": "https://…/actions/runs/555", "waited": false, "conclusion": null, "timed_out": false }
|
|
2844
|
+
// smoke-test (--wait) — "waited": true, "conclusion": "success"|…, "timed_out": bool
|
|
2845
|
+
// refusal / dispatch error — the shared _fail shape:
|
|
2846
|
+
{ "success": false, "error_type": "<type>", "message": "<reason>" }
|
|
2847
|
+
```
|
|
2848
|
+
|
|
2849
|
+
Error types + exits: `not_a_repo` → 2; `github_unauthed`, `runner_disabled`, `smoke_dispatch_failed`
|
|
2850
|
+
→ 1.
|
|
2851
|
+
|
|
2852
|
+
## §8.20 · The capstone supervisor loop (`perk objective run`, Node 3.4)
|
|
2853
|
+
|
|
2854
|
+
The **scheduler** on top of the §8.13 dispatch-record substrate and the §8.17/§8.18 read/control
|
|
2855
|
+
siblings: a **deterministic, no-agentic-reasoning** supervisor that advances an active objective's
|
|
2856
|
+
backlog as far as is autonomously safe, then pauses at the human land gate. `perk objective run
|
|
2857
|
+
<NUMBER>` (alias `obj r`) is a supervisor surface (cli-vs-pi §3.2): `--json` → stdout, human text →
|
|
2858
|
+
stderr, stable exits (`0` ok · `1` invalid/op-failure · `2` not-a-repo), `_fail`/`UserFacingCliError`
|
|
2859
|
+
with a stable `error_type`.
|
|
2860
|
+
|
|
2861
|
+
### Autonomous reach: one dispatch, then stop — and **never land**
|
|
2862
|
+
|
|
2863
|
+
Per invocation the supervisor does **one** thing: it selects the next in-flight node and dispatches
|
|
2864
|
+
the correct **remote** agentic stage (`implement`/`address`), or it pauses at a draft-PR /
|
|
2865
|
+
awaiting-review / planning-required / completion boundary, then exits. It **never lands** — ready+merge
|
|
2866
|
+
stays the human/interactive `/land`, and a node reaches `done` only via that path's
|
|
2867
|
+
`_reconcile_objective_on_land`, which this loop merely *observes* (a `MERGED` PR ⇒
|
|
2868
|
+
`merged_pending_reconcile`). Landing must not route through `launch_stage`: a local stage `os.execvpe`s
|
|
2869
|
+
into interactive pi and never returns, which would destroy the loop.
|
|
2870
|
+
|
|
2871
|
+
### Options
|
|
2872
|
+
|
|
2873
|
+
- `--remote <ref>` — a normal string option (not a flag) defaulting to **`""`** (the default runner);
|
|
2874
|
+
dispatch is always remote, since the supervisor never drives an agentic stage locally.
|
|
2875
|
+
(`resolve_target` treats `""` as the default runner ref, not a kind.)
|
|
2876
|
+
- `--wait` — poll an already-in-flight run to completion (cadence below), then re-evaluate selection
|
|
2877
|
+
**once**; never crosses the land gate.
|
|
2878
|
+
- `--dry-run` — resolve + report the *selection* decision and would-be action only: **skip** the live
|
|
2879
|
+
`observe` overlay + active-run gate (stay offline-safe), and **mint/write/trigger/close nothing**.
|
|
2880
|
+
- `--json` — machine report to stdout (human text to stderr).
|
|
2881
|
+
|
|
2882
|
+
### Single-pass control flow (deterministic)
|
|
2883
|
+
|
|
2884
|
+
1. `require_repo` + `require_config`; `require_github` unless `--dry-run`.
|
|
2885
|
+
2. `state = github.get_objective(NUMBER)`; `None` → `_fail(objective_not_found)`.
|
|
2886
|
+
3. **Cumulative budget report** (always, before any action): enumerate
|
|
2887
|
+
`cache.list_dispatch_records`, keep records whose `plan_ref.objective_id` canonicalizes
|
|
2888
|
+
(`str(...).lstrip("#")`) to NUMBER, sum each `run_report.read_outcome` `budget`
|
|
2889
|
+
(`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
|
|
2894
|
+
`completed` (or timeout → `awaiting_run` + `timed_out:true`, exit 0), then **re-fetch the
|
|
2895
|
+
objective state + rebuild the graph** (the settled run may have advanced GitHub) and re-evaluate
|
|
2896
|
+
selection once.
|
|
2897
|
+
5. **Selection** via `graph.classify_for_planning()` → action:
|
|
2898
|
+
|
|
2899
|
+
| kind | condition | action | effect |
|
|
2900
|
+
|------|-----------|--------|--------|
|
|
2901
|
+
| `complete` | every node terminal | `completed` | print the `(node→status→pr)` audit; unless `--dry-run`, `github.close_issue(NUMBER)` |
|
|
2902
|
+
| `blocked` | every remaining node blocked | `blocked` | pause |
|
|
2903
|
+
| `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
|
+
| `in_flight` | a committed plan exists | (stage resolution ↓) | |
|
|
2905
|
+
|
|
2906
|
+
### In-flight stage resolution (`get_plan(node.pr)` → branch on `plan_state.pr`)
|
|
2907
|
+
|
|
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 |
|
|
2916
|
+
|
|
2917
|
+
A missing `node.pr` or a `None` `get_plan` falls back to `plan_required` (defensive). A draft PR means
|
|
2918
|
+
implement is **complete** — never re-dispatch `implement` from a draft.
|
|
2919
|
+
|
|
2920
|
+
### The `needs_address` predicate (pure, offline-testable)
|
|
2921
|
+
|
|
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).
|
|
2927
|
+
|
|
2928
|
+
### Remote dispatch mechanics
|
|
2929
|
+
|
|
2930
|
+
`_dispatch_stage_remote` reconstructs the node's plan-ref via `resume.reconstruct_plan_ref` (preserving
|
|
2931
|
+
`objective_id` so the eventual human land reconciles the node), writes it to the **repo-root**
|
|
2932
|
+
`cache.plan-ref` (the seam `_drive_remote_target` reads — both `objective-plan`/`run` are `worktree:
|
|
2933
|
+
none`, so repo-root write ↔ repo-root read agree), then drives `launch.launch_stage(..., remote=...)`
|
|
2934
|
+
— capturing its machine output so the supervisor emits a **single** unified payload, surfacing the
|
|
2935
|
+
minted `run_id`. Only `implement`/`address` (the `cold_remote:true` stages) are dispatchable here
|
|
2936
|
+
(`Ensure.invariant` guard; `resolve_target` is belt-and-suspenders).
|
|
2937
|
+
|
|
2938
|
+
### `--wait` polling cadence
|
|
2939
|
+
|
|
2940
|
+
`POLL_INTERVAL_S = 15`, `POLL_TIMEOUT_S = 600`, defined **locally** in the command module (same values
|
|
2941
|
+
as the §8.19 smoke, independent lifecycle). The poll helper takes an injectable `sleep` for tests. A
|
|
2942
|
+
timeout is **inconclusive, not unhealthy** (`awaiting_run` + `timed_out:true`, exit 0).
|
|
2943
|
+
|
|
2944
|
+
### `--json` payload (stdout, stable)
|
|
2945
|
+
|
|
2946
|
+
```jsonc
|
|
2947
|
+
{ "success": true, "error_type": null,
|
|
2948
|
+
"objective": "<id>", // opaque string objective id (§8.21; Node 4.1)
|
|
2949
|
+
"budget": { "runs": 0, "turns": 0, "tokens": 0, "elapsed_ms": 0 },
|
|
2950
|
+
"action": "dispatched" | "ready_for_review" | "awaiting_review" | "awaiting_run"
|
|
2951
|
+
| "plan_required" | "blocked" | "completed" | "merged_pending_reconcile" | "pr_closed",
|
|
2952
|
+
"node": "<id>" | null, "stage": "implement" | "address" | null,
|
|
2953
|
+
"run_id": "<ULID>" | null, // present on dispatched
|
|
2954
|
+
"remediation": "<cmd>" | null, // present on plan_required
|
|
2955
|
+
"closed": false, // present on completed (+ "audit": [{node,status,pr}, …])
|
|
2956
|
+
"timed_out": false, // present on awaiting_run under --wait
|
|
2957
|
+
"dry_run": false }
|
|
2958
|
+
```
|
|
2959
|
+
|
|
2960
|
+
Error types + exits: `not_a_repo` → 2; `objective_not_found`, `github_error`, `dispatch_failed`
|
|
2961
|
+
(propagated from `launch_stage`) → 1. Benign decision kinds (`plan_required`/`blocked`/`awaiting_*`/
|
|
2962
|
+
`ready_for_review`/`merged_pending_reconcile`/`pr_closed`/`completed`) are **not** errors (exit 0).
|
|
2963
|
+
|
|
2964
|
+
---
|
|
2965
|
+
|
|
2966
|
+
## §8.21 · The issue-backend selection (`[issues]`, Objective #252 Nodes 1.3 + 2.4)
|
|
2967
|
+
|
|
2968
|
+
The issue-tracking tier (plan/learn/objective issues — `perk/backends/issue_backend.py`'s `IssueBackend`
|
|
2969
|
+
contract, Node 1.1; the `GitHubIssueBackend` adapter in `perk/backends/github/backend.py` + the resolver in `perk/backends/resolve.py`, Node 1.2;
|
|
2970
|
+
the `LinearIssueBackend` over the `perk/backends/linear/client.py` GraphQL client, Nodes 2.1–2.3, wired live in
|
|
2971
|
+
Node 2.4) is **backend-selectable** via one committed config table:
|
|
2972
|
+
|
|
2973
|
+
> **Note (Objective #548).** Objective storage is now its **own seam** — the objective-storage tier
|
|
2974
|
+
> (`ObjectiveStore`, §8.24), distinct from the issue-tracking tier described here. It shares this
|
|
2975
|
+
> `[issues]` selection (`resolve_objective_store_id` re-exports `resolve_issue_backend_id` — an
|
|
2976
|
+
> objective and its plan/learn issues share one tracker), so the "plan/learn/objective issues" and
|
|
2977
|
+
> objective-id language throughout this section still resolves the same backend; the two tiers are
|
|
2978
|
+
> just no longer one Protocol.
|
|
2979
|
+
|
|
2980
|
+
```toml
|
|
2981
|
+
[issues]
|
|
2982
|
+
backend = "linear" # "github" is the default when unset
|
|
2983
|
+
team = "ENG" # the Linear team key — required when backend = "linear"
|
|
2984
|
+
```
|
|
2985
|
+
|
|
2986
|
+
**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:
|
|
2988
|
+
`load_committed_issues_backend` / `load_committed_issues_team`; TS: `resolveIssueBackendId` reads
|
|
2989
|
+
only the committed file). Rationale: the backend decides where canonical durable state
|
|
2990
|
+
(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`
|
|
2992
|
+
`[linear] api_key`** (an exported env var wins over the config) — **never** in a committed file.
|
|
2993
|
+
The config read is local-file-only (`config.load_local_linear_api_key`, the inverse of the
|
|
2994
|
+
`load_committed_*` readers; fail-soft on malformed TOML — returns `None`, never raised). Two seams
|
|
2995
|
+
bridge it: the Python clients pass `linear.client_from_env(repo_root=…)` (env-first, config
|
|
2996
|
+
fallback), and `launch_stage` seeds the launched session's env with the local key (env wins) so the
|
|
2997
|
+
borrowed in-session `linear_*` tools and any spawned `perk <stage> --json` cold-door worker (which
|
|
2998
|
+
inherit the session env) authenticate. The local file is read from the **main checkout** at launch
|
|
2999
|
+
(the env dict is built before `os.chdir(worktree)`); because it is gitignored it is never copied
|
|
3000
|
+
into the linked worktree, so the env-seed is precisely the bridge that carries the key into the
|
|
3001
|
+
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
|
|
3003
|
+
"secrets in the environment only" rule: the secret may live in the gitignored local file, never a
|
|
3004
|
+
version-controlled one. **Python-plane-only** — the TS plane reads no Linear key, so there is no
|
|
3005
|
+
cross-plane TS mirror (the `launch_stage` env-seed is what carries the key into the TS session).
|
|
3006
|
+
|
|
3007
|
+
**Python is the authoritative validator** (`perk/backends/resolve.py::resolve_issue_backend_id`):
|
|
3008
|
+
|
|
3009
|
+
- absent / `"github"` → `"github"` (the default backend);
|
|
3010
|
+
- `"linear"` → `"linear"` (a live selection);
|
|
3011
|
+
- any other value → **raises** `IssueBackendError` ("unknown issue backend … (known: github,
|
|
3012
|
+
linear)");
|
|
3013
|
+
- malformed committed TOML → `tomllib.TOMLDecodeError` re-raised as `IssueBackendError` (chained,
|
|
3014
|
+
pointing at `perk doctor`).
|
|
3015
|
+
|
|
3016
|
+
Raising (not falling back) is deliberate: a silent fallback would write canonical issues to the
|
|
3017
|
+
wrong tracker. `resolve_issue_backend(repo_root)` resolves the id and constructs the matching
|
|
3018
|
+
backend; every issue-tier consumer already routes `IssueBackendError` through its existing error
|
|
3019
|
+
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`;
|
|
3021
|
+
no/blank `LINEAR_API_KEY` → the hinted message from `client_from_env`. Construction is lazy (no
|
|
3022
|
+
network): the team key is bound and resolved to its UUID on first use.
|
|
3023
|
+
|
|
3024
|
+
**The TS mirror is fail-safe and dormant** (`extension/substrate/config.ts::resolveIssueBackendId`):
|
|
3025
|
+
returns `"github" | "linear"`, falling back to `"github"` on absence/unknown value/any read or
|
|
3026
|
+
parse error — safe because the TS plane only *renders prompts*, never writes canonical issues. No
|
|
3027
|
+
TS consumer exists at this node; Node 3.1 (backend-aware prompt rendering) consumes it (mirrors
|
|
3028
|
+
the providers.ts Node-2.1 dormant-loader precedent). `PerkConfig` carries no `issues` field — an
|
|
3029
|
+
overlay-read shape would contradict the committed-only rule.
|
|
3030
|
+
|
|
3031
|
+
**The `backend_id` discipline + the stamping rule.** The `IssueBackend` Protocol carries
|
|
3032
|
+
`backend_id: str` — the backend's id in the `[issues] backend` vocabulary, stamped **verbatim**
|
|
3033
|
+
into `cache.plan-ref.provider` at every stamp site (`plan_save_cmd.py`'s `PlanRef`;
|
|
3034
|
+
`resume.reconstruct_plan_ref(state, provider=…)`'s callers). This makes "the backend that wrote
|
|
3035
|
+
the issue is the backend that gets stamped" structurally true (see also the §8.10 paragraph:
|
|
3036
|
+
the field is the issue backend, not the seam id).
|
|
3037
|
+
|
|
3038
|
+
**The `issues-backend` doctor check** (group `issues`; no `--fix` arm — the selection is
|
|
3039
|
+
user-owned config):
|
|
3040
|
+
|
|
3041
|
+
| committed selection | status | note |
|
|
3042
|
+
| --- | --- | --- |
|
|
3043
|
+
| absent / `"github"` | `ok` | `issues backend: github` |
|
|
3044
|
+
| `"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]` |
|
|
3047
|
+
| malformed TOML | `warn` | selection not evaluated — defers to the config check (mirrors `providers`) |
|
|
3048
|
+
|
|
3049
|
+
`fail` (not `warn`) for a bad selection is deliberate: unlike `[providers]` (graceful fallback →
|
|
3050
|
+
warn), a bad `[issues]` selection hard-breaks **every** issue-touching command. Network readiness
|
|
3051
|
+
is *not* this offline check's job — that is the `linear` group's (below).
|
|
3052
|
+
|
|
3053
|
+
**The verify-gated `linear` doctor group** (`perk/convergence/doctor/linear_checks.py::_linear_checks`; present only when
|
|
3054
|
+
`verify` AND the committed backend is `"linear"`). All warn-level on failure — network readiness
|
|
3055
|
+
is non-fatal, mirroring the `github` group's D3 discipline. Built from one
|
|
3056
|
+
`linear.check_readiness(client, team_key, ensure_labels=False)` call (the shared
|
|
3057
|
+
init/doctor probe — report-shaped, never raises; phases short-circuit auth → team → labels):
|
|
3058
|
+
|
|
3059
|
+
- `linear-auth` — ok: `authenticated as <user>`; failure (or missing `LINEAR_API_KEY`): warn,
|
|
3060
|
+
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".
|
|
3062
|
+
- `linear-team` — ok: `team <key> found`; failure: warn with the error detail.
|
|
3063
|
+
- `linear-labels` — all five perk labels present (`perk:plan`, `perk:learn`, `perk:consolidated`,
|
|
3064
|
+
`perk:objective`, `perk:objective-node`): ok; otherwise warn listing the missing names,
|
|
3065
|
+
remediation "run `perk init` or `perk doctor --fix`". perk's labels are created
|
|
3066
|
+
**workspace-scoped** (no `teamId` on create — Linear's cross-team-label guidance; the lookup is
|
|
3067
|
+
unscoped, so a pre-existing team-scoped label still counts).
|
|
3068
|
+
- `linear-project-scopes` — ok: `Linear Projects accessible`; warn: `Linear Projects not
|
|
3069
|
+
accessible` (a non-mutating read probe of `team { projects(first:1) }` — read-access is the
|
|
3070
|
+
honest proxy; write/create scope is not probeable without a mutation).
|
|
3071
|
+
- `linear-workflow-states` — ok: `workflow states cover the node-status mirror`; warn when the
|
|
3072
|
+
team lacks a state of a required `type` (the distinct values of `_NODE_STATUS_STATE_TYPE` =
|
|
3073
|
+
`unstarted/started/completed/canceled`, derived in lockstep); warn `workflow states not
|
|
3074
|
+
verified` on a probe error.
|
|
3075
|
+
|
|
3076
|
+
The last two are the **project-backed objective readiness** probe (Node 4.2): both run **only
|
|
3077
|
+
after** `linear-auth` + `linear-team` succeed, via a separate
|
|
3078
|
+
`linear.check_project_readiness(client, team_key)` call (report-shaped, never raises;
|
|
3079
|
+
reuses the client's cached team id — no auth/team re-probe). Non-fatal like the rest of the group.
|
|
3080
|
+
**No `--fix` arm** — workflow states and API-token scopes are user/workspace-owned (perk cannot
|
|
3081
|
+
safely auto-create them).
|
|
3082
|
+
|
|
3083
|
+
**The `--fix` label repair gesture** (`_fix_linear_labels`, verify-gated like the skills sync —
|
|
3084
|
+
network I/O, so never a `ManagedConvergence`): when `fix` AND `verify` AND linear is selected AND
|
|
3085
|
+
key + team are available, `check_readiness(..., ensure_labels=True)` ensures the five labels;
|
|
3086
|
+
created names land on `fixed` (`Linear: created label perk:plan`), failures on `fix_errors`.
|
|
3087
|
+
Lookup-first idempotency: a converged workspace reports nothing (the doctor idempotency rule).
|
|
3088
|
+
|
|
3089
|
+
**The init readiness step** (`perk/convergence/init/__init__.py::_linear_readiness`, verify-gated, non-fatal — the
|
|
3090
|
+
GitHub D3 mirror: file convergence already succeeded). Only when `verify` AND the committed
|
|
3091
|
+
backend is `"linear"`: missing key/team degrade to an errored `LinearReport`; otherwise the probe
|
|
3092
|
+
runs with `ensure_labels=True` (init converges the five perk labels upfront; the lazy write-time
|
|
3093
|
+
`ensure_label` calls remain the safety net). Created labels are reported through the
|
|
3094
|
+
`LinearReport` (the `--json` `linear` key, §8.5; the human `✓ Linear: <user>, team <key>` line) —
|
|
3095
|
+
**never** appended to `InitReport.changes`, which stays a pure filesystem-delta list.
|
|
3096
|
+
`LinearReport` also carries a nullable `project` readiness sub-report
|
|
3097
|
+
(`LinearProjectReadiness` — the same `check_project_readiness` probe as the doctor group, run only
|
|
3098
|
+
when `auth_ok && team_ok`): non-fatal — it does **not** flip `LinearReport.ok`. The init human
|
|
3099
|
+
render adds a `⚠️` sub-line per gap (Projects read-access / missing workflow state types); a
|
|
3100
|
+
fully-ready project readiness prints nothing extra.
|
|
3101
|
+
|
|
3102
|
+
**The `npm:pi-mono-linear` settings convergence** (`perk/convergence/init/settings.py::_converge_linear_package`,
|
|
3103
|
+
composed inside `_converge_settings` — it rides the `settings-wiring` managed convergence, so
|
|
3104
|
+
doctor dry-runs and `--fix`es it for free; no new doctor check, no new capability).
|
|
3105
|
+
Two-directional, mirroring `_converge_provider_packages`: `backend = "linear"` selected → the
|
|
3106
|
+
unpinned plain-string entry is appended (bundled `linear` skill accepted wholesale — no
|
|
3107
|
+
`package_filter`); not selected → any entry matching the `pi-mono-linear` identity is **removed**
|
|
3108
|
+
(perk treats the package as managed by the selection; hand-adding it without selecting linear is
|
|
3109
|
+
unsupported). A malformed committed TOML defers to the config check (selection treated as absent).
|
|
3110
|
+
|
|
3111
|
+
**Backend-aware prompt rendering (Node 3.1).** Every plan-read prompt site branches on
|
|
3112
|
+
`cache.plan-ref.provider` via the per-plane helpers `perk/run/launch/prompts.py::_plan_read_instruction` and
|
|
3113
|
+
`extension/doors/lifecycleGates.ts::planReadInstruction` — byte-parity across planes, asserted by the
|
|
3114
|
+
paired parity suites (`tests/test_worker_prompt_parity.py` + `extension/worker/worker.test.ts`). The
|
|
3115
|
+
`linear` arm references the pi-mono-linear `linear_get_issue` + `linear_list_comments` tools (the
|
|
3116
|
+
plan body is the first comment — true under every backend's `create_plan_issue`) with an
|
|
3117
|
+
`open <url>` fallback; unknown providers keep the plain `open <url>` arm. Learn prompts
|
|
3118
|
+
(`_learn_prompt`, `extension/doors/learn.ts::learnGuidance`) keep the `gh pr list --head plan-<pr_id>
|
|
3119
|
+
--state merged` merged-PR derivation under every backend — PRs are GitHub-universal.
|
|
3120
|
+
`extension/substrate/toolGating.ts::READ_ONLY_TOOLS` allowlists the 19 read-only `linear_*` tool names
|
|
3121
|
+
unconditionally (foreign names are inert when the package is absent); the mutating/sensitive
|
|
3122
|
+
tools (`linear_create_issue`, `linear_update_issue`, `linear_create_comment`, the two
|
|
3123
|
+
`linear_upload_file*`, `linear_configure_auth`) are deliberately excluded. The perk-implement and
|
|
3124
|
+
perk-learn skills carry per-backend `backends/` reference directories (`github`, `linear`),
|
|
3125
|
+
delivered by the whole-directory skills sync. Historical Status notes elsewhere quoting
|
|
3126
|
+
`gh issue view` (e.g. P1.T4c) are records — left untouched.
|
|
3127
|
+
|
|
3128
|
+
The **objective seed prompts** are backend-aware the same way (Node 4.1). The objective-plan cold
|
|
3129
|
+
seed (`perk/cli/commands/objective/plan_cmd.py::_seed_prompt`) and the warm guidance
|
|
3130
|
+
(`extension/factories/objectivePlan.ts::factoryGuidance` / `reconcileGuidance`) branch on the
|
|
3131
|
+
objective backend via the seam-rendered `objective_read_instruction` /
|
|
3132
|
+
`objectiveReadInstruction` helpers (cross-plane byte-parity owned by
|
|
3133
|
+
the `objective-read-*` golden cases — `tests/test_prompts.py` +
|
|
3134
|
+
`extension/substrate/prompts.test.ts` — with per-plane selection tests in
|
|
3135
|
+
`tests/test_objective_prompt_parity.py` + `extension/factories/objectivePlan.test.ts`; see §8.31).
|
|
3136
|
+
The helper returns a **supplemental** clause appended to the
|
|
3137
|
+
existing `perk objective show <id>` step (never a replacement): the `linear` arm references the
|
|
3138
|
+
Linear **Project URL** + the read-only `linear_get_issue` / `linear_list_comments` tools (an
|
|
3139
|
+
`open <url>` fallback when the url is known; the indirect `run \`perk objective show <id>\` for its
|
|
3140
|
+
URL` form when it is not); `github` (and any non-linear) → `""` (the `perk objective show` step
|
|
3141
|
+
already covers GitHub — no churn). The warm plane resolves the backend from
|
|
3142
|
+
`resolveIssueBackendId(ctx.cwd)` (committed `.pi/perk.toml` — authoritative since cross-backend
|
|
3143
|
+
objectives are unsupported by policy) and fetches the Project URL via `perk objective show <id>
|
|
3144
|
+
--json` **only for `linear`** (github needs no clause → no fetch), **fail-open** (any fetch
|
|
3145
|
+
failure / missing url → the indirect form). The cold plane reads `store.backend_id` + `state.url`
|
|
3146
|
+
(both already in hand). New helper/handler params default to the github/empty arm
|
|
3147
|
+
(`backend="github"`, `url=""`) — backward-compatible. PRs stay on `gh` (`reconcileGuidance`'s
|
|
3148
|
+
`gh pr diff`/`gh pr view` is unchanged — PRs are GitHub-universal). `objective author` is excluded
|
|
3149
|
+
(no objective/Project exists at author time).
|
|
3150
|
+
|
|
3151
|
+
**Opaque string issue ids at every machine boundary (Node 4.1).** Issue ids (plan / learn /
|
|
3152
|
+
objective) are **opaque strings** end-to-end — GitHub's are numeric strings (`"42"`), Linear's
|
|
3153
|
+
are the human identifier (`"ENG-123"`; the verified mutations — `issueUpdate`/`commentCreate` —
|
|
3154
|
+
accept the identifier directly, live-verified at the Mode 2 smoke gate, so no identifier→UUID
|
|
3155
|
+
resolution layer remains; `issueRelationCreate` receives issue UUIDs captured at issue-create
|
|
3156
|
+
time, as it is not verified for identifiers). **PR numbers stay `int`** under `pr.number`
|
|
3157
|
+
everywhere — PRs are GitHub-universal. Concretely:
|
|
3158
|
+
|
|
3159
|
+
- Every `--json` envelope emits string issue ids, with the id fields renamed for honesty:
|
|
3160
|
+
`plan-save`'s `issue.number` → **`issue.id`**; `learn capture`'s `learn_issue.number` →
|
|
3161
|
+
**`learn_issue.id`** (and `plan_issue` is a string); `objective create`/`show`'s
|
|
3162
|
+
`objective.number` → **`objective.id`**; `pr submit`/`pr land`'s top-level `issue` stays keyed
|
|
3163
|
+
`issue` but is a string; `pr land`'s `objective` sub-object `number` → **`id`** (string|null)
|
|
3164
|
+
and `learn.closed` carries string ids; `objective reconcile`'s `objective`/`comment_id` are
|
|
3165
|
+
strings; `learn docs --gather`'s `learn_numbers` carries string ids. TS decoders
|
|
3166
|
+
(`planSave.ts`/`learn.ts`/`land.ts`/`objectiveSave.ts`/`learnDocs.ts`) are lockstep-strict on
|
|
3167
|
+
the string shapes.
|
|
3168
|
+
- CLI plan/objective arguments parse through the shared opaque-id validators
|
|
3169
|
+
(`resume_cmd.parse_plan_id` / `objective/shared.parse_objective_id`): strip `#`/whitespace;
|
|
3170
|
+
reject only empty or worktree-unsafe ids (`/`, `.`, `..`) — no int parse. The supervisor's
|
|
3171
|
+
in-flight resolution treats any non-empty node `pr` backlink as the plan id.
|
|
3172
|
+
- Plan worktrees are `plan-<id>` for any id shape (`plan-ENG-123` exploits Linear's branch-name
|
|
3173
|
+
auto-link when the GitHub integration is installed); `worktree wipe` matches `^plan-(\S+)$`.
|
|
3174
|
+
- **Land closure branches per backend.** GitHub keeps the squash footer `Closes #N` autoclose
|
|
3175
|
+
**for default-branch merges** (byte-identical); when the PR's base is a **non-default** branch
|
|
3176
|
+
(GitHub does not autoclose there), perk additionally performs the same explicit fail-open
|
|
3177
|
+
`close_issue` on the plan issue that non-github backends always get. Non-github backends get a
|
|
3178
|
+
plain `Plan: <id> — <url>` footer (no commit magic words — Linear's commit-linking needs a
|
|
3179
|
+
non-assumable webhook) **plus** that explicit fail-open close after the merge
|
|
3180
|
+
(`_close_plan_issue_on_land`, surfaced as the envelope's `plan_issue_closed: bool`; idempotent
|
|
3181
|
+
beside autoclose or any tracker Done-on-merge automation).
|
|
3182
|
+
- The live validation surface is `tests/test_linear_lifecycle.py` (the stateful
|
|
3183
|
+
`FakeLinearWorkspace` offline suite) plus the manual live smoke gate runbook.
|
|
3184
|
+
|
|
3185
|
+
## §8.22 · Linear agent-session emission (Objective #252, Node 5.1 — stretch)
|
|
3186
|
+
|
|
3187
|
+
An **opt-in, fail-soft, one-way** mirror of an implement run into Linear's Agents UI
|
|
3188
|
+
(`perk/backends/linear/agent.py` — Python-plane only; the warm TS doors delegate to the Python hooks, so
|
|
3189
|
+
there is no TS twin).
|
|
3190
|
+
|
|
3191
|
+
- **The gate** (checked inside every emitter): the worktree's stamped
|
|
3192
|
+
`cache.plan-ref.provider == "linear"` (the stamped provider, never config — the Node 3.1 rule)
|
|
3193
|
+
**and** a non-empty **`LINEAR_AGENT_TOKEN`** env var. Without the token, behavior is
|
|
3194
|
+
byte-identical to today (dormant by default; "additive only").
|
|
3195
|
+
- **`LINEAR_AGENT_TOKEN` env contract**: an OAuth `actor=app` access token from a user-created
|
|
3196
|
+
Linear agent application — a personal `LINEAR_API_KEY` is rejected by Linear's agent API. Sent
|
|
3197
|
+
in the OAuth `Authorization: Bearer <token>` header form (`LinearClient(bearer=True)`;
|
|
3198
|
+
personal-key requests keep the plain header byte-identically). Environment only — never
|
|
3199
|
+
config/committed files. No new config keys, no doctor check — the live smoke gate
|
|
3200
|
+
is the verification surface.
|
|
3201
|
+
- **The file**: `.pi/workflow/agent-session.json` (cache tier, §8.1) —
|
|
3202
|
+
`{"session_id": str, "issue": str, "url": str | null}`, written at session create
|
|
3203
|
+
(`cache.write_agent_session`/`read_agent_session`). Absent at a follow-up hook → fail-soft
|
|
3204
|
+
skip with a stderr note (known consequence: a remote-run-created session is invisible to a
|
|
3205
|
+
later local land — that land's emission skips).
|
|
3206
|
+
- **The four hook sites**:
|
|
3207
|
+
1. **implement start (local)** — `launch.launch_stage`, cold-local block, `stage.id ==
|
|
3208
|
+
"implement"` → `agentSessionCreateOnIssue` on the plan issue + one `thought` activity;
|
|
3209
|
+
2. **implement start (remote)** — `run_worker.run_worker` beside `report_started`, with the
|
|
3210
|
+
GitHub Actions run URL as an `externalUrls` entry; a **nonzero** worker exit additionally
|
|
3211
|
+
emits an `error` activity beside `report_terminal` (otherwise a failed remote drive leaves
|
|
3212
|
+
the session dangling-active); a zero exit emits nothing terminal (the in-run `perk pr
|
|
3213
|
+
submit` delegation already emitted the PR activity);
|
|
3214
|
+
3. **submit** — `pr submit`'s `_pr_submit_impl` (never on `--dry-run`) → an `action` activity
|
|
3215
|
+
(PR opened) + `agentSessionUpdate.addedExternalUrls` with the PR link;
|
|
3216
|
+
4. **land** — `pr land`'s `_pr_land_impl` (never on `--dry-run`) → a `response` activity
|
|
3217
|
+
("PR #n squash-merged." + the objective-node summary line when any).
|
|
3218
|
+
- **The fail-soft guarantee**: every emitter is fully wrapped (the
|
|
3219
|
+
`_reconcile_objective_on_land` fail-open discipline) — it never raises and never changes the
|
|
3220
|
+
host command's result/exit code/`--json` payload; failures print one loud-but-non-fatal stderr
|
|
3221
|
+
note (`perk linear-agent: <what> skipped (non-fatal): <exc>`).
|
|
3222
|
+
- **Known limits + deferrals** (flagged in the module docstring): GraphQL field signatures are
|
|
3223
|
+
substring-pinned offline and verified live only at the smoke gate; Linear marks sessions
|
|
3224
|
+
`stale` ~30 min after the last activity (accepted, not mitigated); `perk address` emission,
|
|
3225
|
+
the `agentSessionUpdate.plan` checklist, elicitation activities, retry/backoff, and any
|
|
3226
|
+
webhook receiver (perk never *responds* to Linear prompts) are all deferred.
|
|
3227
|
+
|
|
3228
|
+
## §8.23 · The file-first plan contract (the three plan backends; Objective #339)
|
|
3229
|
+
|
|
3230
|
+
A consolidation-by-reference of the file-first plan pipeline Phase 1–2 of Objective #339 built.
|
|
3231
|
+
The normative detail lives in §8.1 ("File-first plan save" + the `plan_draft` carve-out), §8.3
|
|
3232
|
+
(the `approvalSave` seam + the warm claim carrier), and §8.10 (the interactive save discipline) —
|
|
3233
|
+
with the plannotator/Node 2.5/2.6 Status blocks in contracts-history.md §8.10; this section is the
|
|
3234
|
+
one-stop current shape.
|
|
3235
|
+
|
|
3236
|
+
- **The artifact.** The working plan lives in the session data dir as `plan-draft.md`
|
|
3237
|
+
(`PLAN_DRAFT_ARTIFACT`, `extension/factories/planDraft.ts`), written **only** by the `plan_draft` tool
|
|
3238
|
+
through the session-data accessor seam (`writeSessionArtifact`: file + provenance pointer in one
|
|
3239
|
+
gesture), and consumable **only** via its validated provenance pointer
|
|
3240
|
+
(`readSessionArtifact` — digest-validated, fail-open) (→ §8.1).
|
|
3241
|
+
- **The two resolution chains + the asymmetry law.** **Save** surfaces resolve
|
|
3242
|
+
artifact → `plan` param → transcript scrape (the universal fail-open last resort)
|
|
3243
|
+
(`resolvePlanSource`, → §8.1 "File-first plan save"). **Review** surfaces resolve
|
|
3244
|
+
artifact → param **only** — the transcript tier is excluded because an approval auto-saves the
|
|
3245
|
+
reviewed bytes, and scraped conversation bytes must never be what gets approved (→ §8.10's
|
|
3246
|
+
plannotator Status block).
|
|
3247
|
+
- **The review door + the approval seam.** `plan_review` (in `READ_ONLY_TOOLS`; backend-neutral,
|
|
3248
|
+
`extension/factories/planReview.ts`) dispatches: plannotator-selected → the event-bus bridge; **any**
|
|
3249
|
+
other selection → the first-party `ctx.ui.editor` review. APPROVED (either backend) runs
|
|
3250
|
+
`approvalSave` (`extension/factories/planSave.ts`): save → D1a gate exit on success (→ §8.3). The
|
|
3251
|
+
`/plan-save` command is the **manual failsafe** invocation of the same seam, taking only an
|
|
3252
|
+
optional title argument.
|
|
3253
|
+
- **The three backends.** All three speak review-first
|
|
3254
|
+
(`plan_draft` → `plan_review` → auto-save on approval):
|
|
3255
|
+
|
|
3256
|
+
| provider id | authoring context | review surface | fail-open arm |
|
|
3257
|
+
|---|---|---|---|
|
|
3258
|
+
| `perk-plan` | `PLAN_AUTHORING_CONTEXT` | first-party in-TUI review | present + `/plan-save` |
|
|
3259
|
+
| `plannotator-plan` | `PLAN_ADAPTER_PLANNOTATOR_CONTEXT` | browser bridge | present + `/plan-save` |
|
|
3260
|
+
| `tombell-plan` | `PLAN_ADAPTER_TOMBELL_CONTEXT` (conditioned injection, Node 2.6) | first-party in-TUI review | present + `/plan-save` (incl. tombell's own interactive `/plan` `setActiveTools` restriction arm) |
|
|
3261
|
+
|
|
3262
|
+
- **Link/`consumed_learn` recovery carriers.** Approval-triggered saves carry **no model params**;
|
|
3263
|
+
the **cold** `handoff_extra` carrier (→ §8.2) and the **warm** `objective_node_claim` carrier
|
|
3264
|
+
(→ §8.3) recover `objective_id`/`node_id` with identical semantics — fill both-or-neither,
|
|
3265
|
+
explicit values win outright (even one — never mixed), fail-open (a malformed carrier never
|
|
3266
|
+
blocks a save). `consumed_learn` rides the cold handoff (`_consumed_learn_from_handoff`).
|
|
3267
|
+
|
|
3268
|
+
§8.10's per-node Status blocks remain the historical record of how each piece landed; this section
|
|
3269
|
+
is the consolidated **current** contract.
|
|
3270
|
+
|
|
3271
|
+
## §8.24 · The objective-storage tier (the `ObjectiveStore` seam; Objective #548)
|
|
3272
|
+
|
|
3273
|
+
perk's durable state lives in two conceptually distinct populations: the **issue-tracking tier**
|
|
3274
|
+
(plan/learn issues — the `IssueBackend` contract, §8.21) and the **objective-storage tier**
|
|
3275
|
+
(objectives — the `ObjectiveStore` contract, this section). Today a single backend stores **both**
|
|
3276
|
+
as issues, so the tiers are behaviorally fused; the split exists so a Phase 3 store can make a
|
|
3277
|
+
Linear **Project** a canonical objective (not just an issue). Objective #548 Node 2.1 shipped the
|
|
3278
|
+
dormant contract; Node 2.2 made it live; Node 2.3 (this section) amends the contract + user-docs.
|
|
3279
|
+
|
|
3280
|
+
The two tiers are **named distinctly** at the boundary: the objective tier drops the issue tier's
|
|
3281
|
+
`_issue` method suffix (`find_objective`/`create_objective`, not `find_objective_issue`) and renames
|
|
3282
|
+
the id field `issue_id → objective_id` everywhere, because the stored thing is an objective — a
|
|
3283
|
+
GitHub issue **or** a Linear Project.
|
|
3284
|
+
|
|
3285
|
+
**The contract module** (`perk/backends/objective_store.py`, Node 2.1, dormant — mirrors the
|
|
3286
|
+
`issue_backend.py` dormant-then-extract precedent: the contract ships dormant, a later node extracts
|
|
3287
|
+
the concrete backend behind it):
|
|
3288
|
+
|
|
3289
|
+
- The `ObjectiveStore` `Protocol`: `backend_id: str` plus **twelve** keyword-only methods —
|
|
3290
|
+
`find_objective` / `create_objective` / `get_objective` / `update_objective_header` /
|
|
3291
|
+
`update_objective_node` / `update_objective_body` / `add_objective_node` / `save_node_plan` /
|
|
3292
|
+
`close_objective` / `post_status_update` / `detect_objective_drift` / `repair_objective_drift`
|
|
3293
|
+
(`objective_id` everywhere; the last two added at Node 4.4 — see the amendment). `add_objective_node` inserts
|
|
3294
|
+
a new roadmap node (auto-assigned `<phase>.<n>`, appended within the phase) — the rare
|
|
3295
|
+
node-insertion surface used sparingly during reconciliation (prose-guarded, no audit gate).
|
|
3296
|
+
Each concrete store inserts into the thing it stores: the GitHub + issue-backed Linear stores
|
|
3297
|
+
re-render the roadmap block; the project-backed store materializes a new node-**issue** under the
|
|
3298
|
+
phase milestone. `save_node_plan` + `close_objective` were added
|
|
3299
|
+
at Node 3.4 (see the Node 3.4 amendment): `save_node_plan` is the node↔plan **unification** write
|
|
3300
|
+
(returns the node-issue ref for a unifying store, **`None`** for a store that does not unify — the
|
|
3301
|
+
single "doesn't unify" signal), and `close_objective` retires the objective's **own** entity on
|
|
3302
|
+
completion (each backend closes the thing it actually stores). `post_status_update` was added at
|
|
3303
|
+
Node 4.3 (see the Node 4.3 amendment): it posts a human-readable status update to the objective's
|
|
3304
|
+
native update surface, returning `True` when posted and `False` for a store with no such surface
|
|
3305
|
+
(GitHub, issue-backed Linear) or a `dry_run`.
|
|
3306
|
+
- Six frozen result dataclasses: `ObjectiveRef` (`id`/`url`/`existed`), `ObjectiveState`
|
|
3307
|
+
(`id`/`url`/`title`/`header`/`nodes`), `ObjectiveHeaderUpdate`, `ObjectiveNodeUpdate`,
|
|
3308
|
+
`ObjectiveBodyUpdate`, `ObjectiveNodeAdd` (`objective_id`/`node_id`/`comment_updated`/`dry_run`).
|
|
3309
|
+
- One backend-neutral error type: `ObjectiveStoreError`.
|
|
3310
|
+
|
|
3311
|
+
**The state-ownership invariants** (the four contract disciplines every concrete store MUST honor):
|
|
3312
|
+
|
|
3313
|
+
- **Constructor-bound repo context.** Methods take no `repo_root`; a store instance is constructed
|
|
3314
|
+
for exactly one repo (GitHub binds `repo_root` as the `gh` cwd; Linear binds team/API-key config
|
|
3315
|
+
at construction).
|
|
3316
|
+
- **String ids at the boundary.** Every objective/comment id crossing the boundary is a `str`
|
|
3317
|
+
(GitHub's issue numbers stringified; a Linear Project id is natively a string).
|
|
3318
|
+
- **Backend-owned opaque header values.** The `header` dict is opaque `dict[str, object]`;
|
|
3319
|
+
header-embedded values (e.g. the objective-body comment id) are backend-owned — a caller must
|
|
3320
|
+
never interpret them.
|
|
3321
|
+
- **Error discipline.** Mutations raise `ObjectiveStoreError`; lookups return `… | None` for
|
|
3322
|
+
not-found and **raise** on infra failure — never mask an error as `None`. Concrete stores map
|
|
3323
|
+
their native errors into `ObjectiveStoreError` at their boundary.
|
|
3324
|
+
|
|
3325
|
+
**The concrete stores + the facade refactor** (Node 2.2):
|
|
3326
|
+
|
|
3327
|
+
- `GitHubObjectiveStore` (`perk/backends/github/objective_store.py`) — **late-bound delegation** to
|
|
3328
|
+
the GitHub objective substrate (`perk/backends/github/objectives.py`, a sibling) plus the
|
|
3329
|
+
plan/issue substrate for `read_objective_source`/`close_objective` (a GitHub objective IS an
|
|
3330
|
+
issue); these are the same functions the fused `IssueBackend` used (the equivalence lock: the
|
|
3331
|
+
GitHub writes are byte-for-byte the prior behavior); `repo_root` constructor-bound; string-id
|
|
3332
|
+
boundary with an `int()` edge conversion; `GitHubError → ObjectiveStoreError` verbatim via
|
|
3333
|
+
`_translate`. Carries `backend_id = "github"`.
|
|
3334
|
+
- The **Linear facade refactor** (`perk/backends/linear/`): a shared `_LinearIssueOps`
|
|
3335
|
+
substrate (client + caches + issue helpers); `LinearIssueBackend` as a thin facade over its
|
|
3336
|
+
`_ops`; and `LinearObjectiveStore` with its own `_LinearIssueOps`, the six objective methods, and
|
|
3337
|
+
`IssueBackendError → ObjectiveStoreError` per-method message-verbatim. Both carry
|
|
3338
|
+
`backend_id = "linear"`. The issue-backed `LinearObjectiveStore` is **kept dormant since Node
|
|
3339
|
+
3.4** (directly-constructable, still unit-tested) — the resolver's Linear arm now constructs the
|
|
3340
|
+
project-backed `LinearProjectObjectiveStore` (see the Node 3.4 amendment below).
|
|
3341
|
+
|
|
3342
|
+
**The resolver.** `resolve_objective_store(repo_root)` (`perk/backends/resolve.py`, alongside the
|
|
3343
|
+
issue-tier `resolve_issue_backend`) dispatches on the **`[issues]` selection** (§8.21):
|
|
3344
|
+
`github → GitHubObjectiveStore`; `linear →
|
|
3345
|
+
LinearProjectObjectiveStore` (project-backed, since Node 3.4). Single-sourced:
|
|
3346
|
+
`resolve_objective_store_id` re-exports `resolve_issue_backend_id` rather than reading a separate
|
|
3347
|
+
config key, because an objective and its plan/learn issues share **one** tracker; project-vs-issue
|
|
3348
|
+
is **not** separately selectable — it is simply what "linear" now means for objectives. Every
|
|
3349
|
+
objective consumer routes through `resolve_objective_store(repo_root)`.
|
|
3350
|
+
|
|
3351
|
+
**The `backend_id` stamping rule.** `ObjectiveStore.backend_id` is stamped **verbatim** into
|
|
3352
|
+
`cache.plan-ref.provider` — mirroring `IssueBackend.backend_id` (§8.21): "the backend that wrote the
|
|
3353
|
+
objective is the backend that gets stamped." The objective tier and the issue tier share the stamp
|
|
3354
|
+
vocabulary because (today) they share the backend.
|
|
3355
|
+
|
|
3356
|
+
**Node 3.4 amendment — the project-backed Linear objective is live; node↔plan unification; close
|
|
3357
|
+
through the store.** The resolver's Linear arm is flipped to `LinearProjectObjectiveStore`, so
|
|
3358
|
+
**every** Linear objective is now a Linear **Project** (overview = `objective-header` +
|
|
3359
|
+
Reconcilable prose; the roadmap is materialized as one **node-issue** per node, each carrying an
|
|
3360
|
+
`objective-node` block; phases = milestones; explicit `depends_on` = blocking relations). GitHub is
|
|
3361
|
+
unchanged.
|
|
3362
|
+
|
|
3363
|
+
- **Node↔plan unification (`save_node_plan`).** In the project model a roadmap node already *is* a
|
|
3364
|
+
Linear issue, so an **objective-linked** `plan-save` writes the plan **into that node-issue**
|
|
3365
|
+
rather than minting a second `perk:plan` issue: the `plan-header` block is merged into the
|
|
3366
|
+
node-issue description (Linear-safe inline-code), the plan body is upserted as a single node-issue
|
|
3367
|
+
comment, and the node-issue's **title** (its roadmap identity `"{id}: …"`), `objective-node`
|
|
3368
|
+
block, and prose are untouched (node-issues carry **no** `perk:plan` label — discovered by project
|
|
3369
|
+
membership + the node block). `cache.plan-ref.pr_id` then points at the **node-issue**, and the
|
|
3370
|
+
implement→submit→land loop runs against it. `save_node_plan` returns the node-issue ref for a
|
|
3371
|
+
unifying store and **`None`** otherwise (`GitHubObjectiveStore` + issue-backed
|
|
3372
|
+
`LinearObjectiveStore` always return `None`; the caller falls back to the standalone path). A
|
|
3373
|
+
`dry_run` returns `None` (resolving the node-issue needs a network read). **Standalone
|
|
3374
|
+
(non-objective) `plan-save` is byte-unchanged.**
|
|
3375
|
+
- **The node→plan backlink is the node-issue's own identifier.** `get_objective` derives a node's
|
|
3376
|
+
`pr` as `canonical_pr(identifier)` whenever the node-issue carries a `plan-header` block (a plan
|
|
3377
|
+
was saved into it), else `None` — self-referential (the plan *is* the node-issue) and stable
|
|
3378
|
+
across `pr submit` overwriting `plan-header.pr` with the GitHub PR number, so the land-path match
|
|
3379
|
+
(`nodes_for_pr(nodes, plan_ref.pr_id == identifier)`) holds with no change to `nodes_for_pr` /
|
|
3380
|
+
`pr submit` / `pr land`.
|
|
3381
|
+
- **`close_objective` removes the issue-tier leak.** Objective completion (the `pr land`
|
|
3382
|
+
close-on-complete and the `perk objective run` `complete` branch) now closes through
|
|
3383
|
+
`store.close_objective`, never `IssueBackend.close_issue`: `GitHubObjectiveStore` **closes** the
|
|
3384
|
+
GitHub objective issue (byte-identical to the prior close); the issue-backed `LinearObjectiveStore`
|
|
3385
|
+
moves the objective issue to its Done state; `LinearProjectObjectiveStore` **marks the Linear
|
|
3386
|
+
Project complete** (`projectUpdate(state:"completed")`) — a Project is not an issue. Fail-open is
|
|
3387
|
+
preserved (a close failure never changes the land result).
|
|
3388
|
+
- The objective id is the opaque **Project UUID** across `active_objective` / `--objective-id` /
|
|
3389
|
+
the handoff / `cache.plan-ref.objective_id` — no numeric/`ENG-`-shape assumption anywhere.
|
|
3390
|
+
- **Realized:** the `projectUpdate(state)` mark-complete is **live-verified 2026-06-16** (Node 5.1
|
|
3391
|
+
Mode-4 gate 4.6, `set_project_state`); the **docs/user-docs** operator narrative for the
|
|
3392
|
+
project-backed objective lifecycle was **reconciled in Node 5.2** (this node).
|
|
3393
|
+
|
|
3394
|
+
**Node 4.3 amendment — phase→milestone sync seam + fail-open Project Updates.** Two additive,
|
|
3395
|
+
**non-fatal** enrichments to the Linear project-backed objective (GitHub unchanged: no Project
|
|
3396
|
+
Updates, no milestone seam). Every Linear write added here is best-effort — a failure is logged
|
|
3397
|
+
loud-but-non-fatal to stderr and **never** changes the command's result (a Linear bookkeeping
|
|
3398
|
+
failure never breaks a merge or a node transition).
|
|
3399
|
+
|
|
3400
|
+
- **phases → milestones is a name-keyed lookup-or-create seam.** `_LinearProjectOps.ensure_phase_milestone(*, project_id, name, known=None)`
|
|
3401
|
+
reuses an existing milestone for `name` or creates one. **Name is the deterministic key** —
|
|
3402
|
+
milestone order is NOT insertion order (the 1.4 finding) — and the canonical name source is
|
|
3403
|
+
`objective.enrich_phase_names(prose, [key])` (the overview's `### Phase N: …` headers, falling
|
|
3404
|
+
back to `phase_label` → `"Phase N"`). `create_objective` routes its create-time milestone loop
|
|
3405
|
+
through the seam with a **seeded-empty `known`**, so its network calls stay byte-identical to the
|
|
3406
|
+
prior blind-create loop (no extra `project_milestones` read). The seam is the **"kept in sync on
|
|
3407
|
+
node add"** primitive a future `add_node`-to-an-existing-objective will reuse (with `known=None`)
|
|
3408
|
+
— load-bearing, not fiction; `objective.add_node` stays caller-less in this node. **No
|
|
3409
|
+
phase-key→id registry** — name is the dedup key. The phase-header-text-drift duplicate-milestone
|
|
3410
|
+
edge (reconciliation rewrites a `### Phase N:` header → the stored milestone name no longer
|
|
3411
|
+
matches the re-derived name → a duplicate) is **Node 4.4's** drift-detection + repair concern.
|
|
3412
|
+
- **fail-open Project Updates** (`post_status_update` → `_LinearProjectOps.create_project_update`,
|
|
3413
|
+
the `projectUpdateCreate` mutation; `input = {projectId, body}` only — the `health` field is
|
|
3414
|
+
deliberately **omitted**) are posted on three transitions: **objective created** (`perk objective
|
|
3415
|
+
create`, fresh-create only — skipped on the idempotent found-existing path), **a plan lands**
|
|
3416
|
+
(`_reconcile_objective_on_land` in `pr land`, posted once when ≥1 node was marked, isolated like
|
|
3417
|
+
the existing close fail-open), and **reconciliation runs** (`perk objective reconcile`, on a real
|
|
3418
|
+
non-dry-run update). Bodies come from pure backend-neutral composers in `perk/objective/render.py`
|
|
3419
|
+
(`objective_created_update_body` / `plan_landed_update_body` / `reconciled_update_body`) computed
|
|
3420
|
+
from counts the call site already holds — **no extra network reads**. There is **no** plan-save
|
|
3421
|
+
Project Update (out of this node's scope).
|
|
3422
|
+
- **Realized:** `projectUpdateCreate` / `set_project_state` / `list_projects` are **live-verified
|
|
3423
|
+
2026-06-16** (Node 5.1 Mode-4 gates 4.1 / 4.3 / 4.5 / 4.6).
|
|
3424
|
+
|
|
3425
|
+
**Node 4.4 amendment — the objective manifest + drift detection/repair (`perk objective doctor`).**
|
|
3426
|
+
A Linear Project's roadmap is *observed* state (node-issues, blocking relations, milestones) that a
|
|
3427
|
+
human can edit out from under perk. To detect that divergence, the project overview now persists an
|
|
3428
|
+
authoritative **`objective-manifest`** block (inline-code, between the `objective-header` block and
|
|
3429
|
+
the Reconcilable region) — the intended roadmap's **structural identity**: per node `id` / `slug` /
|
|
3430
|
+
`description` + the explicit `depends_on` edge set (always a list), plus a `phases` map pinning the
|
|
3431
|
+
canonical milestone name per `phase_key_str` (`"2A.1" → "2A"`). `status`/`pr` are **excluded** (they
|
|
3432
|
+
are live/observed state, not identity). Drift is `diff(manifest, observed)`; repair makes the
|
|
3433
|
+
observed state match the manifest for **safe, unambiguous** cases only (perk never *invents*
|
|
3434
|
+
information it has no authority to invent). GitHub + the issue-backed Linear store edit their
|
|
3435
|
+
roadmap atomically with the body — **no divergence surface** — so both new methods are empty no-ops
|
|
3436
|
+
there (the `save_node_plan → None` / `post_status_update → False` precedent).
|
|
3437
|
+
|
|
3438
|
+
- **The pure drift engine** (`perk/objective/drift.py`, fully offline — no network/clock/Click): the
|
|
3439
|
+
store builds an `ObservedSnapshot` (the one network step) and `detect_drift(snapshot)` returns a
|
|
3440
|
+
`DriftReport` of `DriftCondition`s, each carrying a stable machine `code` (`DriftCode`), a
|
|
3441
|
+
`severity` (error/warning/info), `node_id`/`target`, a `message`, and a **`repairable`** flag. A
|
|
3442
|
+
malformed manifest (`MANIFEST_MALFORMED`) or an absent one (`MANIFEST_ABSENT`) short-circuits — no
|
|
3443
|
+
baseline to diff. The catalog of codes: `MANIFEST_ABSENT` (repairable: backfill) ·
|
|
3444
|
+
`MANIFEST_MALFORMED` · `MISSING_NODE_ISSUE` (repairable: recreate) · `DUPLICATE_NODE_IDS` ·
|
|
3445
|
+
`MISSING_NODE_STATUS_BLOCK` · `BLOCKING_RELATION_CYCLE` (manifest-enriched: names the human-added
|
|
3446
|
+
edges) · `UNKNOWN_BLOCKER_REFERENCE` · `DEPENDENCY_MISSING_IN_LINEAR` (repairable: create
|
|
3447
|
+
relation) · `DEPENDENCY_EXTRA_IN_LINEAR` · `DELETED_PHASE_MILESTONE` (repairable: recreate +
|
|
3448
|
+
reattach) · `RENAMED_PHASE_MILESTONE` · `OVERVIEW_MARKER_DAMAGE`.
|
|
3449
|
+
- **Two new `ObjectiveStore` methods + two result dataclasses.** `detect_objective_drift(*,
|
|
3450
|
+
objective_id) → DriftReport` and `repair_objective_drift(*, objective_id, dry_run=False) →
|
|
3451
|
+
RepairResult`. `RepairResult` = `applied: tuple[RepairAction,…]` / `failed: RepairAction | None` /
|
|
3452
|
+
`remaining: tuple[DriftCondition,…]` / `aborted: bool` / `dry_run: bool`; `RepairAction` =
|
|
3453
|
+
`code` / `node_id` / `error` (the write-failure message on the failed action only). Repairs apply
|
|
3454
|
+
in a deterministic order — a manifest backfill short-circuits everything, else milestone → node-
|
|
3455
|
+
issue → dependency (parents before edges), then by node id — and **fail loud**: the first failed
|
|
3456
|
+
Linear write stops the batch (`aborted=True`, the failing condition in `failed`); `applied` records
|
|
3457
|
+
what landed before the abort (durable + idempotent on re-run). A `dry_run` plans the would-apply
|
|
3458
|
+
set without any write. Node-issue recreation is **deferred-edge**: all missing node-issues are
|
|
3459
|
+
created first, then a single post-loop sweep restores every manifest edge **touching a recreated
|
|
3460
|
+
node** that Linear still lacks — in **both** directions (the recreated node's own `depends_on` AND
|
|
3461
|
+
an already-existing dependent's edge to it). Detection cannot raise a `DEPENDENCY_MISSING_IN_LINEAR`
|
|
3462
|
+
action while either endpoint is absent (it only diffs deps between two observed nodes), so the
|
|
3463
|
+
recreate path owns those edges; observed↔observed missing edges stay with the explicit dependency
|
|
3464
|
+
repair (the sweep skips edges whose endpoints are both already-observed, so no double-create). The
|
|
3465
|
+
drain fails loud on a genuinely unresolvable endpoint, never silently skips.
|
|
3466
|
+
- **Two new project ops** (`_LinearProjectOps`, **offline-covered / not-yet-live-proven** — see the
|
|
3467
|
+
correction below): `project_issues_with_milestones` (a `project_issues` sibling joining each node-issue's
|
|
3468
|
+
`projectMilestone`) and `attach_issue_to_milestone` (the deleted-milestone reattach — bare
|
|
3469
|
+
boundary identifier through `_request_issue_mutation`, mirroring post-#622 `attach_issue_to_project`;
|
|
3470
|
+
**no `uuid_for`**, deleted in #622). A recreated missing node-issue uses `_create_issue_raw` to
|
|
3471
|
+
capture the UUID for the UUID-only `issueRelationCreate`.
|
|
3472
|
+
- **Manifest sync on the live write paths.** `create_objective` writes the manifest at create;
|
|
3473
|
+
`add_objective_node` appends the new node's entry (pinning a brand-new phase's name) and — because
|
|
3474
|
+
**the manifest is the phase-name authority for an existing phase** — attaches the node to the
|
|
3475
|
+
manifest-pinned milestone for an already-pinned phase (`enrich_phase_names` only seeds the name for
|
|
3476
|
+
a brand-new phase, so an external overview edit can't divert the node to a wrong/new milestone);
|
|
3477
|
+
`update_objective_node` syncs a node's manifest **description** on a description change (a
|
|
3478
|
+
status/pr-only change does **not** touch it); `update_objective_body` (reconcile) refreshes the
|
|
3479
|
+
`phases` pins to **match** the spliced overview in the **same** write — the overview is the
|
|
3480
|
+
authority on a reconcile, so a pin tracks exactly what `enrich_phase_names` derives, **including
|
|
3481
|
+
reverting to the `Phase N` default** when a reconcile removed/defaulted a header (never preserving
|
|
3482
|
+
a now-stale custom name). Every sync is a clean no-op on a pre-manifest objective (no manifest
|
|
3483
|
+
block); `doctor --fix` backfill is the path that adopts one.
|
|
3484
|
+
- **The worker.** `perk objective doctor <id> [--fix] [--dry-run] [--json]` — detect-only by
|
|
3485
|
+
default; `--fix` applies the repairable repairs; `--dry-run` (with `--fix`) plans them. `--json`
|
|
3486
|
+
emits `{success, error_type, objective, drift: [condition…], fix: null | {applied, failed,
|
|
3487
|
+
remaining, aborted, dry_run}}` to stdout; human text to stderr. Exit `0` ran (drift, even
|
|
3488
|
+
ERROR-severity report-only drift, is a clean report) · `1` op-failure or an **aborted** repair ·
|
|
3489
|
+
`2` not-a-repo.
|
|
3490
|
+
- **Live-unverified (corrected):** the two new project ops (`project_issues_with_milestones`,
|
|
3491
|
+
`attach_issue_to_milestone`) were added in #624 **after** the Node 5.1 gate ran. The Node 5.1
|
|
3492
|
+
Mode-4 run executed with the drift doctor design-only and substituted a `get_objective`
|
|
3493
|
+
perturbation baseline (gate 4.9) for the doctor run, so these two ops were **not** verified at 5.1
|
|
3494
|
+
and remain **offline-covered / not-yet-live-proven** — a live-unverified follow-up (no Phase-5
|
|
3495
|
+
gate now covers them).
|
|
3496
|
+
|
|
3497
|
+
**Node 5.2 amendment — Phase 5 close-out (docs-only reconciliation).** Phase 5 closed Objective
|
|
3498
|
+
#548. Node 5.1 (PR #610) **live-proved** the four targeted Project ops on 2026-06-16 (Mode-4 gates
|
|
3499
|
+
4.1–4.10: `list_projects`, `create_project_update`, `set_project_state`, `_workflow_state_id` both
|
|
3500
|
+
directions). Node 5.2 (this node) finalized the contract + `docs/user-docs/` against what was built
|
|
3501
|
+
and live-verified, relocated the three Linear docs (`linear-masterplan.md`,
|
|
3502
|
+
`the-road-to-using-linear-projects-as-objectives.md`, `linear-smoke-gate.md`) into `docs/planning/`,
|
|
3503
|
+
and annotated the two historical memos as realized. No production logic changed. The two drift ops
|
|
3504
|
+
above remain the one honest live-unverified residual.
|
|
3505
|
+
|
|
3506
|
+
**Idiomatic-Linear amendment (#669) — attribution, attachments, labels, prose-first metadata.**
|
|
3507
|
+
Additive, **Linear-only** (every GitHub-backed render path is byte-identical; the only cross-plane
|
|
3508
|
+
artifact touched is this contract). perk authenticates with a personal `LINEAR_API_KEY`, so the
|
|
3509
|
+
actor is the human user; these changes make perk's footprint read as native:
|
|
3510
|
+
|
|
3511
|
+
- **Attribution = the API-key user (the viewer).** `LinearClient.viewer_id()` resolves + caches
|
|
3512
|
+
the viewer UUID (`query { viewer { id } }`, mirroring `team_id` memoization). **Every**
|
|
3513
|
+
perk-created issue (plan, learn, objective-issue, node-issue — all through
|
|
3514
|
+
`_create_issue_raw`) sets `assigneeId` to the viewer, so it appears in the user's *My Issues*;
|
|
3515
|
+
**every** project (`create_project`) sets `leadId` to the viewer.
|
|
3516
|
+
- **Project `startDate` at create.** `create_project` sets `startDate` to today (ISO `YYYY-MM-DD`),
|
|
3517
|
+
the prerequisite for Linear's project graph; target date stays unset (perk has no deadline
|
|
3518
|
+
signal).
|
|
3519
|
+
- **Project lifecycle → Started on first node work.** `LinearProjectObjectiveStore.update_objective_node`
|
|
3520
|
+
best-effort advances the Project to `started` (`set_project_state`) when a node enters a
|
|
3521
|
+
`started`-type status (planning/in_progress/blocked per `_NODE_STATUS_STATE_TYPE`). Forward-only
|
|
3522
|
+
(it only ever writes `started`; completion is owned by `close_objective`), idempotent, and
|
|
3523
|
+
fail-open. The node-status workflow-state mirror beside it (which nudges the node-issue's Linear
|
|
3524
|
+
state to match the new status) is likewise fail-open, but its failures now print one
|
|
3525
|
+
loud-but-non-fatal stderr note (`perk linear: node status mirror skipped`); the project-lifecycle
|
|
3526
|
+
nudge itself stays a silent `suppress` (a truly-opportunistic forward-only write).
|
|
3527
|
+
- **Workspace-scoped perk labels.** `_ensure_label_id` omits `teamId` on create, so the five
|
|
3528
|
+
`perk:*` labels are created at workspace level (Linear's cross-team-label guidance); the lookup
|
|
3529
|
+
is unscoped, so a pre-existing team-scoped label still counts (no duplicate).
|
|
3530
|
+
- **The fifth label `perk:objective-node`.** Roadmap node-issues now carry it (additive
|
|
3531
|
+
human-filterability — discovery is still by project membership + the `objective-node` block, so
|
|
3532
|
+
`get_objective` is unaffected). It joins `_PERK_LABELS` (init / `doctor --fix` / readiness ensure
|
|
3533
|
+
it) and is applied at `create_objective`, `add_objective_node`, and node-issue drift-recreation.
|
|
3534
|
+
- **Native PR attachments (idempotent by URL).** `_LinearIssueOps.create_attachment(issue_id, *,
|
|
3535
|
+
url, title, subtitle=None)` issues `attachmentCreate` (a sidebar card; re-creating the same URL
|
|
3536
|
+
updates in place — no id to track). `LinearIssueBackend.update_plan_header` posts one
|
|
3537
|
+
best-effort, **fail-open** when the stamped `pr` resolves to a GitHub PR (title `GitHub PR #N`,
|
|
3538
|
+
subtitle the PR state). This single seam covers both a standalone Linear plan issue and a unified
|
|
3539
|
+
node-issue (both stamp `pr` here). The attachment is bookkeeping — a Linear/PR-lookup failure
|
|
3540
|
+
never fails the header stamp, and prints one loud-but-non-fatal stderr note
|
|
3541
|
+
(`perk linear: PR attachment skipped`).
|
|
3542
|
+
- **Prose-first metadata composition.** Linear bodies now render the human prose **first**, the
|
|
3543
|
+
machine blocks after: the project overview is `Reconcilable(prose)` then `objective-header` +
|
|
3544
|
+
`objective-manifest`; node-issues are `description` (prose) then the `objective-node` block.
|
|
3545
|
+
Reads are position-independent (`find_metadata_block` / `replace_reconcilable_section` scan by
|
|
3546
|
+
marker), and the manifest-backfill insert (`_insert_or_replace_manifest`) places the manifest
|
|
3547
|
+
**after** the Reconcilable region. The GitHub `style="html"` `<details>` render is unchanged.
|
|
3548
|
+
- **Deferred — the collapsed-toggle render.** Wrapping the Linear metadata blocks in a native
|
|
3549
|
+
collapsible toggle (the true `<details>` analog) depends on an **undocumented** markdown
|
|
3550
|
+
round-trip and is gated on the live smoke gate (Mode 5).
|
|
3551
|
+
Per the plan's safe-degradation, prose-first ships now and the toggle is deferred until the live
|
|
3552
|
+
round-trip is proven lossless (else dropped). Becoming a true Linear **Agent** (`actor=app`) is a
|
|
3553
|
+
separate, out-of-scope follow-up.
|
|
3554
|
+
|
|
3555
|
+
## §8.25 · The human-engagement read contract (Objective #682, Node 1.2)
|
|
3556
|
+
|
|
3557
|
+
A backend-neutral **READ** surface for human engagement — comments, description edits, and
|
|
3558
|
+
agent-session activities — added to **both** the `IssueBackend` (`issue_id`) and `ObjectiveStore`
|
|
3559
|
+
(`objective_id`) seams. Implemented honestly on the **Linear issue backend** over GraphQL; every
|
|
3560
|
+
other implementer ships a clean empty/no-op conforming impl (honest — **no flow consumers** wire it
|
|
3561
|
+
in Node 1.2; the consuming flows arrive in Phase 2+). Anchored on the Node 1.1 inventory.
|
|
3562
|
+
|
|
3563
|
+
**Result dataclasses** (`perk/backends/engagement.py` — a pure module importing nothing from the
|
|
3564
|
+
backend tiers, so both protocols + every implementer import it without re-coupling the deliberate
|
|
3565
|
+
issue-tier ↔ objective-tier split). All frozen:
|
|
3566
|
+
|
|
3567
|
+
- `EngagementComment(id, body, created_at, edited_at: str | None, author)` — `edited_at` flags an
|
|
3568
|
+
edited comment.
|
|
3569
|
+
- `DescriptionEdit(created_at, author, diff: str | None)` — `diff` is `None` when the backend
|
|
3570
|
+
exposes no inline diff (Linear's issue history carries none — a flagged limit).
|
|
3571
|
+
- `AgentActivity(id, created_at, kind: str, body: str | None, signal: str | None)` — `kind` is the
|
|
3572
|
+
backend's activity-content type discriminator (Linear's content-union `__typename`).
|
|
3573
|
+
- `StopSignalIndicator(stopped: bool, at: str | None)` — **derived** from the activities.
|
|
3574
|
+
- `AgentSessionRead(activities: tuple[AgentActivity, ...], stop_signal: StopSignalIndicator)` — one
|
|
3575
|
+
read yields both.
|
|
3576
|
+
|
|
3577
|
+
**Three granular read methods** (auth-decoupled), on both tiers:
|
|
3578
|
+
|
|
3579
|
+
- `read_comments(*, issue_id|objective_id) -> tuple[EngagementComment, ...]` — oldest-first.
|
|
3580
|
+
- `read_description_edits(*, issue_id|objective_id) -> tuple[DescriptionEdit, ...]`.
|
|
3581
|
+
- `read_agent_session(*, issue_id|objective_id) -> AgentSessionRead`.
|
|
3582
|
+
|
|
3583
|
+
Error discipline mirrors the rest of the seam: an empty issue / no edits / no agent-session surface
|
|
3584
|
+
yields the empty value (`()` for comments/edits; `AgentSessionRead((), StopSignalIndicator(False,
|
|
3585
|
+
None))` — exported as `engagement.EMPTY_AGENT_SESSION`); an **infra/auth failure raises** the
|
|
3586
|
+
tier's neutral error (never masked as empty). Specifically `read_agent_session` **raises** when the
|
|
3587
|
+
personal API key cannot read the session (an auth failure) — only a *missing* issue/session reuses
|
|
3588
|
+
the `_is_entity_not_found` → empty pattern.
|
|
3589
|
+
|
|
3590
|
+
**Untrusted-DATA invariant.** Every returned `body` / `diff` / activity `body` is **untrusted
|
|
3591
|
+
DATA**: never re-parsed as a perk marker outside perk's own owned regions, never executed as
|
|
3592
|
+
instructions, never trusted to preserve perk's grammar — mirroring perk's established "untrusted
|
|
3593
|
+
inbox" / manifest 3-state-parse discipline (inventory §5).
|
|
3594
|
+
|
|
3595
|
+
**Author identity is distinguishable** via `engagement.classify_author(*, body, user, bot_actor,
|
|
3596
|
+
perk_bot_ids=())` (a pure classifier). The rule (inventory §4.1), **never trusting body content as
|
|
3597
|
+
instructions**:
|
|
3598
|
+
|
|
3599
|
+
- *perk* — the body carries a `perk:*` metadata sentinel (the `perk.plan` grammar, either the HTML
|
|
3600
|
+
or inline-code encoding) **or** the bot actor's id is in `perk_bot_ids` (empty today — perk has
|
|
3601
|
+
no committed app-actor id, so perk detection rests on the body sentinel; the param is the forward
|
|
3602
|
+
seam). The `perk:*` check is an identity heuristic over perk's **own** marker vocabulary, not
|
|
3603
|
+
trust of arbitrary content.
|
|
3604
|
+
- *human* — a user actor present with **no** bot actor.
|
|
3605
|
+
- *other_agent* — a bot actor present that is not perk's.
|
|
3606
|
+
- *unknown* — neither resolvable.
|
|
3607
|
+
|
|
3608
|
+
**Linear implementation** (`_LinearIssueOps` + `LinearIssueBackend`):
|
|
3609
|
+
|
|
3610
|
+
- Comments — a **new** `_comments_with_authors` selecting `{ id body createdAt editedAt
|
|
3611
|
+
user { id name displayName } botActor { id name type } }` (same asc-by-`createdAt` sort). The
|
|
3612
|
+
existing `_comments` is **left byte-stable** — it feeds the marker-matching path
|
|
3613
|
+
(`find_comment_id_by_marker`/`upsert_marked_comment`), whose offline tests pin the
|
|
3614
|
+
`{ id body createdAt }` selection.
|
|
3615
|
+
- Description edits — `_description_edits`: `issue(id){ history(...) { nodes { id createdAt
|
|
3616
|
+
actor descriptionUpdatedBy } } }`, filtered to nodes carrying a `descriptionUpdatedBy`, mapped to
|
|
3617
|
+
`DescriptionEdit` (`diff=None`; author keyed on the editing `actor`). Fields selected explicitly
|
|
3618
|
+
(the SDK `relationChanges` pitfall, inventory §3.2). A missing issue → `[]`.
|
|
3619
|
+
- Agent session — `_agent_session_activities`: resolve the issue's session id, then
|
|
3620
|
+
`agentSession(id){ activities(...) { nodes { id createdAt signal content { __typename
|
|
3621
|
+
... on AgentActivity{Prompt,Thought,Response}Content { body } } } } }`. The `StopSignalIndicator`
|
|
3622
|
+
is **derived** (`stopped` when any activity carried `signal == "stop"`; `at` = the first such
|
|
3623
|
+
activity's `created_at`). **Auth caveat (inventory §6.2):** whether the personal API key can read
|
|
3624
|
+
`agentSession.activities` is live-unproven — the live smoke settles it.
|
|
3625
|
+
|
|
3626
|
+
**Honest-now vs dormant.** `LinearIssueBackend` is honest. `GitHubIssueBackend` is now honest for
|
|
3627
|
+
comments + description edits (Node 1.3), both via read-only `gh api graphql`: comments from
|
|
3628
|
+
`IssueComment` (`lastEditedAt` → the `edited_at` flag; `author { __typename databaseId login }` →
|
|
3629
|
+
the bot/human discriminator + opaque id), description edits from `Issue.userContentEdits`
|
|
3630
|
+
(`editedAt` / `editor` / a best-effort `diff` — GitHub may return null). `gh api graphql` does not
|
|
3631
|
+
auto-template `{owner}/{repo}`, so the queries pass explicit `owner`/`name`/`number` variables
|
|
3632
|
+
(cursor-paginated); a not-found issue folds to `()`. `perk_bot_ids` stays empty (perk has no
|
|
3633
|
+
committed GitHub app actor — perk-authored content is detected by its body sentinel). Agent
|
|
3634
|
+
sessions stay a clean GitHub no-op (no agent-session surface). All objective stores
|
|
3635
|
+
(`GitHubObjectiveStore`, the dormant `LinearObjectiveStore`, the live `LinearProjectObjectiveStore`)
|
|
3636
|
+
ship empty — honest project-level reads land with their Phase-2 consumer (Node 2.3). Conformance is
|
|
3637
|
+
ty-enforced across every implementer + fake (the whole-repo `ty check` oracle).
|
|
3638
|
+
|
|
3639
|
+
**No** new config key / command / door / provider in Node 1.2 → **no** `docs/user-docs/` or
|
|
3640
|
+
`perk-expert` change (the user-facing surface arrives with the Phase-2 consumers).
|
|
3641
|
+
|
|
3642
|
+
## §8.26 · Node-issue engagement in `/objective-plan` (Objective #682, Node 2.1)
|
|
3643
|
+
|
|
3644
|
+
The **first flow consumer** of the §8.25 read contract: `/objective-plan` surfaces a roadmap
|
|
3645
|
+
node-issue's **pre-planning** human engagement as untrusted DATA into the plan-authoring context, so
|
|
3646
|
+
the authored plan comprehends any human feedback left on the node-issue **before** perk planned it.
|
|
3647
|
+
Linear-first — GitHub (single-issue objectives) and the dormant issue-backed Linear store cleanly
|
|
3648
|
+
no-op.
|
|
3649
|
+
|
|
3650
|
+
**Node-keyed read.** A new `ObjectiveStore.read_node_engagement(*, objective_id, node_id) ->
|
|
3651
|
+
NodeEngagement` (the §8.25 reads are keyed on the whole objective/issue; this one is keyed on a
|
|
3652
|
+
single roadmap node). `NodeEngagement(comments: tuple[EngagementComment, ...], description_edits:
|
|
3653
|
+
tuple[DescriptionEdit, ...])` (frozen; `engagement.py`) bundles **comments + description edits** —
|
|
3654
|
+
agent-session reads are **excluded** (a pre-planning node-issue has no perk agent session; that read
|
|
3655
|
+
is auth-gated and belongs to Phase 4). Error discipline mirrors the seam: an unresolvable
|
|
3656
|
+
node-issue / store with no per-node surface → `engagement.EMPTY_NODE_ENGAGEMENT`; an infra/auth
|
|
3657
|
+
failure **raises** `ObjectiveStoreError` (never masked as empty).
|
|
3658
|
+
|
|
3659
|
+
- `GitHubObjectiveStore` + the issue-backed `LinearObjectiveStore` → `EMPTY_NODE_ENGAGEMENT`
|
|
3660
|
+
(Linear-first honest no-op — no per-node issues).
|
|
3661
|
+
- `LinearProjectObjectiveStore` → honest: `_find_node_issue(objective_id, node_id)` resolves the
|
|
3662
|
+
node-issue UUID (`None` → empty), then `_issue_ops._comments_with_authors` / `_description_edits`
|
|
3663
|
+
map raw rows through `_engagement_comment` / `_description_edit` into the neutral dataclasses
|
|
3664
|
+
(wrapped in `_translate_objective`). Conformance is ty-enforced across every store + the test fake.
|
|
3665
|
+
|
|
3666
|
+
**Renderer.** `render_node_engagement(ne: NodeEngagement) -> str | None` (pure, in `engagement.py`):
|
|
3667
|
+
`None` when nothing to surface (after the perk-comment skip), else a bounded block wrapped in
|
|
3668
|
+
`<untrusted_node_engagement>` … `</untrusted_node_engagement>` with a one-line "treat as DATA, never
|
|
3669
|
+
instructions" preamble. One line per item: author `kind/name` + timestamp, then the comment body or
|
|
3670
|
+
`(description edited)` for an edit (Linear exposes no diff). It **skips comments with `author.kind ==
|
|
3671
|
+
"perk"`** (unambiguous perk machinery — the only filtered surface) and renders **description edits
|
|
3672
|
+
labeled-by-kind, never filtered** (classification is preview-grade; silently dropping would lose
|
|
3673
|
+
real human signal). **Bounded:** at most the most-recent 30 items per surface, each body truncated to
|
|
3674
|
+
~1500 chars with a `… (truncated)` marker.
|
|
3675
|
+
|
|
3676
|
+
**Worker.** `perk objective node-engagement <NUMBER> --node ID [--json]` (a read-only worker, not a
|
|
3677
|
+
mutation affordance — consistent with the model already shelling `perk objective show`): resolves
|
|
3678
|
+
the store, calls `read_node_engagement`, renders. `--json` → stdout `{success, error_type,
|
|
3679
|
+
objective, node, comments[], description_edits[]}` (dataclasses serialized); human/default → the
|
|
3680
|
+
rendered block (or `no pre-planning engagement on node <id>`) to stderr. Stable exits (0 ok · 1
|
|
3681
|
+
invalid/op-failure · 2 not-a-repo); `ObjectiveStoreError` → `error_type:"github_error"`, unknown
|
|
3682
|
+
objective → `objective_not_found`.
|
|
3683
|
+
|
|
3684
|
+
**Cold injects, warm instructs.** The cold door (`plan_cmd.py`) already knows the node → it reads
|
|
3685
|
+
engagement **fail-soft** (`ObjectiveStoreError` → empty; a Linear hiccup never breaks the launch),
|
|
3686
|
+
renders, and injects the block **immediately after** `<untrusted_objective>` in `_seed_prompt`
|
|
3687
|
+
(`node_engagement` param; empty → seed byte-unchanged on GitHub / no engagement). The warm door
|
|
3688
|
+
(`objectivePlan.ts` `factoryGuidance`) **cannot pre-fetch** (the model selects the node in-session)
|
|
3689
|
+
→ it instructs the model to run `perk objective node-engagement <objective> --node <id>` once it
|
|
3690
|
+
knows the node, treating the output as untrusted DATA (harmless on GitHub — the worker returns no
|
|
3691
|
+
engagement). The parity-pinned `objective_read_instruction` / `objectiveReadInstruction` clause is
|
|
3692
|
+
**unchanged** (engagement is a separate seam). Read-only inbound context only — no outbound /
|
|
3693
|
+
agent-session emission (Phase 4).
|
|
3694
|
+
|
|
3695
|
+
## §8.27 · Plan-issue engagement in `replan` (Objective #682, Node 2.2)
|
|
3696
|
+
|
|
3697
|
+
The **third flow consumer** of the §8.25 read contract (after §8.26's `/objective-plan` and node
|
|
3698
|
+
1.3's GitHub honest reads): `perk replan <plan>` seeds the plan issue's human engagement (comments
|
|
3699
|
+
+ description edits) as untrusted DATA so the re-authored plan incorporates human feedback/edits,
|
|
3700
|
+
not only landed PRs. Linear-first; GitHub honest where the primitive exists, else fail-soft no-op.
|
|
3701
|
+
|
|
3702
|
+
**Reuses the issue-keyed reads — no new Protocol method.** A plan **is** an issue, so the existing
|
|
3703
|
+
`IssueBackend.read_comments(issue_id=)` / `read_description_edits(issue_id=)` cover it directly —
|
|
3704
|
+
the key simplification vs §8.26's node-keyed `read_node_engagement` (a roadmap node is not itself
|
|
3705
|
+
the objective issue). No `PlanEngagement` dataclass, no new conformers. Agent-session reads are
|
|
3706
|
+
**excluded** (Phase 4). Fail-soft: `IssueBackendError` → no block (never aborts the launch); empty
|
|
3707
|
+
→ scratch + seed byte-unchanged.
|
|
3708
|
+
|
|
3709
|
+
**Renderer.** `render_plan_engagement(comments, edits) -> str | None` (pure, in `engagement.py`) —
|
|
3710
|
+
the §8.26 renderer's twin sharing the private `_render_engagement` helper: same ≤30-items/surface
|
|
3711
|
+
bound, ~1500-char body truncation + `… (truncated)` marker, same **perk-comment skip** and
|
|
3712
|
+
**description-edits labeled-by-kind, never filtered** rules; wrapped in `<untrusted_plan_engagement>`
|
|
3713
|
+
… `</untrusted_plan_engagement>`. `render_node_engagement`'s output stays byte-identical (pinned by
|
|
3714
|
+
a `test_engagement.py` byte-stability assert).
|
|
3715
|
+
|
|
3716
|
+
**Cold-only injection (no warm door).** `replan` is a dedicated cold door (no registry stage, no
|
|
3717
|
+
`objectivePlan.ts`-style warm half). It reads engagement up front — **including on `--dry-run`**,
|
|
3718
|
+
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>`
|
|
3720
|
+
(the scratch-file-native home, vs §8.26's inline-seed injection — replan centers on the scratch
|
|
3721
|
+
file the session `read`s). The seed's step 1 points at the block only when present (empty → seed
|
|
3722
|
+
byte-unchanged).
|
|
3723
|
+
|
|
3724
|
+
**Don't-churn unchanged.** Engagement is a new re-investigation *input*, not a new skip-rule clause;
|
|
3725
|
+
the perk-replan skill's "skip if nothing material changed" rule is left verbatim.
|
|
3726
|
+
|
|
3727
|
+
## §8.28 · Objective + node-issue engagement in `/objective-reconcile` (Objective #682, Node 2.3)
|
|
3728
|
+
|
|
3729
|
+
The **fourth flow consumer** of the §8.25 read contract (after §8.26's `/objective-plan`, node
|
|
3730
|
+
1.3's GitHub honest reads, and §8.27's `replan`): the post-merge `/objective-reconcile` pass
|
|
3731
|
+
comprehends **human engagement on the objective + its node-issues** (comments + description edits)
|
|
3732
|
+
as untrusted DATA, not only the landed PR diff. The section-boundary discipline (only the
|
|
3733
|
+
marker-bounded **Reconcilable** prose region is rewritten) and the skip-if-nothing-stale rule are
|
|
3734
|
+
unchanged. Linear-first; GitHub honest where the primitive exists.
|
|
3735
|
+
|
|
3736
|
+
**Honest objective-keyed reads (no new Protocol method).** The §8.25 objective-keyed
|
|
3737
|
+
`read_comments` / `read_description_edits` — empty stubs since 1.2 — become honest:
|
|
3738
|
+
|
|
3739
|
+
- **GitHub** (`GitHubObjectiveStore`): the objective IS a single issue, so `read_comments` /
|
|
3740
|
+
`read_description_edits` reuse `github.read_issue_comments` / `github.read_description_edits` +
|
|
3741
|
+
the shared `issues.py` mappers (`_engagement_comment` / `_description_edit`) over the objective
|
|
3742
|
+
issue. `read_node_engagement` stays a clean no-op (single-issue objective — no per-node issues).
|
|
3743
|
+
- **Linear** (`LinearProjectObjectiveStore`): `read_comments` is honest over the **Linear
|
|
3744
|
+
project's comments** (`_LinearProjectOps._project_comments`, an author-aware cursor-paginated read
|
|
3745
|
+
mirroring the issue `_comments_with_authors` selection, oldest-first); `read_description_edits`
|
|
3746
|
+
stays an honest **empty** `()` — Linear projects expose no description-edit-history primitive
|
|
3747
|
+
analogous to issue `history.descriptionUpdatedBy` (the edit signal lives on the node-issues, which
|
|
3748
|
+
the per-node sections carry — a flagged preview-grade deferral, live-proven at node 4.3). The
|
|
3749
|
+
dormant issue-backed `LinearObjectiveStore` reads are unchanged.
|
|
3750
|
+
|
|
3751
|
+
**Project Updates are NOT read.** Linear Project Updates (`projectUpdates`) are perk's own outbound
|
|
3752
|
+
status feed (`post_status_update` posts them on create/land/reconcile), so reading them back would
|
|
3753
|
+
surface perk's own bookkeeping — explicitly declined. Node 2.3 surfaces project **comments** (human
|
|
3754
|
+
discussion) + node-issue comments/edits only.
|
|
3755
|
+
|
|
3756
|
+
**Per-node reuse.** The worker composes the existing node-keyed `read_node_engagement` (§8.26)
|
|
3757
|
+
looped over **every** roadmap node (reconcile rewrites the whole roadmap prose, so feedback on any
|
|
3758
|
+
node-issue is relevant; empty per-node surfaces are skipped). Accepted cost: on Linear each
|
|
3759
|
+
`read_node_engagement` re-scans project issues via `_find_node_issue`, so all-nodes ≈ N scans —
|
|
3760
|
+
tolerable for an interactive post-merge worker; a batched single-fetch is a possible follow-up.
|
|
3761
|
+
|
|
3762
|
+
**Aggregate renderer.** `render_objective_engagement(*, project_comments, project_description_edits,
|
|
3763
|
+
node_engagements) -> str | None` (pure, in `engagement.py`) emits ONE block wrapped in
|
|
3764
|
+
`<untrusted_objective_engagement>` … `</untrusted_objective_engagement>`: a `project:` sub-section
|
|
3765
|
+
(only when non-empty) then a `node <id>:` sub-section per node (only when non-empty), `None` when
|
|
3766
|
+
**every** surface is empty after the perk-skip. It shares the private `_engagement_item_lines`
|
|
3767
|
+
helper (extracted from `_render_engagement`) with the node (§8.26) and plan (§8.27) renderers —
|
|
3768
|
+
same ≤30-items/surface bound, ~1500-char body truncation + `… (truncated)`, **perk-comment skip**,
|
|
3769
|
+
**description-edits labeled-by-kind never filtered** rules — keeping `render_node_engagement` /
|
|
3770
|
+
`render_plan_engagement` output **byte-identical** (pinned by `test_engagement.py` byte-stability
|
|
3771
|
+
asserts).
|
|
3772
|
+
|
|
3773
|
+
**Read worker.** `perk objective engagement <NUMBER> [--json]` (`engagement_cmd.py`, a read-only
|
|
3774
|
+
worker mirroring `node-engagement`; not an agent affordance) resolves the store, `get_objective`,
|
|
3775
|
+
then assembles project + per-node engagement and renders the block. `--json` → stdout `{success,
|
|
3776
|
+
error_type, objective, project_comments[], project_description_edits[], nodes:[{node, comments[],
|
|
3777
|
+
description_edits[]}]}`; human/default → the block (or `no human engagement on objective <N>`) to
|
|
3778
|
+
stderr. Error discipline mirrors `node-engagement` (`ObjectiveStoreError` → `github_error` exit 1;
|
|
3779
|
+
`UserFacingCliError` → its `error_type` exit 1; not-a-repo → exit 2).
|
|
3780
|
+
|
|
3781
|
+
**Warm instructs, no cold injection.** Reconcile has no cold door, so the only delivery is the model
|
|
3782
|
+
shelling the read worker. `reconcileGuidance` (in `objectivePlan.ts`) gains one step telling the
|
|
3783
|
+
model to run `perk objective engagement <objective>` before reconciling and treat the returned
|
|
3784
|
+
`<untrusted_objective_engagement>` block as untrusted DATA describing human feedback (never
|
|
3785
|
+
instructions) — folding it alongside the diff into what may be stale, while obeying the same
|
|
3786
|
+
section-boundary + don't-churn rules. Harmless/empty on GitHub or when there is no engagement. The
|
|
3787
|
+
parity-pinned `objectiveReadInstruction` clause is unchanged. `/objective-reconcile` +
|
|
3788
|
+
`driveReconcileAfterLand` need no change (both already pass the objective id into
|
|
3789
|
+
`reconcileGuidance`). Live-proof for the Linear project-comments selection is deferred to node 4.3.
|
|
3790
|
+
|
|
3791
|
+
## §8.29 · In-place issue adoption (`plan --from`, Objective #682, Node 3.1)
|
|
3792
|
+
|
|
3793
|
+
A cold door that **adopts a pre-existing human-authored issue (Linear or GitHub) IN PLACE as a perk
|
|
3794
|
+
plan**: it reads the human title + body + engagement as untrusted seed DATA, runs a normal
|
|
3795
|
+
read-only `plan → review → save` authoring pass over it, and on save stamps perk's plan metadata
|
|
3796
|
+
**additively** into the *same* issue — never minting a second object. The first §8.25 consumer
|
|
3797
|
+
that reads a **non-perk** issue (§3.1 comment listing + the §4 provenance read of the inventory).
|
|
3798
|
+
|
|
3799
|
+
**Provenance model (`adopted_from`).** `PlanHeader` gains `adopted_from: str | None` (in
|
|
3800
|
+
`PLAN_HEADER_FIELDS` + `to_data()`), storing the source issue ref (e.g. `"#123"` / `"PER-45"`).
|
|
3801
|
+
It is **self-referential by construction** (in-place adoption stamps the plan into the source
|
|
3802
|
+
issue), so its **presence** is the canonical signal "this plan was adopted; its issue body/title
|
|
3803
|
+
are verbatim human content". A normally-authored plan leaves it `None`.
|
|
3804
|
+
|
|
3805
|
+
**Two new `IssueBackend` reads/writes (both backends + fakes).**
|
|
3806
|
+
|
|
3807
|
+
- `read_issue(*, issue_id) -> AdoptableIssue | None` — reads *any* issue's raw `title`/`body`
|
|
3808
|
+
(untrusted DATA) + normalized `state` (`"OPEN"|"CLOSED"`). Unlike `get_plan` (needs a header) /
|
|
3809
|
+
`get_plan_body` (needs a plan-body block), it reads a non-perk human issue verbatim. `None` when
|
|
3810
|
+
absent; raises `IssueBackendError` on infra failure. GitHub: `gh issue view … --json …`; Linear:
|
|
3811
|
+
`issue(id:)` mapped to the neutral shape.
|
|
3812
|
+
- `adopt_issue_as_plan(*, issue_id, header_fields, plan_markdown, callout, command, dry_run) ->
|
|
3813
|
+
IssueRef` — the in-place additive stamp (mirrors `ObjectiveStore.save_node_plan`): (a) ensure +
|
|
3814
|
+
**add** the `perk:plan` label (never replaces the issue's existing labels); (b) stamp the
|
|
3815
|
+
`plan-header` block additively into the issue **body** (human prose preserved verbatim, **title
|
|
3816
|
+
untouched**); (c) idempotently prepend the `perk impl <id>` callout above the body; (d) upsert
|
|
3817
|
+
the `plan-body` comment carrying the authored markdown. Returns `IssueRef(existed=True)`.
|
|
3818
|
+
Idempotent on re-save; GitHub stamps HTML-encoded, Linear inline-code (Linear-safe).
|
|
3819
|
+
|
|
3820
|
+
**The cold door (`perk plan from <issue>`).** A dedicated launcher verb in the `plan` hybrid group
|
|
3821
|
+
(mirrors `replan`/`resume`; `from` is a valid Click command string). It performs every Linear/GitHub
|
|
3822
|
+
read up front (the read-only plan-mode session has no `gh`/Linear access), then re-launches the
|
|
3823
|
+
`plan` stage seeded to author a plan over the materialized source. It **refuses** when: the issue is
|
|
3824
|
+
not found (`adopt_not_found`), not OPEN (`adopt_not_open`), or already a perk plan
|
|
3825
|
+
(`has_metadata_block(body, plan-header)` → `already_a_plan`, hinting `perk plan replan <id>`).
|
|
3826
|
+
Engagement is read fail-soft (`render_adopted_engagement` → `<untrusted_adopted_issue_engagement>`;
|
|
3827
|
+
`IssueBackendError` → omitted). The source is materialized to `scratch/adopt-<issue_id>.md` (title +
|
|
3828
|
+
body wrapped in `<untrusted_adopted_issue>` + the optional engagement block). A **fresh** `run_id`
|
|
3829
|
+
is minted (vs `replan` reusing the original); the default `binding_trigger` (`stage:plan`) fires the
|
|
3830
|
+
`perk-plan` nudge. `--dry-run` materializes + prints the seed, launches nothing (reads are real,
|
|
3831
|
+
like `replan`). `--remote` is rejected (local-only, resolved up front).
|
|
3832
|
+
|
|
3833
|
+
**The save (rides the handoff).** The `plan from` door stashes `adopt_from` in the run **handoff**,
|
|
3834
|
+
so the adoption link survives **every** save surface (the `/plan-save` command, the `plan_save`
|
|
3835
|
+
tool, approval-driven save — all forward only `{plan, title}`). `perk plan save` gains
|
|
3836
|
+
`--adopt-from <issue>` + `_adopt_from_handoff` recovery (explicit flag wins, else the handoff key).
|
|
3837
|
+
When set on a real save, `_plan_save_impl` sets `header.adopted_from`, calls
|
|
3838
|
+
`adopt_issue_as_plan(...)`, **skips** `create_plan_issue` (`updated=True`, `labels=(perk:plan,)`,
|
|
3839
|
+
`cache.plan-ref.pr_id = adopt_from`). **Mutual exclusion:** `--adopt-from` with
|
|
3840
|
+
`--objective-id`/`--node-id` is rejected (`invalid_input`) — the node-unification path is the
|
|
3841
|
+
in-place writer for objective nodes; the two in-place semantics never mix. `--dry-run` composes +
|
|
3842
|
+
prints the header/body (now including `adopted_from`) without writes.
|
|
3843
|
+
|
|
3844
|
+
**Doctor (awareness note, not a check).** An adopted plan is identified by a populated
|
|
3845
|
+
`adopted_from` plan-header field; `doctor` does **not** rewrite or validate the human prose/title —
|
|
3846
|
+
the substantive deliverable is this contract section, not a new validating check.
|
|
3847
|
+
|
|
3848
|
+
**Backend parity.** Honest on **both** GitHub and Linear (+ clean fake conformers). Live validation
|
|
3849
|
+
is a preview-grade observation here (Mode 7); final live
|
|
3850
|
+
proof is node 4.3.
|
|
3851
|
+
|
|
3852
|
+
## §8.30 · In-place objective adoption (`objective author --from`, Objective #682, Node 3.2)
|
|
3853
|
+
|
|
3854
|
+
The **objective-level analog of §8.29**: it adopts a **pre-existing human source** — a Linear
|
|
3855
|
+
**Project** (and its issues) or a GitHub **issue** — IN PLACE as a perk objective. It reads the
|
|
3856
|
+
human prose + existing issues as untrusted seed DATA, runs a normal read-only objective-authoring
|
|
3857
|
+
pass, and on save stamps perk's objective metadata **additively** into the *same* source, mapping
|
|
3858
|
+
existing issues to roadmap nodes where the author chose, and **never minting a second
|
|
3859
|
+
project/issue**. Linear is the first-class path (project + child issues); GitHub is bounded (single
|
|
3860
|
+
issue, no children).
|
|
3861
|
+
|
|
3862
|
+
**Surface.** A `--from <source>` **flag on `objective author`** (not a new `objective from` verb —
|
|
3863
|
+
an accepted divergence from §8.29's `plan from` verb): it keeps `objective author` the single
|
|
3864
|
+
authoring entry point and matches the node title. When `--from` is absent the door is byte-unchanged
|
|
3865
|
+
(the existing authoring seed).
|
|
3866
|
+
|
|
3867
|
+
**Provenance model (`adopted_from`).** `ObjectiveHeader` gains `adopted_from: str | None` (in
|
|
3868
|
+
`OBJECTIVE_HEADER_FIELDS` + `to_data()`), storing the **source ref**: a Linear project UUID
|
|
3869
|
+
(projects have no human identifier) or a GitHub issue ref (`"#<n>"`). Self-referential by
|
|
3870
|
+
construction; its **presence** is the canonical signal "this objective was adopted; the
|
|
3871
|
+
`Adopted-from` Immutable note holds the original human content". A normally-authored objective
|
|
3872
|
+
leaves it `None`.
|
|
3873
|
+
|
|
3874
|
+
**The mapping carrier (`adopt_issue` + `parse_adopt_mapping`).** An optional per-node `adopt_issue`
|
|
3875
|
+
field on the structured roadmap maps a node to an **existing** project issue (its id/identifier). It
|
|
3876
|
+
is carried **separately** from `ObjectiveNode` (which stays pristine — used pervasively in
|
|
3877
|
+
rendering/manifest/drift): the pure `objective.parse_adopt_mapping(raw) -> dict[str, str]` extracts
|
|
3878
|
+
`{node_id: source_issue_id}` from the same raw roadmap shape `parse_structured_roadmap` accepts. The
|
|
3879
|
+
TS `ROADMAP_PARAM_SCHEMA` (`additionalProperties: false`, shared by `objective_save` +
|
|
3880
|
+
`objective_draft`) gains `adopt_issue` so the field is not rejected at the tool boundary; `roadmap`
|
|
3881
|
+
flows through as `unknown[]`, so the field survives unchanged to the Python cold door.
|
|
3882
|
+
|
|
3883
|
+
**The verbatim-preservation model.** Decisions: (4) the model authors the objective's Reconcilable
|
|
3884
|
+
prose (the human source prose is seed DATA); (5) the source's **original** overview/body is captured
|
|
3885
|
+
verbatim into an `Adopted-from` **Immutable** archive note appended **below** the closing
|
|
3886
|
+
Reconcilable marker (`objective.render_adopted_overview_note`, a perk HTML-comment marker that
|
|
3887
|
+
round-trips through `to_linear_markdown` → inline-code; empty `original` → `""`), never rewritten by
|
|
3888
|
+
reconcile. Mapped issues' titles/bodies are independently preserved verbatim by the additive
|
|
3889
|
+
`objective-node` block stamp.
|
|
3890
|
+
|
|
3891
|
+
**The adoptable-source read contract (two new `ObjectiveStore` methods + result shapes).**
|
|
3892
|
+
|
|
3893
|
+
- `AdoptableSourceIssue` (`id`, `identifier`, `url`, `title`, `body`) — one pre-existing project
|
|
3894
|
+
issue (untrusted DATA). `AdoptableObjectiveSource` (`id`, `url`, `title`, `prose`, `issues`) —
|
|
3895
|
+
the source overview/body + its existing issues (`issues` empty on GitHub).
|
|
3896
|
+
- `read_objective_source(*, source_id) -> AdoptableObjectiveSource | None` — reads *any*
|
|
3897
|
+
pre-existing source (Linear project / GitHub issue) verbatim for adoption (the objective-tier
|
|
3898
|
+
twin of `IssueBackend.read_issue`). `None` when absent; raises on infra failure. Returned even
|
|
3899
|
+
when CLOSED — the cold door does the not-open refusal. A store with no project-source surface
|
|
3900
|
+
(the dormant issue-backed Linear store) returns `None`.
|
|
3901
|
+
- `adopt_source_as_objective(*, source_id, title, prose, run_id, status, base, roadmap_nodes,
|
|
3902
|
+
adopt_map, dry_run) -> ObjectiveRef | None` — stamps perk's objective metadata **additively** into
|
|
3903
|
+
the source IN PLACE. Returns the source's `ObjectiveRef` (`existed=True` on idempotent re-save via
|
|
3904
|
+
`run_id`); returns **`None`** for a store that does not support in-place adoption (the dormant
|
|
3905
|
+
issue-backed Linear store — the unambiguous "doesn't adopt" signal, mirroring `save_node_plan →
|
|
3906
|
+
None`). `dry_run` returns `None` (the source read is a network op; the cold door's `--dry-run` is
|
|
3907
|
+
offline). An empty roadmap raises (the storage backstop).
|
|
3908
|
+
|
|
3909
|
+
**Backend matrix (three implementers + fakes, ty-enforced).**
|
|
3910
|
+
|
|
3911
|
+
- **GitHub (bounded single-issue):** `read_objective_source` maps `github.read_issue` to the neutral
|
|
3912
|
+
source (`prose` = issue body, `issues=()`). `adopt_source_as_objective` → `github
|
|
3913
|
+
.adopt_issue_as_objective` (mirrors `create_objective_issue` + `adopt_issue_as_plan`): idempotency
|
|
3914
|
+
via `find_objective_issue(run_id=)`; read the issue body verbatim; compose `<human body verbatim>`
|
|
3915
|
+
+ `objective-header` (`adopted_from="#<n>"`, `objective_comment_id: null`) + `objective-roadmap`
|
|
3916
|
+
blocks, **add** the `perk:objective` label (never replace), title untouched; post the
|
|
3917
|
+
`objective-body` comment (`render_body_comment(nodes, prose=<model prose>)` + the
|
|
3918
|
+
`render_adopted_overview_note(<original body>)` below the Reconcilable markers + the
|
|
3919
|
+
`perk objective plan <n>` callout prepended), backfill `objective_comment_id`. `adopt_map` is
|
|
3920
|
+
ignored (no child issues).
|
|
3921
|
+
- **Linear project-backed (full):** `_LinearProjectOps.project_issues_for_adoption` (a sibling of
|
|
3922
|
+
`project_issues` selecting `title` too; the byte-stable `project_issues` left untouched).
|
|
3923
|
+
`read_objective_source` → the project overview `content` + its issues. `adopt_source_as_objective`
|
|
3924
|
+
composes the new overview preserving the original verbatim (`to_linear_markdown(`
|
|
3925
|
+
Reconcilable(`<model prose>`) + `objective-header`(`adopted_from=source_id`) + `objective-manifest`
|
|
3926
|
+
+ `render_adopted_overview_note(<original overview>)` below the markers `)`), `update_project
|
|
3927
|
+
_content` (in place, NOT `create_project`), prepends the callout; one milestone per phase via
|
|
3928
|
+
`ensure_phase_milestone` seeded from `project_milestones` (de-dupe against existing); for each
|
|
3929
|
+
node in `node_sort_key` order a **mapped** node stamps the `objective-node` block additively into
|
|
3930
|
+
the existing issue (title/body verbatim, description PATCH + `perk:objective-node` label added +
|
|
3931
|
+
phase-milestone attach), an **unmapped** node mints a fresh node-issue; blocking relations per
|
|
3932
|
+
explicit `depends_on`. Raises on an `adopt_issue` id not in the project (fail-loud). Idempotent on
|
|
3933
|
+
`run_id`.
|
|
3934
|
+
- **Issue-backed Linear (dormant):** both `read_objective_source` and `adopt_source_as_objective`
|
|
3935
|
+
return `None` (honest no-op; keeps `ty` green).
|
|
3936
|
+
|
|
3937
|
+
**The cold door (`perk objective author --from <source>`).** Reads the source up front
|
|
3938
|
+
(`require_github`; the read-only session has no Linear/`gh`), then re-launches the
|
|
3939
|
+
`objective-author` stage seeded to author over the materialized source. It **refuses**:
|
|
3940
|
+
`adopt_not_found` (source `None`); GitHub-only `adopt_not_open` (the source issue is CLOSED, via the
|
|
3941
|
+
issue tier's `read_issue.state` — skipped for Linear projects, which have no OPEN/CLOSED);
|
|
3942
|
+
`already_an_objective` (the source prose already carries an `objective-header` block);
|
|
3943
|
+
`adopt_unsupported` (a `None` adoption return — in practice the resolver never returns the dormant
|
|
3944
|
+
store). Project-level engagement is read fail-soft (`render_adopted_engagement(comments, ())` →
|
|
3945
|
+
`<untrusted_adopted_issue_engagement>`; `ObjectiveStoreError` → omitted; per-issue engagement is
|
|
3946
|
+
Node 4.3's live concern). The source is materialized to `scratch/objective-adopt-<source_id>.md`
|
|
3947
|
+
(title + prose in `<untrusted_adopted_objective>` + a `<untrusted_adopted_project_issues>` listing +
|
|
3948
|
+
the optional engagement block). The seed instructs the model to author the prose + roadmap, mapping
|
|
3949
|
+
existing issues via each node's `adopt_issue`. `--dry-run` materializes + prints the seed, launches
|
|
3950
|
+
nothing; `--remote` is rejected (local-only, resolved up front).
|
|
3951
|
+
|
|
3952
|
+
**The save (rides the handoff).** The door stashes `adopt_from` in the run **handoff**, so the link
|
|
3953
|
+
survives the `objective_save` tool path (which forwards only `{prose, roadmap, title, base,
|
|
3954
|
+
run-id}` — no TS tool change for `adopt_from`). `perk objective create` gains `--adopt-from
|
|
3955
|
+
<source>` + `_adopt_from_handoff` recovery (explicit flag wins, else the handoff key). On a real
|
|
3956
|
+
save it parses `adopt_map = parse_adopt_mapping(raw)` from the same `--roadmap` JSON and calls
|
|
3957
|
+
`adopt_source_as_objective(...)`, **skipping** `create_objective`; a `None` return →
|
|
3958
|
+
`adopt_unsupported`. The fail-open `post_status_update` on fresh-create still fires (adoption
|
|
3959
|
+
produces a fresh perk objective, `existed=False`). `--dry-run` falls through to the offline
|
|
3960
|
+
`create_objective(dry_run=True)` compose-preview (the writer returns `None` on dry-run). No
|
|
3961
|
+
mutual-exclusion guard is needed (`objective create` has no `--node-id`).
|
|
3962
|
+
|
|
3963
|
+
**Backend parity.** Honest on **both** GitHub and Linear (+ clean fake conformers). Live validation
|
|
3964
|
+
is preview-grade here (Mode 8); final live proof is Node
|
|
3965
|
+
4.3 — no new config key, provider seam, or `EXPECTED_SURFACE` change (a flag, not a new
|
|
3966
|
+
command/verb).
|
|
3967
|
+
|
|
3968
|
+
## §8.31 · The prompt render seam + golden parity (Objective #791, Node 1.2)
|
|
3969
|
+
|
|
3970
|
+
Two cross-plane **render seams** load prompt templates by explicit `name` (root-relative under
|
|
3971
|
+
`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.
|
|
3976
|
+
|
|
3977
|
+
- **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).
|
|
3981
|
+
|
|
3982
|
+
**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).
|
|
3996
|
+
|
|
3997
|
+
**Environment-config parity baseline** (both engines): `autoescape` off (prompts are plain text,
|
|
3998
|
+
never HTML-escaped), `trim_blocks` **on** (as of Node 2.4) so a block tag on its own line emits no
|
|
3999
|
+
spurious newline — conditional templates keep their `{% %}` tags off the content lines while
|
|
4000
|
+
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"`).
|