@mgiles/perk 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -44
- package/extension/adapters/planAdapterPlannotator.ts +27 -41
- package/extension/adapters/planAdapterTombell.ts +15 -28
- package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
- package/extension/checkpoints/checkpoints.ts +19 -12
- package/extension/doors/address.ts +4 -4
- package/extension/doors/askUser.ts +12 -8
- package/extension/doors/ciExecutor.ts +21 -14
- package/extension/doors/hunkHandoff.ts +202 -0
- package/extension/doors/land.ts +31 -9
- package/extension/doors/learn.ts +2 -2
- package/extension/doors/learnFactory.ts +144 -0
- package/extension/doors/plannotatorHandoff.ts +509 -0
- package/extension/doors/prReview.ts +4 -4
- package/extension/doors/prReviewBrowser.ts +341 -0
- package/extension/doors/prReviewTerminal.ts +267 -0
- package/extension/doors/selfcheck.ts +238 -5
- package/extension/doors/submit.ts +20 -0
- package/extension/doors/submitPrReview.ts +408 -0
- package/extension/factories/objective.ts +15 -5
- package/extension/factories/objectiveAuthor.ts +15 -32
- package/extension/factories/objectiveDraft.ts +1 -1
- package/extension/factories/objectivePlan.ts +12 -10
- package/extension/factories/objectiveSave.ts +2 -2
- package/extension/factories/planMode.ts +22 -40
- package/extension/factories/planReview.ts +213 -191
- package/extension/factories/planSave.ts +7 -7
- package/extension/index.ts +83 -25
- package/extension/substrate/bindingDelivery.ts +32 -10
- package/extension/substrate/bindings.ts +4 -2
- package/extension/substrate/cache.ts +34 -7
- package/extension/substrate/clipboard.ts +81 -0
- package/extension/substrate/config.ts +88 -65
- package/extension/substrate/git.ts +43 -0
- package/extension/substrate/paths.ts +1 -1
- package/extension/substrate/prompts.ts +2 -2
- package/extension/substrate/providers.ts +62 -8
- package/extension/substrate/sessionPointers.ts +35 -6
- package/extension/substrate/structuredOutput.ts +3 -1
- package/extension/substrate/terminalLaunch.ts +178 -0
- package/extension/substrate/toolGating.ts +330 -79
- package/extension/substrate/toolParams.ts +7 -0
- package/extension/substrate/workflowState.ts +54 -2
- package/extension/surfaces/footerProvider.ts +8 -4
- package/extension/surfaces/surfaces.ts +330 -12
- package/extension/vendor/btw/btw.ts +10 -0
- package/extension/worker/readOnlySession.ts +19 -6
- package/extension/worker/worker.ts +77 -7
- package/extension/workerMain.ts +12 -13
- package/package.json +3 -3
- package/prompts/_fixtures/live.yaml +117 -2
- package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
- package/prompts/contexts/adapters/plannotator-objective.md +7 -0
- package/prompts/contexts/adapters/plannotator-plan.md +6 -0
- package/prompts/contexts/adapters/tombell-plan.md +17 -0
- package/prompts/contexts/objective-authoring.md +20 -0
- package/prompts/contexts/plan-authoring.md +24 -0
- package/prompts/contexts/read-only.md +10 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-orchestrate.md +1 -1
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-plan/guidance.md +1 -1
- package/prompts/stages/objective-plan/seed.md +1 -1
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-replan.md +1 -1
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- package/prompts/stages/pr-review-browser/active.md +11 -0
- package/prompts/stages/pr-review-browser/foreign.md +11 -0
- package/prompts/stages/pr-review-terminal/active.md +12 -0
- package/prompts/stages/pr-review-terminal/foreign.md +13 -0
- package/prompts/stages/pr-review-terminal/local.md +4 -0
- package/prompts/stages/pr-review.md +1 -1
- package/prompts/stages/replan.md +2 -2
- package/prompts/stages/skills/create-from.md +1 -1
- package/prompts/stages/skills/create.md +1 -1
- package/prompts/stages/skills/refine.md +1 -1
- package/shared/README.md +22 -18
- package/shared/bindings.yaml +10 -2
- package/shared/contracts-history.md +24 -0
- package/shared/contracts.md +1442 -1787
- package/shared/providers.yaml +8 -1
- package/shared/registry.yaml +7 -8
- package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
- package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
- package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
- package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
- package/extension/doors/learnCode.ts +0 -100
- package/extension/doors/learnDocs.ts +0 -100
- package/extension/doors/prReviewLocal.ts +0 -229
package/shared/contracts.md
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
# perk cross-plane contracts
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
3
|
+
The language-neutral contracts both planes obey, authored once here and bundled into each
|
|
4
|
+
build artifact. This document holds the numbered **prose contract sections** (`§8.1`–`§8.40`,
|
|
5
|
+
non-contiguous: `§8.8` is skipped and `§8.6a` exists; no parser): the Python CLI (`perk`)
|
|
5
6
|
and the TS extension (`@mgiles/perk`) each implement one side, against the exact names/paths/
|
|
6
|
-
fields pinned
|
|
7
|
+
fields pinned in each section. `perk doctor` verifies conformance. The numbering convention:
|
|
8
|
+
section numbers are stable anchors, never renumbered; grep the existing headings before
|
|
9
|
+
assigning a new one (a gap like `§8.8` stays a gap).
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
Three **parsed** contracts are siblings of this file: `registry.yaml` — the stage
|
|
9
12
|
graph, whose `state_keys` block is the canonical vocabulary referenced throughout this
|
|
10
13
|
document — `bindings.yaml` — the skill-binding set (trigger→skill delivery), specified
|
|
11
14
|
in §8.9 — and `providers.yaml` — the provider-selection supported set, specified in §8.10.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
`Q9`/`Q10` (gateway). Pi mechanics are cited against
|
|
15
|
-
[pi--best-practices.md](../docs/pi--best-practices.md).
|
|
15
|
+
Two more siblings: `contracts-history.md` (the chronological changelog, below) and
|
|
16
|
+
`schemas/` (committed golden snapshots of the boundary models, §8.34).
|
|
16
17
|
|
|
17
18
|
> **History.** The chronological `Status (…)` landing-note changelog lives in the sibling
|
|
18
19
|
> [`contracts-history.md`](./contracts-history.md), grouped by `§N.M` anchor; this file is the
|
|
@@ -185,10 +186,15 @@ The local cache tier — written and read by **both** the CLI (exterior) and the
|
|
|
185
186
|
`/.perk/workflow/` entry managed by `init`) — it is runtime/cache state, not durable source, so
|
|
186
187
|
there is **no committed `.gitkeep`**; a fresh clone has no tracked workflow artifact. The
|
|
187
188
|
canonical plan lives in GitHub; the materialized `plan.md` body and `plan-ref.json` mirror are
|
|
188
|
-
transient local copies and must never be tracked.
|
|
189
|
-
|
|
189
|
+
transient local copies and must never be tracked. The managed block also ignores
|
|
190
|
+
`/.pi-subagents/` — the borrowed `pi-subagents` engine's project-scoped run-artifact root
|
|
191
|
+
(debug artifacts + chain runs in the session cwd): transient, never tracked. `perk doctor --fix`
|
|
192
|
+
untracks a legacy-committed
|
|
193
|
+
copy and drops any stray ungrouped ignore line, migrates a legacy `.pi/workflow/` cache
|
|
190
194
|
forward (untracking a tracked `.gitkeep`, moving the `plan-ref.json`/`agent-session.json` mirrors
|
|
191
|
-
when the target is absent; disposable scratch is left for the user to delete)
|
|
195
|
+
when the target is absent; disposable scratch is left for the user to delete), and untracks
|
|
196
|
+
legacy-committed `.pi-subagents/` artifacts (files kept on disk — a gitignore rule is inert for
|
|
197
|
+
already-tracked files).
|
|
192
198
|
- **`plan-ref.json` (`cache.plan-ref`, T2b):** the provider-agnostic plan-ref payload (§8.4)
|
|
193
199
|
written verbatim. One active ref per checkout/worktree (`.perk/workflow/` is per-checkout). The
|
|
194
200
|
**Python cold door** (`perk plan-save`) writes it on a real save; the **extension** reads it
|
|
@@ -224,10 +230,37 @@ environment before `exec pi`; an initial message or `@file` would pollute LLM co
|
|
|
224
230
|
**Claim (on `session_start`)** — strict verified linkage (`Q3` establish-before-consume):
|
|
225
231
|
1. read `process.env.PERK_RUN_ID`;
|
|
226
232
|
2. load + verify `handoff/<run_id>.json` (read-back; on mismatch raise a hard, actionable
|
|
227
|
-
error — never a silent `pass`)
|
|
233
|
+
error — never a silent `pass`). A handoff already **`consumed: true` by a *different* (or
|
|
234
|
+
unrecorded) session is not claimable** — the id was inherited across a process spawn, so the
|
|
235
|
+
session is an **env-child** and takes the adopt arm below. (A consumed handoff whose recorded
|
|
236
|
+
`pi_session_id` matches the *current* session re-claims idempotently — the original claimer
|
|
237
|
+
whose branch state was lost.);
|
|
228
238
|
3. record `run_id` in `perk:workflow-state` (§8.3);
|
|
229
239
|
4. mark the handoff **consumed**.
|
|
230
240
|
|
|
241
|
+
**Adopt (the env-child arm).** Spawned subagent children run as separate `pi` processes with the
|
|
242
|
+
parent's environment, so they arrive carrying the parent's leaked `PERK_RUN_ID`. When the branch
|
|
243
|
+
has no `run_id` and the env id's handoff is already consumed by a different session (the
|
|
244
|
+
verification rule above), the session **adopts a derived child identity** instead of re-claiming:
|
|
245
|
+
derive **`<run_id>.<n>`** (the fork sibling scheme), isolate the child's scratch, and record
|
|
246
|
+
`{run_id: <child>, pi_session_id, predecessor: <parent run_id>, mode}` with `mode` **inherited
|
|
247
|
+
from the handoff** — so a read-only parent's exploration children keep perk's read-only tool
|
|
248
|
+
gating. The adopted child **never re-consumes the handoff** (its `pi_session_id` keeps the true
|
|
249
|
+
claimer), carries **no `stage`** (no launched-stage impersonation, no stage-binding injection),
|
|
250
|
+
and **never captures session pointers** (§8.35) — it cannot shadow the launched session's
|
|
251
|
+
evidence. Under `PERK_SELFCHECK` the T3 sentinel records `source: "env-child"`.
|
|
252
|
+
|
|
253
|
+
**Corrupt-blob posture (total TS readers).** The TS cache-tier readers
|
|
254
|
+
(`extension/substrate/cache.ts`) are *total*: an unreadable/corrupt `handoff/<run_id>.json` (or
|
|
255
|
+
`plan-ref.json`) is reported loudly on stderr and treated as **absent** (`null`) — so a corrupt
|
|
256
|
+
cold-launch blob degrades to the same loud-unclaimed error as a missing handoff (gate off, never
|
|
257
|
+
an aborted `session_start`), rather than crashing mid-handler. Defense in depth: the interior
|
|
258
|
+
orders the read-only gate sync **before** the plan-ref/stage reconciliation in `session_start`,
|
|
259
|
+
so no cache read can prevent gate engagement — a session that already claimed
|
|
260
|
+
`mode: "read-only"` re-gates on reload even when its handoff has since been corrupted. The Python
|
|
261
|
+
readers (`src/perk/state/cache.py`) deliberately keep **raising** `CacheError` (launch-time
|
|
262
|
+
fail-closed, exterior plane); the cross-plane contract is the *files*, not error semantics.
|
|
263
|
+
|
|
231
264
|
**Optional handoff link context (`objective_id`/`node_id`, #78).** Beyond the claim fields, a
|
|
232
265
|
stage may stash extra keys in its handoff blob (the TS `Handoff` interface already carries
|
|
233
266
|
`[key: string]: unknown`). `objective-plan` writes the `objective_id`/`node_id` it just marked
|
|
@@ -270,11 +303,13 @@ the consume mechanism independent of which save surface the model used.
|
|
|
270
303
|
(matches the registry per-stage `run_id` policy); a *cold* relaunch **mints** a new `run_id`
|
|
271
304
|
in the **Python plane** (`perk/state/run_id.py`) that **records its predecessor**, so resume/relaunch
|
|
272
305
|
chains stay traceable; and a **warm session with no identity** (decideClaim's `none` arm — no
|
|
273
|
-
branch `run_id`, no `PERK_RUN_ID`: ad-hoc `pi`, `pi --plan
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
306
|
+
branch `run_id`, no `PERK_RUN_ID`: ad-hoc `pi`, `pi --plan`) **mints its own ULID in the TS
|
|
307
|
+
plane** (`extension/substrate/runId.ts`) on `session_start`, recording `{run_id, pi_session_id}`
|
|
308
|
+
via the strict append seam (§8.3) — **no predecessor, no handoff, no disk artifacts**. Spawned
|
|
309
|
+
subagent children arrive *with* the parent's leaked `PERK_RUN_ID` and take the **adopt** arm
|
|
310
|
+
above (a derived `<run_id>.<n>`, not a mint). A **failed cold claim never falls back to a mint**
|
|
311
|
+
(`PERK_RUN_ID` set but the handoff missing/mismatched stays a loud unclaimed error — minting
|
|
312
|
+
would mask a launcher bug).
|
|
278
313
|
Under `PERK_SELFCHECK`, the T3 sentinel records a successful warm mint as `source: "mint"`.
|
|
279
314
|
|
|
280
315
|
The Pi session UUID is kept as a **secondary handle** (needed for `SessionManager.open` /
|
|
@@ -284,7 +319,10 @@ The Pi session UUID is kept as a **secondary handle** (needed for `SessionManage
|
|
|
284
319
|
|
|
285
320
|
## §8.3 · The `perk:workflow-state` schema (Q1)
|
|
286
321
|
|
|
287
|
-
The single namespaced session entry holding transient (tier-3) workflow state.
|
|
322
|
+
The single namespaced session entry holding transient (tier-3) workflow state. This section pins
|
|
323
|
+
the state record and the cross-plane delegated shapes (the TS-tool ↔ Python-CLI boundaries);
|
|
324
|
+
single-plane interior mechanics live in their owning modules' headers (the pointer list at the
|
|
325
|
+
end of the section).
|
|
288
326
|
|
|
289
327
|
**Record (per-field last-write-wins):**
|
|
290
328
|
|
|
@@ -293,19 +331,21 @@ The single namespaced session entry holding transient (tier-3) workflow state.
|
|
|
293
331
|
| `run_id` | string (ULID) | the perk run this session belongs to (§8.2) |
|
|
294
332
|
| `predecessor` | string \| null | the prior `run_id` this run forked from (or cold-relaunched after), §8.2; null for an original run |
|
|
295
333
|
| `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` |
|
|
296
|
-
| `mode` | string | the active registry stage `mode` (`read-only` / `read-write`) — **structurally gates tools** (
|
|
297
|
-
| `stage` | string | the registry stage id this run is acting on, recorded at cold **claim** from the handoff
|
|
334
|
+
| `mode` | string | the active registry stage `mode` (`read-only` / `read-write`) — **structurally gates tools** (see below) |
|
|
335
|
+
| `stage` | string | the registry stage id this run is acting on, recorded at cold **claim** from the handoff; lets the interior distinguish two read-only stages (e.g. `objective-author` vs `plan`) and inject the right authoring context |
|
|
298
336
|
| `active_plan_ref` | object \| null | the provider-agnostic plan ref (§8.4); null during early `plan` |
|
|
299
|
-
| `active_objective` | string \| null | the active objective id
|
|
300
|
-
| `last_review_batch` | object \| null | the last processed review batch
|
|
301
|
-
| `
|
|
302
|
-
| `
|
|
303
|
-
| `
|
|
337
|
+
| `active_objective` | string \| null | the active objective id (`/objective <id>` sets it, `/objective clear` nulls it) |
|
|
338
|
+
| `last_review_batch` | object \| null | the last processed review batch: `{ pr, counts:{actionable,informational,praise,question}, resolved_thread_ids:[…], at:ISO }` |
|
|
339
|
+
| `last_pr_review` | object \| null | the last `/pr-review` outcome posted via the warm `post_pr_review` tool: `{ pr, verdict, angles, comment_count, mode, at:ISO }`; best-effort tier (the PR review is the canonical record) |
|
|
340
|
+
| `last_review` | object \| null | the last review-door outcome posted via the warm `submit_pr_review` tool: `{ pr, event, comment_count, mode, at:ISO }`; best-effort tier (the submitted PR review is the canonical record) |
|
|
341
|
+
| `session_artifacts` | object \| null | per-name session-artifact provenance pointers `{run_id, name, path, digest, at}` (§8.1); appends carry the **whole merged map** (per-field LWW); strict-append tier |
|
|
342
|
+
| `objective_node_claim` | object \| null | the objective node this session has claimed `planning` (`{ objective, node }`); written by the warm `objective_node` tool on a successful `planning` transition, cleared on a successful non-planning transition for the same node and after a successful node-linked plan save; best-effort tier (cheaply reconstructable; loud-but-non-fatal) |
|
|
343
|
+
| `conflict_resolution_attempts` | number | the bounded conflict-resolution re-drive counter: incremented each time `/submit` drives the `perk.conflict-resolver` subagent on a definitively-unmergeable PR (cap `CONFLICT_RESOLUTION_ATTEMPT_CAP = 2`), reset to 0 on a clean submit; best-effort tier (cheaply reconstructable) |
|
|
304
344
|
|
|
305
345
|
**Persistence channel:** `pi.appendEntry("perk:workflow-state", data)`. (The *other* Pi
|
|
306
346
|
channel — tool-result `details` — is for state that *is* a tool's output; this is not that.)
|
|
307
347
|
|
|
308
|
-
**Rebuild (non-negotiable discipline
|
|
348
|
+
**Rebuild (non-negotiable discipline):** scan `ctx.sessionManager.getBranch()` for
|
|
309
349
|
`entry.type === "custom" && entry.customType === "perk:workflow-state"`, **on both
|
|
310
350
|
`session_start` AND `session_tree`** (skipping `session_tree` is the bug that makes state
|
|
311
351
|
stale after the user navigates the tree). Apply **per-field last-write-wins** so two tools
|
|
@@ -320,775 +360,146 @@ are **strict** (durable/cross-process → read-back + correct ordering); purely
|
|
|
320
360
|
fields cheaply reconstructable on the next `session_start`/`session_tree` are
|
|
321
361
|
best-effort-with-logging (never silently swallowed).
|
|
322
362
|
|
|
323
|
-
**`active_plan_ref` reconciliation (
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
`
|
|
338
|
-
`
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
`
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
(
|
|
365
|
-
|
|
366
|
-
the
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
`
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
363
|
+
**`active_plan_ref` reconciliation (stage-gated):** on `session_start`, after the run_id claim,
|
|
364
|
+
the extension reconciles `cache.plan-ref` into `active_plan_ref` — but **only when the launched
|
|
365
|
+
stage *consumes* the ref**, i.e. the stage's registry `requires`/`reads` list `cache.plan-ref`
|
|
366
|
+
(the worktree binding stages; the root `worktree: none` stages do not consume it, so a fresh
|
|
367
|
+
planning session never inherits the stale **root selector** — §8.1's duality). The launched stage
|
|
368
|
+
is read from the run's **handoff** blob (`stage`); `fork`/`none` claims carry no launched stage
|
|
369
|
+
and never re-read the file (the LWW rebuild preserves an already-linked ref). The append is
|
|
370
|
+
**idempotent by `(provider, pr_id)`** with a **strict read-back** (loud-but-non-fatal on
|
|
371
|
+
mismatch, headless-safe). If the registry fails to load, reconciliation stays **permissive** when
|
|
372
|
+
a launched stage is present (to preserve implement linkage). **No clearing** of the selector
|
|
373
|
+
anywhere — gating alone fixes the leak.
|
|
374
|
+
|
|
375
|
+
**Warm `/plan-save` direct linkage + the version-skew decode posture:** the in-session warm door
|
|
376
|
+
appends `active_plan_ref` **directly** after a successful save (same strict read-back, idempotent
|
|
377
|
+
by `(provider, pr_id)`), so the live session is linked without waiting for the next
|
|
378
|
+
`session_start` — both writers feed the same LWW field. Its decode of the `perk plan-save --json`
|
|
379
|
+
payload is strict **only** on `plan_ref` (the field appended to workflow-state); the rendered
|
|
380
|
+
issue id/url are derived from it and `existed`/`objective_node` are advisory — so a successful
|
|
381
|
+
cold save can never be reported as a warm failure by render-only payload fields (the
|
|
382
|
+
CLI↔extension version-skew lesson). The objective node→plan link outcome is **surfaced, never
|
|
383
|
+
swallowed**: a failed advance shows a visible `⚠ … NOT advanced — re-run /plan-save` warning
|
|
384
|
+
(the node↔plan link — the objective transition surface below).
|
|
385
|
+
|
|
386
|
+
**Tool gating.** The `mode` field **structurally gates tools** — enforcement, not prompting. When
|
|
387
|
+
`mode == "read-only"` the interior (`extension/substrate/toolGating.ts`): (1) restricts the
|
|
388
|
+
active tool set to `READ_ONLY_TOOLS` (`read`/`grep`/`find`/`ls`/`bash` + `ask_user_question` +
|
|
389
|
+
`plan_review` + the `plan_draft`/`objective_draft` session-data carve-outs + `objective_node`
|
|
390
|
+
(delegates a bounded node transition to the canonical Python plane) + the **`web` seam**
|
|
391
|
+
providers' research tools, the read-only Linear tools, and the pi-subagents delegation family
|
|
392
|
+
(`subagent`/`wait` + the parent supervisor pair — the gated objective-plan explorer spawn must be
|
|
393
|
+
reachable; **accepted no-backstop posture**: spawned children are unscoped by design (§8.40
|
|
394
|
+
adopt-never-impersonates) — the explorer's agent def is write-blocked by its `tools` frontmatter,
|
|
395
|
+
but `subagent` itself can spawn ad-hoc read-write children, a deliberate documented leniency like
|
|
396
|
+
the arg-blind `curl`/`agent-browser` entries, with no agent allowlist) — a static union of foreign
|
|
397
|
+
tool names, inert when a package is absent) via `pi.setActiveTools`, **snapshot-then-restore** (the restore
|
|
398
|
+
falls back to the full configured `pi.getAllTools()` set — never a hardcoded list); (2) blocks
|
|
399
|
+
`edit`/`write` and non-allowlisted `bash` at `tool_call`. The bash sub-allowlist covers read-only
|
|
400
|
+
inspection commands (read-only `git` queries, `jq`, `curl`, …), read-only `gh` **query**
|
|
401
|
+
subcommands (view/list/diff/status/checks/search + `gh auth status`; `gh api` and every mutating
|
|
402
|
+
subcommand stay blocked), the read-only `perk objective` queries (`show`/`next` + aliases and
|
|
403
|
+
`node-engagement`; the mutating subcommands stay blocked), and the command-keyed `ast-grep` /
|
|
404
|
+
`agent-browser` (+ `npx agent-browser`) entries (an accepted arg-blind leniency, like `curl`);
|
|
405
|
+
(3) injects a hidden `[READ-ONLY MODE]` context at `before_agent_start` — **once-only per live
|
|
406
|
+
copy**: the injection is branch-scan dedup'd on the marker (`branchCarries`), so a session carries
|
|
407
|
+
one live copy; compaction dropping the copy makes the scan come up clean and the next
|
|
408
|
+
`before_agent_start` naturally re-injects — and **strips** it from `context` when off. The allowlist is restored on both `session_start` and `session_tree` (re-sync
|
|
409
|
+
from the rebuilt `mode`). **Fail-closed:** a failed state-rebuild never opens the gate, and
|
|
410
|
+
`tool_call` blocks on any internal error. The `enter(ctx?)`/`exit(ctx?)` surface is the API the
|
|
411
|
+
interior consumers (plan mode, the factories, the CI executor) compose — the gate is the single
|
|
412
|
+
read-only authority. Beside the gate, the same rebuild points apply **stage-scoped active tools**
|
|
413
|
+
keyed off the `stage` field (§8.40) — fail-open where the gate is fail-closed.
|
|
414
|
+
|
|
415
|
+
**Checkpoints.** Implementation progress lives in a **dedicated `perk:checkpoint`** session entry
|
|
416
|
+
(high-churn, kept OFF the shared record). The interior (`extension/checkpoints/checkpoints.ts`)
|
|
417
|
+
seeds an ordered step list from the `## Steps` numbered list in the `cache.plan` body
|
|
418
|
+
(`.perk/workflow/plan.md` — **materialized by the Python cold door** at implement launch: the
|
|
419
|
+
cross-plane file contract, written by Python, read by TS), only in an active workflow and only
|
|
420
|
+
once. The `[WIP:n]`/`[DONE:n]` marker grammar is taught to the implement session by the launch
|
|
421
|
+
prompt + the `perk-implement` skill; state is rebuilt on `session_start`/`session_tree`/
|
|
422
|
+
`session_compact` with the scan-after-marker discipline. A prose plan without `## Steps` may get
|
|
423
|
+
a **generated** step list (`extension/checkpoints/planSteps.ts`); every generation failure falls
|
|
424
|
+
back fail-safe (never a failed session start).
|
|
425
|
+
|
|
426
|
+
**The objective transition surface (TS tool ↔ Python CLI).** The genuinely cross-plane shapes:
|
|
427
|
+
|
|
428
|
+
- `active_objective` is set by `/objective <id>` / a successful `objective_save`, nulled by
|
|
429
|
+
`/objective clear`.
|
|
430
|
+
- The warm `objective_save` tool takes `prose` + a **structured `roadmap`** (a JSON array of
|
|
431
|
+
nodes — never hand-written YAML) and delegates to `perk objective create --body <file>
|
|
432
|
+
--roadmap <json> --run-id <rid> --json` — canonical mutation in Python, idempotent on the
|
|
433
|
+
run_id; creation requires **≥1 roadmap node** (`error_type: empty_roadmap`).
|
|
434
|
+
- The warm `objective_node` tool delegates to `perk objective node` with **conditional argv** (a
|
|
435
|
+
`pr`-only backlink omits `--status`; a call carrying none of `status`/`pr`/`description` is
|
|
436
|
+
refused `bad_input`, no exec). When `status === "done"` it requires a non-trivial `audit`
|
|
437
|
+
(`.trim()` ≥ 40 chars, else `audit_required`, no exec) — a **model-boundary-only** property:
|
|
438
|
+
the cold CLI (`perk objective node --status done`) and the on-land auto-node-done are
|
|
439
|
+
deliberately non-audited paths.
|
|
440
|
+
- `objective_node_claim` is a **resumable lease**: `planning` = a claim (intent to plan, no saved
|
|
441
|
+
plan yet — re-selectable; an abandoned claim self-heals); `in_progress` = a committed plan
|
|
442
|
+
(saved, node→plan backlinked, awaiting land).
|
|
443
|
+
- The node↔plan link (the `node.pr` backlink **and** the `planning → in_progress` advance) is
|
|
444
|
+
set **atomically by `plan-save`** when invoked with `--objective-id` + `--node-id` (warm
|
|
445
|
+
`plan_save` params `objective_id` + `node_id`) — fail-open + non-fatal + idempotent on re-save;
|
|
446
|
+
a failed advance is warm-surfaced as `⚠ objective node <id> NOT advanced — re-run /plan-save to
|
|
447
|
+
retry`. When an approval-triggered save carries neither id, the link is recovered
|
|
448
|
+
**both-or-neither** from the rebuilt `objective_node_claim` (any explicit value wins outright —
|
|
449
|
+
never mixed; a malformed claim never blocks the save).
|
|
450
|
+
- **Accepted backlink race:** concurrent `update_objective_node` writes are read-modify-write on
|
|
451
|
+
the issue body, so a simultaneous write can drop one node's update. Accepted, not fixed: the
|
|
452
|
+
loser is recoverable (`/plan-save` re-save retries the link idempotently; `perk objective node`
|
|
453
|
+
is the manual repair). No optimistic-concurrency machinery.
|
|
386
454
|
|
|
387
455
|
State key (registry vocabulary): `session.workflow-state`.
|
|
388
456
|
|
|
389
|
-
**
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
- **Threshold-triggered compaction** (the `trigger-compact.ts` pattern) — on `turn_end`, **only
|
|
412
|
-
when `active_objective != null`**, read `ctx.getContextUsage()` and call `ctx.compact({…})` when
|
|
413
|
-
usage crosses a threshold (default `0.8`; overridable via `[objective] compact_threshold` in
|
|
414
|
-
`.perk/config.toml`, read through `extension/substrate/config.ts` — written as a **quoted** value because the
|
|
415
|
-
TOML subset reads only strings). The decision is the pure `shouldCompact(usage, threshold)`;
|
|
416
|
-
compaction is best-effort (`onError` logs and continues). The custom cheaper-model
|
|
417
|
-
`session_before_compact` summary is **deferred** — T9 ships the simpler `ctx.compact` trigger.
|
|
418
|
-
|
|
419
|
-
No model-facing bounded transition tools are added here — the `objective-plan` stage, the plan
|
|
420
|
-
factory, and the "fire only when…" tools are **T10**.
|
|
421
|
-
|
|
422
|
-
**Objective authoring loop (P3.T2).** Objective *creation* is now a first-class read-only → save
|
|
423
|
-
loop, the mirror of the `plan → save` spine. Two new registry stages precede `objective-plan` as
|
|
424
|
-
the new single initial: `objective-author -> objective-save -> objective-plan -> plan -> …`.
|
|
425
|
-
- **`perk objective-author`** (a dedicated seeded cold door, like `objective-plan`) opens a
|
|
426
|
-
**read-only** authoring session, seeded with the objective-authoring guidance. Its handoff records
|
|
427
|
-
`stage: objective-author`, claimed into `perk:workflow-state.stage`.
|
|
428
|
-
- **Coupling break (the `stage` field).** `extension/factories/planMode.ts` previously injected its
|
|
429
|
-
plan-authoring context on *any* read-only gate. An `objective-author` session is **also**
|
|
430
|
-
read-only, so plan mode now **defers** when `stage === "objective-author"`, and
|
|
431
|
-
`extension/factories/objectiveAuthor.ts` injects its own `perk:objective-author-context` instead (keyed off
|
|
432
|
-
read-only gate **AND** the stage; stripped from `context` when no longer authoring — the same
|
|
433
|
-
hygiene plan mode applies). Exactly one authoring context is present. The injected
|
|
434
|
-
objective-authoring context is optionally extended by the **same** `[workflow] plan_authoring`
|
|
435
|
-
addendum the plan-authoring injection consumes (read per-event via `extension/substrate/config.ts`'s
|
|
436
|
-
`loadPerkConfig`) — verbatim reuse, no new config key.
|
|
437
|
-
- **`objective_save` warm door** (`extension/factories/objectiveSave.ts`, the mirror of `planSave.ts`). The
|
|
438
|
-
`objective_save` **tool** takes `prose` + a **structured `roadmap`** (a JSON array of nodes —
|
|
439
|
-
never hand-written YAML) and delegates the write to `perk objective create --body <file> --roadmap
|
|
440
|
-
<json> --run-id <rid> --json` (canonical mutation in Python, idempotent on the run_id). On success
|
|
441
|
-
it links the live session: appends `active_objective` **and** seeds a fresh `perk:objective-budget`
|
|
442
|
-
activation marker (mirrors `/objective <id>`), so budget tracking starts immediately; it
|
|
443
|
-
**terminates** the turn. The `/objective-save` **command is the artifact-first manual
|
|
444
|
-
failsafe** (#352 Node 2.3): it invokes the shared `objectiveApprovalSave` seam (re-read the
|
|
445
|
-
structured `objective-draft.json` artifact → `saveObjective` → D1a gate exit on success) and
|
|
446
|
-
relays the save message (`error` severity on a failed save — the gate stays read-only). Only
|
|
447
|
-
when **no draft exists** does it fall back to the legacy drive-the-session behavior: exit the
|
|
448
|
-
read-only gate (so the `objective_save` tool becomes reachable) and inject guidance via
|
|
449
|
-
`pi.sendUserMessage` instructing the model to call `objective_save` with `prose` + the
|
|
450
|
-
structured `roadmap` (mirrors `/address`, `/objective-plan`) — objectives have no transcript
|
|
451
|
-
scrape by design (a roadmap is structured data, unscrapeable), so a draftless session still
|
|
452
|
-
needs the driven save path. The tool is structurally unreachable while read-only and remains
|
|
453
|
-
the post-gate-exit direct failsafe.
|
|
454
|
-
- **Structured roadmap (never hand-written YAML).** `create_objective_issue` gains an optional
|
|
455
|
-
`roadmap_nodes`; `perk objective create` gains `--roadmap <json>` (parsed via
|
|
456
|
-
`objective.parse_structured_roadmap`, where per-node `status` is optional and defaults to
|
|
457
|
-
`pending`). When `--roadmap`/`roadmap_nodes` is given the body is pure prose; otherwise the legacy
|
|
458
|
-
body-embedded roadmap parse still applies (the cold-CLI path). **Creation requires ≥1 roadmap
|
|
459
|
-
node**: `perk objective create` rejects an empty roadmap with `error_type: empty_roadmap` (exit 1)
|
|
460
|
-
and `create_objective_issue` raises `GitHubError` — the parse/read layer stays lenient (existing
|
|
461
|
-
node-less issues remain readable/closable). The judgment layer lives in the `perk-objective-author`
|
|
462
|
-
skill, which now speaks the review-first discipline (draft via `objective_draft` → `plan_review`
|
|
463
|
-
→ approval auto-save; `/objective-save` is the artifact-first failsafe) — #352 Node 3.2.
|
|
464
|
-
|
|
465
|
-
**Objective plan factory + transition tools (P2.T10).** The objective **transition** surface on top
|
|
466
|
-
of T9's mechanics (`extension/factories/objectivePlan.ts`, `registerObjectivePlan`):
|
|
467
|
-
- **`/objective-plan [<number>] [--node ID]`** — the warm entry: resolve the objective (arg, else
|
|
468
|
-
`active_objective` from the rebuilt `perk:workflow-state`) and `pi.sendUserMessage(...)` the
|
|
469
|
-
factory guidance to start the loop (mirrors `/address`). Headless-safe. On invocation it ALSO
|
|
470
|
-
**enters the read-only gate** when it is off (skip-if-active: no duplicate `mode` append or
|
|
471
|
-
announce when already read-only): appends `mode: "read-only"` to `perk:workflow-state` via
|
|
472
|
-
`gating.enter` and reports a dedicated announce line — parity with the cold door's registry
|
|
473
|
-
`mode: read-only` handoff claim. Gate **exit** remains owned by `plan_save` (D1a, approval
|
|
474
|
-
auto-save included) / `/plan` off; the no-objective warning path never enters the gate.
|
|
475
|
-
(Objective #352 Node 1.2.) As of #352 Node 3.1 the injected factory guidance (warm
|
|
476
|
-
`factoryGuidance`; mirrored by the cold `_seed_prompt`, which adds handoff claim recovery and
|
|
477
|
-
drops the mark step) instructs the **file-first loop**: the **unconditional** `planning` mark
|
|
478
|
-
(the successful transition records the `objective_node_claim`), `plan_draft`/`plan_review`,
|
|
479
|
-
the approval-driven save with both-or-neither link recovery from the claim, and
|
|
480
|
-
`plan_save`-with-both-ids as the manual failsafe.
|
|
481
|
-
- **`objective_node` tool** — the BOUNDED model-facing transition. It **delegates** the mutation to
|
|
482
|
-
the Python cold door (`perk objective node`, canonical mutations in Python) and **never throws**
|
|
483
|
-
(soft `details.ok`, mirrors `resolve_review_threads`). Params `{ objective, node, status?, pr?,
|
|
484
|
-
audit? }`; exec args are built **conditionally** (matching T9's optional `--status`/`--pr` —
|
|
485
|
-
`--status ""` is a Click error, so it is omitted when no status change): a **`pr`-only backlink**
|
|
486
|
-
(`pr` present, `status` absent) → `["objective","node",N,"--node",id,"--pr",pr,"--json"]` (no
|
|
487
|
-
`--status`, no audit); a **status change** adds `["--status",status]` (and `--pr` only if also
|
|
488
|
-
given). A call with **neither `status` nor `pr`** is refused (`bad_input`, no exec).
|
|
489
|
-
- **Completion-audit gate (model-path-only).** When `status === "done"` the tool requires a
|
|
490
|
-
**non-trivial `audit`** and refuses otherwise (`audit_required`, **no exec**). Non-trivial **iff**
|
|
491
|
-
`audit` is a string whose value **after `.trim()` is ≥ 40 characters**. This is a property of the
|
|
492
|
-
**model-facing boundary**, NOT an invariant on the node-`done` state: the canonical cold CLI
|
|
493
|
-
(`perk objective node --status done`, human/CI) has **no** audit gate, and **T11's auto-on-merge
|
|
494
|
-
node-done deliberately sets `done` without an audit**. Both are intentional non-audited paths — the
|
|
495
|
-
refusal protects the model's path only. The "are we done?" judgment text (prompt-to-artifact
|
|
496
|
-
checklist; treat uncertainty as not-done) lives in the `perk-objective-plan` skill.
|
|
497
|
-
- **The node↔plan link.** plan→objective is carried by the plan header/ref `objective_id` (threaded
|
|
498
|
-
through `perk plan-save --objective-id` + the `plan_save` tool's `objective_id` param). The
|
|
499
|
-
objective→plan backlink (`node.pr`) **and** the `planning → in_progress` advance are now set
|
|
500
|
-
**atomically by `plan-save`** when invoked with `--objective-id` + `--node-id` (warm `plan_save`
|
|
501
|
-
tool params `objective_id` + `node_id`) — a single `update_objective_node(status=in_progress,
|
|
502
|
-
pr="#<issue>")` write, **fail-open + non-fatal + idempotent on re-save** (the plan already exists
|
|
503
|
-
so a link failure is non-fatal and surfaces `objective_node.error`; the same `run_id` re-links on
|
|
504
|
-
a retried save). On a failed advance, the **warm `/plan-save` door surfaces the outcome to the
|
|
505
|
-
user** — it appends a `⚠ objective node <id> NOT advanced — re-run /plan-save to retry` note to
|
|
506
|
-
the save-result text (rendered by both the `plan_save` tool and the `/plan-save` command) and
|
|
507
|
-
notifies at **`warning`** severity (mirrored to stderr in headless runs), not merely a Python
|
|
508
|
-
stderr line. Re-running `/plan-save` with no further arguments retries the advance idempotently.
|
|
509
|
-
The standalone `objective_node` `pr`-only shape remains for **manual
|
|
510
|
-
repair** but is no longer part of the factory loop. T11's reconciliation-on-land consumes both
|
|
511
|
-
directions.
|
|
512
|
-
- **Node lifecycle = a resumable lease (factory selection).** `planning` is a **resumable claim**
|
|
513
|
-
(intent to plan; no saved plan yet — `objective-plan` re-selects it, an abandoned claim self-heals;
|
|
514
|
-
the eager mark is idempotent). `in_progress` is a **committed plan** (saved, node→plan backlinked,
|
|
515
|
-
awaiting land). `done` is set by the land path (`nodes_for_pr`) or the audited tool. Factory
|
|
516
|
-
selection lives in `objective.DependencyGraph`: `plannable_nodes()` (membership: unblocked ∧
|
|
517
|
-
(`pending`, or `planning` with **no** `pr`), position order — feeds the explicit `--node` lookup);
|
|
518
|
-
a `planning` node **with** a `pr` and any `in_progress` node are `in_flight_nodes()`;
|
|
519
|
-
`resumable_claims()` is the unblocked `planning`-with-no-`pr` subset (the "live or abandoned
|
|
520
|
-
claim" set the surfaces report). `next_plannable()` — the single implicit-selection method (so
|
|
521
|
-
`objective next`/`show` resume a claim; the `--json` field name stays `next_node`) — is
|
|
522
|
-
**pending-first**: the first unblocked `pending` node by position, then the first resumable claim
|
|
523
|
-
by position. Rationale: a claim cannot be distinguished from a session actively planning in
|
|
524
|
-
another terminal, so implicit selection never steals/duplicates a possibly-live claim while safe
|
|
525
|
-
pending work exists; self-healing of abandoned claims is preserved as the fallback (and via
|
|
526
|
-
explicit `--node`). This makes **parallel `objective-plan` launches** on independent nodes the
|
|
527
|
-
supported behavior: the first launch marks its node `planning` (removing it from the pending
|
|
528
|
-
set), the second launch selects the next unblocked pending node. The cold door surfaces the
|
|
529
|
-
skipped-claim set (a stderr `note:` line on non-JSON-payload paths + a `skipped_claims` array in
|
|
530
|
-
the `--dry-run --json` payload), and `objective show --json` carries `resumable_claims` (full
|
|
531
|
-
node dicts) for multi-terminal coordination.
|
|
532
|
-
**Accepted backlink race:** concurrent `update_objective_node` writes (two parallel `plan_save`s,
|
|
533
|
-
or a save racing a second door's `planning` mark) are read-modify-write on the issue body, so a
|
|
534
|
-
simultaneous write can drop one node's update. Accepted, not fixed (erk shipped the same as a
|
|
535
|
-
tripwire): the loser is recoverable — `/plan-save` re-save is idempotent and retries the link,
|
|
536
|
-
and `perk objective node` is the manual repair. No optimistic-concurrency machinery.
|
|
537
|
-
`classify_for_planning()` returns
|
|
538
|
-
`plannable`/`in_flight`/`blocked`/`complete` and drives the cold door's honest errors
|
|
539
|
-
(`objective_in_flight` is a new `error_type`, exit 1, in place of the old misleading "all blocked
|
|
540
|
-
or complete"). `objective show --json` gains `selection_kind`.
|
|
541
|
-
|
|
542
|
-
**Objective reconciliation after landing (P2.T11).** When a PR linked to an objective node merges,
|
|
543
|
-
the roadmap reconciles against what actually landed — two seams matching the D9 Mechanical/
|
|
544
|
-
Reconcilable/Immutable typing:
|
|
545
|
-
- **Mechanical (on land).** The land path auto-marks the backlinked node(s) `done` — fail-open and
|
|
546
|
-
non-audited (the audit gate is the model-tool boundary only). The warm `/land` then **auto-drives**
|
|
547
|
-
the reconcile pass: it injects the same `reconcileGuidance` message `/objective-reconcile` injects
|
|
548
|
-
(`deliverAs: "followUp"` from the terminating `land` tool, an immediate turn from the idle `/land`
|
|
549
|
-
command) instead of printing a manual nudge.
|
|
550
|
-
- **Reconcilable (warm, post-merge).** `/objective-reconcile [<number>]` resolves the objective via
|
|
551
|
-
a **three-tier** lookup — arg → `active_objective` → `readPlanRef(cwd).objective_id` (the
|
|
552
|
-
just-landed objective sitting in the plan-ref, so the post-land path works even when the user
|
|
553
|
-
never ran `/objective`) — then `pi.sendUserMessage(...)` the reconcile guidance (mirrors
|
|
554
|
-
`/objective-plan`; headless-safe). The `reconcile_objective` tool (`{ objective, prose }`) writes
|
|
555
|
-
the prose to a run-scoped scratch file and delegates to `perk objective reconcile … --body <path>`
|
|
556
|
-
(never throws); it rewrites ONLY the marker-bounded Reconcilable prose region (the roadmap table +
|
|
557
|
-
Immutable notes are structurally never touched). The `objective_node` tool gains a `description?`
|
|
558
|
-
param (node scope/naming reconciliation) — `buildObjectiveNodeArgs` relaxes its structural refusal
|
|
559
|
-
so a `description`-only call is valid; the `status:"done"` audit gate is unchanged. The judgment
|
|
560
|
-
text lives in the `perk-objective-reconcile` skill.
|
|
561
|
-
|
|
562
|
-
**Session-lifecycle gates (T4b).** The interior guards `session_before_switch` /
|
|
563
|
-
`session_before_fork` with a **dirty-repo check** (`git status --porcelain` via `pi.exec`),
|
|
564
|
-
**scoped to active perk workflows** (`active_plan_ref != null` — perk never interferes with
|
|
565
|
-
non-perk forks/switches). A dirty tree in an active workflow returns `{ cancel: true }` with a
|
|
566
|
-
loud message (notify if UI, else stderr) — **fail-safe-headless** (it cancels in both modes; there
|
|
567
|
-
is no proceed-anyway in Phase 1). A clean tree, or any transition outside a workflow, is allowed
|
|
568
|
-
(returns `undefined`); if `git status` itself fails (e.g. not a repo) the gate allows (it is a
|
|
569
|
-
hygiene guard, not a repo validator). The warm `/implement` command
|
|
570
|
-
*enforces* `implement.doors.warm: false` for the **cross-worktree** transition: outside an impl
|
|
571
|
-
context it refuses and points to the cold door `perk implement`. The proceed-anyway confirm dialog
|
|
572
|
-
+ `git-checkpoint` stash-on-turn are Phase 2.
|
|
573
|
-
|
|
574
|
-
**Warm `/implement` in-worktree handoff (P2.T2b).** `implement.doors.warm` stays **`false`** — the
|
|
575
|
-
plan→implement *stage transition* is cold-only because **no extension-reachable session API can
|
|
576
|
-
change cwd** (the `ExtensionCommandContext` surface exposes `newSession`/`switchSession`, neither of
|
|
577
|
-
which takes a cwd; `cwdOverride` lives only on the lower `SessionManager.open`, out of reach
|
|
578
|
-
in-session — D2). What T2b adds is the in-process twin of the cold door usable **inside** an active
|
|
579
|
-
impl worktree (same cwd): when `/implement` runs in an impl context (read-write + a linked
|
|
580
|
-
`active_plan_ref`), it offers a lossless `ctx.newSession` fresh-context handoff seeded (via
|
|
581
|
-
`withSession` → `sendUserMessage`) with the plan-read priming (`implementHandoffPrompt`, the
|
|
582
|
-
in-session twin of `perk/run/launch/prompts.py`'s `_initial_prompt`: read the plan from its canonical source,
|
|
583
|
-
implement, `/submit` — carry the plan forward, never summarize it). Model-visible output is capped
|
|
584
|
-
(a single short confirmation; the durable state is the worktree's materialized plan-ref + the plan
|
|
585
|
-
issue). Dirty-tree hygiene is gated **manually** in the handler (a `newSession` session-replace may
|
|
586
|
-
bypass the `session_before_*` gate, so the handler re-checks `git status --porcelain` and refuses on
|
|
587
|
-
a dirty tree), fail-safe-headless. This is a **context refresh, not a stage transition** — the
|
|
588
|
-
registry's `implement.doors.warm: false` is unchanged.
|
|
589
|
-
|
|
590
|
-
**Checkpoints (P2.T2c).** Implementation progress is tracked in a **dedicated `perk:checkpoint`**
|
|
591
|
-
session entry (D3) — kept OFF the `perk:workflow-state` record because progress is high-churn (an
|
|
592
|
-
append every advancing `turn_end`), and a separate entry avoids LWW-append smell on the shared
|
|
593
|
-
record. The interior (`extension/checkpoints/checkpoints.ts`) seeds an ordered step list from the plan body's
|
|
594
|
-
`## Steps` numbered list (read from the `cache.plan` body cache) on `session_start` — **only** in an
|
|
595
|
-
active workflow (`active_plan_ref != null`), **only once** (a later session keeps the existing
|
|
596
|
-
entry). The `cache.plan` body (`.perk/workflow/plan.md`) is **materialized by the Python cold door**:
|
|
597
|
-
`perk implement` (`launch._materialize_plan_body`) fetches the plan body from GitHub
|
|
598
|
-
(`github.get_plan_body` → the `plan-body` block in the issue's first comment, parsed by
|
|
599
|
-
`plan.extract_plan_body`) and writes it into the worktree alongside the plan-ref + handoff
|
|
600
|
-
(best-effort + loud-but-non-fatal — an unreachable body just yields inert checkpoints, never a failed
|
|
601
|
-
launch). The cold door also **mirrors `repo_root/.agents/skills/*` into the worktree** as per-skill
|
|
602
|
-
symlinks (`launch.materialize_skills`): a linked worktree never carries the gitignored
|
|
603
|
-
`.agents/skills/` tree and pi discovers skills only up to the worktree's own git root, so without the
|
|
604
|
-
mirror a worktree session sees zero skills (ENOENT on `perk-implement/SKILL.md`). Best-effort +
|
|
605
|
-
loud-but-non-fatal (a missing source set warns; doctor's fail-level `skills-delivery` check owns the
|
|
606
|
-
hard gate); idempotent on resume (an already-correct symlink is left untouched, a real non-symlink
|
|
607
|
-
entry is never clobbered). **After** materialization (and only when the cold door **freshly
|
|
608
|
-
created** the worktree, never on idempotent reuse/dry-run), the cold door runs the project's
|
|
609
|
-
`[worktree] setup` commands (`launch.run_worktree_setup`) — an ordered array of shell command lines
|
|
610
|
-
read from `.perk/config.toml` (overlay-aware) — each via `bash -lc` with `cwd` = the worktree and
|
|
611
|
-
inherited stdio, **aborting the launch** (a `UserFacingCliError`) on any non-zero exit / timeout /
|
|
612
|
-
missing `bash` (a half-built environment is worse than a clear failure; the worktree is left for a
|
|
613
|
-
fixed re-run). This is **Python-plane-only** (no TS twin — the extension never creates worktrees);
|
|
614
|
-
the manual `perk worktree create` runs the same hook, and the remote runner's `position_worktree`
|
|
615
|
-
deliberately does **not** (CI environment setup belongs to the GHA composite action). It is
|
|
616
|
-
**opt-in + inert-by-default (D4)**: perk plans are prose, so when no `## Steps` list is
|
|
617
|
-
present the checkpoint degrades to inert (no entry, no crash); the `perk-plan` skill documents the
|
|
618
|
-
optional `## Steps` section as the forward path. Cross-plane contract: the **file** `cache.plan`
|
|
619
|
-
(`.perk/workflow/plan.md`), written by Python and read by TS. State is **rebuilt on `session_start`, `session_tree`, AND
|
|
620
|
-
`session_compact`** (the `session_compact` re-render — rebuild + render only, NO re-seed, mirroring
|
|
621
|
-
`session_tree` — was adapted from `@juicesharp/rpiv-todo`; its `catch` arm swallows the pi-core
|
|
622
|
-
stale-`ctx` compaction race silently — the proxy `/stale after session replacement/` error fired
|
|
623
|
-
when pi replaces the running session out from under the in-flight handler — while logging genuine
|
|
624
|
-
replay failures); `turn_end` scans the assistant message for `[DONE:n]` and, when a step advances,
|
|
625
|
-
appends a new `perk:checkpoint` marker carrying completion forward. The rebuild uses the
|
|
626
|
-
**scan-after-marker** discipline: the latest `perk:checkpoint` entry is the marker, and `[DONE:n]`/
|
|
627
|
-
`[WIP:n]` are re-folded only from assistant messages **after** it (stale markers from a previous
|
|
628
|
-
execution cannot resurrect a step). An **in-progress (`current`) step** is derived (not persisted):
|
|
629
|
-
the latest live `[WIP:n]` after the marker whose step exists and is incomplete, falling back to the
|
|
630
|
-
lowest incomplete step, else `null`; completion always wins (`▸` never renders on a completed step).
|
|
631
|
-
The `📋 done/total` (plus ` · ▸n` when current) text renders as the **checkpoints segment of the
|
|
632
|
-
single composed `perk` status slot** (ordered objective → checkpoints per charter D2, two-space
|
|
633
|
-
join, composed by `surfaces.ts createPerkStatus` — node 2.3 retired the per-feature
|
|
634
|
-
`perk-checkpoints`/`perk-objective` status slots). The widget keeps its own `perk-checkpoints`
|
|
635
|
-
slot and is a **themed component factory** (`(tui, theme) => { render, invalidate }`, stateless render per charter D10 — themed
|
|
636
|
-
lines are computed inside `render()` per call, never cached) placed **`belowEditor`** (D4); lines
|
|
637
|
-
are `✓/▸/○ <n>. <text>` colored per the charter §5 table (`success`/`accent`/`dim`) with
|
|
638
|
-
completed-step text muted, **windowed to ≤ 4 step lines** (D1: a sliding window anchored on the
|
|
639
|
-
current step sitting second when possible; `… +N earlier` / `… +N later` dim elision markers
|
|
640
|
-
render *in addition* to the step lines, ≤ 6 rendered lines worst case), and every line is
|
|
641
|
-
width-truncated via pi-tui's `truncateToWidth` (D9). `/checkpoints` notifies a **single line**
|
|
642
|
-
(D8): `done/total · ▸n <current step text>` (the ` · ▸n <text>` tail drops when no step is
|
|
643
|
-
current). **Accepted RPC caveat:** pi drops component-factory widgets in RPC mode (only string
|
|
644
|
-
arrays forward), so the checkpoints widget is invisible to RPC clients — the status (now arriving
|
|
645
|
-
under the composed slot `perk`) and `/checkpoints` remain the RPC-visible surfaces. **Footer
|
|
646
|
-
ownership (node 3.1, charter D2):** in TUI mode perk **owns the footer by default** via
|
|
647
|
-
`ctx.ui.setFooter` (`surfaces.ts perkFooter`/`installPerkFooter` — installed once per session on
|
|
648
|
-
`session_start`, headful only) — **unless** a foreign `[providers] footer` provider is selected, in
|
|
649
|
-
which case perk **vacates `installPerkFooter`** (install-site runtime vacating keyed off `ctx.cwd`,
|
|
650
|
-
fail-safe to install; see §8.10's footer interface-seam note) and the foreign footer is the sole
|
|
651
|
-
footer surface. perk's default-owned footer composes one line, in charter order, perk identity
|
|
652
|
-
(`perk v<version>`), the 🎯 objective segment, the 📋 checkpoints segment (left group), then git
|
|
653
|
-
branch, model, thinking level (bare level text, dim; read live via `pi.getThinkingLevel()`, shown
|
|
654
|
-
whenever a model is present including `off`), context usage (`<pct>%/<window>`, warning >70 / error
|
|
655
|
-
>90), and guest extension statuses (right-aligned), with the extended D9 drop order on overflow
|
|
656
|
-
(guests → thinking → model → branch → context → checkpoints; identity + objective never drop). The composed `perk` status slot
|
|
657
|
-
**remains published** (the `createPerkStatus` dual-publish is deliberate) and is the RPC-visible
|
|
658
|
-
surface — `setFooter` is an RPC no-op. The `v<version> loaded` startup notify is **retired**
|
|
659
|
-
(charter D7: identity is standing footer state, not a transition) — `session_start` no longer
|
|
660
|
-
emits a startup notify or its headless stderr mirror; the `PERK_SELFCHECK` `.perk-loaded` sentinel
|
|
661
|
-
is unchanged. D5 (branded working indicator) is **rescinded**: perk never calls
|
|
662
|
-
`setWorkingIndicator`. The **marker protocol is taught to the implement session**
|
|
663
|
-
via `_implement_prompt` (the launch prompt) + the **`perk-implement` skill**, so the implementer
|
|
664
|
-
knows to emit `[WIP:n]`/`[DONE:n]`. **Coarse fallback (P2.T15):** when no `## Steps` checklist exists
|
|
665
|
-
but a plan is active, the status bar shows `📋 <stage>` (the stage label from the handoff,
|
|
666
|
-
`readHandoff(cwd, run_id).stage`, falling back to `"active"`) with a single dim widget line (the
|
|
667
|
-
same themed-factory path, `belowEditor`) noting the plan is prose — so an active plan never goes
|
|
668
|
-
dark; with no active plan, the segment and widget clear. All surfaces are headless-safe (the
|
|
669
|
-
composed-status handle and `setStandingWidget` no-op without UI — headless never touches rich
|
|
670
|
-
UI); `/checkpoints` lists progress (notify when UI, else stderr). State key: a transient tier-3 session entry (not in the registry vocabulary, like
|
|
671
|
-
`perk:workflow-state`'s sibling execution/todo entries). `@juicesharp/rpiv-todo` **is** retired in
|
|
672
|
-
P2.T12 (removed from `init.py`'s `BORROWED_PACKAGES` and `.pi/settings.json`): perk now owns the
|
|
673
|
-
implement-progress overlay via this perk-owned `perk:checkpoint` seam. `@tombell/pi-status` is
|
|
674
|
-
likewise **retired** from `BORROWED_PACKAGES`: `ctx.ui.setFooter` is a single last-wins slot, and
|
|
675
|
-
pi-status's `session_start` footer install replaced perk's footer — a *borrowed* package must never
|
|
676
|
-
own the footer. (Distinct from a *selected* `footer` provider, which legitimately does: the footer
|
|
677
|
-
seam is the sanctioned way to hand the footer to a foreign package — perk vacates `installPerkFooter`
|
|
678
|
-
so there is no last-wins clobber. See §8.10's footer interface-seam note.) **`@tombell/pi-status` is
|
|
679
|
-
now ALSO a selectable footer provider** (`pi-status-footer`, #670): selecting it via `[providers]
|
|
680
|
-
footer` makes `perk init` converge `npm:@tombell/pi-status` into `packages` (object form) and perk
|
|
681
|
-
vacates `installPerkFooter` — the machine-governed way to get pi-status's footer, replacing the
|
|
682
|
-
unmanaged settings.json hand-edit. Unlike `powerline-footer`/`pi-bar-footer`, pi-status does **not**
|
|
683
|
-
render extension statuses, so perk's objective/checkpoints progress is **not shown** under it (an
|
|
684
|
-
accepted limitation, no status-bridge adapter). A sibling `pi-default` provider (`package: null`)
|
|
685
|
-
adds **no** footer package and vacates perk's install gate, leaving pi's stock built-in footer.
|
|
686
|
-
|
|
687
|
-
**Rejected `@juicesharp/rpiv-todo` ideas (deliberate non-adoptions).** A survey of rpiv-todo's
|
|
688
|
-
model-driven todo design against perk's passive, plan-derived, linear checkpoints (see
|
|
689
|
-
`docs/design/checkpoints-rpiv-todo-comparison.md`) adopted only the `session_compact` stale-`ctx`
|
|
690
|
-
robustness above. Rejected with rationale: (1) the **model-callable `todo` tool / `blockedBy`
|
|
691
|
-
dependency graph / dynamic create-update-delete** — reverses the P2.T2c charter that separates a
|
|
692
|
-
read-only plan from a linear, marker-driven, never-model-mutated checklist; (2) the **`activeForm`
|
|
693
|
-
present-continuous label** — there is no channel for the model to supply one (markers are
|
|
694
|
-
`[WIP:n]`/`[DONE:n]`) and the step *text* already serves as the in-progress label (`▸n <text>`);
|
|
695
|
-
adopting it would expand the marker grammar (a protocol change, not polish); (3) the
|
|
696
|
-
**completed-fall-away overlay** — `windowProgress` already does richer overflow handling (a sliding
|
|
697
|
-
window with `… +N earlier`/`… +N later` elision); rpiv's drop-after-next-turn is a different
|
|
698
|
-
philosophy, not clearly better for an ordered linear checklist.
|
|
699
|
-
|
|
700
|
-
**Generated checkpoint steps for prose plans (#342).** When the implement-session `session_start`
|
|
701
|
-
seeding finds a **materialized plan body with no usable `## Steps`** (`extractSteps` → `[]` covers
|
|
702
|
-
both a missing and a malformed section), checkpoints **generate** the step list on the fly via the
|
|
703
|
-
structured-output substrate (`extension/checkpoints/planSteps.ts`, the `planTitle.ts` idiom: a single
|
|
704
|
-
`set_plan_steps` tool call, TypeBox-validated, 2–12 steps sanitized to ≤200 chars each). Trigger
|
|
705
|
-
conditions (ALL required): the perk-checkpoints reference is the selected todo provider; no
|
|
706
|
-
existing `perk:checkpoint` entry (seed-once); an active workflow (`active_plan_ref != null`); a
|
|
707
|
-
non-null plan body whose `extractSteps` is empty; and the **launched stage is `implement`** (the
|
|
708
|
-
handoff's `stage` — address/learn/plan sessions never generate). **Artifact reuse first**: the
|
|
709
|
-
generated list persists as the session artifact `plan-steps.json`
|
|
710
|
-
(`{ plan_id, plan_body_digest, steps }`) written through the §8.1 session-data accessor with a
|
|
711
|
-
§8.3 provenance pointer, and is trusted only when the pointer validates AND its stored
|
|
712
|
-
`plan_body_digest` (the §8.1 `sha256:` convention over the current `plan.md` bytes) matches — a
|
|
713
|
-
replan/rematerialized body invalidates the cache and regenerates. On success the seed is
|
|
714
|
-
byte-identical to the explicit-`## Steps` path (same `perk:checkpoint` entry shape — no schema
|
|
715
|
-
change; rebuild/advance/render untouched); generated-ness is **recomputed, never stored**
|
|
716
|
-
(non-inert AND the current plan body parses to no explicit steps). A once-only
|
|
717
|
-
**`perk:steps-context`** hidden context message (injected at `before_agent_start`, dedup-guarded by
|
|
718
|
-
the branch already carrying the type; **no strip handler** — the checklist never goes stale within
|
|
719
|
-
the session) teaches the model the exact step numbers for `[WIP:n]`/`[DONE:n]`. `/checkpoints`
|
|
720
|
-
appends ` (generated)` when generated-ness recomputes true. **Fail-safe ladder**: the `PERK_NO_LLM`
|
|
721
|
-
offline gate, no model/auth, a model error, schema-invalid args, an unusable sanitized list, or a
|
|
722
|
-
missing session-data substrate each fall back to the coarse prose behavior (byte-identical widget
|
|
723
|
-
text) — never a failed session start. The plan issue is never mutated (generated steps are
|
|
724
|
-
cache-tier, session-local state).
|
|
725
|
-
|
|
726
|
-
**Surfaces discipline (Objective #251, node 4.1).** Every interior rich-UI call — `ctx.ui.notify`,
|
|
727
|
-
`setStatus`, `setWidget`, `setFooter`, `setWorkingMessage` — lives in the surfaces module
|
|
728
|
-
(`extension/surfaces/surfaces.ts` + `extension/surfaces/report.ts`); every other extension module
|
|
729
|
-
reaches the UI only through the seams (`report()`, `createPerkStatus`, `setStandingWidget`,
|
|
730
|
-
`installPerkFooter`, `setWorkingMessage`). `setWorkingIndicator` is never called anywhere (D5
|
|
731
|
-
rescinded); the distinct **`setWorkingMessage`** call (text-only label on pi's default spinner,
|
|
732
|
-
headless-no-op) **is** permitted (it was never declined) and is routed through the
|
|
733
|
-
`setWorkingMessage` surfaces seam — `whimsical` flavors the spinner label through it. **`ctx.ui.custom`
|
|
734
|
-
stays declined for all workflow surfaces** (charter §6 D6); the sole sanctioned exception is **`/btw`**,
|
|
735
|
-
a human-only side-chat popover that is `hasUI`-gated, exposes no model tool, and is not a stage/door —
|
|
736
|
-
so it is never machine-reachable and cannot threaten the machine-executability the decline protects.
|
|
737
|
-
Enforced by the source-scan guard `extension/surfacesGuard.test.ts` (node:test, runs in
|
|
738
|
-
`just test`/`just ci`).
|
|
739
|
-
|
|
740
|
-
**Tool-gating (P2.T1).** The `mode` field **structurally gates tools** — enforcement, not
|
|
741
|
-
prompting. When `mode == "read-only"` the interior (`extension/substrate/toolGating.ts`):
|
|
742
|
-
(1) restricts the active tool set to `READ_ONLY_TOOLS` (`read`/`grep`/`find`/`ls`/`bash` +
|
|
743
|
-
`ask_user_question` + `plan_review` + the `plan_draft`/`objective_draft` session-data carve-outs +
|
|
744
|
-
`objective_node` (never touches the worktree — it delegates a bounded node transition to the
|
|
745
|
-
canonical Python plane, and the objective-plan factory's `objective_node_claim` carrier can only
|
|
746
|
-
be written inside the gated session) + the **`web` seam** providers' research tools — the **union**
|
|
747
|
-
of all provider tool names: `web_search`/`code_search`/`fetch_content`/`get_search_content`
|
|
748
|
-
(`pi-web-access`, the default), `ollama_web_search`/`ollama_web_fetch` (`@ollama/pi-web-search`),
|
|
749
|
-
and `web_fetch` (`@juicesharp/rpiv-web-tools`); foreign tool names are inert
|
|
750
|
-
when their package is absent) via `pi.setActiveTools`, **snapshot-then-restore** (snapshot `pi.getActiveTools()` on the off→on
|
|
751
|
-
transition; restore it on on→off, falling back to the **full** configured tool set
|
|
752
|
-
`pi.getAllTools()` if no snapshot exists — never a hardcoded list, so perk's custom tools survive);
|
|
753
|
-
(2) blocks `edit`/`write`
|
|
754
|
-
and non-allowlisted `bash` commands at `tool_call` with `{ block: true, reason }` (a perk-owned
|
|
755
|
-
copy of plan-mode's destructive/safe regex tables; the bash allowlist additionally includes
|
|
756
|
-
read-only `gh` query subcommands — `gh issue|pr|repo|run|release|label view|list|diff|status|checks`,
|
|
757
|
-
`gh search …`, `gh auth status` — while `gh api` and all mutating `gh` subcommands stay blocked, plus
|
|
758
|
-
the command-keyed `agent-browser` / `npx agent-browser` entries (the browser-automation skill,
|
|
759
|
-
command-keyed like `ast-grep` — its own output flags can write files outside the gate, an accepted
|
|
760
|
-
leniency like `curl`/`fetch_content`); (3) injects a hidden `[READ-ONLY MODE]`
|
|
761
|
-
context at `before_agent_start` and **strips** that marker from `context` when off. The allowlist
|
|
762
|
-
is **restored on both `session_start` and `session_tree`** (re-sync from the rebuilt `mode`).
|
|
763
|
-
**Fail-closed:** the in-memory gate flag drives `tool_call`; a failed state-rebuild never opens the
|
|
764
|
-
gate (the sync is skipped), and `tool_call` blocks on any internal error. `mode` writes are
|
|
765
|
-
best-effort transient (no strict read-back). The `enter(ctx?)`/`exit(ctx?)` surface
|
|
766
|
-
(append `mode` + flip the gate) is the API the perk-owned plan mode (T2) and the read-only CI
|
|
767
|
-
executor (T5) consume; this primitive ships no `/plan` ownership and adds no registry stage.
|
|
768
|
-
|
|
769
|
-
**Perk-owned plan mode (P2.T2a).** `mode` is now perk-owned **end-to-end** — the borrowed
|
|
770
|
-
`@tombell/pi-plan` package is retired (removed from `init.py`'s `BORROWED_PACKAGES` and
|
|
771
|
-
`.pi/settings.json`). The interior (`extension/factories/planMode.ts`) owns the toggle surface over T1's gate:
|
|
772
|
-
a `/plan` command, a `Ctrl+Alt+P` shortcut, and a `--plan` flag all flip `gating.enter`/`exit`
|
|
773
|
-
(perk adds **no** parallel enforcement — T1 is the single read-only authority). It also injects a
|
|
774
|
-
hidden plan-authoring prompt layer under its own `perk:plan-context` customType (keyed off the
|
|
775
|
-
read-only gate; stripped from `context` when off — the same hygiene T1 applies to
|
|
776
|
-
`perk:mode-context`), optionally extended by a `[workflow] plan_authoring` addendum read from
|
|
777
|
-
`.perk/config.toml` + `local.toml` (`extension/substrate/config.ts`, the TS twin of `perk/substrate/config.py`'s
|
|
778
|
-
overlay). `isPlanModeActive` (in `extension/factories/planSave.ts`) now reads perk's own `mode == "read-only"`
|
|
779
|
-
(the P1.T3b `plan-mode-state` soft coupling is gone). The `plan_save` **tool** is structurally
|
|
780
|
-
unreachable while read-only (T1's allowlist excludes it), so there is no auto-exit on the tool path;
|
|
781
|
-
the `/plan-save` **command** *can* run while read-only and, on a successful save, calls
|
|
782
|
-
`gating.exit()` — save marks the read-only → read-write boundary in one gesture (D1a). perk does
|
|
783
|
-
**not** adopt plan-mode's in-session "execution mode" flip: it separates plan (read-only session)
|
|
784
|
-
from implement (cold-door fresh worktree session); `[DONE:n]` checkpoints live in the implement
|
|
785
|
-
session (T2c). The `plan` registry stage now records `writes: [session.workflow-state]` (the
|
|
786
|
-
`/plan` enter/exit `mode` append).
|
|
787
|
-
|
|
788
|
-
**Plan-provider deferral (Node 2.2).** `planMode` now *consumes* the resolved `[providers] plan`
|
|
789
|
-
selection: it reads `loadPerkConfig(ctx.cwd).providers` through `extension/substrate/providers.ts`'s
|
|
790
|
-
`resolveProviders` per-event (`resolvedPlanProviderId(cwd)` / `isPerkPlanReferenceSelected(cwd)`,
|
|
791
|
-
fail-safe to `perk-plan` on any load failure) and **steps its authoring surface aside** when the
|
|
792
|
-
resolved plan provider ≠ `perk-plan` — the `/plan` toggle announces the deferral headless-safe and
|
|
793
|
-
returns, `Ctrl+Alt+P` routes through the same `toggle`, `--plan` defers **silently** (no gate
|
|
794
|
-
entry), and the `perk:plan-context` injection is suppressed (a second defer condition alongside the
|
|
795
|
-
objective-author one). The `context`-strip is unchanged. `savePlan`/the `plan_save` tool/`/plan-save`
|
|
796
|
-
/the read-only gate are the **seam-shared substrate** the Node 2.3 adapter bridges to — they are
|
|
797
|
-
always-registered and never defer (only perk's own authoring surface does).
|
|
798
|
-
|
|
799
|
-
**Todo-provider deferral (Node 3.1).** `checkpoints` (perk's reference todo provider,
|
|
800
|
-
`perk-checkpoints`) now *consumes* the resolved `[providers] todo` selection — the todo-seam mirror
|
|
801
|
-
of the plan-seam deferral above. It reads `loadPerkConfig(ctx.cwd).providers` through
|
|
802
|
-
`extension/substrate/providers.ts`'s `resolveProviders` per-event (`resolvedTodoProviderId(cwd)` /
|
|
803
|
-
`isPerkCheckpointsReferenceSelected(cwd)`, fail-safe to `perk-checkpoints` on any load failure) and
|
|
804
|
-
**steps its progress surface aside** when the resolved todo provider ≠ `perk-checkpoints`: the
|
|
805
|
-
`session_start` / `session_tree` / `turn_end` handlers early-return **silently** (no seed, no
|
|
806
|
-
advance, no `setStatus`/`setWidget` render — the foreign provider owns the surface uncontested) and
|
|
807
|
-
`/checkpoints` **announces** the deferral headless-safe and returns. The pure checkpoint helpers, the
|
|
808
|
-
`perk:checkpoint` session entry, and the `## Steps` seeding are the seam-shared substrate (untouched).
|
|
809
|
-
Fail-safe to the reference: any config-read error → treated as `perk-checkpoints` → everything runs
|
|
810
|
-
exactly as today (the default path is the hard guarantee, zero behavior change).
|
|
811
|
-
|
|
812
|
-
**The `@juicesharp/rpiv-todo` adapter (Node 3.2).** `juicesharp-todo` is now a **real, selectable**
|
|
813
|
-
todo provider (no longer illustrative); the todo seam is **behavior-complete**. The perk-owned shim
|
|
814
|
-
`extension/adapters/todoAdapterJuicesharp.ts` (`registerTodoAdapterJuicesharp`, always registered, wired right
|
|
815
|
-
after `registerCheckpoints`) is an **injection-only** bridge, inert unless `[providers] todo =
|
|
816
|
-
"juicesharp-todo"` **and** the session is an active workflow (`active_plan_ref != null`). When both
|
|
817
|
-
hold it injects a hidden (`display:false`) `perk:todo-adapter-juicesharp` context that carries perk's
|
|
818
|
-
implement-progress **discipline** onto the foreign checklist overlay (seed from `## Steps`, mark each
|
|
819
|
-
item complete in order); a `context` handler strips the stale `[TODO ADAPTER: JUICESHARP]` marker
|
|
820
|
-
once deselected. Two seam asymmetries this node resolves, both deliberate deviations from the Node
|
|
821
|
-
3.1 forward-assumption that "registration-time vacating is the concrete adapter's concern":
|
|
822
|
-
- **(a) NO registration-time vacating** for the todo seam. The plan seam needed it purely because
|
|
823
|
-
perk and `@tombell/pi-plan` both register `/plan` (Pi suffixes duplicate command names). The todo
|
|
824
|
-
seam has **no command-name collision** — perk registers `/checkpoints`, the foreign overlay
|
|
825
|
-
registers its own differently-named command(s) — so Node 3.1's runtime deferral is already
|
|
826
|
-
sufficient and the shim adds none.
|
|
827
|
-
- **(b) The bridge is injection-only + active-workflow-gated** and does **NOT** write
|
|
828
|
-
`perk:checkpoint` or revive the deferred marker scanner (Correction 2). Unlike `cache.plan-ref`
|
|
829
|
-
(a durable cross-plane artifact downstream stages read, so a foreign plan *must* be bridged into
|
|
830
|
-
it), `perk:checkpoint` is a transient TS-only overlay nothing downstream consumes and perk's
|
|
831
|
-
render + scanner are already deferred — re-populating it would be dead duplication. The foreign
|
|
832
|
-
overlay is the sole, uncontested progress surface.
|
|
833
|
-
|
|
834
|
-
The shim **never** owns the read-only gate, **never** `setActiveTools`, and **never** restamps any
|
|
835
|
-
provider field (the todo-provider id lives only in `[providers] todo`). Validation record:
|
|
836
|
-
`docs/design/provider-smoke-juicesharp-todo.md`.
|
|
837
|
-
|
|
838
|
-
**In-process read-only child sessions (P2.T4).** The first context-isolation primitive: a
|
|
839
|
-
deterministic, fully-isolated read-only child spun at the SDK level (`extension/worker/readOnlySession.ts`,
|
|
840
|
-
interior/TS-only). This is the **shared handoff contract** both context-isolation primitives honor
|
|
841
|
-
(T4 in-process here; T6 the spawned shape later), so its shape is locked now and T6 conforms.
|
|
842
|
-
|
|
843
|
-
- **SDK read-only via `createReadOnlySession`.** The child's allowlist is
|
|
844
|
-
`SDK_READ_ONLY_TOOLS = ["read", "grep", "find", "ls"]` — **no `bash`**, stricter than T1's
|
|
845
|
-
in-session `READ_ONLY_TOOLS` (a separate constant, not a reuse). T5 composes its own allowlist
|
|
846
|
-
when it needs a gated test-runner command.
|
|
847
|
-
- **Isolation = `DefaultResourceLoader` `no*` flags + the tools allowlist** — **not**
|
|
848
|
-
`extensionFactories: []` (that is already the default and controls only inline factories; it does
|
|
849
|
-
**not** stop `loader.reload()` from resolving the project's `.pi/settings.json` packages and
|
|
850
|
-
loading perk's own extension into the child). The child loader sets
|
|
851
|
-
`noExtensions/noSkills/noPromptTemplates/noThemes/noContextFiles`, so **no perk machinery loads
|
|
852
|
-
into the child** and the path stays offline/deterministic. A custom loader is **reloaded by the
|
|
853
|
-
caller** (`await loader.reload()` before `createAgentSession`); `agentDir` is a throwaway temp dir
|
|
854
|
-
(a locked-down child loads nothing from it). The read-only guarantee is **structural** —
|
|
855
|
-
provable offline via `getActiveToolNames()` with no `prompt()`.
|
|
856
|
-
- **The handoff contract (`runReadOnlyChild`).** Cap the **model-visible** output
|
|
857
|
-
(`DEFAULT_MODEL_VISIBLE_CAP = 50 KiB`, UTF-8-byte-safe, overridable), keep the **full** result in
|
|
858
|
-
a **verified** scratch file (`write → verify → pass-path`), and return **double-delivery**: compact
|
|
859
|
-
`prose` for the human + a `structured` block for the orchestrator (which T5 places in a tool's
|
|
860
|
-
forking-safe `details`). **Route-don't-relay** is enforced structurally — the raw output never
|
|
861
|
-
enters the parent; only a path/summary does (`scratchPath`). **Fail loud + fail closed:** never
|
|
862
|
-
throws to the parent — on any error (session-create/task throw, failed scratch-verify, or abort)
|
|
863
|
-
it returns `{ success: false, scratchPath: null }` with the error in **both** `prose` and
|
|
864
|
-
`structured.error`. Offline-testability is a hard requirement: the session-running step is behind
|
|
865
|
-
an injectable `runTask` dependency so the cap/scratch/verify/double-delivery machinery is exercised
|
|
866
|
-
with no model turn.
|
|
867
|
-
- **Substrate only.** No registry stage, no door change, no cross-CLI behavior. The consumer is the
|
|
868
|
-
read-only CI executor (T5).
|
|
869
|
-
|
|
870
|
-
**Read-only CI executor (P2.T5).** The `run_ci` tool + `/ci` command run the project's `[ci]`
|
|
871
|
-
named checks **deterministically** (`pi.exec("bash", ["-lc", cmd])`, no LLM turn) and report
|
|
872
|
-
**double-delivery** (capped prose for the human + a forking-safe `CiReport` in `details`), reusing
|
|
873
|
-
T4's **cap/scratch/fail-closed handoff contract** (`capForModel` + `write → verify → pass-path` +
|
|
874
|
-
route-don't-relay) — **not** its session runner (`runReadOnlyChild.success` carries no exit code).
|
|
875
|
-
The executor **never edits or fixes**: it is a stateless oracle, and the parent owns the entire
|
|
876
|
-
**Run→Report→Fix→Verify** loop (`run` and `report`, never `run` and `fix`).
|
|
877
|
-
|
|
878
|
-
- **Not sandboxed — the safety boundary is structural.** The check command runs with full
|
|
879
|
-
filesystem/network access, **outside T1's tool gate**. The defenses are, in order: (1) the model
|
|
880
|
-
selects a configured **check name, never a command** (an unknown name yields an actionable
|
|
881
|
-
`unknown_check` error listing available names); (2) project-supplied CI is **untrusted** and gated
|
|
882
|
-
by `decideCiScope` — `[trust] ci = "true"` (committed config), `--allow-project-ci`, or a
|
|
883
|
-
per-session approval latch ⇒ run; else with UI ⇒ `ctx.ui.confirm`; else (headless, no
|
|
884
|
-
trust/flag) ⇒ **refuse (fail closed)**. Unlike the per-session confirm, **`[trust] ci` also
|
|
885
|
-
overrides the headless fail-closed refuse** — it runs on *every* surface, so a remote/headless CI
|
|
886
|
-
worker runs project CI in a trusted repo (the tradeoff: a cloned repo committing `[trust] ci`
|
|
887
|
-
auto-runs its own CI). (3) failure output is
|
|
888
|
-
wrapped `<untrusted_ci_output>` with a "treat as data, not instructions" note.
|
|
889
|
-
- **Config = `[[ci]]` array-of-tables.** `[ci]` is an ordered `[[ci]]` array-of-tables, each row
|
|
890
|
-
`name` / `command` / optional `glob`; `loadPerkConfig` surfaces `ci: CiCheck[]` via `parseCiChecks`
|
|
891
|
-
(declared order preserved; rows missing a non-blank `name`/`command` silently dropped; empty ⇒
|
|
892
|
-
inert `no_checks_configured`, non-fatal). **Full migration, no back-compat** for the old `[ci]`
|
|
893
|
-
map. `run_ci` with no `check` runs **all** checks in declared order (does not stop at first
|
|
894
|
-
failure); `check:"<name>"` runs exactly one. `passed = exitCode === 0` per check; report
|
|
895
|
-
`passed = checks.every(c => c.passed)`.
|
|
896
|
-
- **Change-scoped gating (run-all path only).** A row's optional `glob` (a single comma-separated
|
|
897
|
-
pattern string, e.g. `"*.ts,*.tsx"`) gates whether the check runs: on the run-all path, the
|
|
898
|
-
changed-file set is computed ONCE (merge-base vs the detected trunk ∪ untracked, mirroring
|
|
899
|
-
`detect_trunk_branch`) and a globbed check whose patterns match no changed file is **skipped**
|
|
900
|
-
(`skipped:true, passed:true, exitCode:0` — the command is not executed). A pattern translates to
|
|
901
|
-
an anchored RegExp (`**`→`.*`, `*`→`[^/]*`; a slash-free pattern matches the path's basename, so
|
|
902
|
-
`*.py` gates any `.py` at any depth). **Fail-open:** any git error ⇒ unknown ⇒ run **everything**
|
|
903
|
-
(never skip on uncertainty, never a false success). A row with **no `glob` always runs**; an
|
|
904
|
-
**explicit `only` check always runs** (no glob gate, no git work); no git work happens when no
|
|
905
|
-
selected row is globbed. An all-skip run is `passed:true`; skipped rows contribute no
|
|
906
|
-
`<untrusted_ci_output>` block.
|
|
907
|
-
- **Interior/TS-only.** No registry stage, no door change (`doors.cold_remote` unchanged). Python
|
|
908
|
-
never reads `[ci]`.
|
|
909
|
-
|
|
910
|
-
**Spawned delegation engine seam (P2.T6).** perk's *second* context-isolation shape is a **spawned**
|
|
911
|
-
read-only child engine, stood up by **borrowing the `pi-subagents` engine** behind a thin seam rather
|
|
912
|
-
than building a spawn primitive. T6 is substrate only (no registry stage, no in-session TS consumer,
|
|
913
|
-
no perk-authored agent definitions, no roster/model-tier config — those land with the first consumer,
|
|
914
|
-
T7 `/address`).
|
|
915
|
-
|
|
916
|
-
- **Borrow boundary.** perk borrows the `pi-subagents` *engine* (its `subagent` tool + spawn/handoff
|
|
917
|
-
machinery); perk **owns** the agent definitions, chains, and acceptance wiring. perk authors **no**
|
|
918
|
-
`subagent` tool of its own — the "one `subagent` tool" is the borrowed one.
|
|
919
|
-
- **Defs location.** perk-owned agent definitions live in **`.pi/agents/`** (committed; scaffolded by
|
|
920
|
-
`perk init` with a `.gitkeep`, *not* gitignored — perk owns and commits its defs). `pi-subagents`
|
|
921
|
-
discovers them as project agents (`agentScope` default `both`).
|
|
922
|
-
- **Handoff reuse.** Spawned children honor the **same handoff contract as the P2.T4 amendment above**
|
|
923
|
-
(cap-model-visible-output, full result in a verified scratch file, double-delivery of compact prose
|
|
924
|
-
+ a structured block, route-don't-relay, fail-closed) — the shared contract both context-isolation
|
|
925
|
-
primitives honor (T4 in-process; T6 spawned).
|
|
926
|
-
- **Never-delegate boundaries** (`erk-subagent-usage.md`): judgment, user interaction, and
|
|
927
|
-
durable-state writes stay with the parent; spawned children do bounded, ideally read-only,
|
|
928
|
-
mechanical work.
|
|
929
|
-
- **Model tiering convention (locked, value deferred to T7).** perk agent defs set a **cheap model** in
|
|
930
|
-
frontmatter for mechanical child work; the parent keeps the top-tier model.
|
|
931
|
-
- **Standing signal vs spike vs live smoke.** `perk doctor`'s `settings-wiring` (the `npm:pi-subagents`
|
|
932
|
-
package entry) + `subagent-agents` (the `.pi/agents/` defs dir) own drift; the **informational**
|
|
933
|
-
`subagent-engine` check is a constant pointer carrying the seam shape and never re-derives that
|
|
934
|
-
drift. The **open-#6 spike** (recorded in the turn outcomes) settles "runs cleanly headlessly"; the
|
|
935
|
-
**live "runs under the worker" smoke is deferred to Phase 3 `doctor workflow`**.
|
|
936
|
-
- **Roster control deferred to T7.** `subagents.disableBuiltins` + the `.agents/`-recursion-collision
|
|
937
|
-
mitigation (perk's `.agents/skills/*/SKILL.md` would otherwise be discovered as stray agents) land
|
|
938
|
-
with the first agent.
|
|
939
|
-
**Review loop (`/address`, P2.T7).** perk's review-handling stage is **classify-then-act**, and the
|
|
940
|
-
first consumer of the T6 spawned-delegation engine. It adds the `address` stage to the registry
|
|
941
|
-
(`submit → address → land`; `mode: read-write`, `worktree: reuse`; per-stage I/O now filled —
|
|
942
|
-
`requires: [github.pr]`, reads the plan-ref + PR + review-threads + comments, writes review-threads
|
|
943
|
-
+ comments + PR + workflow-state).
|
|
944
|
-
|
|
945
|
-
- **Classify in an isolated child.** The verbose feedback fetch + classification runs in a **spawned
|
|
946
|
-
read-only child** (the borrowed `pi-subagents` engine running perk's `perk.review-classifier`
|
|
947
|
-
agent). The child itself runs `perk pr feedback --json`, so the raw GitHub JSON **never transits
|
|
948
|
-
the parent** (route-don't-relay). It honors the **same handoff contract** as the T4/T6 amendments
|
|
949
|
-
(double-delivery: a compact prose table + a structured block; untrusted-text wrapping; fail-closed)
|
|
950
|
-
and returns `{ pr, review_threads[], discussion_comments[], counts }`.
|
|
951
|
-
- **Act = parent.** Only **actionable** items get changes; the parent edits in its own read-write
|
|
952
|
-
turn. The fix is **never delegated** (the three never-delegate boundaries: judgment, the fix,
|
|
953
|
-
durable writes).
|
|
954
|
-
- **Resolve = one batched op.** The warm `resolve_review_threads` tool writes `[{thread_id, comment}]`
|
|
955
|
-
to a run-scoped scratch file and delegates to `perk pr resolve-threads` (D1), then appends
|
|
956
|
-
`last_review_batch` to workflow-state (now in **live use**; shape above).
|
|
957
|
-
- **Plan File Mode.** When the PR's only diff is the plan file, feedback is reinterpreted as edits to
|
|
958
|
-
the plan *text*, not code to implement (parent judgment; captured in the `perk-address` skill).
|
|
959
|
-
- **Untrusted text.** All fetched GitHub text is wrapped `<untrusted_review>…</untrusted_review>` and
|
|
960
|
-
treated as DATA, not instructions (the model T5's `<untrusted_ci_output>` established).
|
|
961
|
-
- **Resolved T6 deferrals.** `subagents.disableBuiltins` is **not** set (builtins like `scout` are
|
|
962
|
-
reused later; disabling now is premature). The `.agents/`-recursion collision (perk's
|
|
963
|
-
`.agents/skills/*/SKILL.md` surface as stray agents) is mitigated by **namespacing** (every perk
|
|
964
|
-
agent def sets `package: perk`) + **explicit-name invocation** (`perk.review-classifier`), not by
|
|
965
|
-
suppressing the borrowed engine's legacy scan; the stray skill agents are benign (never invoked).
|
|
966
|
-
The cheap-model tiering value is realized: the classifier uses `anthropic/claude-haiku-4-5` with a
|
|
967
|
-
`claude-sonnet-4-5` fallback (overridable via the inline per-call `model` override keyed by
|
|
968
|
-
`[subagents] review-classifier` — **not** `subagents.agentOverrides`, which reaches only builtins;
|
|
969
|
-
see the `[subagents]` paragraph below).
|
|
970
|
-
|
|
971
|
-
**PR review (`/pr-review`, #175).** A standalone warm command (like `/ci`, **not** a registry
|
|
972
|
-
stage — `shared/registry.yaml` is unchanged) that conducts **multi-angle** automated code review of
|
|
973
|
-
the active PR. The parent spawns **2–3 angle-specialized `perk.pr-reviewer` children in parallel**
|
|
974
|
-
via the borrowed `pi-subagents` engine with **`context: "fresh"`** (not a fork) so the implementation
|
|
975
|
-
session's history never biases the review; each child reviews **one assigned angle** and **returns
|
|
976
|
-
structured findings** (no posting, no file writes). The **parent reconciles** the per-angle findings
|
|
977
|
-
and records **one** consolidated outcome on the PR via the new warm **`post_pr_review`** tool. The
|
|
978
|
-
outcome is **verdict-driven**: the review lands **as comments on the PR only on an `actionable`
|
|
979
|
-
verdict**; a `clean` verdict posts a single 👍 reaction to the PR description and nothing else —
|
|
980
|
-
comments and `/address` are reserved for actionable feedback, and a clean verdict unambiguously
|
|
981
|
-
routes to `/land`.
|
|
982
|
-
|
|
983
|
-
- **Verdict-driven batch.** The review batch requires a `verdict` of exactly `"clean"` or
|
|
984
|
-
`"actionable"` (a clean verdict with non-empty `comments` is a `bad_batch`). The optional
|
|
985
|
-
`fyi: string[]` field carries borderline notes that are validated and echoed **in-session only**
|
|
986
|
-
— it is structurally never part of any GitHub payload. The clean path's 👍 reaction
|
|
987
|
-
(`add_pr_reaction`, the issues-reactions endpoint — idempotent on rerun) is a **hard error** on
|
|
988
|
-
failure (mutations raise; no fallback ladder — nothing review-shaped is lost).
|
|
989
|
-
|
|
990
|
-
- **Follows the read-only-child convention (multi-angle classify-then-act, #658).** Like `/address`,
|
|
991
|
-
the reviewer children are **read-only and report-only** — they classify their assigned angle and
|
|
992
|
-
**return** findings; the **parent** reconciles and posts. The parent always spawns the **Plan
|
|
993
|
-
fidelity & completeness** reviewer plus **1–2** of: **Correctness & regressions** (security/edge
|
|
994
|
-
cases), **Tests & validation adequacy**, **Code quality, simplicity & docs/contracts accuracy** —
|
|
995
|
-
chosen to fit the change (2–3 reviewers total), with the angle passed per-call in the spawn `task`
|
|
996
|
-
(one parameterized agent, no new defs). Each child returns a fenced JSON block
|
|
997
|
-
`{angle, verdict, findings:[{path,line,body}], fyi}` with inline findings **already anchored to
|
|
998
|
-
diff lines**; the parent **unions + dedupes** across angles (same `path`+`line` → merge bodies),
|
|
999
|
-
**derives the overall verdict** (`actionable` if **any** reviewer is actionable, else `clean`), and
|
|
1000
|
-
passes the findings straight into `post_pr_review`'s `comments[]` — the parent **never re-anchors**
|
|
1001
|
-
(the raw diff never enters the parent; each child runs its own `review-context`). D1 is still
|
|
1002
|
-
honored — the GitHub mutation stays canonical in the **Python gateway**: `post_pr_review` delegates
|
|
1003
|
-
to `perk pr review-post` (the existing cold door) via `runColdDoor` (stdin `--batch`). The review is
|
|
1004
|
-
**advisory `COMMENT` only** — `event` is hardcoded `COMMENT` in the gateway, so the parent can never
|
|
1005
|
-
approve/request-changes.
|
|
1006
|
-
- **Optional ad-hoc operator directive.** Everything after `/pr-review` is captured verbatim as a
|
|
1007
|
-
**free-form operator directive** and threaded into the angle-selection step of the seed guidance
|
|
1008
|
-
(the same inline-conditional mechanism the template uses for the optional `model` var — no new
|
|
1009
|
-
tool, registry, or door change). It biases **angle selection and per-reviewer emphasis only**,
|
|
1010
|
-
honored as DATA from the human: Plan-fidelity stays mandatory, the **2–3-reviewer cap** holds, and
|
|
1011
|
-
the **clean/actionable posting bar is unchanged**. An empty directive (no args) renders the
|
|
1012
|
-
byte-identical seed as before.
|
|
1013
|
-
- **Configurable models via the agent-keyed `[subagents]` table (#196).** Every perk-owned project
|
|
1014
|
-
agent's model is configurable through one flat `[subagents]` table in `.perk/config.toml` (overlaid by
|
|
1015
|
-
`.perk/local.toml`), keyed by the bare agent name — `pr-reviewer`, `review-classifier`,
|
|
1016
|
-
`objective-explorer`, `conflict-resolver`, `learn-analyst` (matching each def's `name:` frontmatter
|
|
1017
|
-
and the `perk.<name>` invocation).
|
|
1018
|
-
Each configured value is injected as a **per-call inline `model` override** on that agent's
|
|
1019
|
-
`subagent` spawn (the agent's frontmatter `model` stays the default when the key is unset). This
|
|
1020
|
-
is wired at the authored spawn sites: the warm TS doors (`prReviewGuidance`,
|
|
1021
|
-
`addressGuidance`, `factoryGuidance`, `conflictResolutionGuidance`), the cold Python prompts
|
|
1022
|
-
(`_address_prompt`, `_seed_prompt`), and the headless worker (`initialPromptFor`). The earlier
|
|
1023
|
-
`[pr-review] model` key is removed
|
|
1024
|
-
outright (clean break, no alias — perk `0.0.1` pre-release, init converges forward). Unknown/typo'd
|
|
1025
|
-
agent keys are silently ignored (mirrors `_parse_providers_selection`); no doctor validation.
|
|
1026
|
-
**Correction to the T7 note above:** `subagents.agentOverrides` does **not** reach project agents
|
|
1027
|
-
— `pi-subagents`' `applyBuiltinOverrides` applies overrides only to **builtin** agents — so the
|
|
1028
|
-
inline per-call override (not an override map) is the configuration mechanism for project agents
|
|
1029
|
-
like `perk.review-classifier` and `perk.pr-reviewer`.
|
|
1030
|
-
- **Workflow-state record (`last_pr_review`, #658).** The `post_pr_review` parent tool turn appends
|
|
1031
|
-
a compact `last_pr_review` (`{pr, verdict, angles, comment_count, mode, at}`) to
|
|
1032
|
-
`perk:workflow-state`, best-effort / non-fatal (mirrors `resolve_review_threads`'s
|
|
1033
|
-
`last_review_batch`). The PR comment stays the canonical record; this is the in-session twin
|
|
1034
|
-
(the earlier deferral is delivered).
|
|
1035
|
-
- **Still a warm command, not a `DriveStage`.** `/pr-review` remains human-invoked — the registry is
|
|
1036
|
-
unchanged and `DriveStage = implement | address` (the headless worker drives only those two). But
|
|
1037
|
-
the new `post_pr_review` tool turn + `last_pr_review` append make it **structurally symmetric with
|
|
1038
|
-
`address`** (an ok tool result + an appended workflow-state field is exactly the terminal signal
|
|
1039
|
-
the worker's `applyEvent`/`evaluateTerminal` latches onto), so a future promotion to a
|
|
1040
|
-
headless-drivable stage is a clean follow-up (deferred — not built here).
|
|
1041
|
-
- **Agent-def delivery.** perk's agent **sources** live at top-level `agents/<name>.md` (no leading
|
|
1042
|
-
dot, so pi never discovers them in the source tree) and are bundled into the wheel as `perk/_agents`
|
|
1043
|
-
(hatchling `force-include`) + the sdist `only-include`. `perk init` materializes them into the
|
|
1044
|
-
consumer-owned **`.pi/agents/perk/`** subdir as a **committed managed convergence** (the
|
|
1045
|
-
`subagent-agents` capability): each `<name>.md` is written byte-for-byte from its source, strays
|
|
1046
|
-
inside `perk/` are pruned, and drift is `doctor --fix`-repaired. The agent frontmatter (`name`,
|
|
1047
|
-
`package: perk`, …) is unchanged, so the runtime names stay `perk.*` and the spawn sites need no
|
|
1048
|
-
edits. perk owns ONLY the `.pi/agents/perk/` subdir — **custom user agents** live at
|
|
1049
|
-
`.pi/agents/<name>.md` (top-level or any non-`perk/` subdir), set their model/tools in frontmatter,
|
|
1050
|
-
and are invoked via pi's native `subagent` tool (the fixed-key `[subagents]` table configures only
|
|
1051
|
-
perk's own agents). Linked worktrees inherit the delivered defs via git checkout (no worktree
|
|
1052
|
-
mirror).
|
|
1053
|
-
|
|
1054
|
-
**Conflict resolution (`/submit`, #556).** After `/submit` opens the draft PR, the Python
|
|
1055
|
-
`perk pr submit` cold door probes the PR's mergeability against the base branch with a deterministic
|
|
1056
|
-
local `git merge-tree` probe and surfaces `base` / `mergeable` (bool \| null) / `conflicts[]` in its
|
|
1057
|
-
`--json` (see §8.4). When the probe is a definitive `mergeable: false` with conflicts, the warm
|
|
1058
|
-
`submit` door (shared by the `/submit` command and the headless worker — both route through the same
|
|
1059
|
-
`submit` tool) drives the perk-owned **`perk.conflict-resolver`** agent via the borrowed
|
|
1060
|
-
`pi-subagents` engine with **`context: "fresh"`** (not a fork). Unlike the read-only
|
|
1061
|
-
classifier/reviewer, the conflict-resolver is **write-capable** and **inherits project context +
|
|
1062
|
-
skills** (resolving conflicts correctly requires understanding the code and running the repo's
|
|
1063
|
-
checks); like the reviewer it **fetches its own plan + PR context** read-only via
|
|
1064
|
-
`perk pr review-context` (the verbatim `plan_body` + `diff` are what let it resolve *correctly*, not
|
|
1065
|
-
merely cleanly), then rebases onto `base_ref`, resolves every conflict, verifies, and force-pushes —
|
|
1066
|
-
the parent then re-runs `/submit` to confirm. The re-drive is **bounded** by
|
|
1067
|
-
`CONFLICT_RESOLUTION_ATTEMPT_CAP = 2` via the `conflict_resolution_attempts` workflow-state field
|
|
1068
|
-
(§8.3; reset to 0 on a clean submit); past the cap the unresolved conflict is surfaced loudly
|
|
1069
|
-
instead of looping. The probe is **fail-open**: an undetermined probe (`mergeable: null`) never
|
|
1070
|
-
blocks submit. Configurable model via `[subagents] conflict-resolver`.
|
|
1071
|
-
|
|
1072
|
-
- **Filing note (deferral).** This §8.3 cluster (T1/T2a/T2b/T2c/T4/T5/T6/T7) has outgrown "the
|
|
1073
|
-
workflow-state schema"; promoting the context-isolation/handoff paragraphs (T4/T5/T6) into a
|
|
1074
|
-
dedicated "context-isolation" section is a **deferred** doc refactor — T6 files as a sibling here to
|
|
1075
|
-
preserve cohesion now.
|
|
457
|
+
**Owning modules (single-plane interior mechanics).** The narrative detail this section once
|
|
458
|
+
carried lives in the owning modules' headers: approval→save orchestration + plan-title
|
|
459
|
+
generation (`extension/factories/planSave.ts` / `planTitle.ts` / `planReview.ts`; §8.23 keeps the
|
|
460
|
+
review-backend contract); objective budget + threshold compaction
|
|
461
|
+
(`extension/factories/objective.ts`); the objective authoring loop
|
|
462
|
+
(`extension/factories/objectiveAuthor.ts` / `objectiveSave.ts`; §8.23/§8.24 own the save/store
|
|
463
|
+
contracts); the objective plan factory + node-lifecycle selection
|
|
464
|
+
(`extension/factories/objectivePlan.ts`, `src/perk/objective/`; §8.24); objective reconciliation
|
|
465
|
+
(the reconcile modules + `skills/perk-objective-reconcile/`; the land-path facts stay in §8.4);
|
|
466
|
+
session-lifecycle gates + the warm `/implement` handoff (`extension/doors/lifecycleGates.ts`,
|
|
467
|
+
`extension/factories/implementHere.ts`); checkpoint rendering/windowing/footer detail
|
|
468
|
+
(`extension/checkpoints/checkpoints.ts` / `planSteps.ts`, `extension/surfaces/surfaces.ts`,
|
|
469
|
+
`docs/design/tui-charter.md`); plan mode + the plan/todo provider deferrals + the juicesharp todo
|
|
470
|
+
adapter (`extension/factories/planMode.ts`, `extension/adapters/todoAdapterJuicesharp.ts`; §8.10
|
|
471
|
+
owns the provider seams); in-process read-only child sessions
|
|
472
|
+
(`extension/worker/readOnlySession.ts`); the read-only CI executor
|
|
473
|
+
(`extension/doors/ciExecutor.ts`); the spawned delegation seam + `/address` + `/pr-review` + `/pr-review-terminal` + `/pr-review-browser`
|
|
474
|
+
(`extension/doors/address.ts` / `prReview.ts` / `prReviewTerminal.ts` /
|
|
475
|
+
`prReviewBrowser.ts` / `submitPrReview.ts` / `hunkHandoff.ts` / `plannotatorHandoff.ts`, `agents/*.md`, `skills/perk-address/` /
|
|
476
|
+
`perk-pr-review/` / `perk-pr-review-terminal/` / `perk-pr-review-browser/`; the gateway op shapes stay in §8.4); the conflict-resolution drive
|
|
477
|
+
(`extension/doors/submit.ts`; the probe contract stays in §8.4).
|
|
478
|
+
|
|
1076
479
|
|
|
1077
480
|
---
|
|
1078
481
|
|
|
1079
482
|
## §8.4 · The GitHub gateway contract (Q9/Q10)
|
|
1080
483
|
|
|
1081
|
-
**One contract,
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
shapes
|
|
1085
|
-
|
|
484
|
+
**One gateway contract, canonical in the Python plane** (`src/perk/github/` — the forge gateway:
|
|
485
|
+
PR/CI/auth/review ops — plus `src/perk/backends/github/` — the issue/objective adapters). The TS
|
|
486
|
+
extension never reimplements a mutation: the warm doors delegate to the cold `perk … --json`
|
|
487
|
+
doors and decode the shapes pinned here — that decode boundary is the actual cross-plane binding,
|
|
488
|
+
and `doctor` verifies conformance.
|
|
489
|
+
|
|
490
|
+
**Durable invariants (all ops):**
|
|
1086
491
|
|
|
1087
|
-
|
|
492
|
+
- **Idempotency is keyed on the header `run_id`**, discovered via the **LIST** endpoint (not the
|
|
493
|
+
eventually-consistent search index), create-then-return (`Q3` establish-before-record).
|
|
494
|
+
- **REST `gh api`, not porcelain** — with two deliberate exceptions: review threads (GraphQL-only:
|
|
495
|
+
REST has no `isResolved` / `resolveReviewThread`) and `gh pr ready` (draft→ready is
|
|
496
|
+
GraphQL-only).
|
|
497
|
+
- **Labels are created lazily** by each gateway create-op on first use (perk never seeds labels
|
|
498
|
+
in `init`).
|
|
499
|
+
- **Mutations raise** on failure (the command boundary maps to `UserFacingCliError`); **lookups
|
|
500
|
+
return `… | null`** — and never mask an infra failure as absence (infra failures raise).
|
|
1088
501
|
|
|
1089
|
-
|
|
1090
|
-
created lazily by `/plan-save` in Phase 1). **Implemented in the Python plane (T5):**
|
|
1091
|
-
`perk/github/` (typed dataclasses mirroring these shapes); the TS plane follows in Phase 1.
|
|
502
|
+
### Verification ops (no mutation)
|
|
1092
503
|
|
|
1093
504
|
```
|
|
1094
505
|
check_auth() -> { ok: bool, user: string|null, scopes: string[], error: string|null }
|
|
@@ -1097,14 +508,11 @@ check_repo_access() -> { ok: bool, repo: string|null, can_push: bool, error: st
|
|
|
1097
508
|
# `gh repo view`; can_push from viewerPermission ∈ {WRITE,MAINTAIN,ADMIN}.
|
|
1098
509
|
```
|
|
1099
510
|
|
|
1100
|
-
`require_github(ctx)` is the **strict DI binding** for
|
|
511
|
+
`require_github(ctx)` is the **strict DI binding** for mutating commands (raises
|
|
1101
512
|
`UserFacingCliError` / `error_type: github_unauthed` when unauthed); `init`/`doctor` call the
|
|
1102
513
|
`check_*` ops directly to *report* (non-fatal — see §8.5).
|
|
1103
514
|
|
|
1104
|
-
###
|
|
1105
|
-
|
|
1106
|
-
**Authored (P1.T2a — the plan write).** REST `gh api`; mutations **raise** on failure (the
|
|
1107
|
-
command boundary maps to `UserFacingCliError`), lookups return `… | null`:
|
|
515
|
+
### The plan write (+ the run_id upsert)
|
|
1108
516
|
|
|
1109
517
|
```
|
|
1110
518
|
create_label{ name, color, description } -> Label{ name, created }
|
|
@@ -1115,38 +523,34 @@ add_issue_comment{ issue, body } -> CommentResult{ posted }
|
|
|
1115
523
|
# POST repos/{o}/{r}/issues/{n}/comments (the plan-body first comment)
|
|
1116
524
|
find_plan_issue{ run_id } -> PlanIssue | null
|
|
1117
525
|
# GET repos/{o}/{r}/issues?labels=perk:plan&state=open + header run_id match
|
|
1118
|
-
```
|
|
1119
|
-
|
|
1120
|
-
- **Idempotency** is keyed on the header `run_id`, discovered via the **list** endpoint (not
|
|
1121
|
-
the eventually-consistent search index), create-then-return (`Q3` establish-before-record).
|
|
1122
|
-
- **`perk:plan` label** is created lazily on first save.
|
|
1123
|
-
- **`perk plan-save` is an upsert keyed on `run_id` (P2.T13).** The *first* save with a `run_id`
|
|
1124
|
-
creates the issue and posts the `plan-body` comment; a *re-save* with the same `run_id` updates
|
|
1125
|
-
the existing issue **in place** instead of no-opping — `create_plan_issue` still dedups (never a
|
|
1126
|
-
second issue per `run_id`), then `update_plan_issue{ number, title, body_comment }` PATCHes the
|
|
1127
|
-
`plan-body` comment with the revised markdown and PATCHes the issue **title** from the (possibly
|
|
1128
|
-
revised) plan H1. The comment is found by marker (REST comment list → first body containing the
|
|
1129
|
-
`plan-body` block; perk stores no comment id), which also repairs legacy plan issues; a missing
|
|
1130
|
-
comment falls back to a fresh POST so the body is never stranded. The anti-duplicate guarantee is
|
|
1131
|
-
preserved. Because `update_plan_issue` rewrites only the `plan-body` comment + the title (never
|
|
1132
|
-
the `plan-header`), a re-save **additionally** merges the planning header fields (`objective_id`,
|
|
1133
|
-
`consumed_learn`) back into the existing `plan-header` via `update_plan_header` when provided —
|
|
1134
|
-
additive, so an omitted field is left intact (no clobber of a previously linked objective/learn
|
|
1135
|
-
set, no reset of the submit-populated `branch`/`pr`/`lifecycle_stage`). This keeps the canonical
|
|
1136
|
-
header (the source `reconstruct_plan_ref` and the on-land `consumed_learn` consume read from)
|
|
1137
|
-
current on every save, not just the first create; the header write is fail-loud (a failure raises
|
|
1138
|
-
`GitHubError` → `github_error`, since this is the canonical save). `--json` carries a top-level
|
|
1139
|
-
`updated` (true on re-save, false on fresh create);
|
|
1140
|
-
`cached` stays true on every real save. The warm `/plan-save` surfaces `details.updated` and an
|
|
1141
|
-
"Updated plan #N" message on the re-save path.
|
|
1142
|
-
|
|
1143
|
-
```
|
|
1144
526
|
update_plan_issue{ number, title, body_comment } -> PlanUpdate{ number, body_updated, title_updated, dry_run }
|
|
1145
527
|
# find the plan-body comment by marker -> PATCH .../issues/comments/{id} (-F body=@file)
|
|
1146
528
|
# (fallback: POST a fresh comment, body_updated:false) ; PATCH .../issues/{n} (-f title=)
|
|
1147
529
|
```
|
|
1148
530
|
|
|
1149
|
-
|
|
531
|
+
- **`perk plan-save` is an upsert keyed on `run_id`.** The *first* save with a `run_id` creates
|
|
532
|
+
the issue and posts the `plan-body` comment; a *re-save* with the same `run_id` updates the
|
|
533
|
+
existing issue **in place** — `create_plan_issue` still dedups (never a second issue per
|
|
534
|
+
`run_id`), then `update_plan_issue` PATCHes the `plan-body` comment with the revised markdown
|
|
535
|
+
and PATCHes the issue **title** from the (possibly revised) plan H1. The comment is found by
|
|
536
|
+
marker (perk stores no comment id — which also repairs legacy issues); a missing comment falls
|
|
537
|
+
back to a fresh POST so the body is never stranded. A re-save **additionally** merges the
|
|
538
|
+
planning header fields (`objective_id`, `consumed_learn`) back into the existing `plan-header`
|
|
539
|
+
via `update_plan_header` when provided — additive, so an omitted field is left intact (no
|
|
540
|
+
clobber of a previously linked objective/learn set, no reset of the submit-populated
|
|
541
|
+
`branch`/`pr`/`lifecycle_stage`). The header write is fail-loud (this is the canonical save).
|
|
542
|
+
`--json` carries a top-level `updated` (true on re-save); the warm `/plan-save` surfaces
|
|
543
|
+
`details.updated` and an "Updated plan #N" message.
|
|
544
|
+
- **`perk replan <plan>` re-authors an OPEN plan *in place*** — a dedicated cold door that
|
|
545
|
+
re-launches the read-only `plan` stage with the target plan's **original `run_id`** (the
|
|
546
|
+
documented exception to "cold mints `run_id`"), so the upsert rewrites the same issue and the
|
|
547
|
+
`plan-header` (and thus the objective/node links) survives. The save lands review-first
|
|
548
|
+
(`plan_review` approval → the same upsert; `/plan-save` is the manual failsafe). It refuses a
|
|
549
|
+
non-OPEN plan (`plan_not_open`), a missing plan, a header without `run_id`, or an empty body.
|
|
550
|
+
The full door contract: §8.27 (plan-issue engagement) +
|
|
551
|
+
`src/perk/cli/commands/plan/replan_cmd.py`; the objective sibling is §8.32.
|
|
552
|
+
|
|
553
|
+
### The submit path
|
|
1150
554
|
|
|
1151
555
|
```
|
|
1152
556
|
default_branch() -> string
|
|
@@ -1155,39 +559,23 @@ find_pr_for_branch{ branch } -> PullRequest | null
|
|
|
1155
559
|
# GET .../pulls?head=<owner>:<branch>&state=all (prefers an open PR)
|
|
1156
560
|
create_pr{ head, base, title, body, draft } -> PullRequest{ number, url, is_draft, state, existed }
|
|
1157
561
|
# POST .../pulls (-F body=@file); idempotent on head (find-then-create)
|
|
562
|
+
reopen_pr{ number } -> void
|
|
563
|
+
# PATCH .../pulls/{n} (state=open); reopens a CLOSED reused PR (submit guard); raises on failure
|
|
1158
564
|
update_plan_header{ issue, fields } -> PlanHeaderUpdate{ fields_updated[], dry_run }
|
|
1159
565
|
# GET issue body -> merge fields into the plan-header block -> PATCH .../issues/{n}
|
|
1160
566
|
# rejects unknown header keys (LBYL on the schema); submit sets branch/pr/lifecycle_stage=impl
|
|
1161
|
-
prepend_plan_callout{ issue, callout, command } -> bool
|
|
567
|
+
prepend_plan_callout{ issue, callout, command } -> bool
|
|
1162
568
|
# GET issue body -> plan.prepend_callout(body, callout, command=) -> PATCH .../issues/{n}
|
|
1163
569
|
# idempotent on `command`; True iff a write occurred (False when already present / dry-run)
|
|
1164
570
|
get_plan{ number } -> PlanState{ number, url, title, header, pr, state } | null
|
|
1165
|
-
# gh issue view --json (+ pulls/{n} when the header carries pr); the `perk resume` read
|
|
571
|
+
# gh issue view --json (+ pulls/{n} when the header carries pr); the `perk resume` read.
|
|
1166
572
|
# `state` is the issue's OPEN/CLOSED state (the `replan` OPEN guard reads it).
|
|
1167
573
|
```
|
|
1168
574
|
|
|
1169
|
-
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
existing plan's run). Because the warm `plan_save` is an upsert keyed on `run_id` (above), the
|
|
1174
|
-
re-save **updates the same plan issue in place** rather than creating a new one — preserving the
|
|
1175
|
-
`plan-header` and thus the plan→objective link (`objective_id`) and the node→plan backlink. The
|
|
1176
|
-
cold door performs every GitHub read up front (read-only `gh` query subcommands are
|
|
1177
|
-
allowlisted, but the cold door still materializes every GitHub read up front — deterministic
|
|
1178
|
-
and token-cheap) and
|
|
1179
|
-
materializes the prior plan body into a `<untrusted_plan>` scratch file the session reads. It
|
|
1180
|
-
**refuses** a non-OPEN plan (`plan_not_open` — a closed plan would silently create a new issue),
|
|
1181
|
-
a missing plan (`plan_not_found`), a header without `run_id` (`no_run_id`), or an empty body
|
|
1182
|
-
(`no_plan_body`). **No extension change is required** (the interior sees an ordinary read-only
|
|
1183
|
-
`plan`-stage session). **Single-plan only** — erk's multi-plan consolidation (`erk-consolidated`)
|
|
1184
|
-
is deliberately deferred.
|
|
1185
|
-
|
|
1186
|
-
- **PR body (P1.T5a, minimal):** `Closes #<issue>` (so the squash-merge closes the plan) + a
|
|
1187
|
-
`Plan: #<issue>` link + a **plain-text** `` `gh pr checkout <n>` `` footer (no HTML — erk's
|
|
1188
|
-
tripwire). Full-plan re-embedding + AI body craft are Phase 2.
|
|
1189
|
-
|
|
1190
|
-
**Authored (P1.T5b — the land path).** Idempotent; the caller checks PR state before merging:
|
|
575
|
+
- **PR body:** `Closes #<issue>` (so the squash-merge closes the plan) + a `Plan: #<issue>` link
|
|
576
|
+
+ a **plain-text** `` `gh pr checkout <n>` `` footer (no HTML).
|
|
577
|
+
|
|
578
|
+
### The land path
|
|
1191
579
|
|
|
1192
580
|
```
|
|
1193
581
|
mark_pr_ready{ number } -> void
|
|
@@ -1196,12 +584,15 @@ merge_pr{ number, commit_message? } -> PullRequest (state MERGED
|
|
|
1196
584
|
# PUT .../pulls/{n}/merge (merge_method=squash); idempotent ("already merged" ⇒ success)
|
|
1197
585
|
```
|
|
1198
586
|
|
|
1199
|
-
- **`Closes #<issue>`** rides in the PR body
|
|
587
|
+
- **`Closes #<issue>`** rides in the PR body so the squash-merge closes the plan issue;
|
|
1200
588
|
`commit_message` repeats it belt-and-suspenders. Post-merge state is **derived from PR**, never
|
|
1201
589
|
stored (Q8).
|
|
590
|
+
- **Deepened squash commit message.** Land passes `merge_pr(commit_message=)` = plain
|
|
591
|
+
`"<plan title>\n\nCloses #<issue>"` (`get_plan(...).title`, fallback `Closes #<issue>` on an
|
|
592
|
+
empty title). Plain text only — the second of the **two PR targets** (the GitHub HTML body is
|
|
593
|
+
the other); HTML never leaks into `git log`.
|
|
1202
594
|
|
|
1203
|
-
|
|
1204
|
-
graduates from a thin marker-clear into a real knowledge-capture pass:
|
|
595
|
+
### Learn ops
|
|
1205
596
|
|
|
1206
597
|
```
|
|
1207
598
|
find_learn_issue{ run_id } -> PlanIssue | null
|
|
@@ -1212,69 +603,31 @@ find_learn_issue{ run_id } -> PlanIssue | null
|
|
|
1212
603
|
create_learn_issue{ title, body, run_id, plan_number } -> PlanIssue{ number, url, existed }
|
|
1213
604
|
# lazy create_label("perk:learn"); idempotent via find_learn_issue (NOT find_plan_issue);
|
|
1214
605
|
# renders a learn-header block { run_id, created, plan } into the body so the finder matches.
|
|
1215
|
-
```
|
|
1216
|
-
|
|
1217
|
-
**Authored (hop-2 — the learned-docs consumer).** The factory cold door gathers + lands the
|
|
1218
|
-
consume; both ops follow the established conventions (REST `gh api`, LIST endpoint, lazy label,
|
|
1219
|
-
mutations raise / lookups never mask infra failure):
|
|
1220
|
-
|
|
1221
|
-
```
|
|
1222
606
|
list_learn_issues{} -> LearnIssueSummary[]{ number, title, url, body }
|
|
1223
607
|
# GET .../issues?labels=perk:learn&state=open (the find_plan_issue list call, label-scoped to
|
|
1224
|
-
# perk:learn). Returns every open learn issue's full body for the inbox; raises on
|
|
1225
|
-
# failure (never masks as empty); skips non-dict / pull_request entries.
|
|
608
|
+
# perk:learn). Returns every open learn issue's full body for the factory inbox; raises on
|
|
609
|
+
# infra failure (never masks as empty); skips non-dict / pull_request entries.
|
|
1226
610
|
close_and_label_consolidated{ issue } -> bool
|
|
1227
611
|
# lazy create_label("perk:consolidated"); POST .../issues/{n}/labels (-f labels[]=perk:consolidated,
|
|
1228
612
|
# ADD not replace) THEN PATCH .../issues/{n} (-f state=closed). Idempotent (re-closing /
|
|
1229
613
|
# re-labelling is success). Raises GitHubError on infra failure.
|
|
1230
614
|
```
|
|
1231
615
|
|
|
1232
|
-
- **
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
`bad_output` message across doors now names probable version skew while keeping the
|
|
1248
|
-
`unexpected payload` substring.
|
|
1249
|
-
|
|
1250
|
-
**P2.T17 — learn is now ACTIVE (primed launch + guided warm door).** The capture mechanism above
|
|
1251
|
-
is unchanged; what's added is the *driver*. The `learn` cold launch is **primed** (`launch/prompts.py`
|
|
1252
|
-
`_learn_prompt`): the session opens already investigating the landed change (read the plan +
|
|
1253
|
-
derive the merged PR from the `plan-<pr_id>` head branch) and is told to call the `learn` tool
|
|
1254
|
-
with synthesized learnings. The warm **bare `/learn`** (interactive) **injects `perk-learn`
|
|
1255
|
-
guidance** via `pi.sendUserMessage` instead of silently clearing the marker (the agent clears it
|
|
1256
|
-
by calling the `learn` tool); **`/learn skip`** preserves the pure marker-clear and **`/learn
|
|
1257
|
-
<text>`** still captures verbatim; **headless** bare `/learn` stays the safe marker-clear
|
|
1258
|
-
(can't drive a turn). The **`perk-learn` skill** is the judgment layer both surfaces point at.
|
|
1259
|
-
No new gateway op — the existing `learn` tool / `learn capture` worker remain the durable-write
|
|
1260
|
-
path. **Tier 3 update (hop-2):** the **`docs/learned/*.md` documentation-plan loop is now BUILT**
|
|
1261
|
-
(see the *Learned-docs consumer (hop-2)* subsection below). The remaining Tier-3 pieces
|
|
1262
|
-
(session-material bundling on land, multi-agent session/diff/docs analysis) stay **deferred** —
|
|
1263
|
-
perk's already-synthesized `perk:learn` records are the materials, replacing erk's session
|
|
1264
|
-
preprocessing.
|
|
1265
|
-
- **Reconciliation typing (D9 — vocabulary established; Reconcilable + objective reconciliation
|
|
1266
|
-
implemented in P2.T11).** Three section types on land: **Mechanical** (command-updated,
|
|
1267
|
-
deterministic — T8b: `pending-learn` + the plain squash commit message; **P2.T11a**: the
|
|
1268
|
-
auto-on-merge node-done); **Reconcilable** (LLM-updated post-merge — **implemented in P2.T11**,
|
|
1269
|
-
see the P2.T11 subsection below); **Immutable** (never touched). The merged state is **PR-derived
|
|
1270
|
-
and not stored** (Q8), so land authors no new stored field. Objective-node reconciliation is
|
|
1271
|
-
**implemented in P2.T11** (the auto-on-merge node-done + the warm `/objective-reconcile` pass).
|
|
1272
|
-
|
|
1273
|
-
**Authored (P2.T7 — the `/address` review loop).** Review threads + their resolution are
|
|
1274
|
-
**GraphQL-only** (REST has no `isResolved`, no `resolveReviewThread`/`addPullRequestReviewThreadReply`);
|
|
1275
|
-
discussion comments stay REST. The GraphQL shapes are verbatim from erk (the durable prior art). The
|
|
1276
|
-
read **raises** on infra failure; the resolve captures **per-item** failures into its result (one bad
|
|
1277
|
-
thread does not sink the batch) but still raises on a hard infra failure (gh missing / timeout):
|
|
616
|
+
- **The capture path.** `perk learn capture --json --body <file>` reads the agent-captured
|
|
617
|
+
learnings markdown from a run-scoped scratch file (the stdin-less worker pattern),
|
|
618
|
+
`create_learn_issue`, posts a back-link comment on the plan issue (best-effort), stamps the
|
|
619
|
+
canonical `learn_state: captured` (§8.36, strictly — before the marker clear), and clears
|
|
620
|
+
`pending-learn`. The warm `/learn` orchestration, the evidence bundle, and the classification
|
|
621
|
+
vocabulary are §8.35 (+ `extension/doors/learn.ts`); the canonical skip path is §8.36.
|
|
622
|
+
- **The learned-docs/learn-code factories** consume the two list/close ops above; the factory
|
|
623
|
+
contract (partition, inbox, `consumed_learn`) is §8.35 +
|
|
624
|
+
`src/perk/cli/commands/learn/factory_common.py`.
|
|
625
|
+
|
|
626
|
+
### Review-loop ops (`/address` — GraphQL threads)
|
|
627
|
+
|
|
628
|
+
The read **raises** on infra failure; the resolve captures **per-item** failures into its result
|
|
629
|
+
(one bad thread does not sink the batch) but still raises on a hard infra failure (gh missing /
|
|
630
|
+
timeout):
|
|
1278
631
|
|
|
1279
632
|
```
|
|
1280
633
|
get_pr_feedback{ pr_number } -> PrFeedback{ pr_number, review_threads[], discussion_comments[], reviews[] }
|
|
@@ -1290,35 +643,418 @@ resolve_review_threads{ batch:[{thread_id, comment?}] } -> BatchResolveResult{ s
|
|
|
1290
643
|
# delegates via `perk pr resolve-threads --json --batch <path>`.
|
|
1291
644
|
```
|
|
1292
645
|
|
|
1293
|
-
- **Batch shape
|
|
646
|
+
- **Batch shape:** `[{ thread_id, comment }]` (objects, not a flat list).
|
|
1294
647
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
648
|
+
### Automated-review ops (`/pr-review`)
|
|
649
|
+
|
|
650
|
+
The `review-post` CLI never passes `event`, so the `/pr-review` posture is **hardcoded
|
|
651
|
+
`COMMENT`** (the agent can never approve/block). Resilience is **event-aware** in the gateway: a
|
|
652
|
+
failed COMMENT-review submission (e.g. a `line` not present in the diff) falls back to posting
|
|
653
|
+
the summary (+ rendered findings) as a single discussion comment, so an advisory review
|
|
654
|
+
**always** lands on the PR; the formal-event arms are documented under the PR-review toolbox
|
|
655
|
+
ops below:
|
|
1301
656
|
|
|
1302
657
|
```
|
|
1303
658
|
get_pr_review_context{ pr_number, branch, plan_body } -> PrReviewContext{ pr_number, base_ref, head_ref, title, body, diff, plan_body }
|
|
1304
659
|
# Read-only. PR meta via `gh api pulls/{n}`, diff via `gh pr diff {n}`. The gateway no longer
|
|
1305
660
|
# reads plan/issue state: `plan_body` is resolved backend-neutrally by the consumer
|
|
1306
661
|
# (`perk pr review-context`) — the materialized `cache.plan` mirror first, else
|
|
1307
|
-
# `IssueBackend.get_plan_body` via the resolver
|
|
1308
|
-
#
|
|
1309
|
-
#
|
|
1310
|
-
|
|
1311
|
-
#
|
|
1312
|
-
|
|
1313
|
-
#
|
|
1314
|
-
#
|
|
1315
|
-
#
|
|
662
|
+
# `IssueBackend.get_plan_body` via the resolver — and passed straight in (best-effort; null
|
|
663
|
+
# lets the review run from the diff). What the spawned child runs.
|
|
664
|
+
# CLI arm: `perk pr review-context --pr <n>` resolves an arbitrary PR by number (existence +
|
|
665
|
+
# head ref via `get_pr`, `plan_body` null, clean `pr_not_found` arm); the flagless plan-ref
|
|
666
|
+
# resolution is byte-identical.
|
|
667
|
+
post_pr_review{ pr_number, summary, comments:[{path,line,body,side?}], event? } -> ReviewPostResult{ ok, mode, pr_number, comment_count }
|
|
668
|
+
# ONE atomic review via POST .../pulls/{n}/reviews — comments + body + event land together or
|
|
669
|
+
# not at all. `event` defaults to COMMENT (wire spelling: COMMENT|APPROVE|REQUEST_CHANGES) and
|
|
670
|
+
# `comments[].side` defaults to RIGHT (LEFT anchors a deleted line) — both defaulted, so every
|
|
671
|
+
# existing caller is byte-identical. The last-resort ladder is EVENT-AWARE:
|
|
672
|
+
# COMMENT — on failure, degrade to one discussion comment (mode "comment_fallback"); raises
|
|
673
|
+
# only when even the fallback fails. No own-PR classification (GitHub permits COMMENT
|
|
674
|
+
# reviews on own PRs).
|
|
675
|
+
# APPROVE/REQUEST_CHANGES — never converted to a non-review comment: an own-PR 422 (stable
|
|
676
|
+
# substring "your own pull request") raises OwnPrReviewError (no retry — it would fail
|
|
677
|
+
# identically); otherwise ONE retry with the comments folded into the body (LEFT anchors
|
|
678
|
+
# keep a " (LEFT)" marker) and the event preserved (mode "review_folded",
|
|
679
|
+
# comment_count = the batch size); a failed retry — or a bare-verdict failure (empty
|
|
680
|
+
# comments, no pointless identical retry) — raises loudly. Never a silent verdict drop.
|
|
681
|
+
# mode ∈ {"review", "comment_fallback", "review_folded", "reaction"}. The warm twin is
|
|
682
|
+
# `/pr-review`'s parent-side `post_pr_review` tool, which delegates via
|
|
683
|
+
# `perk pr review-post --json --batch <path>` (the reviewer children report findings to the
|
|
684
|
+
# parent; they never post; review-post never passes `event` — hardcoded-COMMENT posture).
|
|
685
|
+
add_pr_reaction{ pr_number } -> void
|
|
686
|
+
# the clean-verdict 👍 (issues-reactions endpoint — idempotent on rerun); a hard error on
|
|
687
|
+
# failure (mutations raise; nothing review-shaped is lost).
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
### PR-review toolbox ops (checkout / cleanup / review-submit)
|
|
691
|
+
|
|
692
|
+
The two human-in-the-loop review doors (`/pr-review-terminal`, `/pr-review-browser`) review a
|
|
693
|
+
PR — foreign or the active worktree's own. A foreign review needs a detached checkout of the PR
|
|
694
|
+
head so reviewer children can investigate real surrounding code at head and the review surface
|
|
695
|
+
can diff inside it. Plain cold workers (no registry stages), consumed by the two warm doors
|
|
696
|
+
below:
|
|
697
|
+
|
|
698
|
+
```
|
|
699
|
+
perk pr review checkout --pr <n> --json -> { success, error_type, message, path, pr, url, head_sha, base_sha, base_ref }
|
|
700
|
+
# A DETACHED checkout of the PR head at <worktree_root>/review-<n> — outside the plan-<N>
|
|
701
|
+
# namespace (invisible to `worktree wipe`; `worktree list`/`remove` are the manual fallback).
|
|
702
|
+
# One fetch covers both refs: `git fetch origin "+refs/pull/<n>/head:refs/perk/review/<n>"
|
|
703
|
+
# <base_ref>` — the head pins into an explicit temp ref (FETCH_HEAD is clobber-racy), deleted
|
|
704
|
+
# best-effort once the worktree exists; the bare base refspec updates origin/<base_ref>.
|
|
705
|
+
# base_sha = merge-base(origin/<base_ref>, head_sha) — the 3-dot base GitHub's PR diff (and
|
|
706
|
+
# `gh pr diff`) uses, NOT REST base.sha. Refresh semantics: an existing review-<n> is
|
|
707
|
+
# force-removed and re-created at the CURRENT head (no reuse, no dirty protection — the
|
|
708
|
+
# checkout is disposable investigation material); a failed fetch leaves it untouched.
|
|
709
|
+
# GC backstop: stale sibling review-<n> checkouts (gitlink mtime > 7 days, or a missing
|
|
710
|
+
# gitlink — broken residue) are reaped before creating; per-item failures warn + continue.
|
|
711
|
+
# Any PR state is checkout-able (OPEN/MERGED/CLOSED); non-OPEN adds a stderr note only.
|
|
712
|
+
# UNTRUSTED-CODE POSTURE (structural): the head is foreign code — the door NEVER runs
|
|
713
|
+
# `[worktree] setup` and never installs anything (pinned by a structural spy test).
|
|
714
|
+
# Errors: pr_not_found · github_error · git_error · not_a_repo (exit 2); exits 0/1/2.
|
|
715
|
+
perk pr review cleanup --pr <n> --json -> { success, error_type, message, pr, path, removed }
|
|
716
|
+
# Single-PR and idempotent: nothing to remove → success, removed:false, exit 0. Fully
|
|
717
|
+
# offline (no GitHub calls). Removes a registered worktree (force) or an unregistered
|
|
718
|
+
# leftover dir (rmtree), always followed by `git worktree prune`; also deletes a leftover
|
|
719
|
+
# refs/perk/review/<n> temp ref best-effort.
|
|
720
|
+
perk pr review-submit --pr <n> --event <e> --batch <file> --json -> { success, error_type, message, dry_run, pr, event, mode, comment_count }
|
|
721
|
+
# The comments-first review-submission substrate — consumed by the warm `submit_pr_review`
|
|
722
|
+
# posting tool, not human-CLI-first (a plain cold worker: no launcher half, no registry stage; the
|
|
723
|
+
# structural human gate for formal events lives at the warm layer). `--event` ∈
|
|
724
|
+
# approve|request-changes|comment, DEFAULT comment (an omitted flag can never accidentally
|
|
725
|
+
# post a verdict); the envelope echoes the flag spelling, the gateway gets the wire spelling.
|
|
726
|
+
# Batch (strict; a stray key — incl. `fyi` — is bad_batch): { body: str = "",
|
|
727
|
+
# comments?: [{path, line:int, side?: LEFT|RIGHT = RIGHT, body}] }. `line` is non-nullable:
|
|
728
|
+
# unanchorable findings are folded into the review body UPSTREAM (triage curation), never
|
|
729
|
+
# submitted inline. Event-conditioned checks: comment/request-changes require a non-empty
|
|
730
|
+
# body (approve may be body-less; an entirely empty batch is only legal for approve).
|
|
731
|
+
# VALIDATION (the door's reason to exist): every comment's {path, line, side} anchor is
|
|
732
|
+
# checked against the PR diff (`get_pr_diff` — the merge-base 3-dot diff GitHub validates
|
|
733
|
+
# against, parsed by the pure `diff_anchors` module) BEFORE anything touches GitHub; any
|
|
734
|
+
# failure → bad_anchors (exit 1, NOTHING submitted) with per-comment
|
|
735
|
+
# invalid:[{index, path, line, side, reason}] detail — identical shape for dry-run and real
|
|
736
|
+
# runs (the agent's repair loop: re-run --dry-run until it exits 0). `--dry-run` stops before
|
|
737
|
+
# the mutation (mode "validated") but — unlike review-post's fully-offline dry-run — REQUIRES
|
|
738
|
+
# gh + auth (anchor validation fetches the diff): a deliberate, documented divergence.
|
|
739
|
+
# Dry-run ADDITIONALLY predicts the own-PR 422 for formal events (before the diff fetch):
|
|
740
|
+
# PR author == authenticated viewer ⇒ own_pr, nothing "submittable" — a validated batch must
|
|
741
|
+
# mean the real call can land (the first dogfood saw a human-approved review lost to the
|
|
742
|
+
# rejection). Fail-open when either login is unresolvable; the REAL path keeps GitHub as the
|
|
743
|
+
# authority (the gateway's OwnPrReviewError arm), and `comment` never runs the check.
|
|
744
|
+
# A real run is ONE atomic review submission (comments + body + event) via the gateway's
|
|
745
|
+
# event-aware ladder above — never a silent verdict drop; mode ∈
|
|
746
|
+
# validated|review|review_folded|comment_fallback. Errors: bad_batch · bad_anchors ·
|
|
747
|
+
# pr_not_found · own_pr (OwnPrReviewError) · github_error · github_unauthed · not_a_repo
|
|
748
|
+
# (exit 2); exits 0/1/2.
|
|
1316
749
|
```
|
|
1317
750
|
|
|
1318
|
-
**
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
751
|
+
**The `submit_pr_review` warm tool** (`extension/doors/submitPrReview.ts`). The human-gated
|
|
752
|
+
curated-posting surface both review doors ride — the doors register **no tools of their own**.
|
|
753
|
+
Delegates to the `perk pr review-submit` cold worker above (the batch rides the run-scratch
|
|
754
|
+
stdin channel); nothing perk-driven reaches GitHub before the human triage, and `gh` mutations /
|
|
755
|
+
direct `perk pr review-submit` calls are forbidden on both doors:
|
|
756
|
+
|
|
757
|
+
- **Params (strict whole-batch decode — ANY malformed field ⇒ `bad_input`, nothing
|
|
758
|
+
executed):** `{ pr: int, event: "approve"|"request-changes"|"comment", body: string
|
|
759
|
+
(empty allowed — the cold door owns the event-conditioned body rule), comments?: [{path,
|
|
760
|
+
line:int, side?: LEFT|RIGHT, body}], dry_run?: bool }`.
|
|
761
|
+
- **The gate ladder:** the conversational explicit human go-ahead ALWAYS precedes any non-dry-run
|
|
762
|
+
call (pinned in the tool guidelines + skills + templates); formal events (`approve`/
|
|
763
|
+
`request-changes`) additionally get the structural gate — headless (`!ctx.hasUI`) → soft
|
|
764
|
+
refusal `headless_formal_event`; interactive → a blocking `ctx.ui.confirm` showing the wire
|
|
765
|
+
event, inline-comment count, and the body's first line; declined → `user_declined`, nothing
|
|
766
|
+
executed. `comment` posts on the conversational gate alone.
|
|
767
|
+
- **`dry_run` is the anchor-repair loop:** no gates, no `last_review` record, stops before the
|
|
768
|
+
mutation (`mode: "validated"`). A `bad_anchors` failure whose `invalid[]` rows decode cleanly
|
|
769
|
+
renders a per-comment repair table ("repair these anchors and re-run with dry_run: true");
|
|
770
|
+
any payload drift renders a plain fail (never a half table). A formal event on the viewer's
|
|
771
|
+
own PR fails the dry-run as `own_pr` (the cold door's prediction above) — the repair is the
|
|
772
|
+
event, not the anchors.
|
|
773
|
+
- **Per-door posting ownership — the terminal contract (three invariants):** (1) nothing reaches
|
|
774
|
+
GitHub before the human triage — every posted comment is human-authored or human-approved, raw
|
|
775
|
+
findings are never auto-posted; (2) on `/pr-review-terminal` this tool is the SOLE posting
|
|
776
|
+
path (hunk has no GitHub posting; `gh` mutations and direct `perk pr review-submit` calls are
|
|
777
|
+
forbidden); (3) the verdict lands last, atomically with the comments — never before them.
|
|
778
|
+
- **Per-door posting ownership — the browser contract (the FLIPPED contract — the browser
|
|
779
|
+
surface's native posting IS the GitHub path):** (1) findings stream only into the local
|
|
780
|
+
plannotator session (a UI surface on localhost, never GitHub) — nothing **perk-driven**
|
|
781
|
+
reaches GitHub; (2) **plannotator's native platform-posting is THE GitHub path** — the human
|
|
782
|
+
posts inline comments (their own annotations and perk's pushed findings) plus an
|
|
783
|
+
APPROVE/COMMENT verdict directly from the UI (the UI never posts REQUEST_CHANGES; a platform
|
|
784
|
+
post is a session-ending action — the respond then carries a status string and no
|
|
785
|
+
annotations); (3) **perk composes nothing by default** — all perk-side posting still flows
|
|
786
|
+
through `submit_pr_review` (`gh` mutations and direct `perk pr review-submit` calls stay
|
|
787
|
+
forbidden; the gate ladder applies unchanged), used ONLY for a `request-changes` verdict (the
|
|
788
|
+
one verdict the UI cannot post) or on the human's explicit request, with the batch
|
|
789
|
+
human-settled — never a perk-invented "remainder".
|
|
790
|
+
- **`last_review`** (§8.3): `{ pr, event, comment_count, mode, at:ISO }`, appended best-effort
|
|
791
|
+
with strict read-back on non-dry-run success only.
|
|
792
|
+
|
|
793
|
+
**The agent-driven findings stream (the plannotator push discipline — prose-pinned in the
|
|
794
|
+
`perk-pr-review-browser` skill, no perk code):** the agent maps and pushes findings as atomic
|
|
795
|
+
waves to `POST <url>/api/external-annotations`
|
|
796
|
+
(`{annotations: [{source: "perk:<angle>", type: "concern", filePath, lineStart/lineEnd,
|
|
797
|
+
side: LEFT→"old" / RIGHT-or-omitted→"new", text: "[severity/confidence] …"}]}`; batches are
|
|
798
|
+
atomic; 201 returns `{ids}` — captured for cleanup). The wave cadence: on
|
|
799
|
+
`/pr-review-browser` a wave is pushed per ARRIVING fenced-JSON batch inside the streaming
|
|
800
|
+
`wait({timeoutMs})` loop (a `path`+`line` ledger dedupes — a pushed anchor is never re-pushed —
|
|
801
|
+
and the discipline is hold-and-accumulate: a refused POST before any door failure notice means
|
|
802
|
+
"not up yet", retried on the next wait-loop return, never a degrade).
|
|
803
|
+
`line: null` findings ARE pushed on this surface (path → `scope: "file"`, none →
|
|
804
|
+
`scope: "general"`) but still fold into the review body for any GitHub posting. After the
|
|
805
|
+
reconcile pass the agent removes superseded annotations (`DELETE ?id=<uuid>` or
|
|
806
|
+
`DELETE ?source=perk:<angle>` + repost) — never the human's annotations or another source's.
|
|
807
|
+
Forbidden: `GET <url>/api/diff` (the raw diff never enters the parent session) and any `gh`
|
|
808
|
+
mutation. A failed wave push after the browser is up degrades loudly in-session; triage and
|
|
809
|
+
posting are unchanged.
|
|
810
|
+
|
|
811
|
+
**The adversarial-reviewer angle agent.** A perk-owned project agent
|
|
812
|
+
`agents/adversarial-reviewer.md` (runtime `perk.adversarial-reviewer`) — fresh-context,
|
|
813
|
+
read-only, **report-only** (it never posts, never stages or writes files, never resolves
|
|
814
|
+
threads, never spawns subagents), delivered like its siblings via the managed `.pi/agents/perk/`
|
|
815
|
+
convergence. It reviews **any PR regardless of ownership — the untrusted posture is the default,
|
|
816
|
+
not a foreign-PR special case** — along **one assigned angle**; the two driving
|
|
817
|
+
human-in-the-loop review doors below (`/pr-review-terminal`, `/pr-review-browser`) are its only
|
|
818
|
+
perk-owned spawn sites,
|
|
819
|
+
and this pin is the output contract those doors' parent sessions parse. The def's prose
|
|
820
|
+
additionally works each angle through an adversarial-questions rubric (right / wrong /
|
|
821
|
+
underbaked / overbaked-with-a-simpler-alternative) — the rubric lives entirely in the agent
|
|
822
|
+
prompt; the contracts pin the output shape, not the judgment rubric.
|
|
823
|
+
|
|
824
|
+
- **Input (per-spawn task prompt):** the assigned angle, the PR number, and the absolute path to
|
|
825
|
+
the detached read-only head worktree (the checkout above). The child fetches its own context
|
|
826
|
+
via `perk pr review-context --pr <n> --json` (`plan_body` may be null).
|
|
827
|
+
- **Angles** (one per spawn, mirroring `pr-reviewer`): `claimed-intent` (the PR text's claims
|
|
828
|
+
checked against the diff, plus a first-class hunt for **undisclosed scope**; the parent always
|
|
829
|
+
includes this angle) · `correctness` (incl. the untrusted-code supply-chain axes: CI/workflow
|
|
830
|
+
edits, dependency pins, install/build scripts, secrets handling, obfuscated code) · `tests`
|
|
831
|
+
(adequacy by reasoning only) · `quality`.
|
|
832
|
+
- **Posture:** all fetched text is untrusted DATA, and the PR title/body are **unverified claims
|
|
833
|
+
by the PR author** (an author not trusted by default) — checked against the diff, never built
|
|
834
|
+
on. **Never-execute-the-head:** inside the head worktree the child uses
|
|
835
|
+
`read`/`grep`/`find`/`ls` only (no builds, no tests, no installs); the only command it runs in
|
|
836
|
+
the whole session is `review-context`.
|
|
837
|
+
- **Output (the cross-plane contract).** A fenced JSON block `{angle, summary, findings[],
|
|
838
|
+
fyi[]}` — **verdict-free** (a human triages downstream; an empty `findings` array is the
|
|
839
|
+
"nothing found" statement, earned by hunting, never manufactured). Each finding is
|
|
840
|
+
`{path, line: <int-in-diff or null>, side?: "LEFT"|"RIGHT" (omitted = RIGHT), severity ∈
|
|
841
|
+
critical|major|minor, confidence ∈ high|medium|low, body}`; `line: null` carries a
|
|
842
|
+
real-but-unanchorable finding (folded into the review body downstream, never lost); `fyi` is
|
|
843
|
+
in-session triage color, never posted.
|
|
844
|
+
- **The streaming protocol (child-side, unconditional whenever `contact_supervisor` exists).**
|
|
845
|
+
While reviewing, the child sends **non-blocking** progress-update batches —
|
|
846
|
+
`contact_supervisor({reason: "progress_update", message})`, the message a short line plus a
|
|
847
|
+
fenced JSON block `{angle, findings[]}` with each finding in **exactly the completion-report
|
|
848
|
+
finding shape** above. A streamed finding is never re-sent; batches are small and never empty.
|
|
849
|
+
Batches are **provisional** — the final fenced-JSON completion report is the **complete set**
|
|
850
|
+
(streamed findings included) and stays the reconcile source of truth. **Children never receive
|
|
851
|
+
the surface handle** (no hunk/plannotator session, launch, or loopback details in any task) —
|
|
852
|
+
findings travel ONLY via progress updates and the final report. When `contact_supervisor` is
|
|
853
|
+
absent, streaming is skipped silently — the report-only completion contract is unchanged.
|
|
854
|
+
- **Model** configurable via `[models.subagents] adversarial-reviewer` (both planes; default
|
|
855
|
+
`anthropic/claude-fable-5`, fallback `anthropic/claude-sonnet-4-5` — a deliberately stronger
|
|
856
|
+
tier than `pr-reviewer` for security-sensitive untrusted-code review). A legacy
|
|
857
|
+
`guest-reviewer` key is silently ignored on both planes (`extra="ignore"` — no tripwire).
|
|
858
|
+
|
|
859
|
+
**The `/pr-review-terminal` warm door** (`extension/doors/prReviewTerminal.ts`). The TERMINAL
|
|
860
|
+
entry into human-in-the-loop adversarial PR review — hunk always, **no provider dispatch** (the
|
|
861
|
+
surface-named command IS the selection; it never reads `[providers]`; config is read only for the
|
|
862
|
+
`[models.subagents] adversarial-reviewer` override). It registers **no tools** — posting rides
|
|
863
|
+
`submit_pr_review` above with its gate ladder and description unchanged. Its terminal substrate
|
|
864
|
+
— the door-common PR-token arg grammar (`parseReviewArgs`/`parseReviewDoorArgs`), the strict
|
|
865
|
+
checkout decode, the `hunk --version` presence probe, and the R7 handoff — lives in
|
|
866
|
+
`extension/doors/hunkHandoff.ts`/`prReviewTerminal.ts` (the browser door imports the door-common
|
|
867
|
+
pieces; a neutral re-home is a deferred residual).
|
|
868
|
+
|
|
869
|
+
- **Args:** `/pr-review-terminal [pr number|url] [focus note]` — both tokens optional
|
|
870
|
+
(`parseReviewDoorArgs`). A leading
|
|
871
|
+
PR number/URL (the shared PR-token grammar) selects the **foreign** mode; empty args select the
|
|
872
|
+
**active** mode; any other text is the active-mode focus note — EXCEPT a leading `http(s)://`
|
|
873
|
+
token that fails the PR parse, which is a usage error (a mistyped PR URL never silently becomes
|
|
874
|
+
a focus note).
|
|
875
|
+
- **Entry gates, in order (nothing executed on refusal, each a loud error):** the arg parse →
|
|
876
|
+
headless (`!ctx.hasUI` — the hunk surface and the human triage are constitutive) → the hunk
|
|
877
|
+
probe (refuses with the install hint `npm i -g hunkdiff (or brew install hunk)`) — all before
|
|
878
|
+
any cold-door call.
|
|
879
|
+
- **Foreign mode (a PR arg):** the detached `perk pr review checkout` + strict decode (a failure
|
|
880
|
+
renders the envelope `error_type`/message,
|
|
881
|
+
injects nothing), the adversarial-reviewer flow with the streaming fan-out below, guidance from
|
|
882
|
+
`prompts/stages/pr-review-terminal/foreign.md` (the untrusted-foreign-code posture, the triage
|
|
883
|
+
loop, the posting contract, and the `perk pr review cleanup` step).
|
|
884
|
+
- **The streaming fan-out (foreign + active; guidance-driven — no door plumbing):** the guidance
|
|
885
|
+
spawns the 2–3 reviewers as ONE async `subagent` call (a `tasks` array, `context: "fresh"`,
|
|
886
|
+
`async: true`; each child's task names its angle, the PR number, and the worktree path ONLY —
|
|
887
|
+
never the surface handle), then loops `wait({ timeoutMs })` while the run is active. The why:
|
|
888
|
+
progress updates neither wake `wait()` nor enter pi-subagents' `pending` map — delivery is an
|
|
889
|
+
injected steer message when a tool call returns — so the timed wait loop IS the streaming
|
|
890
|
+
cadence and the parent must hold its turn open (an ended turn stops streaming). Each arriving
|
|
891
|
+
fenced-JSON batch is pushed into hunk incrementally with **`path`+`line` dedupe** (an
|
|
892
|
+
in-conversation ledger; a pushed anchor is never re-pushed; hold-and-accumulate until the
|
|
893
|
+
handshake connects). On the grouped completion notification the parent reconciles from the
|
|
894
|
+
fenced-JSON **completion reports** (union + dedupe — the source of truth for triage and
|
|
895
|
+
posting; streamed batches were provisional), pushes any not-yet-pushed remainder, and — when
|
|
896
|
+
the handshake never connected — applies the unchanged check-in posture (ask, wait, degrade
|
|
897
|
+
only on the human's explicit choice).
|
|
898
|
+
- **Active mode (no PR arg):** the shared active-PR resolution ladder — `perk pr url --json` →
|
|
899
|
+
`resolveReviewTarget` with the plan-ref's pinned base. A resolved PR → the same flow re-homed
|
|
900
|
+
to the human's own worktree (`active.md`: no checkout and **no cleanup step**; the children
|
|
901
|
+
still fetch `perk pr review-context` themselves — the raw diff never enters the parent session;
|
|
902
|
+
the own-PR authorship check carries over as the common case). Every non-`no_pr` fail arm (incl.
|
|
903
|
+
`no_plan_ref`) errors loudly, appending the "pass a PR number/URL, or run from a plan worktree"
|
|
904
|
+
hint.
|
|
905
|
+
- **Pre-PR mode (the `no_pr` arm):** a **surface-only** since-base review — hunk is launched on
|
|
906
|
+
the working tree's since-base diff, **no reviewers are spawned and nothing posts to GitHub**;
|
|
907
|
+
the minimal `local.md` guidance is a notes read-back loop (tell the human to review + leave
|
|
908
|
+
notes, end the turn while they do — never poll on a timer — then
|
|
909
|
+
`hunk session comment list … --type user` and triage the actionable notes in-session).
|
|
910
|
+
- **The since-base sha (active + pre-PR):** `sinceBaseSha(cwd, base)`
|
|
911
|
+
(`extension/substrate/git.ts`, fail-open — null on any failure, never throws): resolve the base
|
|
912
|
+
branch (the plan-ref's pinned base; null ⇒ the repo default via `origin/HEAD`), **best-effort**
|
|
913
|
+
`git fetch origin <branch>` (bounded timeout; a failure — offline, no remote — falls back to
|
|
914
|
+
the stale local ref, keeping the door usable offline), then `merge-base(HEAD, origin/<branch>)`.
|
|
915
|
+
Null ⇒ a loud error naming the pass-a-PR fallback; nothing launched or injected.
|
|
916
|
+
- **The R7 launch handoff (door-side, fail-soft, non-blocking — `handleHunkLaunch` in
|
|
917
|
+
`extension/doors/hunkHandoff.ts`, report-scope-parameterized):** every mode hands off
|
|
918
|
+
`hunk diff <sha12> --agent-notes` (agent notes visible in hunk immediately) in the mode's
|
|
919
|
+
worktree (foreign: the checkout; active/pre-PR: `ctx.cwd`). The door does not merely print the
|
|
920
|
+
launch command — it (a) copies `cd <worktree> && hunk diff <sha12> --agent-notes` to the OS
|
|
921
|
+
clipboard (best-effort) and (b) auto-launches hunk in a terminal the human can see, via a
|
|
922
|
+
first-match ladder: a `PERK_TERMINAL_LAUNCH` custom launcher → a `tmux split-window` pane (when
|
|
923
|
+
`$TMUX`) → the macOS terminal keyed off `$TERM_PROGRAM` (Ghostty ≥ 1.3 native surface / iTerm2 /
|
|
924
|
+
Terminal.app as the universal fallback); no Linux emulator sniffing (tmux + the custom seam
|
|
925
|
+
cover it) → otherwise no launch. The rc-less rungs — ghostty (an argv-exec'd surface command:
|
|
926
|
+
quote-aware word split, a relative arg0 joined onto the working directory, never a shell line)
|
|
927
|
+
and tmux (the server environment) — wrap the command in the human's interactive **login shell**
|
|
928
|
+
(`$SHELL -i -l -c '…'`; `/bin/zsh` on darwin / `/bin/sh` elsewhere when `$SHELL`
|
|
929
|
+
is unset or relative), so the launched window resolves `hunk` — and the `node` its
|
|
930
|
+
`#!/usr/bin/env node` shebang re-resolves — exactly like the human's own terminal (rc-file PATH
|
|
931
|
+
augmentation, e.g. mise/nvm activation, included); the shell-line rungs (iTerm2/Terminal.app)
|
|
932
|
+
and the custom launcher receive the bare command (the former type into an interactive login
|
|
933
|
+
shell the terminal opens; the latter owns its own environment). The printed/clipboard line
|
|
934
|
+
keeps the bare `hunk` (the human's interactive shell resolves it). The launch is raced against
|
|
935
|
+
a soft deadline (~2s) so a
|
|
936
|
+
first-run macOS Automation/TCC dialog never stalls the guidance injection: a clean launch within
|
|
937
|
+
the deadline reports **info** ("opened hunk in a new <surface>"); a failed/absent rung or a
|
|
938
|
+
still-pending launch reports **warning** ("ACTION NEEDED — run hunk in another terminal") with
|
|
939
|
+
the launch line (and "it's on your clipboard" when copied), and a pending launch that later
|
|
940
|
+
succeeds adds a follow-up info note. Every rung is fail-soft (throw/nonzero/killed → no launch);
|
|
941
|
+
the loud print + clipboard are the universal fallback, and the `hunk session get` handshake —
|
|
942
|
+
never a spawn success — remains the ONLY verification hunk is actually up. Two env seams gate the
|
|
943
|
+
side effects: `PERK_TERMINAL_LAUNCH` and `PERK_CLIPBOARD_CMD` each mean *unset* → the platform
|
|
944
|
+
default, *empty* → disabled (the harness default, so no suite spawns a window or clobbers the
|
|
945
|
+
clipboard), *non-empty* → a custom launcher/copier. A third, **internal-only** knob —
|
|
946
|
+
`PERK_REVIEW_LAUNCH_DEADLINE_MS` — overrides the ~2s soft deadline (a test seam: suites drive
|
|
947
|
+
the whole door handler, so env is the only injectable surface; not a user-facing seam,
|
|
948
|
+
deliberately absent from user docs). Mid-flow surface failures DEGRADE instead of refusing:
|
|
949
|
+
when the handshake never connects the degrade is the human's **explicit choice** at the
|
|
950
|
+
check-in (the model re-prints the launch command and waits — never a timer, never the model's
|
|
951
|
+
own initiative); findings surface in-session, the triage loop and posting are unchanged, every
|
|
952
|
+
degradation is loud.
|
|
953
|
+
- **The triage loop:** a human-in-the-loop conversation, not a form — the flow opens
|
|
954
|
+
with a plain-words map (finding count, one-at-a-time keep/drop/reword in the human's own words,
|
|
955
|
+
the human's own surface notes as candidates, the "what kind of review to post" choice last, and
|
|
956
|
+
nothing to GitHub without an explicit go-ahead); each `ask_user_question` names the human's
|
|
957
|
+
position ("finding 2 of 5") and each option says what happens next; a conversational beat
|
|
958
|
+
separates consecutive questionnaires; and a **declined questionnaire drops to plain
|
|
959
|
+
conversation**, not another form.
|
|
960
|
+
- **Binding:** `command:pr-review-terminal` → `perk-pr-review-terminal` (nudge, §8.9), delivered
|
|
961
|
+
on every
|
|
962
|
+
injection — all three modes (the skill's hunk cheat sheets serve the pre-PR read-back too).
|
|
963
|
+
|
|
964
|
+
**The `/pr-review-browser` warm door** (`extension/doors/prReviewBrowser.ts`). The BROWSER entry
|
|
965
|
+
into human-in-the-loop adversarial PR review — plannotator always, **no provider dispatch** (the
|
|
966
|
+
surface-named command IS the selection; it never reads `[providers]`; config is read only for
|
|
967
|
+
the `[models.subagents] adversarial-reviewer` override). It registers **no tools** — the
|
|
968
|
+
annotation waves are agent-driven HTTP (above) and perk-side posting rides `submit_pr_review`
|
|
969
|
+
with its gate ladder unchanged. Its shared substrate lives in
|
|
970
|
+
`extension/doors/plannotatorHandoff.ts` (the `hunkHandoff.ts` mirror — the pinned `code-review`
|
|
971
|
+
envelope, the presence probe, the active-PR ladder, the respond routing, and the browser-open
|
|
972
|
+
core), imported by this door and `/pr-review-terminal`'s active mode.
|
|
973
|
+
|
|
974
|
+
- **Args:** `/pr-review-browser [pr number|url] [focus note]` — the exact `/pr-review-terminal`
|
|
975
|
+
arg semantics (the door imports `parseReviewDoorArgs` — one function ⇒ identical grammar
|
|
976
|
+
by construction): a leading PR number/URL selects the **foreign** mode; empty args select the
|
|
977
|
+
**active** mode; any other text is the active-mode focus note — EXCEPT a leading `http(s)://`
|
|
978
|
+
token that fails the PR parse, which is a usage error.
|
|
979
|
+
- **Entry gates, in order (nothing executed on refusal, each a loud error):** the arg parse →
|
|
980
|
+
headless (`!ctx.hasUI` — the browser surface and the human are constitutive) → the plannotator
|
|
981
|
+
presence probe (the `plannotator-review` command; the refusal names the fix: select the
|
|
982
|
+
plannotator plan provider — `[providers] plan = "plannotator-plan"` —
|
|
983
|
+
run `perk init`, then restart pi).
|
|
984
|
+
- **The background open (foreign + active):** the handler starts `startPlannotatorBrowser`,
|
|
985
|
+
injects the mode guidance IMMEDIATELY (the URL is deterministic once the port is picked — no
|
|
986
|
+
blocking readiness poll in the handler), and ends its turn. The readiness promise is observed
|
|
987
|
+
in a background task: `ready` → an info note ("plannotator is up at <url> — browser opening");
|
|
988
|
+
`timeout`, or a bridge that settled error/unavailable → a loud error report PLUS a degrade
|
|
989
|
+
notice injected to the model (idle → immediate, streaming → `followUp`): render the findings
|
|
990
|
+
in-session, posting unchanged. The bridge respond stays background-awaited and routes via the
|
|
991
|
+
shared `respondMessage` (below).
|
|
992
|
+
- **Server addressing (the preset-`PLANNOTATOR_PORT` mechanism — `startPlannotatorBrowser`, the
|
|
993
|
+
browser-open core in `plannotatorHandoff.ts`):** perk's extension and plannotator's
|
|
994
|
+
in-process `node:http` review server share one Node process, and plannotator's port resolution
|
|
995
|
+
reads `PLANNOTATOR_PORT` at bind time — so the server URL is KNOWN the moment the port is
|
|
996
|
+
picked, before the server is up. The core picks a free ephemeral port, saves + presets the env
|
|
997
|
+
var, emits the `code-review` bridge request (the PR-mode payload `{prUrl, cwd}` byte-for-byte,
|
|
998
|
+
background-awaited), and polls `GET http://127.0.0.1:<port>/api/diff` (a review-server-only
|
|
999
|
+
route; 1s cadence, 120s budget — the poll stops early on turn abort or when the bridge settles
|
|
1000
|
+
first, an early error/unavailable respond meaning the server never comes), ALWAYS restoring
|
|
1001
|
+
the prior env value (delete if previously unset) in a `finally` when the poll ends.
|
|
1002
|
+
Concurrency caveat: a second plannotator server starting in the same process during the window
|
|
1003
|
+
would collide on the fixed port — rare, loud (EADDRINUSE → plannotator throws → the bridge
|
|
1004
|
+
settles error), never silent.
|
|
1005
|
+
- **Respond routing (the PR modes — `respondMessage` /
|
|
1006
|
+
`routeBrowserRespond` in `plannotatorHandoff.ts`):** the bridge's single respond routes back
|
|
1007
|
+
into the session via the pure `respondMessage(outcome)` mapping — `handled`+`exit` → the
|
|
1008
|
+
closed-without-submitting ask; `handled`+approved+no annotations → the review-is-complete note
|
|
1009
|
+
(perk posts nothing; `submit_pr_review` offered only on explicit ask); `handled` otherwise →
|
|
1010
|
+
the feedback text + (when annotations exist) a fenced JSON block of the decoded annotations +
|
|
1011
|
+
the flipped triage pointer (source-less = human-authored; `perk:*`-badged = perk's own
|
|
1012
|
+
findings returning; perk composes nothing by default — `submit_pr_review` ONLY for
|
|
1013
|
+
request-changes or on explicit request); `unavailable`/`error` → `report()` error, the flow
|
|
1014
|
+
continues in-session. Injection is idle → immediate, streaming → `followUp`. The decoded
|
|
1015
|
+
annotation shape (`CodeReviewAnnotation`: `{filePath, lineStart, lineEnd, side: "old"|"new"}`
|
|
1016
|
+
+ optional `text`/`suggestedCode`/`type`/`scope`/`source`/`severity`) and the `exit` flag ride
|
|
1017
|
+
the shared bridge decode — the pre-PR local mode routes separately
|
|
1018
|
+
(`routePrReviewOutcome`, keyed on `annotationCount` alone, `exit` checked before the
|
|
1019
|
+
approved/feedback arms).
|
|
1020
|
+
- **Foreign mode (a PR arg):** the same `perk pr review checkout` + strict decode as the
|
|
1021
|
+
terminal door (a failure renders the envelope `error_type`/message, injects nothing), then the
|
|
1022
|
+
background open on the checkout's PR `url`; guidance from
|
|
1023
|
+
`prompts/stages/pr-review-browser/foreign.md` (the untrusted-foreign-code posture, the
|
|
1024
|
+
`perk pr review cleanup` step).
|
|
1025
|
+
- **The streaming fan-out (foreign + active; guidance-driven — no door plumbing):** the 2–3
|
|
1026
|
+
adversarial reviewers spawn as ONE async `subagent` call and the parent holds the
|
|
1027
|
+
`wait({timeoutMs})` streaming loop, exactly as on `/pr-review-terminal` — but each arriving
|
|
1028
|
+
fenced-JSON batch is pushed as ONE atomic wave to `POST <url>/api/external-annotations` (the
|
|
1029
|
+
ledger dedupe + hold-and-accumulate discipline in the findings-stream block above). Children
|
|
1030
|
+
never receive the surface handle — not the URL, not the port. Once the fan-out turn ends the
|
|
1031
|
+
session is free while the human reviews in the browser; the respond arrives later as a
|
|
1032
|
+
message (one shot).
|
|
1033
|
+
- **Active mode (no PR arg):** the shared active-PR ladder — `perk pr url --json` →
|
|
1034
|
+
`resolveReviewTarget` with the plan-ref's pinned base. A resolved PR → the same flow re-homed
|
|
1035
|
+
to the human's own worktree (`active.md`: no checkout, **no cleanup step**; the browser door
|
|
1036
|
+
never computes a since-base sha — plannotator owns the diff). Every non-`no_pr` fail arm
|
|
1037
|
+
(incl. `no_plan_ref`) errors loudly, appending the "pass a PR number/URL, or run from a plan
|
|
1038
|
+
worktree" hint.
|
|
1039
|
+
- **Pre-PR mode (the `no_pr` arm):** the since-base local browser review — the door reports
|
|
1040
|
+
"No PR yet …", emits the local bridge payload `{cwd, diffType: "since-base",
|
|
1041
|
+
defaultBranch: <plan-ref base, omitted when null>}` in the background, and ends immediately.
|
|
1042
|
+
**No reviewers, no guidance injection, no port dance** (no waves to stream — no endpoint
|
|
1043
|
+
needed; nothing posts to GitHub in this mode). The single respond routes via
|
|
1044
|
+
`routePrReviewOutcome` under the `pr-review-browser` scope (exit → the closed note, checked
|
|
1045
|
+
before the approved arm; approved → the approved note; feedback → an injected turn + the
|
|
1046
|
+
triage suffix when annotations exist).
|
|
1047
|
+
- **The posting flip applies to both PR modes** (the browser posting contract above):
|
|
1048
|
+
native platform-posting from the UI is the GitHub path; perk composes nothing by default;
|
|
1049
|
+
`submit_pr_review` only for request-changes or on explicit request.
|
|
1050
|
+
- **Binding:** `command:pr-review-browser` → `perk-pr-review-browser` (nudge, §8.9), delivered
|
|
1051
|
+
on the
|
|
1052
|
+
foreign/active injections (the pre-PR mode injects nothing).
|
|
1053
|
+
|
|
1054
|
+
### PR-body craft ops (+ the submit self-checks)
|
|
1055
|
+
|
|
1056
|
+
The submit body is composed in `perk pr submit` via **create-then-update** (the checkout footer
|
|
1057
|
+
needs the PR number, unknown until `create_pr` returns):
|
|
1322
1058
|
|
|
1323
1059
|
```
|
|
1324
1060
|
update_pr_body{ number, body } -> PrBodyUpdate{ number, dry_run }
|
|
@@ -1330,78 +1066,69 @@ get_pr_body{ number } -> string | null
|
|
|
1330
1066
|
validate_pr_body(body, *, pr_number) -> string[] (empty == valid)
|
|
1331
1067
|
# PURE (no gh). Footer-scoped ONLY (the <details> embed is explicitly fine): the footer must be
|
|
1332
1068
|
# present, plain-backtick (not HTML-wrapped), and carry the PR number (word-boundary: #12 ≠
|
|
1333
|
-
# …checkout 123). This is the self-check that catches
|
|
1069
|
+
# …checkout 123). This is the self-check that catches an issue-numbered footer.
|
|
1334
1070
|
```
|
|
1335
1071
|
|
|
1336
|
-
- **The two-target split
|
|
1072
|
+
- **The two-target split.** The HTML-enhanced body — a best-effort `<details>` embed of the
|
|
1337
1073
|
verbatim plan (via `get_plan_body`; `None` → no embed, no raise) + the checkout footer — goes
|
|
1338
1074
|
**only** into the GitHub PR body (`update_pr_body`). The squash **commit message** is the OTHER
|
|
1339
|
-
target: plain text, set at land
|
|
1340
|
-
- **Mergeability probe
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
- **`pr check` (D5).** `perk pr submit` runs `validate_pr_body` as a **post-write self-check** and
|
|
1075
|
+
target: plain text, set at land, so HTML never leaks into `git log`.
|
|
1076
|
+
- **Mergeability probe.** **After** the PR is created + the body validated, `perk pr submit` runs
|
|
1077
|
+
a deterministic **local** `git merge-tree --write-tree origin/<base> <branch>` probe (no GitHub
|
|
1078
|
+
round-trip, no reliance on GitHub's eventually-consistent `mergeable` field) and surfaces three
|
|
1079
|
+
`--json` fields: `base` (the target branch), `mergeable` (`true` clean / `false` conflicts /
|
|
1080
|
+
`null` undetermined), and `conflicts[]` (the conflicted paths). The probe is **fail-open**: an
|
|
1081
|
+
unresolvable base or any `merge-tree` exit other than 0/1 yields `mergeable: null` and never
|
|
1082
|
+
changes submit's exit code — the warm-door conflict-resolver drive (§8.3's owning-modules list)
|
|
1083
|
+
fires only on a **definitive** `mergeable: false`. `--dry-run` stays fully offline. The submit
|
|
1084
|
+
still **succeeds mechanically** (exit 0) when conflicts are present — mergeability is reported
|
|
1085
|
+
separately, not an op failure.
|
|
1086
|
+
- **`pr check`.** `perk pr submit` runs `validate_pr_body` as a **post-write self-check** and
|
|
1352
1087
|
**raises** (`error_type: pr_check_failed`) on failure. A thin `perk pr check --json` (active
|
|
1353
|
-
plan-ref → find PR → `get_pr_body` → `validate_pr_body`) is the supervisor surface (exit 0
|
|
1354
|
-
1 invalid·op-failure / 2 not-a-repo).
|
|
1088
|
+
plan-ref → find PR → `get_pr_body` → `validate_pr_body`) is the supervisor surface (exit 0
|
|
1089
|
+
valid / 1 invalid·op-failure / 2 not-a-repo).
|
|
1355
1090
|
- **`pr url` (the active-PR locator).** A thin read-only `perk pr url --json` worker (active
|
|
1356
1091
|
plan-ref → `resolve_plan_worktree_name` → `find_pr_for_branch`) emits `{pr:{number,url}}` (exit
|
|
1357
|
-
0 ok / 1 no-plan·no-PR·op-failure / 2 not-a-repo)
|
|
1358
|
-
|
|
1359
|
-
(
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
- **
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
- **Phase-2 caveat:** a fresh-clone resume (remote branch with no local remote-tracking ref) may
|
|
1384
|
-
hit a `stale info` lease failure and need a targeted `git fetch origin <branch>` before the
|
|
1385
|
-
lease; deferred with remote-branch resume (Phase 2).
|
|
1386
|
-
|
|
1387
|
-
### Plan-ref payload (provider-agnostic; full schema → Phase 1)
|
|
1388
|
-
|
|
1389
|
-
`active_plan_ref` / `cache.plan-ref` is **provider-agnostic** from day one (PRIOR_ART §2 —
|
|
1390
|
-
erk migrated away from GitHub-specific refs and issue-numbers-in-branch-names):
|
|
1092
|
+
0 ok / 1 no-plan·no-PR·op-failure / 2 not-a-repo). It fronts the active modes of the warm
|
|
1093
|
+
`/pr-review-browser` and `/pr-review-terminal` doors
|
|
1094
|
+
(`extension/doors/plannotatorHandoff.ts` owns the envelope + fallback ladder).
|
|
1095
|
+
- **Draft → ready is a deliberate gesture.** Submit keeps the PR **draft**; perk does **not**
|
|
1096
|
+
auto-publish. `perk pr ready` (warm `/ready`) is the explicit review gate — `mark_pr_ready` if
|
|
1097
|
+
draft, idempotent. Land's mark-ready-if-draft stays a safety net. Completion is never inferred
|
|
1098
|
+
from PR open/closed state alone.
|
|
1099
|
+
- **Re-submit on rewritten history.** `perk pr submit` **force-pushes the perk-owned plan branch
|
|
1100
|
+
with `--force-with-lease`** (auto-force; a no-op on the first push): plan branches (`plan-<n>`)
|
|
1101
|
+
are single-author and expected to diverge after amend/squash/rebase, while the lease still
|
|
1102
|
+
rejects an *unexpected* origin move (teammate safety). Two stable error surfaces:
|
|
1103
|
+
`error_type: dirty_tree` (submit refuses on a dirty worktree — uncommitted work isn't pushed
|
|
1104
|
+
and would silently fail to update the PR) and `error_type: push_rejected` (a non-fast-forward /
|
|
1105
|
+
lease failure maps to an actionable "remote moved unexpectedly; fetch/rebase and re-submit"
|
|
1106
|
+
message; `git_error` remains the fallback).
|
|
1107
|
+
- **Non-OPEN reused PR (the replan-after-closed-attempt shape).** A replan reuses branch
|
|
1108
|
+
`plan-<n>`, so `find_pr_for_branch` can return a prior attempt's PR in a **non-OPEN** state.
|
|
1109
|
+
Submit never silently decorates it (which would re-embed the plan into a closed PR that `/land`
|
|
1110
|
+
then refuses to merge): a **CLOSED** reuse is reopened via `reopen_pr` (a loud
|
|
1111
|
+
`↺ reopened closed PR #n` note on stderr) and submit proceeds byte-identically; a **MERGED** reuse
|
|
1112
|
+
is refused with a new `error_type: pr_already_merged` (nothing sane to reuse). A reopen failure
|
|
1113
|
+
propagates as `error_type: github_error` (no silent fallback). OPEN reuse is unchanged.
|
|
1114
|
+
|
|
1115
|
+
### Plan-ref payload (provider-agnostic)
|
|
1116
|
+
|
|
1117
|
+
`active_plan_ref` / `cache.plan-ref` is **provider-agnostic** from day one:
|
|
1391
1118
|
|
|
1392
1119
|
```
|
|
1393
1120
|
{ provider: string, # the resolved issue backend ("github" today — §8.21)
|
|
1394
1121
|
pr_id: string, # STRING (allows non-numeric ids like Jira "PROJ-123")
|
|
1395
1122
|
url: string, # during planning: the plan issue url/id; branch/pr staged null
|
|
1396
1123
|
labels: string[], # ["perk:plan"]
|
|
1397
|
-
objective_id: string|null, #
|
|
1398
|
-
consumed_learn: string[], #
|
|
1399
|
-
# land) — opaque strings (§8.21
|
|
1400
|
-
base: string|null } #
|
|
1124
|
+
objective_id: string|null, # the linked objective (opaque string id — §8.21)
|
|
1125
|
+
consumed_learn: string[], # perk:learn issue ids a docs plan consolidates (closed on
|
|
1126
|
+
# land) — opaque strings (§8.21)
|
|
1127
|
+
base: string|null } # the pinned PR merge target / worktree start-point branch;
|
|
1401
1128
|
# null ⇒ fall back to the GitHub default branch
|
|
1402
1129
|
```
|
|
1403
1130
|
|
|
1404
|
-
**Plan-header block (
|
|
1131
|
+
**Plan-header block (the queryable metadata in the issue *body*).** The minimal
|
|
1405
1132
|
observably-distinct set; rendered as a `perk:metadata-block:plan-header` collapsible YAML
|
|
1406
1133
|
block; the full plan markdown lives in the `plan-body` first comment:
|
|
1407
1134
|
|
|
@@ -1411,323 +1138,81 @@ block; the full plan markdown lives in the `plan-body` first comment:
|
|
|
1411
1138
|
branch: string|null, # staged — populated at submit
|
|
1412
1139
|
pr: string|null, # staged — populated at submit
|
|
1413
1140
|
created: string, # ISO-8601 UTC
|
|
1414
|
-
objective_id: string|null, #
|
|
1415
|
-
consumed_learn: string[], #
|
|
1416
|
-
base: string|null } #
|
|
1141
|
+
objective_id: string|null, # the linked objective (opaque string id — §8.21)
|
|
1142
|
+
consumed_learn: string[], # perk:learn issue ids (opaque strings — §8.21)
|
|
1143
|
+
base: string|null } # the pinned PR merge target / worktree start-point branch;
|
|
1417
1144
|
# null ⇒ fall back to the GitHub default branch
|
|
1418
1145
|
```
|
|
1419
1146
|
|
|
1420
|
-
**The copyable command callout
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
**
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
`base`
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
`
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
**Label taxonomy (minimal, PRIOR_ART §2/§6):** `perk:plan` (green `1f883d`), `perk:learn` (purple
|
|
1451
|
-
`8250df`), `perk:objective` (indigo `5319e7`, description "perk objective issue", since P2.T9),
|
|
1452
|
-
`perk:objective-node` (indigo `5319e7`, on Linear project-backed roadmap node-issues; #669), and
|
|
1453
|
-
— since hop-2 — `perk:consolidated` (gray `6e7781`, description "perk learn issue consolidated into
|
|
1454
|
-
docs/learned"), each **lazily created** by its gateway create-op on first use (perk never seeds
|
|
1455
|
-
labels in `init`). Query by a **single** label — GitHub label filters are AND-semantics. (On
|
|
1456
|
-
Linear, `perk init` / `doctor --fix` proactively ensure the five `perk:*` labels at **workspace**
|
|
1457
|
-
scope — §8.21.)
|
|
1458
|
-
|
|
1459
|
-
**The `pending-learn` semaphore (P1.T5b; Q2/Q5).** An existence-only `cache.markers` file
|
|
1147
|
+
**The copyable command callout.** A freshly-created plan issue's body **leads with a visible,
|
|
1148
|
+
copyable ` ```perk impl <id>``` ` callout** (bold label + fenced block + italic hint), injected
|
|
1149
|
+
on the fresh standalone-create path with the **server-assigned** id (only known post-create).
|
|
1150
|
+
`<id>` is the artifact's own ref id (GitHub number, Linear `ENG-N`, or a raw project UUID). Pure
|
|
1151
|
+
portable Markdown, **idempotent** (keyed on the literal command string), and structurally
|
|
1152
|
+
**above** the `plan-header` block, so header parsing and the submit-time header rewrite are
|
|
1153
|
+
unaffected. Forward-only (older artifacts are not retro-fitted). Objectives carry the sibling
|
|
1154
|
+
` ```perk objective plan <id>``` ` callout on their human-readable surface (same idempotency, same
|
|
1155
|
+
above-every-marker placement).
|
|
1156
|
+
|
|
1157
|
+
**The pinned base (`base`).** A plan or objective can declare a **non-default target branch**.
|
|
1158
|
+
`perk plan save` resolves the effective base **once** — the linked objective's own `base` (the
|
|
1159
|
+
`objective-header` `base`) → the repo's `[workflow] base` config → `None` — and pins it into BOTH
|
|
1160
|
+
the `plan-header.base` and the `cache.plan-ref.base`. Three consumers read it: `create_pr` (the
|
|
1161
|
+
PR merge target), the worktree start-point (`origin/<base>` instead of the detected trunk), and
|
|
1162
|
+
the `/submit` merge-conflict probe (chain: `cache.plan-ref.base` → `plan-header.base` →
|
|
1163
|
+
`default_branch()`). An explicit `implement`/`run-worker` `--base` flag (a one-off git
|
|
1164
|
+
start-point override for stacking) still wins the start-point verbatim; `reconstruct_plan_ref`
|
|
1165
|
+
carries `base` from the `plan-header` so resume paths recover the pinned value.
|
|
1166
|
+
|
|
1167
|
+
**Label taxonomy (minimal):** `perk:plan` (green `1f883d`), `perk:learn` (purple `8250df`),
|
|
1168
|
+
`perk:objective` (indigo `5319e7`, description "perk objective issue"), `perk:objective-node`
|
|
1169
|
+
(indigo `5319e7`, on Linear project-backed roadmap node-issues), and `perk:consolidated` (gray
|
|
1170
|
+
`6e7781`, description "perk learn issue consolidated into docs/learned"), each **lazily created**
|
|
1171
|
+
by its gateway create-op on first use. Query by a **single** label — GitHub label filters are
|
|
1172
|
+
AND-semantics. (On Linear, `perk init` / `doctor --fix` proactively ensure the five `perk:*`
|
|
1173
|
+
labels at **workspace** scope — §8.21.)
|
|
1174
|
+
|
|
1175
|
+
**The `pending-learn` semaphore.** An existence-only `cache.markers` file
|
|
1460
1176
|
(`.perk/workflow/markers/pending-learn`, name shared as `PENDING_LEARN` in both planes): **`land`
|
|
1461
|
-
sets it** (after a successful merge)
|
|
1462
|
-
|
|
1463
|
-
`
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
**
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
- `
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
comment (issue-backed) / the project **overview** (Linear project-backed) — now **leads with a
|
|
1501
|
-
visible, copyable ` ```perk objective plan <id>``` ` callout** (bold label + fenced block + italic
|
|
1502
|
-
hint), the objective sibling of the plan callout. For an issue-backed objective the callout is folded
|
|
1503
|
-
into the `objective-body` comment at compose time (the `created.number`/`created.id` is known before
|
|
1504
|
-
the comment is posted — **no extra write**); for a Linear project-backed objective it is written into
|
|
1505
|
-
the overview with one post-create `update_project_content` (the project UUID is only known after
|
|
1506
|
-
`create_project`). It is idempotent (keyed on the command string), pure portable Markdown, and sits
|
|
1507
|
-
**above** every metadata/marker block, so the table re-render and the §8.4 reconcile splice (which
|
|
1508
|
-
work strictly between markers) preserve it.
|
|
1509
|
-
|
|
1510
|
-
**Explicit-status-only (foundation open #3).** A node's `status` is **never inferred from a PR
|
|
1511
|
-
column** — `update_node` takes `status` verbatim or preserves it; setting `pr` never changes
|
|
1512
|
-
`status`. This is the deliberate departure from erk's two-tier infer-from-PR model.
|
|
1513
|
-
|
|
1514
|
-
**Gateway ops (canonical Python plane; same idempotency + two-step pattern as plan/learn):**
|
|
1515
|
-
- `find_objective_issue(*, run_id, repo_root) -> ObjectiveIssue | None` — label-scoped to
|
|
1516
|
-
`perk:objective` + the `objective-header` block (delegates to the parameterized `find_plan_issue`).
|
|
1517
|
-
- `create_objective_issue(*, title, body, repo_root, run_id, status="active", base=None,
|
|
1518
|
-
dry_run=False) -> ObjectiveIssue` — the **two-step** create (`base` (#633) persists into the
|
|
1519
|
-
`objective-header`): idempotency check → lazy `perk:objective` label →
|
|
1520
|
-
compose body (`objective-header` with `objective_comment_id: null` + `objective-roadmap`) → POST
|
|
1521
|
-
issue → POST `objective-body` comment (capturing its id) → **backfill** `objective_comment_id`
|
|
1522
|
-
into the header.
|
|
1523
|
-
- `get_objective(*, number, repo_root) -> ObjectiveState | None` — parse header + roadmap nodes;
|
|
1524
|
-
`None` if absent, raises on infra failure / invalid roadmap.
|
|
1525
|
-
- `update_objective_node(*, number, node_id, status=None, pr=None, description=None, repo_root,
|
|
1526
|
-
dry_run=False) -> ObjectiveNodeUpdate` — re-render the authoritative `objective-roadmap` block in
|
|
1527
|
-
the issue body **and** the rendered table in the `objective-body` comment (best-effort); raises if
|
|
1528
|
-
the node is not found.
|
|
1529
|
-
- `add_objective_node(*, number, phase, description, status=PENDING, slug=None, depends_on=None,
|
|
1530
|
-
comment=None, repo_root, dry_run=False) -> ObjectiveNodeAdd` — insert a new node into `phase`
|
|
1531
|
-
(auto-assigned `<phase>.<n>`, appended after that phase's last node) with the same re-render
|
|
1532
|
-
discipline; raises on an id collision. The rare node-insertion surface for reconciliation
|
|
1533
|
-
(prose-guarded, no audit gate — like the other workers).
|
|
1534
|
-
- `update_objective_header(*, number, fields, repo_root, dry_run=False) -> ObjectiveHeaderUpdate` —
|
|
1535
|
-
the `update_plan_header` twin (read-merge-PATCH), rejecting unknown keys (LBYL on
|
|
1536
|
-
`OBJECTIVE_HEADER_FIELDS`).
|
|
1537
|
-
|
|
1538
|
-
**Cold-door workers (`perk objective …` — a dev/CI/T10 surface, not an agent affordance):**
|
|
1539
|
-
`create --body @FILE [--title]`, `show NUMBER`, `node NUMBER --node ID [--status][--pr][--description]`,
|
|
1540
|
-
`node-add NUMBER --phase N --description STR [--status][--slug][--depends-on …][--comment]`,
|
|
1541
|
-
`next NUMBER` (the dependency-graph `build_graph(nodes).next_plannable()` selection T10's
|
|
1542
|
-
`/objective-plan` consumes). All supervisor surfaces (`--json` → stdout, human → stderr, exit
|
|
1543
|
-
`0`/`1`/`2`). The objective issues are pure REST (issues + comments), no GraphQL.
|
|
1544
|
-
|
|
1545
|
-
State key (registry vocabulary): `github.objective` (live since P2.T9 storage; its **stage** —
|
|
1546
|
-
`objective-plan` — exists since P2.T10).
|
|
1547
|
-
|
|
1548
|
-
### Authored (P2.T10 — the objective plan factory)
|
|
1549
|
-
|
|
1550
|
-
The objective **transition** layer on top of T9's mechanics — the plan factory + the node↔plan link.
|
|
1551
|
-
|
|
1552
|
-
- **`objective-plan` registry stage + cold door.** A new stage (`mode: read-only`, `worktree:
|
|
1553
|
-
none`, `doors.cold_remote: false`) inserted as the **single initial** before `plan`
|
|
1554
|
-
(`objective-plan → plan`); `requires/reads: [github.objective]`, `writes: [github.objective,
|
|
1555
|
-
session.workflow-state]`. Its cold door is a **dedicated** command (`DEDICATED_STAGES`),
|
|
1556
|
-
`perk objective-plan [NUMBER] [--node ID]` (the generic launcher cannot select a node): it
|
|
1557
|
-
requires an explicit NUMBER (a cold session has no `active_objective`), selects the next actionable
|
|
1558
|
-
node (pending-first dependency-graph order — unblocked `pending` nodes by position, then resumable
|
|
1559
|
-
`planning`-no-`pr` claims; or `--node`), marks it `planning` (`update_objective_node`), and launches
|
|
1560
|
-
a read-only
|
|
1561
|
-
plan-mode session seeded with the node (via `launch_stage(prompt_override=…)`). Supervisor surface
|
|
1562
|
-
(`--json`/exits `0`/`1`/`2`); error types `objective_required`/`objective_not_found`/
|
|
1563
|
-
`no_actionable_node`/`remote_blocked`.
|
|
1564
|
-
- **`launch_stage(prompt_override=…)`.** A minimal seam: when given, the override is the seeded
|
|
1565
|
-
initial prompt instead of the stage-derived `_initial_prompt` (objective-plan has no plan-ref, so
|
|
1566
|
-
`_initial_prompt` returns `None`). All existing callers pass `None`, unaffected.
|
|
1567
|
-
- **`--objective-id` thread.** `perk plan-save --objective-id N` (and the warm `plan_save` tool's
|
|
1568
|
-
`objective_id` param) populate `plan.PlanHeader.objective_id` + `plan.PlanRef.objective_id` (both
|
|
1569
|
-
fields already existed). This persists the plan→objective direction; non-objective plans omit it.
|
|
1570
|
-
- **Node mutations stay canonical Python.** The `objective_node` model tool delegates to
|
|
1571
|
-
`perk objective node` — there is **no audit gate at the CLI layer** (the audit refusal is the
|
|
1572
|
-
model-facing tool boundary only, §8.3). Whole-objective rollup-to-`done` (`update_objective_header`
|
|
1573
|
-
via a CLI) is **deferred** (T10's completion-audit unit is the node); auto-on-merge node-done is
|
|
1574
|
-
**T11**.
|
|
1575
|
-
|
|
1576
|
-
### Authored (P2.T11 — objective reconciliation after landing)
|
|
1577
|
-
|
|
1578
|
-
Close the objective loop: when a PR linked to an objective node merges, the roadmap reconciles
|
|
1579
|
-
against what was *actually* built. Two seams (PRIOR_ART §3), matching the D9 section-boundary typing:
|
|
1580
|
-
|
|
1581
|
-
**T11a — Mechanical (deterministic, on land).** The cold land path (`perk pr land`) auto-marks the
|
|
1582
|
-
objective node(s) backlinked to the just-merged plan `done` — **fail-open** (the merge already
|
|
1583
|
-
succeeded; objective tracking must never block landing) and **deliberately non-audited** (per the
|
|
1584
|
-
T10 §8.3 note, the audit gate protects the model-facing tool path only).
|
|
1585
|
-
- `objective.nodes_for_pr(nodes, pr_number) -> [ObjectiveNode]` (pure) — returns nodes whose `pr`
|
|
1586
|
-
backlink matches `pr_number` canonicalized to `"#<n>"` (`"#6"` / `6` / `"6"` interchangeably).
|
|
1587
|
-
- `pr_land_cmd._reconcile_objective_on_land(*, plan_ref, repo_root) -> ObjectiveLandUpdate`
|
|
1588
|
-
(`{ objective, nodes_marked, skipped_reason, closed }`) — best-effort, **never raises**: it parses
|
|
1589
|
-
`plan_ref.objective_id` (`skipped_reason` ∈ `no_objective_link` / `bad_objective_id` /
|
|
1590
|
-
`objective_not_found` / `no_linked_node`, or `error: <exc>` on any failure, logged loud-but-non-fatal
|
|
1591
|
-
to stderr), then `update_objective_node(... status=DONE)` for each non-terminal matched node. Called
|
|
1592
|
-
in `_pr_land_impl`'s **non-dry-run** branch only, **after** `set_marker(PENDING_LEARN)`; the
|
|
1593
|
-
dry-run branch sets an inert `ObjectiveLandUpdate(None, (), "dry_run")` and stays fully offline.
|
|
1594
|
-
`_result_to_dict` always emits `"objective": { id, nodes_marked, skipped_reason, closed }`
|
|
1595
|
-
(`id` an opaque string objective id — §8.21; Node 4.1);
|
|
1596
|
-
`_render_human` adds an `objective #N: marked node(s) X done` line when non-empty (and an
|
|
1597
|
-
`objective #N complete — closed` line when `closed`).
|
|
1598
|
-
- **Close-on-complete.** After the marking loop (targets non-empty only — the early-return skips
|
|
1599
|
-
above never reach it), the land path checks completeness **locally** over the post-mark node
|
|
1600
|
-
list (every backlinked target counts as terminal, all other nodes as fetched — the same
|
|
1601
|
-
all-terminal predicate as `DependencyGraph.is_complete`, no re-fetch, no graph construction).
|
|
1602
|
-
When complete it calls `github.close_issue(number=...)` — idempotent REST PATCH, **no closing
|
|
1603
|
-
comment** (symmetric with the §8.20 supervisor close) — and sets `closed=True`. The check runs
|
|
1604
|
-
even when zero nodes were marked (all targets already terminal), so a **re-land is idempotent**:
|
|
1605
|
-
re-landing the final PR still converges the objective to closed. The close is wrapped in its own
|
|
1606
|
-
**isolated fail-open** handler: a close failure preserves the already-marked `nodes_marked`,
|
|
1607
|
-
logs loud-but-non-fatal to stderr, and reports `skipped_reason = "close_failed: <exc>"` with
|
|
1608
|
-
`closed=False` — the land result is never affected.
|
|
1609
|
-
- The warm `extension/doors/land.ts` surfaces `objective.nodes_marked` and **auto-drives** the reconcile
|
|
1610
|
-
pass via `driveReconcileAfterLand`, which injects
|
|
1611
|
-
`reconcileGuidance(...) + bindingSuffix(..., "command:objective-reconcile")` — byte-for-byte the
|
|
1612
|
-
message `/objective-reconcile` injects — when the land succeeded with a node marked done.
|
|
1613
|
-
Delivery branches on `ctx.isIdle()`: the streaming `land` tool path uses
|
|
1614
|
-
`deliverAs: "followUp"` (delivered after the terminating batch), the idle `/land` command path an
|
|
1615
|
-
immediate turn. `land` stays **terminating** because `terminate` only skips the *automatic*
|
|
1616
|
-
follow-up LLM call — an injected `followUp` user message is a separate deliberate new turn, so the
|
|
1617
|
-
two compose. The success text reports the auto-reconciliation rather than a copy-pasteable nudge;
|
|
1618
|
-
the merge itself is unchanged. `land.ts` decodes `objective.closed` **leniently** (missing or
|
|
1619
|
-
non-boolean → `false`, sub-object kept — advisory display detail) and adds an
|
|
1620
|
-
`Objective #N complete — closed.` success line when `closed`; `driveReconcileAfterLand` is
|
|
1621
|
-
unchanged — the reconcile pass still auto-drives after a closing land (a closed issue's
|
|
1622
|
-
body/comments remain editable).
|
|
1623
|
-
- The `land` stage I/O gains `github.objective` in both `reads` (the node lookup) and `writes` (the
|
|
1624
|
-
mechanical node-done).
|
|
1625
|
-
|
|
1626
|
-
**T11b — Reconcilable (LLM judgment, post-merge, warm).** A `/objective-reconcile` surface +
|
|
1627
|
-
`perk-objective-reconcile` skill drive the model to reconcile stale objective **prose** (and node
|
|
1628
|
-
descriptions) against the real diff. The objective-body prose is a marker-bounded **Reconcilable**
|
|
1629
|
-
region; everything outside it (the Mechanical roadmap table, any Immutable notes below) is
|
|
1630
|
-
**structurally** protected.
|
|
1631
|
-
- `objective.OBJECTIVE_RECONCILABLE_MARKER_START/_END` + `replace_reconcilable_section(comment_body,
|
|
1632
|
-
new_prose) -> str | None` (pure; splices between the markers, preserving the table block above +
|
|
1633
|
-
Immutable notes below; `None` when markers absent). `render_body_comment(nodes, *, prose="")` now
|
|
1634
|
-
wraps prose in the Reconcilable markers — even empty prose emits the (empty) marker pair so every
|
|
1635
|
-
objective has a splice target; objectives created before P2.T11 (no markers) yield a clean
|
|
1636
|
-
`reconcile_target_missing` rather than a clobber.
|
|
1637
|
-
- `github.update_objective_body(*, number, prose, repo_root, dry_run=False) -> ObjectiveBodyUpdate`
|
|
1638
|
-
(`{ number, comment_id, updated, dry_run }`) — reads the `objective-header` `objective_comment_id`,
|
|
1639
|
-
fetches the comment, `replace_reconcilable_section`, PATCHes it; raises `GitHubError` (`no body
|
|
1640
|
-
comment` / `no reconcilable region`) on a missing target. The table block + Immutable prose are
|
|
1641
|
-
never touched (structural Immutable-safety).
|
|
1642
|
-
- `perk objective reconcile NUMBER --body @FILE [--dry-run] [--json]` — the cold worker (stdin-less
|
|
1643
|
-
file-arg pattern, mirroring `learn capture`); maps the two missing-target `GitHubError`s to a
|
|
1644
|
-
stable `reconcile_target_missing`, other infra to `github_error`. Node-description reconciliation
|
|
1645
|
-
reuses the existing `objective node --description` (no new flag).
|
|
1646
|
-
- `extension/factories/objectivePlan.ts` gains: a `description?` param on the `objective_node` tool
|
|
1647
|
-
(`buildObjectiveNodeArgs` pushes `--description` and **relaxes** the structural refusal so a call
|
|
1648
|
-
carrying only `description` is valid — a deliberate, flagged extension of T10's contract; the
|
|
1649
|
-
`status:"done"` audit gate is unchanged); a `reconcile_objective` warm tool
|
|
1650
|
-
(`{ objective, prose }` → run-scoped scratch file → `perk objective reconcile … --body <path>`,
|
|
1651
|
-
never throws); and a `/objective-reconcile [<number>] [--pr <plan>]` command with **three-tier
|
|
1652
|
-
objective resolution** (arg → `active_objective` → `readPlanRef(cwd).objective_id` — so the
|
|
1653
|
-
post-land path works in the landing session even when `active_objective` is unset).
|
|
1654
|
-
- The judgment layer is `skills/perk-objective-reconcile/SKILL.md`: PR diff + `objective show` as
|
|
1655
|
-
untrusted DATA; the Mechanical/Reconcilable/Immutable boundary; the contradiction taxonomy; skip
|
|
1656
|
-
if nothing is stale; never-delegate judgment + durable writes.
|
|
1657
|
-
|
|
1658
|
-
### Authored (hop-2 — the learned-docs consumer)
|
|
1659
|
-
|
|
1660
|
-
perk's `/learn` already synthesizes durable learnings into terminal `perk:learn` issues; hop-2 is
|
|
1661
|
-
the missing **consumer** that consolidates them into committed `docs/learned/`. It is a **plan
|
|
1662
|
-
factory** (mirrors `objective-plan`, NOT a direct doc-writer), triggered on-demand/batched — so it
|
|
1663
|
-
adds **no `registry.yaml` stage** (it borrows the existing `plan` stage descriptor to launch) and
|
|
1664
|
-
uses existing state keys (`github.learn`, `github.plan`, `cache.scratch`).
|
|
1665
|
-
|
|
1666
|
-
- **The factory cold door + warm command.** `perk learn docs` (`commands/learn/docs_cmd.py`, no
|
|
1667
|
-
alias): `list_learn_issues` → materialize the inbox
|
|
1668
|
-
`.perk/workflow/scratch/learn-docs-inbox.md` (a `## Learning #<n>` section per issue, each body in
|
|
1669
|
-
`<untrusted_learning>`) → `launch_stage(plan_stage, prompt_override=<seed>)` (a read-only
|
|
1670
|
-
plan-mode session). `--gather` materializes the inbox + emits `{ inbox_path, learn_numbers }`
|
|
1671
|
-
with no launch (the warm path + tests consume this); `--dry-run` gathers + prints; `--remote` is
|
|
1672
|
-
rejected (`remote_blocked`, the `plan` stage is `cold_remote:false`); no open learn issues →
|
|
1673
|
-
exit 1 `no_learn_issues`. The warm `/learn-docs` (`extension/doors/learnDocs.ts`) delegates to
|
|
1674
|
-
`perk learn docs --gather --json` (gate-safe — extension `pi.exec` is not subject to the
|
|
1675
|
-
read-only bash gate), then `pi.sendUserMessage`s the factory guidance pointing at the
|
|
1676
|
-
`perk-learn-docs` skill. **Headless-safe** (the inbox is still materialized; no turn is driven).
|
|
1677
|
-
- **`learn` is a hybrid group (Node 2.2).** `perk learn` is a hand-written default-dispatch group
|
|
1678
|
-
(`commands/learn/`): a bare/non-verb invocation falls through to a hidden launcher built from
|
|
1679
|
-
the generic registry factory (byte-identical to the generated `learn` stage launcher), while
|
|
1680
|
-
`capture` and `docs` are the cold workers (no aliases). Warm ids (`/learn`, `/learn-docs`,
|
|
1681
|
-
`command:learn-docs`, the inbox artifact) are unchanged — they key off warm command ids, not
|
|
1682
|
-
cold CLI spellings.
|
|
1683
|
-
- **The factory discipline is inbox-over-gh.** The seeded factory session reads the materialized
|
|
1684
|
-
inbox via the `read` tool as its canonical input. Read-only `gh` query subcommands are now
|
|
1685
|
-
allowlisted in the read-only bash gate (`extension/substrate/toolGating.ts`), so ad-hoc GitHub reads are
|
|
1686
|
-
*possible* — but the cold door remains the canonical gatherer (deterministic, token-cheap), and
|
|
1687
|
-
factory sessions should not re-fetch the inbox's contents via `gh`.
|
|
1688
|
-
- **The `consumed_learn` thread.** `perk plan-save --consumed-learn "45,50"` (and the warm
|
|
1689
|
-
`plan_save` tool's `consumed_learn` array param) populate `plan.PlanHeader.consumed_learn` +
|
|
1690
|
-
`plan.PlanRef.consumed_learn` (parsed to a sorted unique `tuple[str, ...]` of opaque string ids
|
|
1691
|
-
— §8.21; only empty tokens are dropped — there is no int parse). The warm param decode
|
|
1692
|
-
(`idArrayParam`) accepts strings and coerces bare numbers via `String()` (the learn-docs
|
|
1693
|
-
guidance renders bare numeric ids on GitHub). This persists which `perk:learn` issues the docs
|
|
1694
|
-
plan consolidates; non-factory
|
|
1695
|
-
plans omit it. Because the read-only factory saves via the `/plan-save` *command* (which forwards
|
|
1696
|
-
only `{plan, title}`), `plan-save` also recovers `consumed_learn` from the run's handoff
|
|
1697
|
-
(`_consumed_learn_from_handoff`, #102) when the flag is absent — see §8.2's handoff-carrier note.
|
|
1698
|
-
- **On-land consume (Mechanical, deterministic).** `pr_land_cmd._consume_learn_on_land(*, plan_ref,
|
|
1699
|
-
repo_root) -> LearnConsumeUpdate{ closed, skipped_reason }` reads `plan_ref.consumed_learn` and
|
|
1700
|
-
`close_and_label_consolidated` for each issue — **fail-open, never raises, never changes the land
|
|
1701
|
-
result** (mirrors `_reconcile_objective_on_land`). Each issue is closed **independently** (#102
|
|
1702
|
-
per-issue isolation): one bad issue (already-deleted / transient infra error) is logged
|
|
1703
|
-
loud-but-non-fatal and rolled into a `failed: #a, #b` `skipped_reason` while the rest still close.
|
|
1704
|
-
`skipped_reason` ∈ `no_consumed_learn` / `bad_consumed_learn` / `failed: …` / `error: <exc>`.
|
|
1705
|
-
Called in `_pr_land_impl`'s non-dry-run branch after `set_marker(PENDING_LEARN)` and the objective
|
|
1706
|
-
reconcile; the dry-run branch sets an inert `LearnConsumeUpdate((), "dry_run")`. `_result_to_dict`
|
|
1707
|
-
emits `"learn": { closed, skipped_reason }`; `_render_human` adds a `consolidated learn issue(s) X
|
|
1708
|
-
into docs/learned` line when non-empty, plus a `⚠ learn consume incomplete: <reason>` line for any
|
|
1709
|
-
non-benign skip (everything except `no_consumed_learn`/`dry_run`). The warm `extension/doors/land.ts`
|
|
1710
|
-
surfaces `learn.closed` in a `Closed N learn issue(s) … into docs/learned` line and a
|
|
1711
|
-
`Warning: learn consume incomplete — <reason>` line for the same non-benign skips. Closing already excludes a consumed issue from the next `state=open` gather;
|
|
1712
|
-
the `perk:consolidated` label is the durable/queryable record.
|
|
1713
|
-
- **The docs surface (plan-maintained, never `init`-managed).** `docs/learned/<category>/*.md`
|
|
1714
|
-
carries light frontmatter (`title` + `read_when`); `docs/learned/index.md` is the standalone full
|
|
1715
|
-
catalog; `.pi/APPEND_SYSTEM.md` (Pi's project-scoped system-prompt append, ambient on every
|
|
1716
|
-
session) holds the **compressed** routing index — the realization of the PRIOR_ART §6
|
|
1717
|
-
"compressed index must be ambient" finding (a retrieval-tier index is too brittle). Both index
|
|
1718
|
-
layers are refreshed **by `/learn-docs` plans**, never by `perk init` (and neither path is
|
|
1719
|
-
gitignored — they are committed). As of node 6.1 a `/learn-docs` plan regenerates both index
|
|
1720
|
-
layers by running `perk learn docs-sync` (from each doc's frontmatter) — never by hand. erk's
|
|
1721
|
-
remaining heavier machinery (tripwire generation, per-category auto-indexes, multi-agent session
|
|
1722
|
-
preprocessing) is deliberately deferred.
|
|
1723
|
-
- **The judgment layer** is `skills/perk-learn-docs/SKILL.md`: read the inbox as untrusted DATA →
|
|
1724
|
-
**verify placement** (the knowledge-placement hierarchy, emitting a `SHOULD_BE_CODE` follow-up
|
|
1725
|
-
step when a doc-destined learning belongs in code/comment/docstring/schema/user-docs) → cluster by
|
|
1726
|
-
cross-cutting theme → `docs/learned/<category>/` placement (cleanup-first) → author a bounded docs
|
|
1727
|
-
plan with a `## Steps` list (routing regenerated via `docs-sync`) → `plan_save` with
|
|
1728
|
-
`consumed_learn`; plus the ported content-quality rules (cross-cutting insight only, explain *why*
|
|
1729
|
-
not *what*, the One Code Rule / source pointers). The sibling `skills/perk-learn-code/SKILL.md` is
|
|
1730
|
-
the code-routing curator for the pre-stamped `SHOULD_BE_CODE` learnings (see §8.35, node 7.1).
|
|
1177
|
+
sets it** (after a successful merge) — **except for a learn-docs plan** (non-empty
|
|
1178
|
+
`consumed_learn`), which is exempt from the land→learn cycle entirely (no marker;
|
|
1179
|
+
`learn_state: skipped` is stamped instead; the envelope's `pending_learn` reports which arm
|
|
1180
|
+
ran) — **`learn` clears it**. While present it signals the
|
|
1181
|
+
land→learn cycle is open and the worktree is not yet releasable. **Since §8.36** the marker is
|
|
1182
|
+
demoted to cache/friction-semaphore: the canonical post-merge learn state lives on the
|
|
1183
|
+
plan-header `learn_state` field, and the marker is only the local retry signal + the legacy
|
|
1184
|
+
resolution fallback (and the `worktree wipe` guard).
|
|
1185
|
+
|
|
1186
|
+
### Objective storage + the land-path reconciliation (compact)
|
|
1187
|
+
|
|
1188
|
+
The objective tier's full storage contract lives in **§8.24** (the `ObjectiveStore` seam); the
|
|
1189
|
+
mechanics live in `src/perk/objective/` + `src/perk/backends/github/objectives.py`, the land-path
|
|
1190
|
+
handlers in `src/perk/cli/commands/pr/land_cmd.py`. The gateway-level facts:
|
|
1191
|
+
|
|
1192
|
+
- **Storage blocks (perk-namespaced, schema 1).** An objective is an issue + first comment:
|
|
1193
|
+
`objective-header` (issue body — compact, queryable: `{ run_id, created, objective_comment_id,
|
|
1194
|
+
status, base }`), `objective-roadmap` (issue body — the **canonical** flat-node YAML
|
|
1195
|
+
frontmatter: `{ schema_version: "1", nodes: [ { id, slug, description, status, pr, depends_on?,
|
|
1196
|
+
comment? } ] }`; phase membership derives from the ID prefix), and `objective-body` (first
|
|
1197
|
+
comment — the human-readable rendered roadmap table, marker-bounded and deterministically
|
|
1198
|
+
re-rendered from the frontmatter, + prose in a marker-bounded **Reconcilable** region).
|
|
1199
|
+
- **Explicit-status-only.** A node's `status` is **never inferred from a PR column** —
|
|
1200
|
+
`update_node` takes `status` verbatim or preserves it; setting `pr` never changes `status`.
|
|
1201
|
+
- **Two-step create.** `create_objective_issue` is idempotency-check → lazy `perk:objective`
|
|
1202
|
+
label → compose body (`objective-header` with `objective_comment_id: null` +
|
|
1203
|
+
`objective-roadmap`) → POST issue → POST `objective-body` comment (capturing its id) →
|
|
1204
|
+
**backfill** `objective_comment_id` into the header.
|
|
1205
|
+
- **The land path is Mechanical + fail-open.** `perk pr land` auto-marks the node(s) backlinked
|
|
1206
|
+
to the just-merged plan `done` (non-audited by design — the audit gate is the model-tool
|
|
1207
|
+
boundary only, §8.3), checks completeness locally over the post-mark node list and
|
|
1208
|
+
**closes the objective when complete** (idempotent on re-land), and **consumes the
|
|
1209
|
+
`consumed_learn` issues** (`close_and_label_consolidated`, per-issue isolation — one bad issue
|
|
1210
|
+
never blocks the rest). All three are fail-open on expected store/backend failures and never
|
|
1211
|
+
change the land result; the warm `/land` surfaces the outcomes and auto-drives the Reconcilable
|
|
1212
|
+
pass (§8.28).
|
|
1213
|
+
|
|
1214
|
+
State key (registry vocabulary): `github.objective` (the objective storage); `github.learn` (the
|
|
1215
|
+
learn issues).
|
|
1731
1216
|
|
|
1732
1217
|
## §8.5 · The `init` machine surface (T5; cli-vs-pi §3.2)
|
|
1733
1218
|
|
|
@@ -1833,7 +1318,9 @@ missing = `warn`) · `github` (auth/access; non-fatal `warn`) ·
|
|
|
1833
1318
|
labels — §8.21) · `runner` (remote-runner prereqs; report-only, non-fatal — §8.16) ·
|
|
1834
1319
|
`package` (settings wiring + perk-package ref reconcile + the `extension-install` install-ownership
|
|
1835
1320
|
check + the `required-perk-version` managed check over the committed `.perk/required-perk-version`
|
|
1836
|
-
pin + the report-only `cli-version` CLI-vs-repo-pin warning (warn, never fail)
|
|
1321
|
+
pin + the report-only `cli-version` CLI-vs-repo-pin warning (warn, never fail) + the report-only
|
|
1322
|
+
`resource-overrides` probe over pi resource overrides that touch perk's own resources (warn, never
|
|
1323
|
+
fail, no `--fix` arm — §8.6a);
|
|
1837
1324
|
`--fix` also migrates a former git-clone consumer forward by removing the orphaned clone — §8.6a) ·
|
|
1838
1325
|
`repository` (gitignore/agents blocks + config present/valid) ·
|
|
1839
1326
|
`registry` (the registry self-check) · `skills` (the skills-CLI manifest fragment + the
|
|
@@ -1860,12 +1347,28 @@ Keeping a consumer's pi-loaded perk extension runnable rests on two invariants:
|
|
|
1860
1347
|
perk's own npm identity is version-reconciled; the borrowed npm packages stay unpinned/append-only
|
|
1861
1348
|
(distinguished by `_npm_name` identity vs `_npm_name(NPM_PACKAGE)`), and a user's other packages
|
|
1862
1349
|
are never in the desired set so they stay untouched/append-only. The in-body migration strips a
|
|
1863
|
-
repo's legacy **`git:` perk** entry (any ref,
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
`
|
|
1868
|
-
|
|
1350
|
+
repo's legacy **`git:` perk** entry (any ref, **any entry form** — by
|
|
1351
|
+
`_package_identity == GIT_PACKAGE`, covering a user-rewritten object-form entry) so the flip from
|
|
1352
|
+
the old git wiring converges; a user's unrelated `git:` packages are preserved. **Presence is
|
|
1353
|
+
computed by identity across ALL entry forms** (string entries and pi's object-form
|
|
1354
|
+
`{ "source": <spec>, **filter }` shape alike, via `_entry_spec`) — pi's `pi config -l` flow
|
|
1355
|
+
rewrites entries to object form to filter resources, and an unrecognized object-form entry would
|
|
1356
|
+
otherwise be duplicate-appended (latent settings corruption). When perk's own identity exists in
|
|
1357
|
+
object form, the entry's `source` is reconciled to the desired pin **in place, preserving the
|
|
1358
|
+
user's filter keys byte-for-byte**; when both forms share perk's identity, the object-form entry
|
|
1359
|
+
is canonical (it carries user data perk cannot reconstruct — the filters) and the duplicates are
|
|
1360
|
+
dropped. Invariant 2, re-worded: **perk never *creates* an object-form entry for its own
|
|
1361
|
+
package**; it may update the `source` pin inside a user-created one. This rides the existing
|
|
1362
|
+
`settings-wiring` `ManagedConvergence` — version-pin drift becomes a `settings-wiring` **fail**
|
|
1363
|
+
that `--fix` repairs, with **no new doctor wiring**. The separate report-only
|
|
1364
|
+
**`resource-overrides`** doctor check (group `package`, offline, **warn at worst, never fail, no
|
|
1365
|
+
`--fix` arm**) names what convergence deliberately leaves alone: an object-form perk entry's
|
|
1366
|
+
filter keys (filtering perk's own extension silently breaks every interactive stage session),
|
|
1367
|
+
and any `-`/`!` disable pattern in the top-level `extensions`/`skills`/`prompts`/`themes`
|
|
1368
|
+
override arrays whose body mentions `@mgiles/perk` or a perk skill name (an honest substring
|
|
1369
|
+
heuristic — perk does not reimplement pi's filter-pattern semantics). The only conceivable
|
|
1370
|
+
`--fix` would strip user-chosen filters — hostile; the remediation tells the operator to review
|
|
1371
|
+
via `pi config -l` instead.
|
|
1869
1372
|
- **perk owns the `@mgiles/perk` *npm install*, superseding pi's `git:`-clone extension lifecycle
|
|
1870
1373
|
(#812).**
|
|
1871
1374
|
Node 2.2 flipped perk's own extension to a pinned `npm:@mgiles/perk@{__version__}` settings entry; this
|
|
@@ -1966,6 +1469,9 @@ The division of labor: **`perk doctor` checks the disk** (files converged); **`/
|
|
|
1966
1469
|
checks the prompt** (the converged context actually reached the model via Pi's
|
|
1967
1470
|
`getSystemPromptOptions()`, available only on a command context). selfcheck logs only derived
|
|
1968
1471
|
booleans/counts — never the raw prompt text (the options expose the full system prompt).
|
|
1472
|
+
`/perk-selfcheck` additionally reports a per-surface payload **census** (append prompt, context
|
|
1473
|
+
files, skills section, tool definitions, perk-injected `custom_message` branch context) — still
|
|
1474
|
+
derived identifiers/counts/chars only, never prompt text; report-only (no gate).
|
|
1969
1475
|
|
|
1970
1476
|
The `.perk/workflow/.perk-t3.json` diagnostics sentinel additionally records **`run_mode`** — Pi's
|
|
1971
1477
|
`ctx.mode` (`tui`/`rpc`/`json`/`print`) — distinct from the workflow **`mode`** (`read-only`/
|
|
@@ -1997,12 +1503,24 @@ stage. This keeps the default set free of redundant stage+command pairs for one
|
|
|
1997
1503
|
|
|
1998
1504
|
**Binding model — `{ trigger, skill, mode }`:** `trigger` is the `<kind>:<id>` string; `skill` is a
|
|
1999
1505
|
skill name (a `skills/*/` dir name today); `mode ∈ {nudge, transclude}` is **per-binding** —
|
|
2000
|
-
`nudge` delivers a short pointer to follow the named skill
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
1506
|
+
`nudge` delivers a short pointer to follow the named skill — the pointer line carries the skill's
|
|
1507
|
+
read path (`.agents/skills/<skill>/SKILL.md`) unconditionally, so it works even for skills hidden
|
|
1508
|
+
from the ambient system prompt — `transclude` inlines the skill body. The same skill may be a nudge
|
|
1509
|
+
at one trigger and a transclude at another.
|
|
1510
|
+
|
|
1511
|
+
**Skill visibility (prompt-hidden workflow skills):** perk's 14 workflow skills — every shipped
|
|
1512
|
+
`perk-*` skill except `perk-expert` — ship `disable-model-invocation: true` in their frontmatter,
|
|
1513
|
+
so pi excludes them from every session's ambient `<available_skills>` system-prompt listing. This
|
|
1514
|
+
scopes **visibility only**: the on-disk body, its `references/` routing, and the `/skill:<name>`
|
|
1515
|
+
command all remain, and the worktree mirror keeps delivering every skill's files. Each door reaches
|
|
1516
|
+
its skill through the delivered pointer (a binding nudge or a seed-template line), which is why the
|
|
1517
|
+
pointer carries the read path. `perk-expert` (description-discovery IS its routing — no binding
|
|
1518
|
+
points at it) and `ast-grep` (general-purpose; nudged by the managed AGENTS.md block) stay ambient.
|
|
1519
|
+
Older pi ignores unknown frontmatter keys, so the flag degrades gracefully (the skill simply stays
|
|
1520
|
+
visible).
|
|
1521
|
+
|
|
1522
|
+
**Shipped default set (all `nudge` — the pointer carries the read path, so it suffices even though
|
|
1523
|
+
perk's workflow skills are prompt-hidden; `transclude` exists for the user-binding case):**
|
|
2006
1524
|
|
|
2007
1525
|
| trigger | skill | mode |
|
|
2008
1526
|
|---|---|---|
|
|
@@ -2013,9 +1531,14 @@ skills, so a pointer suffices; `transclude` exists for the user-binding case):**
|
|
|
2013
1531
|
| `stage:address` | `perk-address` | `nudge` |
|
|
2014
1532
|
| `stage:learn` | `perk-learn` | `nudge` |
|
|
2015
1533
|
| `command:objective-reconcile` | `perk-objective-reconcile` | `nudge` |
|
|
1534
|
+
| `command:objective-replan` | `perk-objective-replan` | `nudge` |
|
|
2016
1535
|
| `command:learn-docs` | `perk-learn-docs` | `nudge` |
|
|
2017
1536
|
| `command:learn-code` | `perk-learn-code` | `nudge` |
|
|
2018
1537
|
| `command:pr-review` | `perk-pr-review` | `nudge` |
|
|
1538
|
+
| `command:pr-review-terminal` | `perk-pr-review-terminal` | `nudge` |
|
|
1539
|
+
| `command:pr-review-browser` | `perk-pr-review-browser` | `nudge` |
|
|
1540
|
+
| `command:skills-create` | `perk-skill-author` | `nudge` |
|
|
1541
|
+
| `command:skills-refine` | `perk-skill-author` | `nudge` |
|
|
2019
1542
|
|
|
2020
1543
|
**Validation depth (shape-only, registry-free):** the loaders/validators check that
|
|
2021
1544
|
`schema_version == 1` (else a structural load error), each binding has a non-empty `skill`, a
|
|
@@ -2055,7 +1578,9 @@ Mechanism A instead. The launch trigger is `stage:<stage.id>` by default; the `l
|
|
|
2055
1578
|
`binding_trigger` parameter, so it never fires `stage:plan`. `objective-reconcile` is a non-launching
|
|
2056
1579
|
**worker** (it rewrites the objective body, no initial prompt), so `command:objective-reconcile` has
|
|
2057
1580
|
**no cold delivery surface** — it fires only at the warm door. `nudge` renders a ``Follow the
|
|
2058
|
-
`<skill>` skill
|
|
1581
|
+
`<skill>` skill (read `.agents/skills/<skill>/SKILL.md`).`` pointer line (the read path is
|
|
1582
|
+
unconditional — no frontmatter read at render time; it is identical for visible and prompt-hidden
|
|
1583
|
+
skills alike); `transclude` inlines `.agents/skills/<skill>/SKILL.md` with its YAML
|
|
2059
1584
|
frontmatter stripped, degrading to the nudge pointer with a **loud-but-non-fatal** warning when the
|
|
2060
1585
|
file is absent/unreadable. Resolver `issues` and delivery `warnings` are surfaced loud-but-non-fatal
|
|
2061
1586
|
on every launch and never block it. Target-existence remains **`doctor`** (Node 3.1).
|
|
@@ -2080,10 +1605,15 @@ The **cross-plane dedup marker is the render header itself** — `BINDING_HEADER
|
|
|
2080
1605
|
byte-for-byte to the cold `_HEADER` (Python) by a literal test in **both** planes. The cold door
|
|
2081
1606
|
already puts `stage:<id>` bindings in a cold-launched session's **initial prompt**, and
|
|
2082
1607
|
`before_agent_start` fires for that same session, so Mechanism A injects **iff** a launched `stage`
|
|
2083
|
-
exists, the resolved render is non-empty,
|
|
2084
|
-
already carries `BINDING_HEADER` (the cold prompt OR a prior warm inject)
|
|
1608
|
+
exists, the resolved render is non-empty, no entry on `ctx.sessionManager.getBranch()`
|
|
1609
|
+
already carries `BINDING_HEADER` (the cold prompt OR a prior warm inject), **and** the submitting
|
|
1610
|
+
turn's prompt (`event.prompt`) does not carry it either. The prompt scan is load-bearing on the
|
|
1611
|
+
launch turn: at `before_agent_start` the just-submitted prompt is **not yet** on the branch, so the
|
|
1612
|
+
branch scan alone missed the cold seed on that turn and double-delivered (the fixed hole). The
|
|
1613
|
+
injected custom and the
|
|
2085
1614
|
cold prompt both carry the header → idempotent across turns/reloads; after compaction drops the
|
|
2086
|
-
original the header disappears and it **re-delivers** (its ongoing value
|
|
1615
|
+
original the header disappears and it **re-delivers** (its ongoing value — later prompts don't
|
|
1616
|
+
carry the header, so the prompt scan stays inert there). Mechanism B is a one-shot
|
|
2087
1617
|
`sendUserMessage` suffix at an invocation distinct from any cold launch, so it cannot auto-double. A
|
|
2088
1618
|
narrower-than-`planMode` `context` strip removes a **stale** `perk:binding-context` custom (stage
|
|
2089
1619
|
changed / overlay removed) while **never** stripping a user message that carries the header (a cold
|
|
@@ -2101,11 +1631,12 @@ so it uses that path **only** (no self-repo fallback).
|
|
|
2101
1631
|
check (`perk/convergence/doctor/checks.py::_bindings_check`) over the **full resolved set** (`resolve_bindings(user,
|
|
2102
1632
|
defaults=load_bindings().bindings)`). It surfaces the resolver's dropped-user-binding `issues` plus,
|
|
2103
1633
|
per delivered binding: **skill-presence** — the skill is installed under `.agents/skills/<name>/
|
|
2104
|
-
SKILL.md
|
|
2105
|
-
|
|
1634
|
+
SKILL.md` (`bindings.is_skill_installed(root, skill)`), **strict on the delivery read path** in
|
|
1635
|
+
self-repo and consumer trees alike — the only path warm injection reads, so the committed
|
|
1636
|
+
self-repo `skills/<name>/SKILL.md` layout never substitutes (it once did, hiding a dangling
|
|
1637
|
+
injected pointer — the R3 blind spot). perk's own `perk-*` skills are
|
|
2106
1638
|
delivered into `.agents/skills/` by the `skills` CLI in **both** self-repo and consumer trees (the
|
|
2107
|
-
Pi package no longer declares `pi.skills`, so Pi never discovers the package `skills/` dir)
|
|
2108
|
-
`skills/<name>` fallback covers only the window before `skills update --sync` has run — and
|
|
1639
|
+
Pi package no longer declares `pi.skills`, so Pi never discovers the package `skills/` dir) — and
|
|
2109
1640
|
**target-existence**
|
|
2110
1641
|
— `stage:<id>` must be a `registry.load_registry().stage_ids()` member, and `command:<id>` must be in
|
|
2111
1642
|
`DELIVERABLE_COMMAND_TARGETS = {objective-reconcile, learn-docs, learn-code, …}` (the only command triggers perk's
|
|
@@ -2146,7 +1677,21 @@ dangling-pointer warning, which stays a last-resort signal).
|
|
|
2146
1677
|
silent pass); (b) the perk fragment (`.agents/manifest.d/perk.yaml`) exists but
|
|
2147
1678
|
`.agents/manifest.yaml` does not (`skills init` failed or never ran, so `skills update --sync`
|
|
2148
1679
|
can never run); (c) any `MANAGED_SKILL_NAMES` name (perk-authored + the required external
|
|
2149
|
-
skills) not installed per `bindings.is_skill_installed`.
|
|
1680
|
+
skills) not installed per `bindings.is_skill_installed` (strict on `.agents/skills/`).
|
|
1681
|
+
Consumers fail (c) plainly. The **self-repo** classifies a missing delivery further — the
|
|
1682
|
+
committed `skills/` layout is never an ok-level substitute. The classification applies to
|
|
1683
|
+
**perk-authored names only** (`PERK_SKILLS`); a missing required **external** skill
|
|
1684
|
+
(`REQUIRED_EXTERNAL_SKILLS` — upstream-sourced, never in the committed `skills/` dir) fails
|
|
1685
|
+
plainly ("required external skill(s) not delivered"), never misread as uncommitted. For
|
|
1686
|
+
perk-authored names: committed AND present on the skills
|
|
1687
|
+
source ref as locally known (`origin/main`, ONE `git ls-tree` probe, shelled only when a
|
|
1688
|
+
perk-authored name is missing-and-committed) → **fail** (delivered set stale — re-sync fixes it
|
|
1689
|
+
now); committed but not on the local
|
|
1690
|
+
`origin/main` → **warn** (the documented pre-merge first appearance — deliverable after merge +
|
|
1691
|
+
re-sync; the local remote-tracking ref can lag, so a merged-but-unfetched skill degrades to this
|
|
1692
|
+
warn, never a false fail and never silent — the warn text carries the fetch remediation);
|
|
1693
|
+
committed nowhere → **fail**. A `GitError` on the probe degrades to `warn` naming the missing
|
|
1694
|
+
skills (no silent pass).
|
|
2150
1695
|
- **`doctor --fix`:** the repair-gesture sync's failure message is carried on
|
|
2151
1696
|
`DoctorReport.fix_errors` (rendered loudly; `fix_errors` in the `--json` report — §8.6); the
|
|
2152
1697
|
post-fix re-verify keeps the failing `skills-delivery` check so the exit code reflects the
|
|
@@ -2236,7 +1781,20 @@ footer is governed **exclusively** by `[providers] footer` — no footer outcome
|
|
|
2236
1781
|
`web`, **`package: "npm:pi-web-access"`** — the first non-null-package default — / `adapter: null` /
|
|
2237
1782
|
`default: true`) plus two **real** foreign web providers `ollama-web-search` (→ `npm:@ollama/pi-web-search`)
|
|
2238
1783
|
and `juicesharp-web-tools` (→ `npm:@juicesharp/rpiv-web-tools`) make the **web** seam a **third interface
|
|
2239
|
-
seam** (vacate-only, `adapter: null`) — see the web status note in contracts-history.md §8.10.
|
|
1784
|
+
seam** (vacate-only, `adapter: null`) — see the web status note in contracts-history.md §8.10. There is **no review
|
|
1785
|
+
seam**: a sixth seam (the DISPATCH posture — the selection picked which review surface a
|
|
1786
|
+
dispatching `/review` door drove) existed and is **retired** — the two surface-named review
|
|
1787
|
+
doors (`/pr-review-terminal` = hunk, `/pr-review-browser` = plannotator; §8.4) ARE the
|
|
1788
|
+
selection now (the command is the surface pick); the full seam history lives in the review-seam
|
|
1789
|
+
status note in contracts-history.md §8.10. What remains outside the seam machinery: the hunk
|
|
1790
|
+
CLI is an **external CLI** (npm `hunkdiff`, binary `hunk`), not a Pi package — `perk init`
|
|
1791
|
+
(verified) attempts a best-effort `npm install -g hunkdiff` **unconditionally** when the binary
|
|
1792
|
+
is absent (failure → a warning, never fatal), and doctor owns the warn-level **`review-cli`**
|
|
1793
|
+
check (always probes PATH, verify-gated; `perk doctor --fix` retries the install). The
|
|
1794
|
+
plannotator package `npm:@plannotator/pi-extension` is desired via the **plan** seam's
|
|
1795
|
+
`plannotator-plan` entry alone; the desired-**union** convergence mechanism stays generic
|
|
1796
|
+
(dict-keyed across every resolved seam) but its cross-seam instance retired with the seam. The
|
|
1797
|
+
**default** path (the reference providers) is unaffected and is the hard guarantee.
|
|
2240
1798
|
|
|
2241
1799
|
**`cache.plan-ref.provider` is the issue backend, not the seam id.** Despite
|
|
2242
1800
|
`docs/design/provider-contract.md` framing the `cache.plan-ref` `provider` field as the plan
|
|
@@ -2264,7 +1822,25 @@ Both planes parse it raw (`perk/substrate/config.py` → `Config.providers`; `ex
|
|
|
2264
1822
|
wins (standard local-override precedence). The pure resolver
|
|
2265
1823
|
`perk.substrate.providers.resolve_providers(selection, providers)` returns `ResolvedProviders { plan, todo,
|
|
2266
1824
|
askuser, footer, web, issues }`: an absent key falls back to the default **silently**; an unknown id or a seam mismatch
|
|
2267
|
-
falls back to the default and records a **loud-but-non-fatal** `Issue`.
|
|
1825
|
+
falls back to the default and records a **loud-but-non-fatal** `Issue`. **The TS resolver is
|
|
1826
|
+
per-seam fail-open on a missing default** (a named cross-plane difference): when the bundled
|
|
1827
|
+
catalog carries no `default: true` entry for a seam, `resolveProviders` resolves that seam to a
|
|
1828
|
+
synthesized built-in reference provider (the `REFERENCE_FALLBACKS` map, built from the exported
|
|
1829
|
+
reference-id constants) and appends a loud-but-non-fatal issue — never a throw — so one seam's
|
|
1830
|
+
catalog gap can never collapse another seam's resolution. The warm plane is the long-lived,
|
|
1831
|
+
skew-prone one: an in-memory extension reading a live-edited `shared/providers.yaml` across a
|
|
1832
|
+
seam add/retire (either direction) hits exactly this gap; the four TS resolution call sites
|
|
1833
|
+
(plan/todo/askuser/footer) keep their reference-id fallback catches but log the error loudly
|
|
1834
|
+
(`consoleCapture` routes it into the session log). The Python `_require_default` **stays
|
|
1835
|
+
strict**: Python is the authoritative validator and its processes are short-lived, reading the
|
|
1836
|
+
wheel-bundled `perk/_shared`, so code/file skew cannot arise there. Per-event freshness of the
|
|
1837
|
+
resolution reads is deliberately kept (config edits apply without relaunch). The retired `review` key
|
|
1838
|
+
gets the **legacy-tripwire treatment** in the Python reader (`ProvidersTable`'s
|
|
1839
|
+
`model_validator`, the `_reject_legacy_tables` precedent): a present `[providers] review` key
|
|
1840
|
+
**hard-fails config load** with a pointer naming the two surface doors and the removal —
|
|
1841
|
+
deliberate hard break, no dual-read, no `doctor --fix` arm (diagnostics, not compat). The TS
|
|
1842
|
+
reader needs no twin — it silently ignores the key (the documented fail-safe posture, pinned by
|
|
1843
|
+
test on both planes).
|
|
2268
1844
|
|
|
2269
1845
|
**`perk init` two-directional settings wiring:** provider wiring composes on top of the static
|
|
2270
1846
|
`_desired_packages` (perk + `BORROWED_PACKAGES`: `npm:@tombell/pi-diff`,
|
|
@@ -2280,8 +1856,9 @@ Unlike today's append-only convergence, provider wiring is **two-directional**:
|
|
|
2280
1856
|
existing `packages` entry whose identity is provider-managed but **not** desired (a deselect), and
|
|
2281
1857
|
**adds** each desired foreign package in **object form** (`{ "source": <spec>, **package_filter }`,
|
|
2282
1858
|
omitting the filter keys when absent). Entries outside the managed set (perk's own, borrowed, user)
|
|
2283
|
-
are never touched. **perk
|
|
2284
|
-
|
|
1859
|
+
are never touched. **perk never filters its own package and never *creates* an object-form entry
|
|
1860
|
+
for it** (Invariant 2, re-worded — §8.6a: a user may rewrite perk's entry to object form via
|
|
1861
|
+
`pi config -l`; perk recognizes it and reconciles only its `source` pin, preserving the filters). **Resolved ambiguity (Node 1.3 step 4):** any `packages`
|
|
2285
1862
|
entry whose identity matches a provider's `package` is treated as **provider-managed** (removable
|
|
2286
1863
|
when deselected); hand-adding a provider's package *without* selecting it is unsupported — a user
|
|
2287
1864
|
who wants that package selects the provider via `[providers]`. The retired `@tombell/pi-plan` /
|
|
@@ -2303,10 +1880,12 @@ sessions by converging into the committed `.pi/settings.json` `compaction` objec
|
|
|
2303
1880
|
natively at session boot). It is **Python-plane-only** — the extension never reads it (pi consumes
|
|
2304
1881
|
`settings.json` itself), so `extension/substrate/config.ts` is untouched. Three snake_case keys map to pi's
|
|
2305
1882
|
camelCase `settings.json` keys: `enabled`→`enabled`, `reserve_tokens`→`reserveTokens`,
|
|
2306
|
-
`keep_recent_tokens`→`keepRecentTokens`. Validation
|
|
2307
|
-
`
|
|
2308
|
-
|
|
2309
|
-
`
|
|
1883
|
+
`keep_recent_tokens`→`keepRecentTokens`. Validation goes through a pydantic table model
|
|
1884
|
+
(`perk/substrate/config.py::CompactionTable`, read via `load_committed_compaction`): an ill-typed
|
|
1885
|
+
or non-positive value raises a `ConfigError` surfaced by doctor's `config` check (init convergence
|
|
1886
|
+
defers to that check); `reserve_tokens = true` is rejected explicitly (the bool-is-int gotcha —
|
|
1887
|
+
it never reads as 1); absent keys still fall to pi defaults. The convergence composes inside
|
|
1888
|
+
`_converge_settings` (`load_committed_compaction` +
|
|
2310
1889
|
`perk/convergence/init/settings.py::_converge_compaction`), so it stays in the `settings-wiring` `ManagedConvergence` —
|
|
2311
1890
|
`doctor` dry-runs/fixes it for free, **no** new check. **Committed-only read** (the deliberate
|
|
2312
1891
|
divergence from `[providers]`' overlaid `load_config` read): `[compaction]` is read from committed
|
|
@@ -2318,9 +1897,70 @@ is present, its mapped keys merge over any existing `settings.json` `compaction`
|
|
|
2318
1897
|
win; unrelated hand-added keys survive; unspecified keys are left to pi's defaults); when
|
|
2319
1898
|
**absent**, `settings.json` is left untouched (perk cannot prove ownership of a bare `compaction`
|
|
2320
1899
|
key, so removal is unsafe — removing `[compaction]` from `config.toml` leaves a stale block to clean
|
|
2321
|
-
up by hand). A malformed-TOML error defers to the config check (treated as
|
|
2322
|
-
`_converge_provider_packages`). perk's headless worker
|
|
2323
|
-
|
|
1900
|
+
up by hand). A malformed-TOML or ill-typed-value error defers to the config check (treated as
|
|
1901
|
+
empty here, mirroring `_converge_provider_packages`). perk's headless worker
|
|
1902
|
+
(`compaction: { enabled: false }`) and the
|
|
1903
|
+
objective threshold compaction (`[compaction] objective_threshold`) are orthogonal and unaffected.
|
|
1904
|
+
|
|
1905
|
+
**`[models]` → `settings.json` default-model convergence (init-owned):** the `[models]` namespace
|
|
1906
|
+
in `.perk/config.toml` (`default` + `thinking`, either alone; the `stages`/`subagents` sub-tables
|
|
1907
|
+
are runtime-read siblings) sets the **repo-default model + thinking**
|
|
1908
|
+
by converging into pi's **top-level** `settings.json` keys `defaultProvider` / `defaultModel` /
|
|
1909
|
+
`defaultThinkingLevel` (scalars, not a nested dict — the structural difference from
|
|
1910
|
+
`[compaction]`), which pi reads natively at session boot: perk cold doors, plain `pi`, and the
|
|
1911
|
+
headless worker (local **and** remote — the SDK session path resolves the same keys from the
|
|
1912
|
+
checkout's disk-layered settings, so the worker's model becomes configurable here). It is
|
|
1913
|
+
**Python-plane-only** — the extension never reads it (pi consumes `settings.json` itself), so
|
|
1914
|
+
`extension/substrate/config.ts` reads only the runtime sub-tables. pi's settings default is an
|
|
1915
|
+
**exact** provider+id lookup, so `default` must be `provider/id`; perk splits on the **first** `/`
|
|
1916
|
+
(openrouter ids keep their inner slashes). A `:thinking` suffix on `default` is accepted and split
|
|
1917
|
+
at convergence under
|
|
1918
|
+
the **pi-subagents-shared suffix rule**: the last-colon segment is a thinking level **only when**
|
|
1919
|
+
it is in pi's vocabulary (ollama-style tags like `llama3:70b` stay part of the id); an explicit
|
|
1920
|
+
`thinking` key wins over a differing suffix (doctor's `models` check warns on the conflict).
|
|
1921
|
+
Validation goes through `perk/substrate/config.py::ModelsTable` (read via
|
|
1922
|
+
`load_committed_models` / `load_committed_models_table`) with a **hard-`ConfigError` posture**: an
|
|
1923
|
+
invalid `thinking` or a slash-less `default` never converges into the committed `settings.json` —
|
|
1924
|
+
init defers (converges everything else), and doctor's `_config_check` **fails** with the field
|
|
1925
|
+
path (the one committed-read probe in `_config_check`; the `[compaction]`/`[issues]` parse gaps
|
|
1926
|
+
keep their current owners). The convergence composes inside `_converge_settings`
|
|
1927
|
+
(`perk/convergence/init/settings.py::_converge_models`), so it stays in the `settings-wiring`
|
|
1928
|
+
`ManagedConvergence` (desired/observed portions fold the three keys — drift classifies like
|
|
1929
|
+
compaction drift; `doctor --fix` reconverges). **Committed-only read** (a `local.toml` `[models]`
|
|
1930
|
+
`default`/`thinking` is ignored) and **write-when-present / leave-when-absent per key**: an absent
|
|
1931
|
+
table touches nothing; removing it leaves the written keys to clean up by hand (perk cannot prove
|
|
1932
|
+
ownership of a bare settings key). Relatedly, `[models.subagents]` values are **blessed** to carry
|
|
1933
|
+
the same `:thinking` suffix (and pi-subagents' `inherit` sentinel — child inherits the parent
|
|
1934
|
+
session's model), resolved by pi-subagents on the per-call inline `model` override; doctor's
|
|
1935
|
+
warn-level `models` check flags suspicious suffixes (alphabetic-only last-colon segment outside
|
|
1936
|
+
the vocabulary) across `[models].default`, `[models.subagents]` values, and
|
|
1937
|
+
`[models.stages.<id>].model`. Resulting precedence — cold launch: explicit `perk <stage>
|
|
1938
|
+
--model/--thinking` > `[models.stages.<id>]` > `[models]`-converged settings default > pi's
|
|
1939
|
+
curated per-provider defaults > first authenticated model; subagents: `[models.subagents]`
|
|
1940
|
+
(optionally `…:level` / `inherit`) > agent frontmatter `model:` (the settings default never
|
|
1941
|
+
applies to perk's agents — frontmatter picks per-role economy).
|
|
1942
|
+
|
|
1943
|
+
**`subagents.disableBuiltins` convergence (init-owned):** perk converges the constant
|
|
1944
|
+
`"subagents": {"disableBuiltins": true}` into `.pi/settings.json` in **every** perk repo —
|
|
1945
|
+
**constant desired with no config read**, the deliberate divergence from `[compaction]`/`[models]`'s
|
|
1946
|
+
write-when-present shape: perk borrows pi-subagents as the delegation *engine only* and delivers
|
|
1947
|
+
its own `perk.*` agent defs, so the builtin agents are model-facing noise everywhere perk works.
|
|
1948
|
+
There is **no opt-out knob** (and none can be added under that spelling — the legacy `[subagents]`
|
|
1949
|
+
table remains a schema-v2 tripwire in `perk/substrate/config.py` that hard-fails toward
|
|
1950
|
+
`[models.subagents]`). It is **Python-plane-only** (pi-subagents consumes `settings.json` itself at
|
|
1951
|
+
session boot; `extension/substrate/config.ts` is untouched), a **merge-preserving single-key
|
|
1952
|
+
write** (only `disableBuiltins` is perk-owned — sibling keys in the user's `subagents` object
|
|
1953
|
+
survive byte-for-byte) with a **delta-gated change fragment** (an already-`true` key contributes no
|
|
1954
|
+
`report.changes` line — the genuine-delta rule; a constant desired would otherwise emit a phantom
|
|
1955
|
+
fragment on every run that changes anything else). The write composes inside `_converge_settings`
|
|
1956
|
+
(`perk/convergence/init/settings.py::_converge_subagents`), so it stays in the `settings-wiring`
|
|
1957
|
+
`ManagedConvergence` (doctor dry-runs/fixes it for free — no new check) and folds into the
|
|
1958
|
+
desired/observed settings portions like compaction (the observed twin reduces the live `subagents`
|
|
1959
|
+
dict to the `disableBuiltins` key; drift classifies normally). The sanctioned re-enable is a
|
|
1960
|
+
**project-settings** per-agent `subagents.agentOverrides.<name>.disabled: false` entry, which
|
|
1961
|
+
pi-subagents' `applyBuiltinOverrides` consults **before** the bulk flag and which perk's merge
|
|
1962
|
+
never touches; a **user-global** `~/.pi/agent/settings.json` re-enable does **not** work (the
|
|
1963
|
+
project bulk-disable is checked before user-scope overrides).
|
|
2324
1964
|
|
|
2325
1965
|
> **Interactive save discipline (as of Node 2.5 the present + `/plan-save` flow is
|
|
2326
1966
|
> FALLBACK-ONLY on every interactive path — perk-plan included):** the prior
|
|
@@ -2343,11 +1983,20 @@ objective threshold compaction (`[objective] compact_threshold`) are orthogonal
|
|
|
2343
1983
|
> list; the present + `/plan-save` (artifact-preferred, scrape-fallback) flow remains its
|
|
2344
1984
|
> explicit **fail-open** arm — including when `@tombell/pi-plan`'s own interactive `/plan`
|
|
2345
1985
|
> `setActiveTools` restriction hides `plan_draft`/`plan_review` from the tool set.
|
|
2346
|
-
> `savePlan()` / the `plan_save` tool / `/plan-save` are **untouched**.
|
|
2347
|
-
> **factory
|
|
2348
|
-
> **
|
|
1986
|
+
> `savePlan()` / the `plan_save` tool / `/plan-save` are **untouched**. No orchestrated
|
|
1987
|
+
> **factory flow** instructs an autonomous `plan_save` tool call any longer:
|
|
1988
|
+
> **objective-plan** is review-first as of #352 Node 3.1 — the
|
|
2349
1989
|
> approval-driven save recovers the node link from the `objective_node_claim` carrier, with
|
|
2350
|
-
> `plan_save`-with-both-ids demoted to the manual failsafe.
|
|
1990
|
+
> `plan_save`-with-both-ids demoted to the manual failsafe. The **learn factories**
|
|
1991
|
+
> (learn-docs/learn-code) are review-first too (their seeds + skills speak it): in the gated
|
|
1992
|
+
> read-only cold sessions the approval-driven save recovers `consumed_learn` from the cold
|
|
1993
|
+
> handoff carrier (→ §8.2), and `plan_save`-with-`consumed_learn` applies only where the tool
|
|
1994
|
+
> is active (warm read-write sessions — which write no handoff, so the explicit param is
|
|
1995
|
+
> load-bearing there). **replan** and **plan-from** are review-first too — their gated
|
|
1996
|
+
> read-only sessions land the save via `plan_review` approval (replan's approval-save updates
|
|
1997
|
+
> the existing plan in place via the `run_id` upsert — the original `run_id` rides the
|
|
1998
|
+
> session; plan-from's `adopt_from` link rides the handoff carrier), with `/plan-save` the
|
|
1999
|
+
> manual failsafe.
|
|
2351
2000
|
|
|
2352
2001
|
## §8.11 · The headless stage-drive worker contract (Node 1.2)
|
|
2353
2002
|
|
|
@@ -2368,8 +2017,8 @@ exactly as in a warm session (§8.4).
|
|
|
2368
2017
|
| `stage` | `"implement" \| "address"` | the only `doors.cold_remote: true` read-write stages (`shared/registry.yaml`) |
|
|
2369
2018
|
| `run_id` | ULID, present as `PERK_RUN_ID` in env | minted by positioning; the worker **inherits** it and never re-mints |
|
|
2370
2019
|
| handoff / plan-ref / plan-body | files under `<worktree>/.perk/workflow/` | materialized by positioning; the worker does not re-write them |
|
|
2371
|
-
| `initialPrompt` | string | re-derived by `initialPromptFor(stage, planRef)` — the TS twin of `perk/run/launch/prompts.py._implement_prompt`/`_address_prompt` (parity asserted reciprocally in `extension/worker/worker.test.ts` + `tests/test_worker_prompt_parity.py`); the
|
|
2372
|
-
| `model` + `auth` | `Model` + `AuthStorage`/`ModelRegistry` | explicit worker input, else env-var key resolution (`ANTHROPIC_API_KEY` etc., Gap 5); **no model ⇒ a fail-soft `failed`/`no_model` outcome, never a throw
|
|
2020
|
+
| `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 prompt carries **no skill-binding suffix** — the worker's bindings arrive via §8.9 Mechanism A (the extension's `before_agent_start` injection, which fires because the handoff records the stage and no branch entry carries `BINDING_HEADER`); the injected content is byte-identical to the cold door's prompt suffix (`tests/test_binding_render_parity.py`; the named mechanism difference is §8.38 row 2) |
|
|
2021
|
+
| `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**. The workerMain shim resolves an explicit `--model` flag through pi's `resolveCliModel` (CLI parity: fuzzy matching, `provider/pattern`, a `:thinking` suffix — `resolveWorkerModel`); a parsed thinking level rides the additive `DriveStageOptions.thinkingLevel` input, applied at session creation (absent ⇒ the settings default) |
|
|
2373
2022
|
| `budget` | `{ maxTurns, maxTokens, wallClockMs }` | worker input; the watchdog that drives abort (Gap 2) |
|
|
2374
2023
|
| `signal` | `AbortSignal` | external cancellation; OR'd with the budget watchdog |
|
|
2375
2024
|
|
|
@@ -2452,6 +2101,9 @@ subagent-under-worker smoke stays the carried risk below).
|
|
|
2452
2101
|
}
|
|
2453
2102
|
```
|
|
2454
2103
|
|
|
2104
|
+
`budget.tokens` counts **fresh work only** — assistant `input + output` per `turn_end`; cache
|
|
2105
|
+
reads/writes and provider `reasoning` breakdowns (subsets of `output` in pi-ai's normalization)
|
|
2106
|
+
are deliberately excluded from the sum.
|
|
2455
2107
|
`error.summary` is a short, model-free synthesis capped via the `route-don't-relay`/double-delivery
|
|
2456
2108
|
discipline (`capForModel`); the PR is extracted **directly from the captured terminal tool event**,
|
|
2457
2109
|
not a new Python `find-pr-for-branch` JSON command. Node 1.3 surfaces this outcome as the run-event
|
|
@@ -2461,10 +2113,10 @@ channel.
|
|
|
2461
2113
|
> **Open dependency (carried risk).** The `address` drive's seeded prompt instructs the model to
|
|
2462
2114
|
> spawn `perk.review-classifier` via the borrowed `pi-subagents` `subagent` tool. `pi-subagents`
|
|
2463
2115
|
> now loads in the worker from the managed settings `packages` list (Gap 4 above). The worker's
|
|
2464
|
-
> address prompt now also injects the configured classifier model when `[subagents]
|
|
2116
|
+
> address prompt now also injects the configured classifier model when `[models.subagents]
|
|
2465
2117
|
> review-classifier` is set in the worktree's `.perk/config.toml` (#196), as a per-call inline `model`
|
|
2466
2118
|
> override byte-identical to `_address_prompt`'s parity twin. The **subagent-under-worker live
|
|
2467
|
-
> smoke** stays
|
|
2119
|
+
> smoke** stays an open dependency **deferred to the Phase-3 `doctor workflow`**;
|
|
2468
2120
|
> Node 1.2 does not prove it.
|
|
2469
2121
|
|
|
2470
2122
|
## §8.12 · The structured run-event stream (Node 1.3)
|
|
@@ -2701,7 +2353,10 @@ so `init` writes them and `doctor` verifies/repairs them through the one shared
|
|
|
2701
2353
|
is checked out and hard-reset to the remote tip; when it does not (a fresh, never-implemented
|
|
2702
2354
|
plan — a remote dispatch positions nothing, §8.13), the step creates `plan-<plan>` from
|
|
2703
2355
|
`origin/<base>` with a `::notice::`, so a fresh-plan remote `implement` needs no pre-existing
|
|
2704
|
-
branch.
|
|
2356
|
+
branch. The `Drive the stage headlessly` step runs **`gh auth setup-git`** before invoking
|
|
2357
|
+
`perk run-worker` — it installs `gh` as git's https credential helper using the step's
|
|
2358
|
+
`GH_TOKEN` (= `PERK_GH_PAT`), so the skills CLI's sync during positioning (step 3 below) can
|
|
2359
|
+
clone private skill sources. A final **`Upload run diagnostics`** step (`actions/upload-artifact@v4`) uploads
|
|
2705
2360
|
`.perk/workflow/scratch/runs/<run_id>/` — the §8.12 durable run-event stream (`events.ndjson`
|
|
2706
2361
|
and friends), which is otherwise written into the runner's checkout and lost at teardown — as
|
|
2707
2362
|
artifact `perk-run-<run_id>` for **every real run, pass or fail**
|
|
@@ -2715,13 +2370,19 @@ so `init` writes them and `doctor` verifies/repairs them through the one shared
|
|
|
2715
2370
|
toolchains (uv + Node 22), then perk (the exterior CLI — `--from . perk` for the self-repo,
|
|
2716
2371
|
an exact-version-pinned PyPI install `uv tool install perk=={__version__}` for a consumer,
|
|
2717
2372
|
baked in at `perk init` time so the runner reproduces the wiring perk version), pi (the interior the
|
|
2718
|
-
worker drives), the
|
|
2373
|
+
worker drives), the **skills CLI** (`go install github.com/mattgiles/skills/cmd/skills@latest`
|
|
2374
|
+
— built from source because its release binaries are darwin-only; the runner's preinstalled Go +
|
|
2375
|
+
`GOTOOLCHAIN=auto` suffice, and the step is **fatal**: a failed install fails the job — no
|
|
2376
|
+
skills, no drive), the Node worker's peer deps, and a final **git-identity** step (`perk[bot]`,
|
|
2719
2377
|
`--global`) so the worker's commits succeed on a fresh runner. The worker-deps step is repo-kind
|
|
2720
2378
|
aware: **self** uses `npm ci` (the self-repo has the `package.json`/lockfile/devDeps the worker
|
|
2721
|
-
resolves); **consumer** installs the pinned `@mgiles/perk`
|
|
2722
|
-
(`npm install @mgiles/perk@{__version__} --prefix .pi/npm
|
|
2723
|
-
|
|
2724
|
-
|
|
2379
|
+
resolves); **consumer** installs the pinned `@mgiles/perk` **plus the unpinned pi SDK**
|
|
2380
|
+
(`npm install @mgiles/perk@{__version__} @earendil-works/pi-coding-agent --prefix .pi/npm
|
|
2381
|
+
--legacy-peer-deps`, the perk pin baked in at `perk init` time so the runner reproduces the wiring
|
|
2382
|
+
perk version). `@mgiles/perk` ships **zero** runtime `dependencies` (the pi packages are peers) and
|
|
2383
|
+
`--legacy-peer-deps` makes npm skip peer installation entirely — the SDK spec is what lands the
|
|
2384
|
+
worker's imports: its real deps (pi-ai, pi-tui, typebox) close the worker graph's bare-import set
|
|
2385
|
+
under `.pi/npm/node_modules/`, resolvable from the staged `consumer-npm` entry (step 4 below).
|
|
2725
2386
|
|
|
2726
2387
|
Full-file managed (like the settings/gitignore/AGENTS blocks): a hand-edited file reads as drift and
|
|
2727
2388
|
is converged back to the template. The templates are authored as code (string constants), not
|
|
@@ -2737,12 +2398,22 @@ by the workflow **after** it checks out the plan branch (so cwd = the checkout =
|
|
|
2737
2398
|
2. Reconstruct the `cache.plan-ref` from the plan's GitHub state (`github.get_plan` +
|
|
2738
2399
|
`resume.reconstruct_plan_ref`); a missing plan ⇒ `plan_not_found`.
|
|
2739
2400
|
3. **Position** the worktree (mirroring `launch.launch_stage`): `cache.ensure_layout`,
|
|
2740
|
-
`write_handoff({stage, mode})`, `write_plan_ref`, then materialize the plan body.
|
|
2741
|
-
|
|
2401
|
+
`write_handoff({stage, mode})`, `write_plan_ref`, then materialize the plan body. Positioning
|
|
2402
|
+
also delivers `.agents/skills/` via the canonical `sync_skills` gesture (the same one
|
|
2403
|
+
`perk init` runs) against the checkout's **committed** manifests — the checkout has no
|
|
2404
|
+
`.agents/skills/` to mirror from (gitignored), so without the sync every stage skill-binding
|
|
2405
|
+
pointer would dangle. A delivery failure is **fatal** (`UserFacingCliError(skills_sync_failed)`)
|
|
2406
|
+
and pre-empts the worker spawn (and `report_started`) — the deliberate posture asymmetry vs the
|
|
2407
|
+
loud-but-non-fatal local worktree mirror (§8.38 named difference 2). The worker inherits the
|
|
2408
|
+
prepared worktree and never re-writes it (the §B inputs table).
|
|
2742
2409
|
4. Resolve the Node worker entrypoint — `PERK_WORKER_ENTRY` override (`env`), else the self-repo
|
|
2743
2410
|
`extension/workerMain.ts` (`self`), else the consumer npm install under
|
|
2744
|
-
`.pi/npm/node_modules/@mgiles/perk
|
|
2745
|
-
`
|
|
2411
|
+
`.pi/npm/node_modules/@mgiles/perk/` (`consumer-npm`), which is **staged**: the whole package is
|
|
2412
|
+
copied (fresh per resolve) to `.pi/npm/perk-worker/` and the staged `extension/workerMain.ts`
|
|
2413
|
+
spawned — Node's type stripping refuses `.ts` files under any `node_modules` directory
|
|
2414
|
+
(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), while the full-package copy keeps
|
|
2415
|
+
package-root-relative resources (`shared/`, `prompts/`, `package.json`) reachable and bare
|
|
2416
|
+
imports resolving by walking up to `.pi/npm/node_modules`. A miss ⇒ `worker_entry_missing`.
|
|
2746
2417
|
5. **Spawn** `node <entry> <stage> --worktree <repo_root>` with `PERK_RUN_ID=<run_id>` in the env
|
|
2747
2418
|
(inherited stdio — the worker owns stdout/the `RunOutcome` JSON), and **exit with the worker's
|
|
2748
2419
|
exit code** so the workflow step reflects the drive outcome.
|
|
@@ -2773,9 +2444,11 @@ Two calls bracket the worker spawn in `run_worker(...)`:
|
|
|
2773
2444
|
- **terminal** — `report_terminal(...)` after `_spawn_worker` returns the exit code and **before**
|
|
2774
2445
|
`run-worker` returns it.
|
|
2775
2446
|
|
|
2776
|
-
Both are **
|
|
2777
|
-
|
|
2778
|
-
|
|
2447
|
+
Both are **fail-soft for expected failures**: an `IssueBackendError` from the backend (plus a
|
|
2448
|
+
filesystem `OSError` on the terminal path) inside reporting is caught, logged via `user_output` to
|
|
2449
|
+
stderr, and swallowed — those never change the worker's exit code or crash the runner —
|
|
2450
|
+
observability is best-effort (mirrors the worker's fail-soft event sink). A programming error in
|
|
2451
|
+
reporting propagates.
|
|
2779
2452
|
|
|
2780
2453
|
### The surfaces
|
|
2781
2454
|
|
|
@@ -2959,7 +2632,7 @@ records only (every row `source: "local"`, `pr`/`run` forced `null`).
|
|
|
2959
2632
|
`refreshed = not no_refresh`; `pr`/`run` are `null` under `--no-refresh` or a failed/empty overlay.
|
|
2960
2633
|
The top-level shape is unchanged from the pre-discovery surface; `source` is the per-row addition.
|
|
2961
2634
|
`success` is always `true` for a successful enumeration (even zero runs); only `require_repo` failing
|
|
2962
|
-
(`not_a_repo`) routes through `
|
|
2635
|
+
(`not_a_repo`) routes through `fail` (exit 2). No other error type is introduced.
|
|
2963
2636
|
|
|
2964
2637
|
### Human table (stderr)
|
|
2965
2638
|
|
|
@@ -3028,7 +2701,7 @@ run); `cancel`/`retry` act only on the handle. The error vocabulary is unchanged
|
|
|
3028
2701
|
"run_id": "01J…", "run_ref": "1234567", "runner": "", "kind": "github-actions",
|
|
3029
2702
|
"url": "https://…/actions/runs/1234567" }
|
|
3030
2703
|
// success (retry) — adds: "failed_only": false
|
|
3031
|
-
// failure → the shared
|
|
2704
|
+
// failure → the shared fail shape:
|
|
3032
2705
|
{ "success": false, "error_type": "<type>", "message": "<gh's own error>" }
|
|
3033
2706
|
```
|
|
3034
2707
|
|
|
@@ -3098,7 +2771,7 @@ leftover).
|
|
|
3098
2771
|
{ "success": true, "action": "smoke-test", "run_id": "01J…", "run_ref": "555",
|
|
3099
2772
|
"url": "https://…/actions/runs/555", "waited": false, "conclusion": null, "timed_out": false }
|
|
3100
2773
|
// smoke-test (--wait) — "waited": true, "conclusion": "success"|…, "timed_out": bool
|
|
3101
|
-
// refusal / dispatch error — the shared
|
|
2774
|
+
// refusal / dispatch error — the shared fail shape:
|
|
3102
2775
|
{ "success": false, "error_type": "<type>", "message": "<reason>" }
|
|
3103
2776
|
```
|
|
3104
2777
|
|
|
@@ -3111,7 +2784,7 @@ The **scheduler** on top of the §8.13 runner/discovery substrate and the §8.17
|
|
|
3111
2784
|
siblings: a **deterministic, no-agentic-reasoning** supervisor that advances an active objective's
|
|
3112
2785
|
backlog as far as is autonomously safe, then pauses at the human land gate. `perk objective run
|
|
3113
2786
|
<NUMBER>` (alias `obj r`) is a supervisor surface (cli-vs-pi §3.2): `--json` → stdout, human text →
|
|
3114
|
-
stderr, stable exits (`0` ok · `1` invalid/op-failure · `2` not-a-repo), `
|
|
2787
|
+
stderr, stable exits (`0` ok · `1` invalid/op-failure · `2` not-a-repo), `fail`/`UserFacingCliError`
|
|
3115
2788
|
with a stable `error_type`.
|
|
3116
2789
|
|
|
3117
2790
|
### Autonomous reach: one dispatch, then stop — and **never land**
|
|
@@ -3138,7 +2811,7 @@ into interactive pi and never returns, which would destroy the loop.
|
|
|
3138
2811
|
### Single-pass control flow (deterministic)
|
|
3139
2812
|
|
|
3140
2813
|
1. `require_repo` + `require_config`; `require_github` unless `--dry-run`.
|
|
3141
|
-
2. `state = github.get_objective(NUMBER)`; `None` → `
|
|
2814
|
+
2. `state = github.get_objective(NUMBER)`; `None` → `fail(objective_not_found)`.
|
|
3142
2815
|
3. **Cumulative budget report** (always, before any action): enumerate
|
|
3143
2816
|
`cache.list_dispatch_records`, keep records whose `plan_ref.objective_id` canonicalizes
|
|
3144
2817
|
(`str(...).lstrip("#")`) to NUMBER, sum each `run_report.read_outcome` `budget`
|
|
@@ -3430,7 +3103,7 @@ everywhere — PRs are GitHub-universal. Concretely:
|
|
|
3430
3103
|
`issue` but is a string; `pr land`'s `objective` sub-object `number` → **`id`** (string|null)
|
|
3431
3104
|
and `learn.closed` carries string ids; `objective reconcile`'s `objective`/`comment_id` are
|
|
3432
3105
|
strings; `learn docs --gather`'s `learn_numbers` carries string ids. TS decoders
|
|
3433
|
-
(`planSave.ts`/`learn.ts`/`land.ts`/`objectiveSave.ts`/`
|
|
3106
|
+
(`planSave.ts`/`learn.ts`/`land.ts`/`objectiveSave.ts`/`learnFactory.ts`) are lockstep-strict on
|
|
3434
3107
|
the string shapes.
|
|
3435
3108
|
- CLI plan/objective arguments parse through the shared opaque-id validators
|
|
3436
3109
|
(`resume_cmd.parse_plan_id` / `objective/shared.parse_objective_id`): strip `#`/whitespace;
|
|
@@ -3482,10 +3155,12 @@ there is no TS twin).
|
|
|
3482
3155
|
(PR opened) + `agentSessionUpdate.addedExternalUrls` with the PR link;
|
|
3483
3156
|
4. **land** — `pr land`'s `_pr_land_impl` (never on `--dry-run`) → a `response` activity
|
|
3484
3157
|
("PR #n squash-merged." + the objective-node summary line when any).
|
|
3485
|
-
- **The fail-soft guarantee**: every emitter is
|
|
3486
|
-
`
|
|
3487
|
-
|
|
3488
|
-
|
|
3158
|
+
- **The fail-soft guarantee**: every emitter is wrapped for its typed expected failures
|
|
3159
|
+
(`IssueBackendError`, plus `OSError` on the session-create write — the
|
|
3160
|
+
`_reconcile_objective_on_land` fail-open discipline) — an expected failure never raises and
|
|
3161
|
+
never changes the
|
|
3162
|
+
host command's result/exit code/`--json` payload; it prints one loud-but-non-fatal stderr
|
|
3163
|
+
note (`perk linear-agent: <what> skipped (non-fatal): <exc>`). A programming error propagates.
|
|
3489
3164
|
- **Known limits + deferrals** (flagged in the module docstring): GraphQL field signatures are
|
|
3490
3165
|
substring-pinned offline and verified live only at the smoke gate; Linear marks sessions
|
|
3491
3166
|
`stale` ~30 min after the last activity (accepted, not mitigated); `perk address` emission,
|
|
@@ -3516,7 +3191,10 @@ one-stop current shape.
|
|
|
3516
3191
|
other selection → the first-party `ctx.ui.editor` review. APPROVED (either backend) runs
|
|
3517
3192
|
`approvalSave` (`extension/factories/planSave.ts`): save → D1a gate exit on success (→ §8.3). The
|
|
3518
3193
|
`/plan-save` command is the **manual failsafe** invocation of the same seam, taking only an
|
|
3519
|
-
optional title argument.
|
|
3194
|
+
optional title argument. Every `plan_review` arm carries the universal `details.ok` discriminant
|
|
3195
|
+
(`ok:false` + `error`/`error_type` on unavailable / save-failed / bad_input / no_plan /
|
|
3196
|
+
no_objective_draft; `ok:true` on verdicts and the sanctioned fail-open skips), so `tool_outcome`
|
|
3197
|
+
run events classify it via `details.ok` rather than the `!isError` fallback.
|
|
3520
3198
|
- **The three backends.** All three speak review-first
|
|
3521
3199
|
(`plan_draft` → `plan_review` → auto-save on approval):
|
|
3522
3200
|
|
|
@@ -3679,6 +3357,25 @@ unchanged.
|
|
|
3679
3357
|
moves the objective issue to its Done state; `LinearProjectObjectiveStore` **marks the Linear
|
|
3680
3358
|
Project complete** (`projectUpdate(state:"completed")`) — a Project is not an issue. Fail-open is
|
|
3681
3359
|
preserved (a close failure never changes the land result).
|
|
3360
|
+
- **`reopen_objective` is close-on-complete's mirror — the reopen-on-incomplete invariant.**
|
|
3361
|
+
`ObjectiveStore.reopen_objective{objective_id, dry_run} -> bool` is a **converge-to-open**
|
|
3362
|
+
gesture (`True` iff a reopen write actually happened; already-open / untouchable states /
|
|
3363
|
+
`dry_run` → `False`; infra failures raise `ObjectiveStoreError`): `GitHubObjectiveStore`
|
|
3364
|
+
re-opens the issue via `plans.reopen_issue` (GET `state`, PATCH `state=open` only when closed);
|
|
3365
|
+
`LinearProjectObjectiveStore` moves a `completed` Project back to `started` (and ONLY from
|
|
3366
|
+
`completed` — `canceled` is a human cancel, not perk's to undo); the issue-backed
|
|
3367
|
+
`LinearObjectiveStore` moves a `completed`-type issue state back to the team's `started` state.
|
|
3368
|
+
The ONE caller is `perk objective node-add`: a successful **non-dry-run** add of a
|
|
3369
|
+
**non-terminal** node (roadmap incomplete again ⇒ the objective must be open — an objective a
|
|
3370
|
+
human closed early *does* reopen; inserting live work expresses intent that it is live) calls it
|
|
3371
|
+
in an isolated **fail-open** block (the exact posture of land's close — a reopen failure never
|
|
3372
|
+
discards the add). The one exemption is **superseded lineage**, guarded backend-neutrally at the
|
|
3373
|
+
door (never in a store): a non-empty `superseded_by` in the objective-header (a perk-schema
|
|
3374
|
+
field) skips the reopen with a stderr note — policy, not an error. The node-add `--json` payload
|
|
3375
|
+
carries `reopened: bool` and `reopen_error: string|null` (`null` on the superseded skip).
|
|
3376
|
+
**Deliberate boundary:** the invariant rides `add_objective_node` only —
|
|
3377
|
+
`update_objective_node` flipping a terminal node back to non-terminal on a closed objective does
|
|
3378
|
+
NOT auto-reopen.
|
|
3682
3379
|
- The objective id is the opaque **Project UUID** across `active_objective` / `--objective-id` /
|
|
3683
3380
|
the handoff / `cache.plan-ref.objective_id` — no numeric/`ENG-`-shape assumption anywhere.
|
|
3684
3381
|
- **Realized:** the `projectUpdate(state)` mark-complete is **live-verified 2026-06-16** (Node 5.1
|
|
@@ -4281,9 +3978,21 @@ nothing, the subset being shared).
|
|
|
4281
3978
|
- **TS:** `extension/substrate/prompts.ts::render(name, vars)`, delegating to the vendored,
|
|
4282
3979
|
zero-dependency `extension/substrate/miniJinja.ts` renderer (the frozen-subset engine that
|
|
4283
3980
|
replaced nunjucks). The seam is LIVE on both planes: `render` is imported by the worker, the
|
|
4284
|
-
learn/address/
|
|
4285
|
-
objective-reconcile doors, the objective-plan factory,
|
|
4286
|
-
plan
|
|
3981
|
+
learn/address/learnFactory/lifecycleGates doors, the warm pr-review / submit / objective-save /
|
|
3982
|
+
objective-reconcile doors, the objective-plan factory, the tool-gating read-only mode context,
|
|
3983
|
+
the plan/objective authoring contexts, the three provider-adapter shims
|
|
3984
|
+
(tombell / plannotator / juicesharp), and — on the Python side — the cold
|
|
3985
|
+
plan-from / replan / objective-author / objective-replan doors. The seven injected mode/bridge
|
|
3986
|
+
contexts (the persistent `before_agent_start` injections stripped on `context`, each injection
|
|
3987
|
+
**dedup-guarded by a branch scan on its marker** — `branchCarries` in
|
|
3988
|
+
`extension/substrate/workflowState.ts` — so a session carries ONE live copy of each context;
|
|
3989
|
+
compaction dropping a copy naturally re-injects it) live under
|
|
3990
|
+
`prompts/contexts/` — the mode contexts at the top level, the adapter bridges under
|
|
3991
|
+
`prompts/contexts/adapters/` — with each module's identity marker passed as the `{{ marker }}`
|
|
3992
|
+
render var (never a template literal), so the marker the strip handler scans for cannot drift
|
|
3993
|
+
from the injected prose; the marker-as-render-var invariant now serves both the strip **and**
|
|
3994
|
+
the dedup key (plannotator's two flavors share one customType but dedup per-flavor on their
|
|
3995
|
+
distinct markers).
|
|
4287
3996
|
|
|
4288
3997
|
**Fail loudly on a missing var.** jinja2 uses `StrictUndefined` (raises `jinja2.UndefinedError`);
|
|
4289
3998
|
the vendored `miniJinja` renderer matches it — a referenced name that is **absent OR non-string**
|
|
@@ -4504,13 +4213,14 @@ skill is not a backend object). `--dry-run` JSON adds `"from": <source>` and —
|
|
|
4504
4213
|
`"scratch_path"`. This is a Python-only change (no TS plane); the authoring judgment lives in the
|
|
4505
4214
|
`perk-skill-author` skill.
|
|
4506
4215
|
|
|
4507
|
-
## §8.34 ·
|
|
4216
|
+
## §8.34 · JSON Schema golden snapshots of the boundary models (Objective #943, Node 4.1)
|
|
4508
4217
|
|
|
4509
4218
|
perk's cross-plane machine surfaces are Pydantic boundary models (`perk/boundary.py`'s three roles).
|
|
4510
|
-
Their `model_json_schema()` is
|
|
4511
|
-
|
|
4219
|
+
Their `model_json_schema()` is committed as **golden snapshots** under `shared/schemas/` — their
|
|
4220
|
+
function is making machine-surface shape changes reviewable in PRs via the drift test, not serving
|
|
4221
|
+
as a runtime resource or a consumer-facing publication.
|
|
4512
4222
|
|
|
4513
|
-
**What is
|
|
4223
|
+
**What is snapshotted (19 top-level models, three categories).**
|
|
4514
4224
|
|
|
4515
4225
|
- **Shared-YAML parse contracts** (`LenientParseModel`) → `shared/schemas/contracts/`:
|
|
4516
4226
|
`registry.schema.json` (`RegistryFile`), `bindings.schema.json` (`BindingsFile`),
|
|
@@ -4521,18 +4231,18 @@ so a consumer of perk's machine surfaces has a precise, reviewable contract.
|
|
|
4521
4231
|
`handoff-arg.schema.json` (`HandoffArgInput`),
|
|
4522
4232
|
`structured-roadmap-node.schema.json` (`StructuredRoadmapNode`).
|
|
4523
4233
|
- **`--json` output envelopes** (`OutputModel`) → `shared/schemas/outputs/`: `plan-save`,
|
|
4524
|
-
`pr-submit`, `pr-ready`, `pr-land`, `pr-feedback`, `pr-review-context`, `
|
|
4525
|
-
`learn-skip`, `init-report`, `doctor-report`
|
|
4526
|
-
`DoctorReportOut`).
|
|
4234
|
+
`pr-submit`, `pr-ready`, `pr-land`, `pr-feedback`, `pr-review-context`, `pr-review-checkout`,
|
|
4235
|
+
`pr-review-cleanup`, `learn-capture`, `learn-skip`, `init-report`, `doctor-report`
|
|
4236
|
+
(`.schema.json` each, for `PlanSaveOut` … `DoctorReportOut`).
|
|
4527
4237
|
|
|
4528
4238
|
**How they are generated.** `model_json_schema()` from the live boundary models. The mode is
|
|
4529
4239
|
**per category** — parse/input contracts describe what perk **accepts**, so they use **validation
|
|
4530
4240
|
mode** (the default); output envelopes describe what `--json` consumers **receive**, so they use
|
|
4531
4241
|
**serialization mode**. Nested `*Out` / `*Entry` sub-models ride along in `$defs`.
|
|
4532
4242
|
|
|
4533
|
-
**Cross-plane status.** The
|
|
4534
|
-
(`perk/_shared/schemas/` in the wheel, `shared/schemas/` in npm
|
|
4535
|
-
|
|
4243
|
+
**Cross-plane status.** The snapshots are **bundled into both artifacts, read at runtime by
|
|
4244
|
+
neither** (`perk/_shared/schemas/` in the wheel, `shared/schemas/` in npm — TS still reads the
|
|
4245
|
+
YAML directly; Python validates via the live models).
|
|
4536
4246
|
|
|
4537
4247
|
**Drift discipline.** The committed files are regenerated only via
|
|
4538
4248
|
`PERK_UPDATE_SCHEMAS=1 uv run pytest tests/test_contract_schemas.py`, and
|
|
@@ -4541,15 +4251,20 @@ no-orphans/no-gaps coverage test, and a per-category mode-correctness smoke) —
|
|
|
4541
4251
|
always reviewed intentionally. The harness mirrors the value-golden harness (`tests/_golden.py`):
|
|
4542
4252
|
it always re-reads + asserts after a regen, so a non-roundtrippable schema still fails loudly.
|
|
4543
4253
|
|
|
4544
|
-
**Non-goals.** `
|
|
4545
|
-
serializers `PlanHeaderOut` / `PlanRefOut`
|
|
4254
|
+
**Non-goals.** `ConfigFileModel` (TOML, not a shared YAML contract) is not snapshotted; the
|
|
4255
|
+
stored-block serializers `PlanHeaderOut` / `PlanRefOut` get no standalone snapshots (`PlanRefOut`
|
|
4546
4256
|
rides transitively in `PlanSaveOut`'s `$defs`).
|
|
4547
4257
|
|
|
4548
4258
|
## §8.35 · The learn evidence-bundle contract (Objective #896, Node 1.1)
|
|
4549
4259
|
|
|
4550
|
-
`/learn` examines a **bundle of session-grounded evidence** for a landed plan — not only plan +
|
|
4551
|
-
This section pins the bundle's shapes and vocabulary
|
|
4552
|
-
|
|
4260
|
+
`/learn` examines a **bundle of session-grounded evidence** for a landed plan — not only plan +
|
|
4261
|
+
diff. This section pins the bundle's shapes and vocabulary — the cross-plane machine contract.
|
|
4262
|
+
The pipeline mechanics live in their owning modules (`src/perk/learn/export.py` — the byte-copy
|
|
4263
|
+
session export; `session_jsonl.py` — the lenient JSONL grammar parse; `normalize.py` — the
|
|
4264
|
+
deterministic normalization pipeline + renderer + budget splitter; `docs_scan.py` — the
|
|
4265
|
+
inventory + rich docs scan; `docs_sync.py` — the generated routing/catalog + `docs-check`); the
|
|
4266
|
+
angle-agent spec lives in `agents/learn-analyst.md` + `skills/perk-learn/`; the warm orchestrator
|
|
4267
|
+
in `extension/doors/learn.ts`.
|
|
4553
4268
|
|
|
4554
4269
|
**The evidence bundle (definition + invariants).** The bundle is the full set of session-grounded
|
|
4555
4270
|
artifacts `/learn` reasons over for a landed plan. Invariants:
|
|
@@ -4557,31 +4272,26 @@ artifacts `/learn` reasons over for a landed plan. Invariants:
|
|
|
4557
4272
|
- Every quoted artifact in the bundle is **untrusted DATA**, fenced as such — never instructions.
|
|
4558
4273
|
- A missing source is **surfaced, never guessed**: the bundle reports a per-source status, and one
|
|
4559
4274
|
missing/ambiguous source never fails the whole command.
|
|
4560
|
-
- The bundle is **resolved cross-run** from a landed plan's identity, not from the current
|
|
4561
|
-
identity — so a later or worktree session can rebuild it.
|
|
4562
|
-
|
|
4563
|
-
**Minimum manifest categories.** The bundle manifest lists at least these five categories:
|
|
4564
|
-
`pr`, `planning-session`, `implementation-session`, `existing-docs`. Each category carries
|
|
4565
|
-
per-source status drawn from the fixed set **`found` / `missing` / `ambiguous`**.
|
|
4566
|
-
|
|
4567
|
-
**Session classes.** Two session classes: **`planning`** (the session that
|
|
4568
|
-
the plan) and **`implementation`** (the session(s) that implemented it).
|
|
4569
|
-
**both** a main session and a worker run, labelled distinctly when both
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
run
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
scratch dir at `<main-checkout>/.perk/workflow/scratch/runs/<run_id>/` — where `<main-checkout> =
|
|
4581
|
-
main_worktree_root(cwd) or cwd` (`perk/substrate/git.py::main_worktree_root` /
|
|
4582
|
-
`extension/substrate/git.ts::mainCheckoutRoot`) so a linked-worktree run and a later resolver agree
|
|
4583
|
-
on ONE shared location. The path is built only through the `run_scratch_dir`/`runScratchDir` seam.
|
|
4584
|
-
Schema (byte-identical across planes):
|
|
4275
|
+
- The bundle is **resolved cross-run** from a landed plan's identity, not from the current
|
|
4276
|
+
session's identity — so a later or worktree session can rebuild it.
|
|
4277
|
+
|
|
4278
|
+
**Minimum manifest categories.** The bundle manifest lists at least these five categories:
|
|
4279
|
+
`plan`, `pr`, `planning-session`, `implementation-session`, `existing-docs`. Each category carries
|
|
4280
|
+
a per-source status drawn from the fixed set **`found` / `missing` / `ambiguous`**.
|
|
4281
|
+
|
|
4282
|
+
**Session classes.** Two session classes: **`planning`** (the session that
|
|
4283
|
+
authored/reviewed/saved the plan) and **`implementation`** (the session(s) that implemented it).
|
|
4284
|
+
Each class may resolve **both** a main session and a worker run, labelled distinctly when both
|
|
4285
|
+
are available.
|
|
4286
|
+
|
|
4287
|
+
**The canonical run-cache pointer carrier.** Session pointers are recorded **against the run in
|
|
4288
|
+
the run cache, keyed by `run_id`**: the record is `session-pointers.json`, written under the
|
|
4289
|
+
run's scratch dir at `<main-checkout>/.perk/workflow/scratch/runs/<run_id>/` — where
|
|
4290
|
+
`<main-checkout> = main_worktree_root(cwd) or cwd` — so a linked-worktree run and a later
|
|
4291
|
+
resolver agree on ONE shared location. The path is built only through the
|
|
4292
|
+
`run_scratch_dir`/`runScratchDir` seam (`perk/state/cache.py` /
|
|
4293
|
+
`extension/substrate/cache.ts`). The plan branch's workflow-state **may mirror** the pointers for
|
|
4294
|
+
provenance but is **not primary**. Schema (byte-identical across planes):
|
|
4585
4295
|
|
|
4586
4296
|
```json
|
|
4587
4297
|
{
|
|
@@ -4592,56 +4302,30 @@ Schema (byte-identical across planes):
|
|
|
4592
4302
|
```
|
|
4593
4303
|
|
|
4594
4304
|
`Pointer = { "pi_session_id": str, "session_file": str, "parent_pi_session_id": str|null, "at":
|
|
4595
|
-
ISO-8601 }`. Each run is **self-keyed**: it writes
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
`
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4305
|
+
ISO-8601 }`. Each run is **self-keyed**: it writes ONLY under its OWN `run_id`, and fills only
|
|
4306
|
+
the slots it owns (planning runs → `planning.*`; implement runs → `implementation.*`). The four
|
|
4307
|
+
class/site slots are always present (null when unset) so a read-modify-write merges trivially.
|
|
4308
|
+
`main` vs `worker` is distinguished by **capture site** (deterministic), not by inspection: the
|
|
4309
|
+
interior `session_start` writes `.main`, the headless `worker.driveStage` writes `.worker`, and
|
|
4310
|
+
the `/submit` warm door additionally captures `.main` at `impl_run_ids`-stamping time (so a
|
|
4311
|
+
submitted run resolves `found` regardless of its launched stage). The interior capture is
|
|
4312
|
+
**claimer-only and first-write-wins** (a foreign-session overwrite is skipped with a loud stderr
|
|
4313
|
+
warning; a same-session re-capture refreshes), and **env-inherited children never capture** (the
|
|
4314
|
+
§8.2 adopt arm carries no stage). The submit-door capture is first-write-wins too.
|
|
4603
4315
|
|
|
4604
4316
|
**The plan-header linkage.** The planning `run_id` is already on the `plan-header`. The
|
|
4605
4317
|
implementation run id(s) are stamped onto the header as `impl_run_ids: tuple[str, ...]`, a
|
|
4606
|
-
**submit-staged** field (null/empty at save, exactly like `branch`/`pr`) union-merged at
|
|
4607
|
-
(`perk pr submit --run-id <run_id>` appends the current run id iff absent — dedup,
|
|
4608
|
-
The header is the canonical, GC-proof cross-run LINKAGE; the run cache is the
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
(implementation)} → read each run's
|
|
4613
|
-
status is from
|
|
4614
|
-
(plan/header/run_id absent, the record file
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
**The session-export seam (node 2.2, `perk/learn/export.py::export_session_jsonl`).** Given a
|
|
4619
|
-
resolved pointer, materialize a current-branch JSONL artifact as a faithful **byte copy** of the
|
|
4620
|
-
pointer's `session_file`. Decision: **Option A — Python reads the on-disk session JSONL directly,
|
|
4621
|
-
on demand** (no Pi export primitive, no TS capture-time export). Rationale: the session file IS the
|
|
4622
|
-
JSONL (Pi persists each session as an append-only JSONL log — a header line + entry lines); the
|
|
4623
|
-
slot captures are all **mid-session** (the session keeps appending afterward, so a capture-time
|
|
4624
|
-
export would be a partial prefix); `/learn` runs **later** in a separate session, by when the
|
|
4625
|
-
planning + implementation sessions have finished writing, so the on-disk file is the COMPLETE
|
|
4626
|
-
transcript; and the files live under the home agent dir, so they **survive worktree deletion** (the
|
|
4627
|
-
captured absolute `session_file` stays valid — only Pi-side GC removes it → `missing`).
|
|
4628
|
-
|
|
4629
|
-
- Signature: `export_session_jsonl(pointer: SessionPointer | None, dest: Path) -> SessionExport`,
|
|
4630
|
-
with `SessionExport = { status: "found" | "missing", source: str | None, artifact: Path | None }`
|
|
4631
|
-
(a frozen `@dataclass`; `source`/`artifact` set only when `found`).
|
|
4632
|
-
- The copy is **faithful** (`shutil.copyfile`, not parse-and-reserialize) so the artifact preserves
|
|
4633
|
-
the raw JSONL exactly — the session header line, compaction/branch-summary entries, abandoned
|
|
4634
|
-
branches, unknown custom entries. Parsing/normalization is **node 3.2's** concern.
|
|
4635
|
-
- The stored absolute `session_file` is **authoritative**; the seam never re-derives the path from
|
|
4636
|
-
the capture cwd (a possibly-deleted worktree; the session dir is cwd-encoded).
|
|
4637
|
-
- It **never raises** (mirroring `read_session_pointers`): a `None` pointer, an empty
|
|
4638
|
-
`session_file`, a non-existent source, or any `OSError` (warned to stderr) → `missing`.
|
|
4639
|
-
- The export status **composes with** resolution: a `found` resolution **downgrades to `missing`**
|
|
4640
|
-
at export time if the source file is gone.
|
|
4641
|
-
- `dest` is **caller-composed** (dest-agnostic, the full target file path): the node-3.1 consumer
|
|
4642
|
-
composes the destination under the bundle scratch dir via the `run_scratch_dir`/`scratch_dir`
|
|
4643
|
-
seam; node 2.2 picks no naming convention. There is **no `--json` surface in this node** (so no
|
|
4644
|
-
`OutputModel` — the serialize-edge lands with node 3.1's manifest).
|
|
4318
|
+
**submit-staged** field (null/empty at save, exactly like `branch`/`pr`) union-merged at
|
|
4319
|
+
`/submit` (`perk pr submit --run-id <run_id>` appends the current run id iff absent — dedup,
|
|
4320
|
+
order-preserving). The header is the canonical, GC-proof cross-run LINKAGE; the run cache is the
|
|
4321
|
+
primary POINTER store.
|
|
4322
|
+
|
|
4323
|
+
**Cross-run resolution (`perk/learn/sessions.py::resolve_plan_sessions`).** `plan_id →
|
|
4324
|
+
plan-header → {run_id (planning), impl_run_ids (implementation)} → read each run's
|
|
4325
|
+
session-pointers record under the main checkout`. Per-role status is from the fixed set: `found`
|
|
4326
|
+
(the slot's pointer is present) / `missing` (plan/header/run_id absent, the record file
|
|
4327
|
+
GC'd/absent, or the slot is null); a `found` resolution downgrades to `missing` at export time if
|
|
4328
|
+
the source session file is gone.
|
|
4645
4329
|
|
|
4646
4330
|
**The classification vocabulary (two distinct, related sets).**
|
|
4647
4331
|
|
|
@@ -4657,88 +4341,55 @@ captured absolute `session_file` stays valid — only Pi-side GC removes it →
|
|
|
4657
4341
|
- `SKIP` — nothing durable; create no issue, clear the marker only.
|
|
4658
4342
|
- **The durable CAPTURED metadata shape** — persisted on the `perk:learn` issue header (both
|
|
4659
4343
|
backends). It is the DECISION set **minus `SKIP`** (a skip creates no issue) **plus an optional
|
|
4660
|
-
`target
|
|
4661
|
-
|
|
4662
|
-
the
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
are **untrusted external data** → a **`LenientParseModel` read-edge** (`extra="ignore"`), converted
|
|
4690
|
-
to a **frozen `@dataclass`** domain object — never read raw dicts into domain logic.
|
|
4691
|
-
- The `decision` token is a **closed set** → modelled as a `StrEnum` (the five captured tokens; the
|
|
4692
|
-
transient reconciliation may also yield `SKIP`), never a free string; `target` is **omittable**
|
|
4693
|
-
(`str | None = None`), distinguishing "no target" from a present value.
|
|
4694
|
-
- `model_validate` for the untrusted edges; the constructor for trusted Python-shaped values (the
|
|
4695
|
-
`ty`-friendly habit, dignified-pydantic §38).
|
|
4696
|
-
|
|
4697
|
-
**Classification-aware hop-2 consolidation (node 7.1).** The typed `parse_learn_header` read-back
|
|
4698
|
-
(above) is the **gather-time default classification route** for the two learn plan factories. Both
|
|
4699
|
-
are read-only plan factories (author + save a `perk:plan`; never write docs/code directly) sharing
|
|
4700
|
-
`perk/cli/commands/learn/factory_common.py` (parameterized by a frozen `LearnFactoryKind`; the two
|
|
4701
|
-
thin click commands `docs_cmd.py`/`code_cmd.py` delegate to `run_factory`). The cold-door
|
|
4702
|
-
`gather` lists all open `perk:learn` issues and **partitions by `decision`**: a pre-stamped
|
|
4703
|
-
`SHOULD_BE_CODE` routes to the code factory; **every other classification — and any
|
|
4704
|
-
legacy/unclassified issue (absent/malformed header) — defaults to docs** (the catch-all).
|
|
4705
|
-
|
|
4706
|
-
- **`perk learn docs` / `/learn-docs`** consolidates the **doc-destined** subset into
|
|
4707
|
-
`docs/learned/`, cleanup-first, regenerating routing via `docs-sync` (never by hand). It stays a
|
|
4708
|
-
curator **AND verifier**: it applies the knowledge-placement hierarchy and **emits a
|
|
4709
|
-
`SHOULD_BE_CODE` follow-up step** when a doc-destined learning actually belongs in
|
|
4710
|
-
code/comment/docstring/schema/user-docs (the original node requirement; the verifier exit). Its
|
|
4711
|
-
inbox is **widened** with each learning's captured classification line (`decision` + optional
|
|
4712
|
-
`target`) and the node-5.1 existing-docs scan (`scan_existing_docs` inventory +
|
|
4713
|
-
`scan_docs_richly` findings) for cleanup-first + UPDATE-vs-NEW.
|
|
4714
|
-
- **`perk learn code` / `/learn-code`** *(new, additive)* is the dedicated sweep for the pre-stamped
|
|
4715
|
-
`SHOULD_BE_CODE` learnings, routing each into its real code home. Its inbox is **lean**
|
|
4716
|
-
(classification + `target` + the codebase it reads directly; no docs scan).
|
|
4717
|
-
|
|
4718
|
-
The partition is the *default* route, not the only path to a destination: `/learn-docs`'s verifier
|
|
4719
|
-
re-routes a doc-stamped item to code when warranted, and `/learn-code`'s skill may note an item
|
|
4720
|
-
better suited to a doc. Each factory **consumes its full filtered inbox** — whatever it places (a doc
|
|
4721
|
-
OR a verify-re-routed code step) stays in `consumed_learn` (carried through `launch_stage`'s
|
|
4722
|
-
`handoff_extra`); no per-item subsetting. The `--gather --json` envelope is unchanged
|
|
4723
|
-
(`{inbox_path, learn_numbers, launched}`). Parallel wiring SSOTs: `shared/bindings.yaml`
|
|
4724
|
-
(`command:learn-code` → `perk-learn-code` nudge), `bindings.py::DELIVERABLE_COMMAND_TARGETS`
|
|
4725
|
-
(`learn-code`), `init/skills.py::PERK_SKILLS` (`perk-learn-code`), the `learn` verb group, the warm
|
|
4726
|
-
`extension/doors/learnCode.ts`, and `prompts/_fixtures/live.yaml` (`stages/learn-code.md`).
|
|
4344
|
+
`target`** (a routable pointer, e.g. an existing doc path). The fields extend the existing
|
|
4345
|
+
`learn-header` metadata block → `{ run_id, created, plan, decision, target? }`, rendered via
|
|
4346
|
+
the shared `render_learn_header` helper (optional fields only when present) so the header is
|
|
4347
|
+
byte-identical in shape on both backends. `decision` is a `plan.CapturedDecision` `StrEnum`
|
|
4348
|
+
(the five captured tokens). The typed read-back is `plan.parse_learn_header(body) ->
|
|
4349
|
+
LearnHeader | None` — **never-raise**: it scans both block styles, returns `None` when the
|
|
4350
|
+
block is absent/malformed, and degrades an unknown/future `decision` token to `None`. It is the
|
|
4351
|
+
gather-time classification route the learn factories read.
|
|
4352
|
+
|
|
4353
|
+
The learn shapes follow perk's boundary-model convention (§8.34 / `perk/boundary.py`): lenient
|
|
4354
|
+
read-edges for untrusted data (session JSONL, header read-back), `OutputModel` serialize-edges
|
|
4355
|
+
for the `--json` envelopes, closed sets as `StrEnum`s.
|
|
4356
|
+
|
|
4357
|
+
**The docs/code factory partition rule.** The two learn plan factories (`perk learn docs` /
|
|
4358
|
+
`/learn-docs` and `perk learn code` / `/learn-code`) are read-only plan factories sharing
|
|
4359
|
+
`src/perk/cli/commands/learn/factory_common.py`. Gather partitions the open `perk:learn` issues
|
|
4360
|
+
by their captured `decision`: a pre-stamped `SHOULD_BE_CODE` routes to the code factory;
|
|
4361
|
+
**every other classification — and any legacy/unclassified issue — defaults to docs** (the
|
|
4362
|
+
catch-all). The partition is the *default* route, not the only path to a destination
|
|
4363
|
+
(`/learn-docs`'s verifier may re-route a doc-stamped item to code; `/learn-code`'s skill may note
|
|
4364
|
+
an item better suited to a doc); each factory consumes its **full filtered inbox** into
|
|
4365
|
+
`consumed_learn`. The docs navigation (`docs/learned/index.md` + `.pi/APPEND_SYSTEM.md`) is
|
|
4366
|
+
generated from per-doc frontmatter — the SSOT — via `perk learn docs-sync`, never by hand;
|
|
4367
|
+
freshness **and the per-cue budget** gate the on-demand `perk learn docs-check`: each `read_when`
|
|
4368
|
+
is ≤ `200` chars (measured on the parsed value — what the generators emit) and free of the YAML
|
|
4369
|
+
plain-scalar hazards that silently corrupt the rendered cue (a ` #` truncates the plain scalar, a
|
|
4370
|
+
`: ` fails the whole frontmatter parse, a multi-line value breaks the one-line routing grammar;
|
|
4371
|
+
a quoted scalar is the sanctioned escape). A pytest enforces the same cue budget in CI; freshness
|
|
4372
|
+
deliberately stays out of CI (on-demand only).
|
|
4727
4373
|
|
|
4728
4374
|
**The non-empty `consumed_learn` discriminator.** A plan whose `plan-header` `consumed_learn` is
|
|
4729
|
-
**non-empty** *is* a learn-docs consolidation plan. `/learn`
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
skipped"*.
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
mirroring `perk learn capture`); gathers the bundle, materializes the artifacts
|
|
4737
|
-
`cache.scratch_dir(repo_root) / "learn-evidence"`, and emits the manifest. Exit codes: `0`
|
|
4738
|
-
OR gathered manifest) · `1` no plan-ref / invalid · `2` not-a-repo. `require_github` is
|
|
4739
|
-
called — GitHub reads degrade per-source
|
|
4740
|
-
|
|
4741
|
-
The `--
|
|
4375
|
+
**non-empty** *is* a learn-docs consolidation plan. `/learn` and `perk learn evidence` detect
|
|
4376
|
+
this **up-front** and return a stable **no-op**: clear `pending-learn`, create no `perk:learn`
|
|
4377
|
+
issue, gather no bundle, spawn no children — reporting *"learn-docs plan; learn capture
|
|
4378
|
+
skipped"*. A plan-**fetch** failure is **never** a skip signal — the command proceeds to gather
|
|
4379
|
+
with the `plan` source `missing`.
|
|
4380
|
+
|
|
4381
|
+
**The bundle-manifest CLI (`perk learn evidence --json`).** Reads the local `cache.plan-ref` (no
|
|
4382
|
+
positional arg, mirroring `perk learn capture`); gathers the bundle, materializes the artifacts
|
|
4383
|
+
under `cache.scratch_dir(repo_root) / "learn-evidence"`, and emits the manifest. Exit codes: `0`
|
|
4384
|
+
ok (skip OR gathered manifest) · `1` no plan-ref / invalid · `2` not-a-repo. `require_github` is
|
|
4385
|
+
**not** called — GitHub reads degrade per-source (*expected absence* → `missing` silently; a
|
|
4386
|
+
*genuine error* → `missing` + a stderr warning, loud-but-non-fatal), so the manifest still
|
|
4387
|
+
gathers sessions + docs offline. The opt-in `--render` flag projects the found session JSONLs
|
|
4388
|
+
into bounded, untrusted-DATA-fenced Markdown chunks under `<bundle_dir>/chunks/` and reports on
|
|
4389
|
+
the envelope's **additive `render` field** (declared LAST, always serialized, `null` unless
|
|
4390
|
+
`--render`); the pipeline, fence format, and report fields are `normalize.py`'s contract.
|
|
4391
|
+
|
|
4392
|
+
The `--json` envelope (`OutputModel` serialize edge — the contract the warm orchestrator decodes):
|
|
4742
4393
|
|
|
4743
4394
|
```
|
|
4744
4395
|
EvidenceBundle = {
|
|
@@ -4747,7 +4398,8 @@ EvidenceBundle = {
|
|
|
4747
4398
|
plan_id: str|null, bundle_dir: str|null, # bundle_dir relative to repo_root
|
|
4748
4399
|
sources: EvidenceSource[],
|
|
4749
4400
|
existing_docs: DocEntry[],
|
|
4750
|
-
docs_findings: DocFindings,
|
|
4401
|
+
docs_findings: DocFindings, # the rich docs scan (declared after existing_docs)
|
|
4402
|
+
render: RenderReport|null, # additive; null unless --render
|
|
4751
4403
|
}
|
|
4752
4404
|
EvidenceSource = { category, label, status, artifact: str|null, detail: str|null }
|
|
4753
4405
|
DocEntry = { kind, path, title: str|null, snippet: str|null }
|
|
@@ -4758,269 +4410,39 @@ BrokenDocPath = { doc, target }
|
|
|
4758
4410
|
DuplicateGroup = { basis, key, docs: str[] } # basis ∈ {title, read_when}
|
|
4759
4411
|
```
|
|
4760
4412
|
|
|
4761
|
-
`status ∈ {found, missing, ambiguous}`. `artifact` paths are **relative to repo_root**
|
|
4762
|
-
The full shape is always serialized (no `exclude_unset`) so absent values render
|
|
4413
|
+
`status ∈ {found, missing, ambiguous}`. `artifact` paths are **relative to repo_root**
|
|
4414
|
+
(portable). The full shape is always serialized (no `exclude_unset`) so absent values render
|
|
4415
|
+
`null`. `EvidenceBundleOut` is deliberately absent from `shared/schemas/` (§8.34's registered set
|
|
4416
|
+
publishes `learn-capture` only), and there is no TS twin — the warm orchestrator shells the
|
|
4417
|
+
Python command.
|
|
4763
4418
|
|
|
4764
4419
|
**Category → source mapping.** `plan` (1; materializes `plan-body.md`), `pr` (1; materializes
|
|
4765
|
-
`pr.diff` when `found
|
|
4766
|
-
|
|
4767
|
-
`
|
|
4768
|
-
`
|
|
4769
|
-
`
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
`
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
`
|
|
4784
|
-
`head=<owner>:<branch>&state=all` list, all states; `find_pr_for_branch` is left unchanged). The PR
|
|
4785
|
-
source: `0` matches → `missing`; exactly one MERGED PR (even alongside closed/superseded PRs) →
|
|
4786
|
-
`found`; exactly one match (any state) → `found`; otherwise (`>1`, not exactly one merged) →
|
|
4787
|
-
**`ambiguous`** (no diff materialized).
|
|
4788
|
-
|
|
4789
|
-
**The existing-docs roots (node 3.1's basic inventory).** `scan_existing_docs(repo_root)` scans
|
|
4790
|
-
three conventional roots: `docs/learned/**/*.md` (frontmatter `title`/`read_when`),
|
|
4791
|
-
`docs/user-docs/**/*.md` (first `# ` heading + first paragraph), and `.perk/skills/*/SKILL.md`
|
|
4792
|
-
(frontmatter `name`/`description`). **Top-level `skills/` is deliberately excluded** — it is perk's
|
|
4793
|
-
own codebase, not the workflow-managed skill surface. Per entry: `DocEntry{kind, path, title,
|
|
4794
|
-
snippet}`, snippets bounded (`≈240` chars), sorted by path (deterministic); non-existent roots yield
|
|
4795
|
-
nothing.
|
|
4796
|
-
|
|
4797
|
-
**The rich existing-docs checker (node 5.1, `perk/learn/docs_scan.py::scan_docs_richly`).** A
|
|
4798
|
-
**deterministic, advisory** enrichment of the basic inventory: `scan_existing_docs` and the rich
|
|
4799
|
-
scan live together in a dependency-light pure leaf (`perk/learn/docs_scan.py`, imports only stdlib +
|
|
4800
|
-
`yaml` + `perk.boundary`) so node 6.1's `docs-check` reuses it without dragging in
|
|
4801
|
-
`github`/`backends`; `evidence.py` re-exports `DocEntry`/`scan_existing_docs` so existing call sites
|
|
4802
|
-
are byte-identical. `scan_docs_richly(repo_root) -> DocFindings` re-globs the same three roots,
|
|
4803
|
-
reads full bodies, is **deterministic** (sorted output, no wall-clock/random), **never raises**
|
|
4804
|
-
(per-doc try/except; `OSError` → skip), and is **bounded** (each doc read once; each finding family
|
|
4805
|
-
sorted **then** capped at `_MAX_FINDINGS = 200` — a pathological guard that never bites a normal
|
|
4806
|
-
corpus). It produces verifiable FACTS only; the de-dup **decision** is the analyst's (below).
|
|
4807
|
-
|
|
4808
|
-
- **`DocFindings`** (frozen dataclasses → `OutputModel` serialize edge): `stale_pointers:
|
|
4809
|
-
StalePointer[]`, `broken_doc_paths: BrokenDocPath[]`, `duplicate_groups: DuplicateGroup[]` (each
|
|
4810
|
-
always present, empty tuples when nothing found; empty `DocFindings()` on a skip bundle).
|
|
4811
|
-
- **Stale source pointers (phantoms) — the high-value check.** For each doc, inline-code spans
|
|
4812
|
-
(`` `([^`\n]+)` ``) whose *entire* content matches
|
|
4813
|
-
`^(?P<path>[\w./-]+\.(?:py|ts|tsx|js))(?:::(?P<symbol>[\w.]+))?$` **and** whose `path` first
|
|
4814
|
-
segment is in `_SOURCE_ROOTS = ("perk", "extension", "shared", "tests", "agents")` (the real
|
|
4815
|
-
source dirs — excludes example/third-party/runtime; `.md` is the broken-link rule). Verify:
|
|
4816
|
-
`repo_root/path` not a file → `StalePointer(reason="missing-file")`; file present **and** a
|
|
4817
|
-
`::symbol` present **and** `symbol.split(".")[-1]` **not** a substring of the file text →
|
|
4818
|
-
`StalePointer(reason="missing-symbol")`. Deduped per doc; sorted by `(doc, pointer)`.
|
|
4819
|
-
- **Broken doc paths — the routing-drift check.** Markdown links (`\[[^\]]*\]\(([^)]+)\)`); strip a
|
|
4820
|
-
trailing `#fragment`; keep **only** targets ending in `.md`; **skip** any target with whitespace
|
|
4821
|
-
or `|` (drops the validated false-positive code-snippet shapes `](cmd: C)` / `](scratch|runs)`)
|
|
4822
|
-
and `http(s)://`/`mailto:`. Resolve relative to the doc's parent dir (normalizing `..`, so
|
|
4823
|
-
cross-tree links resolve); a non-existent target → `BrokenDocPath`. Sorted by `(doc, target)`.
|
|
4824
|
-
(Catches stale `index.md` catalog links.)
|
|
4825
|
-
- **Duplicate / routing collisions — the cheap guard (rare by design).** Normalize =
|
|
4826
|
-
`" ".join(value.lower().split())`. Group **same-kind** docs by normalized non-empty `title`
|
|
4827
|
-
(≥2 → `basis="title"`); group **learned** docs by normalized non-empty `read_when` (≥2 →
|
|
4828
|
-
`basis="read_when"`). Sorted by `(basis, key)`; `docs` sorted within. Expected **empty** on a
|
|
4829
|
-
healthy curated corpus — it guards accidental literal duplication and is node 6.1's "duplicated
|
|
4830
|
-
read_when" substrate, **not** the dedup mechanism.
|
|
4831
|
-
- **De-dup is candidate-vs-corpus (the analyst, not Python).** The decision "does the learning being
|
|
4832
|
-
captured already live in an existing doc?" is the existing-docs angle's, made against the **full**
|
|
4833
|
-
`existing_docs[]` inventory **plus** these verified facts (it is never starved). The scan is
|
|
4834
|
-
corpus-wide and **high-recall** — learned docs carry historical pointers — so the analyst weighs
|
|
4835
|
-
findings by relevance to the candidate doc(s) for THIS capture (whole-corpus hygiene is node
|
|
4836
|
-
6.1's `docs-check`). Within-corpus exact collision is only the guard above.
|
|
4837
|
-
- **`gather_evidence`** calls `scan_docs_richly(repo_root)` unconditionally on a non-skip bundle and
|
|
4838
|
-
stores the result on `EvidenceBundle.docs_findings`; the `--json` envelope (`EvidenceBundleOut`)
|
|
4839
|
-
serializes `docs_findings: DocFindings` declared **after `existing_docs`, before `render`**, so
|
|
4840
|
-
the `manifest.json` write carries it automatically. The human summary line gains a findings tail
|
|
4841
|
-
(`docs: N (stale-ptr: a, broken-link: b, dup-groups: c)`).
|
|
4842
|
-
|
|
4843
|
-
**The session-normalization render (node 3.2, `perk learn evidence --render`).** An opt-in `--render`
|
|
4844
|
-
flag projects the bundle's **found** session JSONLs into bounded, untrusted-DATA-fenced Markdown
|
|
4845
|
-
chunks through a deterministic, ordered normalization pipeline, and (with `--json`) emits a stable
|
|
4846
|
-
normalization report on the envelope's additive `render` field. The decisions are ported from erk's
|
|
4847
|
-
`preprocess_session.py` (its *driving decisions*, not its code/form): bound by **splitting at entry
|
|
4848
|
-
boundaries**, never by leaving chunks uncapped and never by eliding the middle — every entry survives
|
|
4849
|
-
in some chunk; the only lossy compression is per-payload. perk reimplements them in its idiom
|
|
4850
|
-
(lenient boundary model → frozen dataclass → typed pipeline → `OutputModel` report) and diverges
|
|
4851
|
-
where it must (Pi sessions are a `parentId` tree, so the pipeline adds branch selection; perk
|
|
4852
|
-
preserves Pi `compaction`/`branch_summary` entries with their `readFiles`/`modifiedFiles`).
|
|
4853
|
-
|
|
4854
|
-
- **Two pure leaves.** `perk/learn/session_jsonl.py` is the **JSONL grammar parser** — a lenient
|
|
4855
|
-
`SessionEntryModel` (`LenientParseModel`, `extra="ignore"`) → a frozen `SessionEntry`/`ToolCall`
|
|
4856
|
-
projection via `to_domain` + `parse_session_jsonl(path) -> ParsedSession`. `perk/learn/normalize.py`
|
|
4857
|
-
is the **ordered pipeline + renderer + budget splitter + report** (`normalize_session`, the XML-ish
|
|
4858
|
-
renderer + `escape_xml`, `split_to_chunks`, the `RenderReport`/`SessionReport`/`BoilerplateDigest`
|
|
4859
|
-
dataclasses, and `render_evidence`). `normalize.py` imports `session_jsonl`; neither imports
|
|
4860
|
-
`evidence.py` (the command bridges them — no cycle).
|
|
4861
|
-
- **The Pi session JSONL grammar.** A session file is an append-only JSONL log: **line 1 is the
|
|
4862
|
-
header** (`{type:"session", …}`), each later line is one entry; a `parentId` tree threads entries
|
|
4863
|
-
and the last entry is the active leaf (the active branch is the `parentId` walk from it to the
|
|
4864
|
-
root). Entry types: `message` (`role ∈ user/assistant/toolResult` + `bashExecution`), `compaction`
|
|
4865
|
-
(`summary`, `tokensBefore`, `details.readFiles`/`modifiedFiles`), `branch_summary` (`fromId`,
|
|
4866
|
-
`summary`), `custom`/`custom_message` (extension/injected state), plus session mechanics
|
|
4867
|
-
(`model_change`/`thinking_level_change`/`label`/`session_info`). **Unknown future `type` values
|
|
4868
|
-
exist** (real logs carry `active_long_running`/`needs_attention`) → the parse edge is **lenient**:
|
|
4869
|
-
a non-JSON / non-object / type-less line is counted in `malformed_lines`, never raised; a missing
|
|
4870
|
-
file → an empty `ParsedSession`.
|
|
4871
|
-
- **The fixed ordered pipeline (deterministic).** (1) **Select branch evidence** — keep only entries
|
|
4872
|
-
on the leaf's `parent_id` chain (off-branch entries drop). (2) **Classify** — PRESERVED
|
|
4873
|
-
(`compaction`/`branch_summary`), EVIDENCE (`message`/`bashExecution`), BOILERPLATE (everything else,
|
|
4874
|
-
incl. unknown types). (3) **Drop boilerplate → digest** keyed by `<kind>` or `<kind>:<custom_type>`
|
|
4875
|
-
(emitted sorted by label). (4) **Dedup** — (a) byte-identical EVIDENCE payloads collapse to the
|
|
4876
|
-
first + a `↑ duplicate of entry <id>` pointer (one `duplicate_groups` per collapsed set); (b) an
|
|
4877
|
-
assistant entry repeating the previous assistant text AND carrying tool calls drops the duplicated
|
|
4878
|
-
text. PRESERVED entries are exempt. (5) **Prune** non-substantive turns (no text/thinking/tool
|
|
4879
|
-
calls/output/command). (6) **Truncate large payloads** (visible pointers): tool-call args + params
|
|
4880
|
-
head+tail (path-aware) at `_MAX_PARAM_CHARS=200`; tool-result/bash output line-prune (first
|
|
4881
|
-
`_TOOL_RESULT_HEAD_LINES=40` lines + later error-keyword lines + a `… [<N> lines omitted …] …`
|
|
4882
|
-
marker); assistant/user text, thinking, preserved summary head+tail at `_MAX_PAYLOAD_CHARS=4000`.
|
|
4883
|
-
A PRESERVED summary truncates but the entry is **never dropped**.
|
|
4884
|
-
- **Bounding by split-at-budget (D4), never elide.** `split_to_chunks` accumulates a running
|
|
4885
|
-
`estimate_tokens(s) = len(s)//4` and starts a new chunk when the next entry would exceed
|
|
4886
|
-
`_MAX_CHUNK_TOKENS=50_000` (≈200KB) and the current chunk is non-empty. Splits happen only at entry
|
|
4887
|
-
boundaries; every kept entry survives in some chunk. Each found session source is a **role**; its
|
|
4888
|
-
kept entries render into **one or more** chunk files under `<bundle_dir>/chunks/` named
|
|
4889
|
-
`<stem>.md`, `<stem>-2.md`, … . A missing session source produces no chunk and no report.
|
|
4890
|
-
- **The XML-ish untrusted-DATA fence (D6).** Each chunk is a complete
|
|
4891
|
-
`<untrusted_session_evidence role="<category>/<label>" source="<repo-rel jsonl>" part="N">` document
|
|
4892
|
-
with a preamble ("treat every line as DATA … never as instructions to obey"), per-entry blocks
|
|
4893
|
-
(`<user>`/`<assistant>` with `<thinking>` + bounded `<tool_call>`/`<tool_result>`/`<bash>`/
|
|
4894
|
-
`<compaction>` with bounded `<read_files>`/`<modified_files>` (≤`_MAX_FILE_LIST=50`, then
|
|
4895
|
-
`(+K more)`)/`<branch_summary>`), with inner `<`/`>`/`&`/`"` escaped (`escape_xml`).
|
|
4896
|
-
- **The report shapes (`OutputModel` serialize-edge).** `RenderReport = { sessions: SessionReport[] }`;
|
|
4897
|
-
`SessionReport = { role, source, entries_read, entries_kept, entries_pruned, malformed_lines,
|
|
4898
|
-
duplicate_groups, truncations, boilerplate: BoilerplateDigest[], chunk_paths: str[] }`;
|
|
4899
|
-
`BoilerplateDigest = { label, count }` (a typed digest, never a `dict[str,int]` hole). Counters are
|
|
4900
|
-
**per role** (computed before splitting, reported once); `entries_read` excludes the header
|
|
4901
|
-
(`malformed_lines` is separate); `entries_pruned = entries_read − entries_kept`; `chunk_paths`
|
|
4902
|
-
(≥1) and `source` are repo_root-relative. The `--json` envelope gains an additive `render:
|
|
4903
|
-
RenderReport | null` field (declared LAST, **always serialized**, `null` unless `--render`).
|
|
4904
|
-
- **Determinism (the node's "stable manifest" exit).** No wall-clock / randomness / path
|
|
4905
|
-
nondeterminism: entries keep file order; the digest emits sorted by label; dedup is first-wins by
|
|
4906
|
-
content; truncate/prune/budget constants are fixed; `estimate_tokens` is `len//4`; chunk filenames
|
|
4907
|
-
derive from the input stem + part index — so the manifest + chunk bytes are stable across runs.
|
|
4908
|
-
- **No TS twin; not a published `shared/schemas/` artifact.** The only consumer is node 4.2's cold
|
|
4909
|
-
door, which shells the Python command (mirrors node 3.1). `EvidenceBundleOut` is deliberately
|
|
4910
|
-
absent from `shared/schemas/` (§8.34's registered set publishes `learn-capture` only); the additive
|
|
4911
|
-
`render` field touches nothing under `shared/schemas/`. The JSONL parse is a `LenientParseModel`
|
|
4912
|
-
read-edge → frozen `@dataclass`; the report is an `OutputModel` serialize-edge (realizing this
|
|
4913
|
-
section's forward-looking discipline).
|
|
4914
|
-
|
|
4915
|
-
**The learn-analyst angle agent (node 4.1).** A perk-owned project agent
|
|
4916
|
-
`agents/learn-analyst.md` (runtime `perk.learn-analyst`) — fresh-context, read-only, and
|
|
4917
|
-
**report-only** (it never captures learnings, never creates a `perk:learn` issue, never posts,
|
|
4918
|
-
never stages or writes files, never spawns subagents). Delivered like its siblings via `PERK_AGENTS`
|
|
4919
|
-
+ the managed `.pi/agents/perk/` convergence. It is the cross-plane **output contract** node 4.2's
|
|
4920
|
-
warm `/learn` orchestrator parses; only the reconciliation logic is deferred.
|
|
4921
|
-
|
|
4922
|
-
- **Four angles** (one assigned per spawn, mirroring `pr-reviewer`): `plan-vs-implementation`
|
|
4923
|
-
(plan vs what shipped), `session-deviations` (course-corrections & durable gotchas),
|
|
4924
|
-
`validation-risk` (what stayed risky / under-tested), `existing-docs` (doc routing onto the
|
|
4925
|
-
manifest's `existing_docs[]` inventory **plus the node-5.1 `docs_findings`**: de-dup is
|
|
4926
|
-
candidate-vs-corpus — decide whether THIS capture's learning already lives in an existing doc,
|
|
4927
|
-
weighing `stale_pointers`/`broken_doc_paths`/`duplicate_groups` **by relevance** to the candidate
|
|
4928
|
-
doc(s); carries the two erk clauses — *VERIFY-not-HARMONIZE* (confirm both docs reference real,
|
|
4929
|
-
existing code before disambiguating) and *one-ghost-+-one-real → `STALE_DOC` the ghost*).
|
|
4930
|
-
- **Input.** The task prompt names (a) the assigned angle and (b) the absolute path to the
|
|
4931
|
-
`perk learn evidence --render --json` manifest (§8.35 above) plus the bundle dir. The child reads
|
|
4932
|
-
the shared bundle — manifest statuses, `existing_docs[]`, `render.sessions[].chunk_paths`,
|
|
4933
|
-
`plan-body.md`, `pr.diff` — and **never re-gathers** (the parent runs the gather once so all
|
|
4934
|
-
angles share one bundle). `missing`/`ambiguous` sources are surfaced in `fyi`, never guessed.
|
|
4935
|
-
- **Output (parsed by node 4.2).** A fenced JSON block `{angle, verdict, candidates[], fyi[]}`.
|
|
4936
|
-
`verdict ∈ {clean, actionable}` is **derived** — any candidate whose `decision` is not `SKIP` ⇒
|
|
4937
|
-
`actionable`, else `clean` (no default verdict — enumerate candidates first, then derive). Each
|
|
4938
|
-
candidate is `{decision ∈ the §8.35 DECISION set, summary, target: str|null, evidence}`. `SKIP`
|
|
4939
|
-
candidates may appear (a weighed-and-rejected item, for the parent's transparency); the durable
|
|
4940
|
-
CAPTURED metadata persists only non-`SKIP` decisions.
|
|
4941
|
-
- **Model** configurable via `[subagents] learn-analyst` (both planes; default
|
|
4942
|
-
`anthropic/claude-sonnet-4-5`, fallback `anthropic/claude-haiku-4-5`).
|
|
4943
|
-
- **Landed (node 4.2) — the warm `/learn` orchestrator.** Bare interactive `/learn` is a multi-angle
|
|
4944
|
-
orchestrator (`extension/doors/learn.ts`, mirroring `/pr-review`): **TS owns the deterministic
|
|
4945
|
-
spine** (gather + branch), **the model owns the judgment** (spawn / reconcile / capture).
|
|
4946
|
-
- **Gather once.** The parent runs `perk learn evidence --render --json` via `runColdDoor` (the
|
|
4947
|
-
single gather — §8.35 "the parent gathers once") and **also writes `<bundle_dir>/manifest.json`**
|
|
4948
|
-
(the full `EvidenceBundleOut` payload, the same as `--json` stdout incl. `render`) so the
|
|
4949
|
-
children can `read` the manifest (they cannot read the door's stdout). Written unconditionally on
|
|
4950
|
-
a materialized bundle (independent of `--json`), deterministic (no wall-clock); no write on a skip.
|
|
4951
|
-
- **Deterministic learn-docs short-circuit.** A success envelope `skipped:true` (a non-empty
|
|
4952
|
-
`consumed_learn` plan) → clear `pending-learn`, report *"learn-docs plan; learn capture skipped"*,
|
|
4953
|
-
inject **no** prompt, spawn **no** children, create **no** issue.
|
|
4954
|
-
- **Graceful degrade.** A gather failure (`!r.ok`) — or a success envelope with a null `bundle_dir`
|
|
4955
|
-
(defensive) — falls back to the prior simple `learnGuidance` injection (`/learn` is never a dead
|
|
4956
|
-
end). The evidence decode (`decodeEvidence`) is fully LENIENT — never null — so the `bad_output`
|
|
4957
|
-
arm is unreachable (mirrors `decodeLearnCapture`).
|
|
4958
|
-
- **Prompt-driven spawn/reconcile/capture.** Otherwise the parent injects the new warm-only
|
|
4959
|
-
`prompts/stages/learn-orchestrate.md` seed (rendered by `learnOrchestrateGuidance`), carrying the
|
|
4960
|
-
absolute manifest path + bundle dir + the configured `[subagents] learn-analyst` model (a per-call
|
|
4961
|
-
inline override on every spawn). The model spawns 2–4 fresh-context analysts (always incl.
|
|
4962
|
-
`session-deviations`, emphasizing off-track/dead-ends/wasted-effort), reconciles the per-angle
|
|
4963
|
-
`{angle, verdict, candidates[], fyi[]}` reports into ONE classified `decision` + a synthesized
|
|
4964
|
-
markdown body recording the per-angle nuance, then calls the `learn` tool to capture (with
|
|
4965
|
-
`decision`/`target`) or — on `SKIP`/nothing durable — with **no `summary`** (clears the marker, no
|
|
4966
|
-
issue). A missing/malformed child report is a skipped angle (noted, never fatal).
|
|
4967
|
-
- **The `learn` tool** gains `decision` (JSON-schema enum of the five captured tokens) + `target`
|
|
4968
|
-
(string) params; the tool-boundary decode mirrors the `summary` strictness (a present-but-mistyped
|
|
4969
|
-
or out-of-enum value ⇒ `bad_input`, marker NOT cleared; absent ⇒ the decision-less path).
|
|
4970
|
-
- **Unchanged paths.** Headless bare `/learn` stays the safe marker-clear (cannot drive a turn /
|
|
4971
|
-
spawn children); `/learn <text>` / `/learn skip` stay the verbatim-capture / marker-clear escape
|
|
4972
|
-
hatches (decision-less); cold `perk learn` launch stays the simple investigate+capture
|
|
4973
|
-
(`stages/learn.md` unchanged — the four `learn-*` golden cases + cold/warm parity preserved).
|
|
4974
|
-
**Deferred (out of scope):** cold-launch orchestration (the node is the warm orchestrator).
|
|
4975
|
-
|
|
4976
|
-
**Generated routing + on-demand checks (node 6.1) — `perk learn docs-sync` / `docs-check`.** The
|
|
4977
|
-
`docs/learned/` navigation is **generated from per-doc frontmatter** (the SSOT) and drift is
|
|
4978
|
-
**detectable on demand**, without wiring freshness into `just ci`/`just test`.
|
|
4979
|
-
|
|
4980
|
-
- **SSOT.** Every `docs/learned/**/*.md` doc **except `docs/learned/index.md`** carries `title` +
|
|
4981
|
-
`read_when` YAML frontmatter. The doc set's category = the posix relative dir under `docs/learned/`
|
|
4982
|
-
(e.g. `workflow`); slug = the filename stem; ordering is **alphabetical by `(category, slug)`** (no
|
|
4983
|
-
hardcoded category list). `read_learned_docs(repo_root)` (in `perk/learn/docs_scan.py`) is the
|
|
4984
|
-
shared, never-raising full-metadata reader (untruncated values; `OSError`/parse failure → `None`).
|
|
4985
|
-
- **Two generated artifacts** (`perk/learn/docs_sync.py`, a pure deterministic leaf importing only
|
|
4986
|
-
`docs_scan`):
|
|
4987
|
-
- **The terse routing block** → `.pi/APPEND_SYSTEM.md` (loaded ambiently into every session's
|
|
4988
|
-
system prompt): one line per doc, `- **<category>/<slug>** — <read_when>` (full, untruncated; no
|
|
4989
|
-
escaping — not a table).
|
|
4990
|
-
- **The per-doc catalog table** → `docs/learned/index.md`: a fixed header plus one
|
|
4991
|
-
`| <category> | [<slug>.md](<category>/<slug>.md) | <read_when, `|`-escaped> |` row per doc (links
|
|
4992
|
-
use the real filename; a table cell escapes `|`→`\|`).
|
|
4993
|
-
- **Markers + preamble.** Each artifact wraps its generated region in
|
|
4994
|
-
`<!-- BEGIN perk docs-sync … -->` / `<!-- END perk docs-sync -->`. `render_with_markers` replaces
|
|
4995
|
-
strictly **between** the markers when both are present (a hand-editable preamble outside them
|
|
4996
|
-
survives), else bootstraps `preamble + BEGIN + region + END`. Generation is **byte-for-byte
|
|
4997
|
-
deterministic** (sorted, fixed formatting, single trailing newline, no wall-clock/random) — re-running
|
|
4998
|
-
`docs-sync` on a converged tree is a no-op.
|
|
4999
|
-
- **`perk learn docs-sync`** (options `--json`, `--dry-run`; `require_repo` only — purely local) writes
|
|
5000
|
-
only artifacts whose content changed (`--dry-run` writes nothing) and reports `written`/`unchanged`.
|
|
5001
|
-
Exit `0` ok · `2` not-a-repo.
|
|
5002
|
-
- **`perk learn docs-check`** (option `--json`; `require_repo` only; read-only) splits **freshness**
|
|
5003
|
-
(each artifact's live marked region must match a fresh render — absent markers or a mismatch ⇒
|
|
5004
|
-
stale) from **advisory hygiene** (`missing_frontmatter`, `source_code_blocks`, plus the reused
|
|
5005
|
-
`docs_scan.scan_docs_richly` dup-`read_when`/stale-pointer/broken-link facts). **Freshness gates the
|
|
5006
|
-
exit; hygiene is advisory** (always printed, never changes a fresh exit): exit `0` fresh · `1` stale
|
|
5007
|
-
· `2` not-a-repo. **Not added to `[[ci]]`** (a deliberate follow-up — promoting freshness into CI is
|
|
5008
|
-
a one-line `[[ci]]` add gating only on freshness).
|
|
5009
|
-
- **The source-code-block heuristic.** A fenced block is flagged copied-source-looking only when its
|
|
5010
|
-
info-string is a source language (`py/python/ts/typescript/js/javascript/tsx/jsx/rust/rs/go`) **and**
|
|
5011
|
-
its body has `>= 10` non-blank lines (`_MAX_SOURCE_BLOCK_LINES`). Data-format/CLI fences
|
|
5012
|
-
(`json/yaml/toml/text/console/sh/bash/diff/ini`) and untagged fences are always allowed. Advisory only.
|
|
5013
|
-
- **Live-tree converge.** Node 6.1 ran `docs-sync` once and committed the regenerated
|
|
5014
|
-
`docs/learned/index.md` + `.pi/APPEND_SYSTEM.md`. A deliberate, documented consequence: the routing
|
|
5015
|
-
order is now `pi`, `toolchain`, `workflow` (alphabetical), the per-category mega-line blob is gone
|
|
5016
|
-
(replaced by per-doc lines), and the catalog's renamed-file links + row counts are corrected.
|
|
4420
|
+
`pr.diff` when `found`; `0` branch matches → `missing`, exactly one MERGED match — or exactly one
|
|
4421
|
+
match of any state — → `found`, otherwise → **`ambiguous`**, no diff materialized),
|
|
4422
|
+
`planning-session` (2: `main`/`worker`), `implementation-session` (per `impl_run_ids` entry ×
|
|
4423
|
+
`main`/`worker`; **one `missing` entry labelled `(none)`** when there are no impl runs),
|
|
4424
|
+
`existing-docs` (1 roll-up: `found` when the inventory is non-empty, else `missing`; the detail
|
|
4425
|
+
rides the separate `existing_docs[]` + `docs_findings`).
|
|
4426
|
+
|
|
4427
|
+
**The `manifest.json` write rule.** The warm orchestrator runs the gather ONCE (`perk learn
|
|
4428
|
+
evidence --render --json`) and **also writes `<bundle_dir>/manifest.json`** — the full
|
|
4429
|
+
`EvidenceBundleOut` payload, the same as `--json` stdout incl. `render` — so the spawned analyst
|
|
4430
|
+
children can `read` the manifest (they cannot read the door's stdout). Written unconditionally on
|
|
4431
|
+
a materialized bundle, deterministic (no wall-clock); no write on a skip.
|
|
4432
|
+
|
|
4433
|
+
**The `learn` tool's classification params.** The warm `learn` tool carries `decision` (a
|
|
4434
|
+
JSON-schema enum of the five captured tokens) + `target` (string), threaded to `perk learn
|
|
4435
|
+
capture --decision/--target`. The tool-boundary decode mirrors the `summary` strictness: a
|
|
4436
|
+
present-but-mistyped or out-of-enum value ⇒ `bad_input`, marker NOT cleared; absent ⇒ the
|
|
4437
|
+
decision-less path. Headless bare `/learn` stays the safe marker-clear; `/learn <text>` /
|
|
4438
|
+
`/learn skip` stay the verbatim-capture / marker-clear escape hatches (decision-less).
|
|
5017
4439
|
|
|
5018
4440
|
## §8.36 · Canonical post-merge learn state (the plan-header `learn_state` field)
|
|
5019
4441
|
|
|
5020
4442
|
Post-merge learn state is **canonical in the issue backend**, not the local marker: the plan-header
|
|
5021
4443
|
carries a land-staged `learn_state` field, so a merged-but-unlearned plan resolves identically from
|
|
5022
|
-
any machine, a fresh clone, or the main checkout. The local `pending-learn` marker (§
|
|
5023
|
-
|
|
4444
|
+
any machine, a fresh clone, or the main checkout. The local `pending-learn` marker (§8.4) is
|
|
4445
|
+
**demoted to cache/friction-semaphore**: the in-worktree retry
|
|
5024
4446
|
signal and the `worktree wipe` guard — never the source of truth.
|
|
5025
4447
|
|
|
5026
4448
|
**Vocabulary (`plan.LearnState`, a `StrEnum`; `"learn_state"` ∈ `PLAN_HEADER_FIELDS`).**
|
|
@@ -5038,13 +4460,20 @@ preserved on re-save).
|
|
|
5038
4460
|
|
|
5039
4461
|
**The three writers.**
|
|
5040
4462
|
|
|
5041
|
-
1. **`perk pr land`** (`_stamp_learn_state`, non-dry-run, after merge
|
|
4463
|
+
1. **`perk pr land`** (`_stamp_learn_state`, non-dry-run, after the merge; `set_marker` runs only
|
|
4464
|
+
on the non-exempt arm): stamps
|
|
5042
4465
|
`skipped` when `plan_ref.consumed_learn` is non-empty (a learn-docs consolidation plan skips its
|
|
5043
|
-
learn pass by design — it must never read forever-pending)
|
|
4466
|
+
learn pass by design — it must never read forever-pending) **and sets no marker** (the plan is
|
|
4467
|
+
exempt from the land→learn cycle; the envelope carries `pending_learn: false`); every other
|
|
4468
|
+
plan keeps today's set-marker + `pending` stamp (`pending_learn: true`). The warm `/land`
|
|
4469
|
+
mirrors the envelope's `pending_learn` (lenient decode — missing/mistyped defaults to `true`
|
|
4470
|
+
under version skew, degrading to the legacy marker + `/learn` nudge). **Never-downgrade
|
|
5044
4471
|
guard**: an existing `captured`/`skipped` is kept (an idempotent re-land after `/learn` must not
|
|
5045
4472
|
resurrect a done plan) and returned as the effective state. **Fail-open loud** (the on-land
|
|
5046
|
-
secondary-bookkeeping shape): never raises
|
|
5047
|
-
`
|
|
4473
|
+
secondary-bookkeeping shape): never raises on an expected backend failure
|
|
4474
|
+
(`IssueBackendError`) — it warns on stderr and the envelope carries `learn_state: null`; a
|
|
4475
|
+
programming error propagates. `PrLandOut.learn_state` is declared last (field byte-order
|
|
4476
|
+
preserved).
|
|
5048
4477
|
2. **`perk learn capture`**: stamps `captured` **strictly** (an `IssueBackendError` propagates,
|
|
5049
4478
|
exit 1) and **before** `cache.clear_marker` — the local marker is cleared only once canonical
|
|
5050
4479
|
state is terminal; a failed stamp leaves the marker set and the retry converges (capture is
|
|
@@ -5060,7 +4489,9 @@ preserved on re-save).
|
|
|
5060
4489
|
delegation the warm door does NOT clear the marker (never silently close the cycle on
|
|
5061
4490
|
uncertainty). The warm decode is fully lenient (render-only fields; `bad_output` unreachable).
|
|
5062
4491
|
The learn-docs short-circuit in bare `/learn` stays a local marker-clear only — land already
|
|
5063
|
-
stamped `skipped` for a `consumed_learn` plan
|
|
4492
|
+
stamped `skipped` for a `consumed_learn` plan (and, since the land→learn exemption, sets no
|
|
4493
|
+
marker for it — the short-circuit remains as the defensive path for markers set by older
|
|
4494
|
+
CLIs / legacy lands).
|
|
5064
4495
|
|
|
5065
4496
|
**The reader (`resume.resolve_next_action`'s MERGED arm, §8.37).**
|
|
5066
4497
|
|
|
@@ -5133,5 +4564,229 @@ change requests).
|
|
|
5133
4564
|
### The parity guarantee
|
|
5134
4565
|
|
|
5135
4566
|
For the same plan state, `perk plan resume <id> --dry-run --json` and
|
|
5136
|
-
`perk objective run <N> --dry-run --json` report the **same `next_action`**
|
|
5137
|
-
(`
|
|
4567
|
+
`perk objective run <N> --dry-run --json` report the **same `next_action`** — and, for
|
|
4568
|
+
launchable verdicts, select the **same stage** (`resumed_stage` == `stage`), modulo the named
|
|
4569
|
+
learn divergence (§8.38 row 1) — `tests/test_next_action_parity.py`.
|
|
4570
|
+
|
|
4571
|
+
## §8.38 · Per-stage path parity (warm / cold-local / remote)
|
|
4572
|
+
|
|
4573
|
+
The "one implementation per stage" claim (`docs/user-docs/explanation/how-perk-thinks.md`),
|
|
4574
|
+
backed by tests: on the six surfaces where the warm, cold-local, and remote paths meet, each
|
|
4575
|
+
row names the **shared implementation**, the **enforcing tests**, and — where a path
|
|
4576
|
+
intentionally differs — the **named difference** (the docs name it instead of implying
|
|
4577
|
+
identity).
|
|
4578
|
+
|
|
4579
|
+
| # | surface | shared implementation | enforced by |
|
|
4580
|
+
|---|---|---|---|
|
|
4581
|
+
| 1 | next-action resolution | `resume.resolve_next_action` (§8.37) — consumed by `plan resume` and the `objective run` supervisor (incl. its remote dispatch arm) | `tests/test_next_action_parity.py` (verdict **and** stage-selection equality across both dry-runs), `tests/test_resume.py` |
|
|
4582
|
+
| 2 | prompt generation (local vs worker) | canonical templates `prompts/stages/*` via the §8.31 render seam; `_implement_prompt`/`_address_prompt` ↔ `initialPromptFor` ↔ `implementHandoffPrompt`/`addressGuidance` | `tests/test_prompt_parity.py` (live cross-engine byte parity) + goldens; reciprocal substring suites `tests/test_worker_prompt_parity.py` ↔ `extension/worker/worker.test.ts`; binding-content byte parity `tests/test_binding_render_parity.py` (via `extension/testing/renderBindingsLive.ts`) |
|
|
4583
|
+
| 3 | submit side effects | one Python door, `perk pr submit --json`; the warm `submit` tool/`/submit` command delegate via `submitPr` (`extension/doors/submit.ts`), and the remote worker drives that same registered tool | `extension/worker/workerE2e.test.ts` (implement HAPPY drives the real tool through the real extension into a stubbed `PERK_BIN` router), `extension/doors/submit.test.ts`, `tests/test_pr_submit.py` |
|
|
4584
|
+
| 4 | address terminal criteria | the `resolve_review_threads` tool (`extension/doors/address.ts`) delegates to `perk pr resolve-threads --json` and appends `last_review_batch`; the worker's terminal predicate (`evaluateTerminal`) reads exactly that write | `workerE2e.test.ts` (address HAPPY binds the real door write to the worker classification), `worker.test.ts` `evaluateTerminal` matrix; post-address the supervisor re-classifies via row 1 |
|
|
4585
|
+
| 5 | plan-ref reconstruction + positioning | one function, `resume.reconstruct_plan_ref` — all four reconstruction sites converge on it (`plan/resume_cmd.py`, `objective/run_cmd.py`, `implement_cmd.py`, `run/run_worker.py`); `run_worker.position_worktree` mirrors `launch_stage`'s positioning | `tests/test_plan_ref_parity.py` (the save→reconstruct round trip + the `PlanRef` field census), `tests/test_resume.py`, `tests/test_run_worker.py::test_positioning_parity_local_launch_vs_remote_worker` (artifact byte parity, `run_id` excepted) |
|
|
4586
|
+
| 6 | run reporting | **remote-only by design**: `perk/run/run_report.py` derives the §8.15 plan-issue comments + job summary solely from the §8.12 events stream + exit code | `tests/test_run_report.py` (incl. the `RunOutcome` lockstep literals) ↔ `worker.test.ts` (the frozen `assembleOutcome` shapes) |
|
|
4587
|
+
|
|
4588
|
+
### The named intentional differences
|
|
4589
|
+
|
|
4590
|
+
1. **`learn` is resume-only.** `perk plan resume` launches the `learn` stage locally; the
|
|
4591
|
+
`objective run` supervisor never dispatches it — it reports `merged_pending_reconcile` with a
|
|
4592
|
+
`perk plan resume <id>` remediation. `submit`/`land`/`learn` have no remote door (registry
|
|
4593
|
+
`cold_remote: false`).
|
|
4594
|
+
2. **Binding delivery mechanism differs; content does not.** Cold-local launches append the
|
|
4595
|
+
rendered bindings as a prompt suffix (`render_cold_bindings`); warm sessions and the remote
|
|
4596
|
+
worker receive the same render via §8.9 Mechanism A (in-session injection), dedup'd by
|
|
4597
|
+
`BINDING_HEADER`. Content byte-parity is enforced (`tests/test_binding_render_parity.py`).
|
|
4598
|
+
Skill *installation* also differs by path: cold-local mirrors `repo_root/.agents/skills/`
|
|
4599
|
+
into the worktree (`materialize_skills`, loud-but-non-fatal); the remote worker populates the
|
|
4600
|
+
checkout's `.agents/skills/` via the skills-CLI sync during positioning (**fatal**,
|
|
4601
|
+
`skills_sync_failed` — §8.14 step 3). Binding *content* parity is unchanged either way.
|
|
4602
|
+
3. **`address --preview` is local-only.** The classify-only preview flag exists on the
|
|
4603
|
+
warm/cold-local doors; the remote worker always renders the action template.
|
|
4604
|
+
4. **The `--run-id` impl-run stamp + the conflict-resolver drive need a session.** `submitPr`
|
|
4605
|
+
stamps the implement run (workflow-state `run_id`) and drives conflict resolution
|
|
4606
|
+
(`driveConflictResolution`) only where a session exists (warm + worker); a bare shell
|
|
4607
|
+
`perk pr submit` *reports* `mergeable`/`conflicts` without driving resolution.
|
|
4608
|
+
5. **Terminal classification is worker-only.** Only the headless worker machine-classifies a
|
|
4609
|
+
stage terminal (`evaluateTerminal`); warm/cold-local stages end with the human observing the
|
|
4610
|
+
same tool results.
|
|
4611
|
+
6. **Run reporting (§8.15) is remote-only.** Local runs are observed directly (the terminal /
|
|
4612
|
+
the session); no started/terminal plan-issue comments are posted for them.
|
|
4613
|
+
7. **Skill-exposure scoping (§8.39) is cold-local-only.** Only the cold-local launch composes
|
|
4614
|
+
the `--no-skills`/`--skill` scoping argv; the remote worker builds its session via the SDK
|
|
4615
|
+
(no pi-CLI arg parsing) and gets skills on disk via the skills-CLI sync (difference 2) — no
|
|
4616
|
+
scoping applies there. Warm sessions and bare interactive `pi` are likewise untouched.
|
|
4617
|
+
|
|
4618
|
+
## §8.39 · The layered skills-exposure model (cold stage launches)
|
|
4619
|
+
|
|
4620
|
+
A cold stage launch may scope pi's skill discovery to the skills relevant to its stage instead of
|
|
4621
|
+
inheriting the full unscoped set. The Python plane owns the whole mechanism
|
|
4622
|
+
(`perk/substrate/skill_exposure.py`, composed into the launch argv by
|
|
4623
|
+
`perk/run/launch/__init__.py::_skill_exposure_argv`); the TS plane deliberately does **not**
|
|
4624
|
+
consume the `[skills]` namespace (its `parseTomlSubset` drops array values and keeps scalars under
|
|
4625
|
+
dotted sections — fail-safe by construction, pinned by a non-interference test).
|
|
4626
|
+
|
|
4627
|
+
**The three layers.** For each candidate skill, exposure resolves as:
|
|
4628
|
+
|
|
4629
|
+
1. a **`[skills.stages]` config row** (keyed by skill name — frontmatter `name` else the skill
|
|
4630
|
+
dir name) — wins whenever the key is present, including a config `"all"` re-widening a
|
|
4631
|
+
narrower frontmatter declaration;
|
|
4632
|
+
2. the skill's **`stages:` SKILL.md frontmatter** — the string `all`, or a list of registry
|
|
4633
|
+
stage ids (pi ignores unknown frontmatter fields, so the declaration is upstream-safe);
|
|
4634
|
+
3. **undeclared → `all`** (fail-open; an undeclared skill behaves like today).
|
|
4635
|
+
|
|
4636
|
+
A skill is exposed to a launch iff its resolved value is `all` or contains the launch stage's id.
|
|
4637
|
+
An **explicit empty list** (`stages: []` or a `= []` config row) means exposed to **no** stage
|
|
4638
|
+
launches (an interactive-only skill; bare interactive sessions are untouched). A **malformed**
|
|
4639
|
+
`stages:` value (wrong type, blank/non-string entries, unparseable frontmatter) is treated as
|
|
4640
|
+
`all` + one warning (fail-open, loud-but-non-fatal). Unknown stage ids are kept, inert — the
|
|
4641
|
+
parser stays registry-free (mirroring `[models.stages.<id>]`); doctor owns any nudge. The
|
|
4642
|
+
vocabulary is **stage ids only**: stage-borrowing commands resolve through the stage they borrow
|
|
4643
|
+
(a `learn-docs` session sees `plan`-staged skills); their own orchestration skill arrives via the
|
|
4644
|
+
bound-skill union on their `command:<id>` trigger.
|
|
4645
|
+
|
|
4646
|
+
**Bound skills always win.** Any skill referenced by a resolved binding (§8.9;
|
|
4647
|
+
shipped-defaults ⊕ user overlay) whose trigger equals the launch trigger (`binding_trigger` else
|
|
4648
|
+
`stage:<stage.id>` — the same defaulting the seed-prompt assembler uses) is unioned into the
|
|
4649
|
+
exposed set, trumping every layer including an explicit `= []` row — even when not installed
|
|
4650
|
+
(the entry dangles and pi emits its own missing-path diagnostic, the existing dangling-binding
|
|
4651
|
+
symptom; remediation `perk init`).
|
|
4652
|
+
|
|
4653
|
+
**The `[skills]` config namespace** (overlay-aware via `load_config` — `.perk/local.toml`
|
|
4654
|
+
dominates; a local `include_dirs` array replaces wholesale, matching `[worktree] setup`):
|
|
4655
|
+
|
|
4656
|
+
- `include_dirs` (default `[]`): a whitelist of directories passed wholesale as `--skill <dir>`
|
|
4657
|
+
args. Default: pi's global/user skill dirs (`~/.pi/agent/skills`, `~/.agents/skills`) and
|
|
4658
|
+
project `.pi/skills` are **dropped** from scoped launches unless whitelisted. Entries get
|
|
4659
|
+
`~`-expansion; relative entries resolve against the **main repo root** and are passed
|
|
4660
|
+
**absolute** (relative entries would silently break in worktree sessions).
|
|
4661
|
+
- `include_packages` (`bool`; unset = participate): the blanket toggle for the npm-package tier.
|
|
4662
|
+
An explicitly-set value (either way) counts toward engagement.
|
|
4663
|
+
- `[skills.stages]`: skill name → `"all"` or a list of stage-id strings, applying to project
|
|
4664
|
+
**and** package skills by name. Ill-typed values raise `ConfigError` (the standard loud
|
|
4665
|
+
posture); unknown skill names are kept inert.
|
|
4666
|
+
|
|
4667
|
+
**Engagement.** The composition engages only when the model is in use: at
|
|
4668
|
+
least one enumerated skill (project or package) declares `stages:`, **or** any `[skills]` config
|
|
4669
|
+
content exists (`stages` rows, non-empty `include_dirs`, or `include_packages` explicitly set).
|
|
4670
|
+
Otherwise it contributes nothing and the launch argv (and stderr) is **byte-identical** to
|
|
4671
|
+
unscoped discovery. Enumeration always runs to detect frontmatter declarations. The zero-change
|
|
4672
|
+
rollout clause is now **historical**: perk's shipped skills declare `stages:` at source, so any
|
|
4673
|
+
repo whose `.agents/skills/` mirror is synced to current perk is **engaged by default** — an
|
|
4674
|
+
un-synced mirror stays unengaged (fail-open) until the next `perk init`/`doctor --fix` re-sync.
|
|
4675
|
+
Personal/global skill dirs then need the `include_dirs` whitelist to reach scoped launches. New
|
|
4676
|
+
repo-authored skills are **born declared**: the `perk skills scaffold`/`create` stub template
|
|
4677
|
+
declares `stages: all` (with a narrowing TODO), and doctor's `repo-skills` check warns on
|
|
4678
|
+
repo-authored skills that leave `stages:` undeclared or declare unknown stage ids.
|
|
4679
|
+
|
|
4680
|
+
**The composed argv.** When engaged, `launch_stage` inserts, between the per-stage model args and
|
|
4681
|
+
`pi_args` (build-argv-once, so `--dry-run --json` previews it and user-passed flags stay last;
|
|
4682
|
+
an extra user `--skill` stays additive — pi merges explicit skill paths even under
|
|
4683
|
+
`--no-skills`):
|
|
4684
|
+
|
|
4685
|
+
1. `--no-skills`;
|
|
4686
|
+
2. the `include_dirs` whitelist entries (absolute `--skill <dir>`, config order);
|
|
4687
|
+
3. the **npm-package skills** (unless `include_packages = false`): from `.pi/settings.json`
|
|
4688
|
+
`packages` (strings or `{source}` rows), **`npm:` sources only** →
|
|
4689
|
+
`.pi/npm/node_modules/<name>`. Local-path sources (the self-repo's `".."`) and `git:` sources
|
|
4690
|
+
are deliberately **not** enumerated — first-party skills come from `.agents/skills` full stop
|
|
4691
|
+
(no committed-`skills/` fallback); enumerating the self-repo's local-path (`..`) package would
|
|
4692
|
+
also re-import the committed-`skills/` vs `.agents/skills` name-collision noise (the 16-way
|
|
4693
|
+
duplicate set in the self-repo) into scoped sessions. Per package, skill roots = `pi.skills` plain-path entries
|
|
4694
|
+
when declared, else the conventional `skills/` dir; each root is enumerated one level
|
|
4695
|
+
(`<root>/<name>/SKILL.md`), each skill resolved through the three layers. A root with no
|
|
4696
|
+
one-level `SKILL.md` children degrades to one wholesale `--skill <root>` arg; a pattern
|
|
4697
|
+
(non-path) `pi.skills` entry degrades the package to one wholesale `--skill <package dir>`
|
|
4698
|
+
arg. Paths are repo-relative (the worktree `.pi/npm` clone from `materialize_extensions`
|
|
4699
|
+
makes them resolve in worktree sessions);
|
|
4700
|
+
4. the **project skills**: each child dir of `repo_root/.agents/skills/` (the exact set
|
|
4701
|
+
`materialize_skills` mirrors — the exposure path reads `.agents/skills` **only**; a
|
|
4702
|
+
just-landed un-synced skill is softly absent until `perk init`), resolved through the three
|
|
4703
|
+
layers; exposed ones become relative `--skill .agents/skills/<name>` args, sorted by name
|
|
4704
|
+
(bound-but-unenumerated skills join this tier as dangling delivery-path entries).
|
|
4705
|
+
|
|
4706
|
+
Relative paths resolve against pi's cwd *after* `launch_stage`'s `os.chdir` — the worktree for
|
|
4707
|
+
worktree stages (mirror + `.pi/npm` clone exist by exec time), the repo root otherwise. The
|
|
4708
|
+
2→3→4 order fixes first-wins collision outcomes (whitelisted dirs > packages > project),
|
|
4709
|
+
approximating pi's native user-before-project precedence.
|
|
4710
|
+
|
|
4711
|
+
**Fail-open ladder.** The whole composition is wrapped: any unexpected exception → one warning +
|
|
4712
|
+
**no flags** (the launch degrades to unscoped discovery; never blocked). A listed `npm:` package
|
|
4713
|
+
whose install dir is absent at composition time (cold `.pi/npm`, first launch), or an
|
|
4714
|
+
unreadable/malformed `.pi/settings.json` while the package tier is enabled, degrades the
|
|
4715
|
+
**whole composition** to unscoped + a warning (argv is built before the warm-install phase, so
|
|
4716
|
+
this is the honest fail-open — per-package skips would silently drop whole packages; it
|
|
4717
|
+
self-heals on the next launch). Per-skill soft issues (unreadable/malformed SKILL.md or
|
|
4718
|
+
`stages:`) default that skill to `all` + a warning. The only loud failure is `ConfigError` from
|
|
4719
|
+
`load_config` — the pre-existing config gate, raised before composition runs.
|
|
4720
|
+
|
|
4721
|
+
**Scope boundaries.** Cold-local stage launches only: bare interactive `pi`, warm in-session
|
|
4722
|
+
transitions, and the remote worker (§8.38 named difference 7) are untouched.
|
|
4723
|
+
|
|
4724
|
+
---
|
|
4725
|
+
|
|
4726
|
+
## §8.40 · Stage-scoped active tools (the warm plane)
|
|
4727
|
+
|
|
4728
|
+
A stage session's model carries only the perk tool schemas its stage's flows can actually invoke.
|
|
4729
|
+
The mechanism is extension-owned end to end: a curated per-stage map (`STAGE_TOOLS`, beside
|
|
4730
|
+
`READ_ONLY_TOOLS` in `extension/substrate/toolGating.ts`, keyed by registry stage ids) applied at
|
|
4731
|
+
the existing `session_start`/`session_tree` rebuild points via `syncFromState(mode, stage)`. The
|
|
4732
|
+
key is the branch-LWW workflow-state **`stage`** field (§8.3): claim syncs the handoff-recorded
|
|
4733
|
+
stage just appended; keep/none sync the branch-rebuilt stage; **fork inherits** the parent's
|
|
4734
|
+
stage (a forked implement session is an implement session); **adopt never impersonates** (spawned
|
|
4735
|
+
subagent children stay unscoped — their fresh branch carries no stage). Stage-borrowing cold
|
|
4736
|
+
doors land on real stage ids (`plan from`/`plan replan`/`learn docs`/`learn code` borrow `plan`;
|
|
4737
|
+
`objective replan`/`objective author --from` borrow `objective-author`; `skills create/refine`
|
|
4738
|
+
borrow `save`), so the per-stage sets cover every borrower. **Scoped universe:
|
|
4739
|
+
`PERK_TOOLS ∪ BORROWED_TOOLS`** — perk's own name-keyed census plus the enumerated
|
|
4740
|
+
borrowed-package census (the web-provider union, pi-mono-linear's 25 tools, pi-subagents'
|
|
4741
|
+
delegation four, `todo`, `plannotator_submit_plan`); builtins and un-enumerated foreign names
|
|
4742
|
+
pass through untouched (fail-open — enumeration is diet-completeness, not correctness).
|
|
4743
|
+
|
|
4744
|
+
**The borrowed census posture.** Static names, inert when absent (the `READ_ONLY_TOOLS`
|
|
4745
|
+
posture — `setActiveTools` simply has nothing to enable; no presence detection). Every census
|
|
4746
|
+
name registers at load time EXCEPT pi-subagents' parent supervisor pair (`subagent_supervisor`,
|
|
4747
|
+
`intercom`), which registers during `session_start` after perk's sync and deliberately leaks
|
|
4748
|
+
past rebuild-point filtering at launch (accepted + test-pinned; a later tree-navigation
|
|
4749
|
+
re-apply filters over the original snapshot — which lacks the late names — and drops them, the
|
|
4750
|
+
pre-existing snapshot behavior). `ask_user_question` stays governed ONCE, name-keyed, via
|
|
4751
|
+
`PERK_TOOLS` (the foreign askuser provider registers the identical name — the name must never
|
|
4752
|
+
appear in `BORROWED_TOOLS`; hygiene-tested). Foreign packages that run their own
|
|
4753
|
+
`setActiveTools` (plannotator's phase machinery, @tombell/pi-plan's plan mode) win between
|
|
4754
|
+
perk's rebuild points (the fail-open direction), and a mid-session rebuild re-installs perk's
|
|
4755
|
+
stage set over a foreign restriction — recorded interplay, not re-engineered. Stage placement:
|
|
4756
|
+
the research families (web union + Linear reads) ride EVERY stage list; delegation
|
|
4757
|
+
(`subagent`/`wait`/the supervisor pair) and `todo` are worktree-family only among the gate-OFF
|
|
4758
|
+
stage lists (delegation additionally rides the read-only gate — §8.3);
|
|
4759
|
+
`LINEAR_MUTATING_TOOLS` (incl. `linear_configure_auth`, which writes `~/.pi/agent/auth.json`)
|
|
4760
|
+
and `plannotator_submit_plan` appear in NO stage list — in the census, so subtracted from every
|
|
4761
|
+
stage session; bare/unscoped sessions keep full access. Child-session tools
|
|
4762
|
+
(`contact_supervisor`, `structured_output`) are out of scope — spawned children stay unscoped
|
|
4763
|
+
by design (adopt-never-impersonates above).
|
|
4764
|
+
|
|
4765
|
+
**Composition with the read-only gate (§8.3).** Gate ON → `setActiveTools(READ_ONLY_TOOLS)`
|
|
4766
|
+
**unchanged** — no stage filter, preserving every gated carve-out byte-for-byte (a strict
|
|
4767
|
+
intersection would break the documented warm `/objective-plan` carve-out and recreate the
|
|
4768
|
+
seed/gate contradiction class); the gated set includes the delegation family, so the
|
|
4769
|
+
objective-plan explorer spawn stays reachable while gated. Gate OFF + known stage → a **subtractive filter over the one
|
|
4770
|
+
shared pre-engagement snapshot**: non-perk names pass through; perk names survive only when the
|
|
4771
|
+
stage's list carries them. The rule "the gate never widens a stage's set and vice versa" holds:
|
|
4772
|
+
engaging the gate only ever narrows, and stage scoping never adds a tool. Both concerns share
|
|
4773
|
+
ONE snapshot, taken on first engagement of either; neither engaged → restore the snapshot if one
|
|
4774
|
+
exists. The worktree family (implement/submit/address/land/learn) is deliberately **one shared
|
|
4775
|
+
PR-loop list** — any PR-loop warm command works in any worktree session (warm doors inject
|
|
4776
|
+
guidance naming their companion tool; a per-stage cut would dead-end e.g. `/land` run inside the
|
|
4777
|
+
implement session). The reconcile trio (`reconcile_objective`/`add_objective_node`/
|
|
4778
|
+
`objective_node`) rides the worktree family in addition to the three objective stages: `/land`
|
|
4779
|
+
auto-drives the objective-reconcile pass inside the current worktree session and the manual
|
|
4780
|
+
`/objective-reconcile` gesture is registered globally — both inject guidance naming all three;
|
|
4781
|
+
`objective_node` likewise rides all three objective stages (the guidance's node-description
|
|
4782
|
+
reconcile).
|
|
4783
|
+
|
|
4784
|
+
**Fail postures.** Stage scoping is **fail-open** where the gate is fail-closed: no stage, an
|
|
4785
|
+
unknown stage id (version skew), or any lookup miss → no filtering. Vacated/absent tool names
|
|
4786
|
+
are inert (`setActiveTools` ignores unknown names — e.g. `ask_user_question` under a foreign
|
|
4787
|
+
`[providers] askuser` selection registers the identical name, so name-keyed scoping governs
|
|
4788
|
+
both). There is no `tool_call` backstop for stage scoping (schema removal is the same structural
|
|
4789
|
+
lever the gate's allowlist uses; `edit`/`write`/`bash` blocking remains the gate's job) and no
|
|
4790
|
+
config surface for the map (the §8.39 non-interference posture; fail-open on unknown ids covers
|
|
4791
|
+
version skew). **Bare-session zero-change guarantee:** a session that never engages either
|
|
4792
|
+
concern gets **zero `setActiveTools` calls** — bare warm sessions stay byte-identical.
|