@mgiles/perk 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +7 -21
  2. package/extension/checkpoints/checkpoints.ts +2 -1
  3. package/extension/doors/address.ts +2 -1
  4. package/extension/doors/ciExecutor.ts +3 -2
  5. package/extension/doors/land.ts +2 -1
  6. package/extension/doors/learn.ts +239 -33
  7. package/extension/doors/learnCode.ts +100 -0
  8. package/extension/doors/learnDocs.ts +4 -3
  9. package/extension/doors/lifecycleGates.ts +2 -1
  10. package/extension/doors/prReview.ts +20 -35
  11. package/extension/doors/prReviewLocal.ts +229 -0
  12. package/extension/doors/ready.ts +2 -1
  13. package/extension/doors/selfcheck.ts +2 -1
  14. package/extension/doors/submit.ts +17 -19
  15. package/extension/factories/implementHere.ts +116 -0
  16. package/extension/factories/objective.ts +2 -1
  17. package/extension/factories/objectivePlan.ts +4 -24
  18. package/extension/factories/objectiveSave.ts +5 -15
  19. package/extension/factories/planMode.ts +5 -1
  20. package/extension/factories/planReview.ts +103 -11
  21. package/extension/factories/planSave.ts +16 -1
  22. package/extension/index.ts +42 -3
  23. package/extension/substrate/bindings.ts +1 -1
  24. package/extension/substrate/cache.ts +2 -2
  25. package/extension/substrate/command.ts +25 -0
  26. package/extension/substrate/config.ts +12 -13
  27. package/extension/substrate/consoleCapture.ts +90 -0
  28. package/extension/substrate/git.ts +34 -0
  29. package/extension/substrate/miniJinja.ts +480 -0
  30. package/extension/substrate/paths.ts +38 -0
  31. package/extension/substrate/prompts.ts +15 -24
  32. package/extension/substrate/providers.ts +1 -1
  33. package/extension/substrate/sessionData.ts +1 -1
  34. package/extension/substrate/sessionPointers.ts +155 -0
  35. package/extension/substrate/toolGating.ts +8 -0
  36. package/extension/surfaces/surfaces.ts +10 -3
  37. package/extension/worker/worker.ts +111 -25
  38. package/extension/workerMain.ts +5 -3
  39. package/package.json +1 -5
  40. package/prompts/README.md +56 -5
  41. package/prompts/_fixtures/cases.yaml +52 -131
  42. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  43. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  44. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  45. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  46. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  47. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  48. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  49. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  50. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  51. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  52. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  53. package/prompts/_fixtures/live.yaml +281 -0
  54. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  55. package/prompts/_fixtures/templates/cond_if.md +7 -0
  56. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  57. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  58. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  59. package/prompts/_fixtures/templates/trim_block.md +5 -0
  60. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  61. package/prompts/stages/conflict-resolution.md +4 -0
  62. package/prompts/stages/learn-code.md +8 -0
  63. package/prompts/stages/learn-docs.md +7 -6
  64. package/prompts/stages/learn-orchestrate.md +6 -0
  65. package/prompts/stages/learn.md +1 -1
  66. package/prompts/stages/objective-author/adopt.md +12 -0
  67. package/prompts/stages/objective-author/file.md +9 -0
  68. package/prompts/stages/objective-author/seed.md +9 -0
  69. package/prompts/stages/objective-plan/seed.md +2 -1
  70. package/prompts/stages/objective-reconcile.md +7 -0
  71. package/prompts/stages/objective-replan.md +14 -0
  72. package/prompts/stages/objective-save.md +9 -0
  73. package/prompts/stages/plan-from/adopt.md +10 -0
  74. package/prompts/stages/plan-from/file.md +9 -0
  75. package/prompts/stages/pr-review.md +6 -0
  76. package/prompts/stages/replan.md +13 -0
  77. package/prompts/stages/skills/create-from.md +15 -0
  78. package/prompts/stages/skills/create.md +9 -0
  79. package/prompts/stages/skills/refine.md +9 -0
  80. package/shared/README.md +7 -1
  81. package/shared/bindings.yaml +12 -0
  82. package/shared/contracts-history.md +167 -0
  83. package/shared/contracts.md +1307 -342
  84. package/shared/registry.yaml +3 -3
  85. package/shared/schemas/contracts/bindings.schema.json +38 -0
  86. package/shared/schemas/contracts/providers.schema.json +89 -0
  87. package/shared/schemas/contracts/registry.schema.json +98 -0
  88. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  89. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  90. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  91. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  92. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  93. package/shared/schemas/outputs/init-report.schema.json +419 -0
  94. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  95. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  96. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  97. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  98. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  99. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  100. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  101. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  102. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  103. package/prompts/_fixtures/golden/address-action.txt +0 -10
  104. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  105. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  106. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  107. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  108. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  109. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  110. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  111. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  112. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  113. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  114. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  115. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  116. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  117. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  118. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  119. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  120. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
package/README.md CHANGED
@@ -1,17 +1,16 @@
1
1
  # perk
2
2
 
3
- A Pi-native, plan-oriented engineering workflow a Python `perk` CLI (the session
3
+ A Pi-native, plan-oriented engineering workflow: a Python `perk` CLI (the session
4
4
  *exterior*) plus a TypeScript Pi extension (the session *interior*).
5
5
 
6
- > Start at [`docs/user-docs/`](docs/user-docs/index.md).
6
+ > Inspired by `erk`, formely maintained by the team at [dagster](https://github.com/dagster-io/dagster)
7
7
 
8
- > Originally based on prior art `erk`, by the team at [dagster](https://github.com/dagster-io/dagster)
8
+ perk was built with perk.
9
9
 
10
10
  ## What perk is
11
11
 
12
- perk implements a plan-oriented engineering workflow (explore read-onlysave a plan
13
- implement on a branch submit → land → learn) to [Pi](https://github.com/earendil-works),
14
- split across **two planes**:
12
+ perk implements a plan-oriented engineering workflow (plan implement submit review
13
+ address → land → learn) for [Pi](https://github.com/earendil-works), split across **two planes**:
15
14
 
16
15
  - the **exterior** — a Python `perk` CLI that scaffolds repos, positions worktrees, mints
17
16
  run ids, and launches primed `pi` sessions (everything that happens *outside* a session);
@@ -19,10 +18,7 @@ split across **two planes**:
19
18
  *inside* a running session.
20
19
 
21
20
  A language-neutral [`shared/`](shared/) contract (the stage registry + cross-plane specs)
22
- is the single source both planes read, so the two stay in lockstep without a codegen step.
23
-
24
- perk is built to **bootstrap its own development**: each phase leaves perk capable of
25
- driving the next, and perk's own repo is the first thing it scaffolds.
21
+ is the single source both planes read, hopefully staying in lockstep without codegen.
26
22
 
27
23
  ## Quickstart
28
24
 
@@ -42,8 +38,7 @@ For the guided first run, follow
42
38
 
43
39
  ## Documentation
44
40
 
45
- The operator-facing docs live under [`docs/user-docs/`](docs/user-docs/index.md), organized as
46
- the four [Divio](https://docs.divio.com/documentation-system/) quadrants:
41
+ The operator-facing docs live under [`docs/user-docs/`](docs/user-docs/index.md), organized as:
47
42
 
48
43
  - **[Tutorials](docs/user-docs/tutorials/index.md)** — learning-oriented lessons; start with
49
44
  [Get started with perk](docs/user-docs/tutorials/get-started.md).
@@ -86,15 +81,6 @@ just test # pytest + node:test (the regression gate)
86
81
  just ci # setup + lint + typecheck + test
87
82
  ```
88
83
 
89
- After `just setup` (or `just install-cli`), call `perk` directly — no `uv run`. The install is
90
- **editable**, so a `git pull` reflects Python changes live; re-run `just install-cli` after a
91
- dependency change. It lands in uv's tool bin (`~/.local/bin`) — if `perk` isn't found, that dir
92
- is not on your `PATH`; run `uv tool update-shell` (then restart your shell). Remove it with
93
- `uv tool uninstall perk`.
94
-
95
- Releasing perk → see [docs/releasing.md](docs/releasing.md) (version SSOT, dual-plane runbook,
96
- the `validate-release-versions` tag gate).
97
-
98
84
  `just setup` also runs `just hooks` (`prek install`), wiring a [prek](https://prek.j178.dev)
99
85
  pre-commit hook that runs `ruff check` on staged Python (config in `prek.toml`; the ruff
100
86
  env is built by prek from the remote ruff-pre-commit repo, so it never depends on a
@@ -29,6 +29,7 @@
29
29
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
30
30
  import { truncateToWidth } from "@earendil-works/pi-tui";
31
31
  import { readHandoff, readPlanBody } from "../substrate/cache.ts";
32
+ import { registerPerkCommand } from "../substrate/command.ts";
32
33
  import { loadPerkConfig } from "../substrate/config.ts";
33
34
  import {
34
35
  loadProviders,
@@ -516,7 +517,7 @@ export function registerCheckpoints(pi: ExtensionAPI, status: PerkStatusHandle):
516
517
  }
517
518
  });
518
519
 
519
- pi.registerCommand("checkpoints", {
520
+ registerPerkCommand(pi, "checkpoints", {
520
521
  description: "Show perk implementation checkpoints (read-only).",
521
522
  handler: async (_args, ctx) => {
522
523
  // Todo-provider deferral: announce the deferral headless-safe and step aside when a
@@ -20,6 +20,7 @@ import {
20
20
  runColdDoor,
21
21
  stringField,
22
22
  } from "../substrate/coldDoor.ts";
23
+ import { registerPerkCommand } from "../substrate/command.ts";
23
24
  import { loadPerkConfig } from "../substrate/config.ts";
24
25
  import { render } from "../substrate/prompts.ts";
25
26
  import { failFor, ok, type Result } from "../substrate/result.ts";
@@ -324,7 +325,7 @@ export function registerAddress(pi: ExtensionAPI): void {
324
325
  },
325
326
  });
326
327
 
327
- pi.registerCommand("address", {
328
+ registerPerkCommand(pi, "address", {
328
329
  description:
329
330
  "Classify PR review feedback (isolated child) and resolve threads (submit → address). " +
330
331
  "Pass --preview to classify only (take no action).",
@@ -29,6 +29,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
29
29
  import { join } from "node:path";
30
30
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
31
31
  import { ensureRunScratch, scratchDir } from "../substrate/cache.ts";
32
+ import { registerPerkCommand } from "../substrate/command.ts";
32
33
  import { type CiCheck, loadPerkConfig } from "../substrate/config.ts";
33
34
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
34
35
  import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
@@ -367,7 +368,7 @@ export function renderCiProse(report: CiReport): string {
367
368
  );
368
369
  }
369
370
  if (report.error_type === "no_checks_configured") {
370
- return "No CI checks configured ([[ci]] in .pi/perk.toml is empty). Nothing to run.";
371
+ return "No CI checks configured ([[ci]] in .perk/config.toml is empty). Nothing to run.";
371
372
  }
372
373
  if (report.error_type === "unknown_check") {
373
374
  return `perk CI: ${report.error}`;
@@ -565,7 +566,7 @@ export function registerCiExecutor(pi: ExtensionAPI): void {
565
566
  },
566
567
  });
567
568
 
568
- pi.registerCommand("ci", {
569
+ registerPerkCommand(pi, "ci", {
569
570
  description: "Run the project's configured CI checks and report results (never auto-fixes).",
570
571
  handler: async (args, ctx) => {
571
572
  const check = args.trim() === "" ? undefined : args.trim();
@@ -15,6 +15,7 @@ import {
15
15
  runColdDoor,
16
16
  stringField,
17
17
  } from "../substrate/coldDoor.ts";
18
+ import { registerPerkCommand } from "../substrate/command.ts";
18
19
  import { failFor, ok, type Result } from "../substrate/result.ts";
19
20
  import { report } from "../surfaces/report.ts";
20
21
 
@@ -208,7 +209,7 @@ export function registerLand(pi: ExtensionAPI): void {
208
209
  },
209
210
  });
210
211
 
211
- pi.registerCommand("land", {
212
+ registerPerkCommand(pi, "land", {
212
213
  description: "Merge the active plan's PR and set pending-learn (submit → land).",
213
214
  handler: async (_args, ctx) => {
214
215
  const result = await landPr(pi, ctx);
@@ -1,13 +1,34 @@
1
- // The deepened warm `/learn` door. Graduates the thin marker-clear into a real knowledge-capture
2
- // pass: when a `summary` is given, DELEGATE to `perk learn capture --json` via the shared cold-door
3
- // client (`runColdDoor` the body rides the run-scratch stdin channel; GitHub writes canonical in
4
- // Python), which creates a `perk:learn` issue + clears
5
- // `pending-learn`; then mirror the marker-clear in-session (idempotent). With no `summary`, stay
6
- // the thin TS-only marker-clear (graceful no empty issue). Never throws (soft `details.ok`);
7
- // the capture decode is fully LENIENT a `success: true` envelope always yields the captured-ok
8
- // terminating result even when `learn_issue` is undecodable (render-only field; see
9
- // `decodeLearnCapture`).
1
+ // The warm `/learn` door a multi-angle knowledge-capture orchestrator (mirrors `/pr-review`).
2
+ //
3
+ // Bare interactive `/learn` gathers a reproducible evidence bundle ONCE via the cold door
4
+ // (`perk learn evidence --render --json`; the parent owns the gather per §8.35), then branches:
5
+ // a learn-docs plan short-circuits to a deterministic marker-clear no-op; a gather failure (or a
6
+ // bundle-less success) degrades to the simple `learnGuidance` injection (/learn is never a dead
7
+ // end); otherwise it injects the orchestration seed (`learnOrchestrateGuidance`) so the model spawns
8
+ // 2–4 fresh-context `perk.learn-analyst` children, reconciles their reports into ONE classified
9
+ // decision, and captures (via the `learn` tool, with the routable `decision`/`target` persisted on
10
+ // the issue header — both backends) or skips.
11
+ //
12
+ // The `learn` tool is the capture half: with a `summary`, DELEGATE to `perk learn capture --json`
13
+ // via the shared cold-door client (`runColdDoor` — the body rides the run-scratch stdin channel,
14
+ // the `decision`/`target` classification rides flags; canonical write in Python), creating a
15
+ // `perk:learn` issue + clearing `pending-learn`, then mirror the marker-clear in-session
16
+ // (idempotent). With no `summary`, DELEGATE to `perk learn skip --json` (contracts.md §8.36) —
17
+ // the deliberate skip is recorded canonically on the plan-header (`learn_state: skipped`, unless
18
+ // already `captured`), never a TS-only marker-clear.
19
+ // Never throws (soft `details.ok`); both decodes are fully LENIENT — a `success: true`
20
+ // envelope always yields the terminating ok result even when the payload is undecodable
21
+ // (render-only fields; see `decodeLearnCapture` / `decodeLearnSkip`).
22
+ //
23
+ // Headless bare `/learn` stays the safe no-summary path (cannot drive a turn / spawn children).
24
+ // `/learn <text>` / `/learn skip` stay the existing verbatim-capture / skip-recording paths
25
+ // (decision-less escape hatches). Cold `perk learn` launch stays the simple investigate+capture.
26
+ //
27
+ // The analyst model is configurable via `[subagents] learn-analyst` in `.perk/config.toml`; because
28
+ // `subagents.agentOverrides` does NOT reach project agents, the orchestration seed injects that
29
+ // model as a per-call inline `model` override on every analyst spawn.
10
30
 
31
+ import { join } from "node:path";
11
32
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
33
  import { bindingSuffix } from "../substrate/bindingDelivery.ts";
13
34
  import {
@@ -24,6 +45,8 @@ import {
24
45
  runColdDoor,
25
46
  stringField,
26
47
  } from "../substrate/coldDoor.ts";
48
+ import { registerPerkCommand } from "../substrate/command.ts";
49
+ import { loadPerkConfig } from "../substrate/config.ts";
27
50
  import { render } from "../substrate/prompts.ts";
28
51
  import { failFor, ok, type Result } from "../substrate/result.ts";
29
52
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
@@ -46,6 +69,61 @@ interface LearnCapturePayload {
46
69
  learn_issue?: { id: string; url: string; existed: boolean };
47
70
  }
48
71
 
72
+ /** The decoded `perk learn skip --json` payload slice (render-only fields). */
73
+ interface LearnSkipPayload {
74
+ learn_state: string | null;
75
+ pending_cleared: boolean | null;
76
+ }
77
+
78
+ /**
79
+ * Decode the `perk learn skip --json` success payload — fully LENIENT (mirrors `decodeEvidence`):
80
+ * it **never returns null**, so any success envelope yields a usable object and the `bad_output`
81
+ * arm is deliberately unreachable for this door. Both fields are render-only (they flavor the
82
+ * report text); the `success: true` envelope is the cold door's authoritative statement that the
83
+ * skip was recorded and the on-disk marker cleared.
84
+ */
85
+ function decodeLearnSkip(payload: ColdJson): LearnSkipPayload {
86
+ return {
87
+ learn_state: stringField(payload, "learn_state") ?? null,
88
+ pending_cleared: booleanField(payload, "pending_cleared") ?? null,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The closed CAPTURED-classification set persisted on a `perk:learn` header (contracts.md §8.35) —
94
+ * the reconciliation DECISION set minus `SKIP` (a skip creates no issue). Mirrors
95
+ * `plan.CapturedDecision` (the Python SSOT) and the `learn` tool's JSON-schema enum.
96
+ */
97
+ const CAPTURED_DECISIONS = [
98
+ "CAPTURE_LEARN",
99
+ "SHOULD_BE_CODE",
100
+ "UPDATE_EXISTING_DOC",
101
+ "NEW_DOC",
102
+ "STALE_DOC",
103
+ ] as const;
104
+
105
+ /** The decoded `perk learn evidence --json` slice the orchestrator branches on. */
106
+ interface EvidenceDecode {
107
+ skipped: boolean;
108
+ skip_reason: string | null;
109
+ bundle_dir: string | null;
110
+ }
111
+
112
+ /**
113
+ * Decode the `perk learn evidence --render --json` success payload — fully LENIENT (mirrors
114
+ * `decodeLearnCapture`): it **never returns null**, so any success envelope yields a usable object
115
+ * and the `runColdDoor` `bad_output` arm is deliberately unreachable for this door. A missing/
116
+ * mistyped `skipped` defaults false; `bundle_dir`/`skip_reason` default null. `!r.ok` (exec /
117
+ * transport / `success:false`) routes to the gather-failure fallback, not here.
118
+ */
119
+ function decodeEvidence(payload: ColdJson): EvidenceDecode {
120
+ return {
121
+ skipped: booleanField(payload, "skipped") ?? false,
122
+ skip_reason: stringField(payload, "skip_reason") ?? null,
123
+ bundle_dir: stringField(payload, "bundle_dir") ?? null,
124
+ };
125
+ }
126
+
49
127
  /**
50
128
  * Narrow the `perk learn capture --json` success payload — fully LENIENT, per the decode-policy
51
129
  * criterion (strict iff the field is appended to workflow-state; see
@@ -78,29 +156,48 @@ function clearPending(ctx: ExtensionContext): { wasPending: boolean } {
78
156
  }
79
157
 
80
158
  /**
81
- * The single learn implementation both surfaces call. With a `summary`, delegate the capture to the
82
- * Python cold door (then mirror the marker-clear); without one, stay the thin marker-clear. Returns
83
- * a soft result (never throws).
159
+ * The single learn implementation both surfaces call. With a `summary`, delegate the capture to
160
+ * the Python cold door; without one, delegate the skip-recording to `perk learn skip` (§8.36
161
+ * the canonical `learn_state: skipped` stamp, no empty issue). Both arms mirror the marker-clear
162
+ * in-session on success. Returns a soft result (never throws).
84
163
  */
85
164
  export async function learnDone(
86
165
  pi: ExtensionAPI,
87
166
  ctx: ExtensionContext,
88
167
  summary?: string,
168
+ decision?: string,
169
+ target?: string,
89
170
  ): Promise<LearnResult> {
90
171
  const trimmed = (summary ?? "").trim();
172
+ const fail = failFor(ctx, "learn");
91
173
 
92
- // No summary: the thin, graceful path just clear the marker (no empty issue).
174
+ // No summary: record the deliberate skip canonically (the cold door stamps the plan-header and
175
+ // clears the marker; the skip carries no classification, so `decision`/`target` are
176
+ // intentionally ignored on this arm). On failure the marker is NOT cleared — never silently
177
+ // close the learn cycle on uncertainty (the marker is the retry signal).
93
178
  if (trimmed.length === 0) {
179
+ const r = await runColdDoor<LearnSkipPayload>(pi, ctx, ["learn", "skip", "--json"], {
180
+ label: "perk learn skip",
181
+ decode: decodeLearnSkip,
182
+ });
183
+ if (!r.ok) return fail(r.message, r.errorType);
184
+ // Mirror the marker-clear in-session (idempotent; the worker already cleared it on disk).
94
185
  const { wasPending } = clearPending(ctx);
95
- const text = wasPending
96
- ? "Cleared pending-learn — the worktree is releasable. (No summary given; no learn issue created.)"
97
- : "No pending-learn setnothing to clear.";
186
+ const text =
187
+ r.data.learn_state === "captured"
188
+ ? "Learnings were already captured kept; pending-learn cleared."
189
+ : "Skip recorded on the plan; pending-learn cleared — the worktree is releasable. " +
190
+ "(No summary given; no learn issue created.)";
98
191
  return ok(text, { was_pending: wasPending, captured: false }, { terminate: true });
99
192
  }
100
193
 
101
- const fail = failFor(ctx, "learn");
194
+ // The captured classification (contracts.md §8.35) rides flags on the capture argv; Click parses
195
+ // them regardless of order, and the `--body` stdin channel is unchanged.
196
+ const argv = ["learn", "capture", "--json"];
197
+ if (decision !== undefined) argv.push("--decision", decision);
198
+ if (target !== undefined) argv.push("--target", target);
102
199
 
103
- const r = await runColdDoor<LearnCapturePayload>(pi, ctx, ["learn", "capture", "--json"], {
200
+ const r = await runColdDoor<LearnCapturePayload>(pi, ctx, argv, {
104
201
  label: "perk learn capture",
105
202
  decode: decodeLearnCapture,
106
203
  stdin: { flag: "--body", content: `${trimmed}\n`, filename: `learn-${Date.now()}.md` },
@@ -128,7 +225,7 @@ export async function learnDone(
128
225
  }
129
226
 
130
227
  const TOOL_GUIDELINES = [
131
- "Call learn after a plan has landed; pass a `summary` of the durable learnings to capture them in a perk:learn issue (and clear pending-learn). Omit `summary` to just clear the marker.",
228
+ "Call learn after a plan has landed; pass a `summary` of the durable learnings to capture them in a perk:learn issue (and clear pending-learn). Omit `summary` to record the skip on the plan and clear the marker.",
132
229
  "The summary is captured verbatim — write the learnings as markdown (what changed vs. the plan, deviations, residual risks).",
133
230
  ];
134
231
 
@@ -167,6 +264,26 @@ export function learnGuidance(planRef: PlanRef | null): string {
167
264
  });
168
265
  }
169
266
 
267
+ /**
268
+ * The orchestration seed the warm bare `/learn` injects to spawn the angle-specialized analysts and
269
+ * reconcile their reports into one classified capture/skip (the perk-learn skill pointer rides the
270
+ * skill-binding suffix — stage:learn — not hardcoded here). Pure + exported for offline tests
271
+ * (mirrors `prReviewGuidance`). When `model` is set, EVERY analyst spawn carries an inline `model`
272
+ * override; otherwise the agent's default is used. `manifestPath` is absolute; `bundleDir` is the
273
+ * absolute bundle directory.
274
+ */
275
+ export function learnOrchestrateGuidance(opts: {
276
+ model?: string;
277
+ manifestPath: string;
278
+ bundleDir: string;
279
+ }): string {
280
+ return render("stages/learn-orchestrate.md", {
281
+ model: opts.model ?? "",
282
+ manifest_path: opts.manifestPath,
283
+ bundle_dir: opts.bundleDir,
284
+ });
285
+ }
286
+
170
287
  /** Register the warm door: the `learn` terminating tool + the `/learn` command twin. */
171
288
  export function registerLearn(pi: ExtensionAPI): void {
172
289
  pi.registerTool({
@@ -174,8 +291,8 @@ export function registerLearn(pi: ExtensionAPI): void {
174
291
  label: "Finish learn",
175
292
  description:
176
293
  "Capture learnings from a landed plan into a perk:learn issue (pass `summary`), then clear " +
177
- "the pending-learn semaphore and release the worktree. Omit `summary` to only clear the marker. " +
178
- "Terminating: ends the turn.",
294
+ "the pending-learn semaphore and release the worktree. Omit `summary` to record the skip " +
295
+ "on the plan and clear pending-learn. Terminating: ends the turn.",
179
296
  promptSnippet:
180
297
  "Capture learnings (optional summary) and clear pending-learn (terminates the turn)",
181
298
  promptGuidelines: TOOL_GUIDELINES,
@@ -186,30 +303,60 @@ export function registerLearn(pi: ExtensionAPI): void {
186
303
  properties: {
187
304
  summary: {
188
305
  type: "string",
189
- description: "Markdown learnings to capture in a perk:learn issue. Omit to only clear.",
306
+ description:
307
+ "Markdown learnings to capture in a perk:learn issue. Omit to record the skip.",
308
+ },
309
+ decision: {
310
+ type: "string",
311
+ enum: [...CAPTURED_DECISIONS],
312
+ description:
313
+ "The reconciled captured-classification token, persisted on the perk:learn header. " +
314
+ "Omit on a verbatim /learn <text> capture (the decision-less escape hatch).",
315
+ },
316
+ target: {
317
+ type: "string",
318
+ description:
319
+ "An optional routable pointer (e.g. an existing doc path) for the classification.",
190
320
  },
191
321
  },
192
322
  },
193
323
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
194
- // Tool-boundary decode: absent → undefined (the marker-clear path); mistyped →
324
+ // Tool-boundary decode (mirrors the `summary` strictness): absent → undefined (the
325
+ // marker-clear / decision-less path); a present-but-mistyped/out-of-enum value →
195
326
  // strict-fail — never silently clear the pending-learn marker on uncertainty.
196
327
  const p = paramsOf(params);
328
+ const fail = failFor(ctx, "learn");
197
329
  const summary = p === null ? undefined : stringParam(p, "summary");
198
330
  if (summary === null) {
199
- return failFor(ctx, "learn")("learn `summary` must be a string", "bad_input");
331
+ return fail("learn `summary` must be a string", "bad_input");
200
332
  }
201
- return learnDone(pi, ctx, summary);
333
+ const decision = p === null ? undefined : stringParam(p, "decision");
334
+ if (decision === null) {
335
+ return fail("learn `decision` must be a string", "bad_input");
336
+ }
337
+ if (decision !== undefined && !(CAPTURED_DECISIONS as readonly string[]).includes(decision)) {
338
+ return fail(
339
+ `learn \`decision\` must be one of ${CAPTURED_DECISIONS.join(", ")}`,
340
+ "bad_input",
341
+ );
342
+ }
343
+ const target = p === null ? undefined : stringParam(p, "target");
344
+ if (target === null) {
345
+ return fail("learn `target` must be a string", "bad_input");
346
+ }
347
+ return learnDone(pi, ctx, summary, decision, target);
202
348
  },
203
349
  });
204
350
 
205
- pi.registerCommand("learn", {
351
+ registerPerkCommand(pi, "learn", {
206
352
  description:
207
353
  "Investigate the landed change and capture learnings (bare /learn drives the workflow); " +
208
- "/learn skip clears pending-learn only; /learn <text> captures the text verbatim.",
354
+ "/learn skip records the skip on the plan and clears pending-learn; " +
355
+ "/learn <text> captures the text verbatim.",
209
356
  handler: async (args, ctx) => {
210
357
  const trimmed = (args ?? "").trim();
211
358
 
212
- // Explicit text (or `skip`): the existing learnDone path — capture verbatim / marker-clear.
359
+ // Explicit text (or `skip`): the existing learnDone path — capture verbatim / record skip.
213
360
  if (trimmed.length > 0) {
214
361
  const summary = trimmed === "skip" ? "" : args;
215
362
  const result = await learnDone(pi, ctx, summary);
@@ -220,16 +367,75 @@ export function registerLearn(pi: ExtensionAPI): void {
220
367
  return;
221
368
  }
222
369
 
223
- // Bare `/learn`: headless can't drive a turn — stay the safe marker-clear (fail-safe). An
224
- // interactive session injects the perk-learn guidance so the agent does the capture pass
225
- // (it clears the marker itself by calling the `learn` tool — do NOT clear it here).
370
+ // Bare `/learn`: headless can't drive a turn or spawn children take the safe no-summary
371
+ // path (the canonical skip recording; fail-safe).
226
372
  if (!ctx.hasUI) {
227
373
  const result = await learnDone(pi, ctx, "");
228
374
  console.error(`perk: /learn invoked (headless) — ${result.content[0]?.text ?? "cleared"}`);
229
375
  return;
230
376
  }
231
- report(ctx, "learn", "info", "investigate the landed change and capture learnings");
232
- pi.sendUserMessage(learnGuidance(activePlanRef(ctx)) + bindingSuffix(ctx.cwd, "stage:learn"));
377
+
378
+ // Interactive bare `/learn`: the multi-angle orchestrator (mirrors /pr-review). Gather the
379
+ // evidence bundle ONCE (the parent owns the gather — §8.35), then branch.
380
+ const fallback = () => {
381
+ // Graceful degrade — /learn is never a dead end. Fall back to the simple learn pass (the
382
+ // prior behavior); the agent clears the marker itself via the `learn` tool.
383
+ pi.sendUserMessage(
384
+ learnGuidance(activePlanRef(ctx)) + bindingSuffix(ctx.cwd, "stage:learn"),
385
+ );
386
+ };
387
+
388
+ const r = await runColdDoor<EvidenceDecode>(
389
+ pi,
390
+ ctx,
391
+ ["learn", "evidence", "--render", "--json"],
392
+ { label: "perk learn evidence", decode: decodeEvidence },
393
+ );
394
+
395
+ // Gather failure (exec / transport / success:false): degrade to the simple learn pass.
396
+ if (!r.ok) {
397
+ report(
398
+ ctx,
399
+ "learn",
400
+ "info",
401
+ "evidence gather unavailable — falling back to the simple learn pass",
402
+ );
403
+ fallback();
404
+ return;
405
+ }
406
+
407
+ // Short-circuit: a learn-docs consolidation plan — clear the local marker only, inject
408
+ // nothing (land already stamped `learn_state: skipped` for a `consumed_learn` plan, §8.36 —
409
+ // no cold skip delegation needed here).
410
+ if (r.data.skipped) {
411
+ clearPending(ctx);
412
+ report(ctx, "learn", "info", "learn-docs plan; learn capture skipped");
413
+ return;
414
+ }
415
+
416
+ // Defensive: a success envelope with no bundle dir — same graceful fallback.
417
+ if (r.data.bundle_dir === null) {
418
+ report(
419
+ ctx,
420
+ "learn",
421
+ "info",
422
+ "evidence bundle unavailable — falling back to the simple learn pass",
423
+ );
424
+ fallback();
425
+ return;
426
+ }
427
+
428
+ // Orchestrate: spawn analysts over the shared bundle, reconcile, capture-or-skip. `bundle_dir`
429
+ // is repo_root-relative; the door's cwd is the worktree root the command resolved against.
430
+ const bundleDir = join(ctx.cwd, r.data.bundle_dir);
431
+ const manifestPath = join(bundleDir, "manifest.json");
432
+ const model = loadPerkConfig(ctx.cwd).subagents["learn-analyst"];
433
+ report(ctx, "learn", "info", "multi-angle learn: spawn analysts → reconcile → capture");
434
+ // The agent captures via the `learn` tool (clearing the marker itself) — do NOT clear here.
435
+ pi.sendUserMessage(
436
+ learnOrchestrateGuidance({ model, manifestPath, bundleDir }) +
437
+ bindingSuffix(ctx.cwd, "stage:learn"),
438
+ );
233
439
  },
234
440
  });
235
441
  }
@@ -0,0 +1,100 @@
1
+ // hop-2 — the learn-code plan factory's warm transition surface: the `/learn-code` command.
2
+ //
3
+ // The warm twin of the `perk learn code` cold door (sibling of `/learn-docs`). It DELEGATES the
4
+ // gather to the Python plane (`perk learn code --gather --json` via the shared cold-door client
5
+ // `runColdDoor` — gate-safe, not subject to the read-only bash allowlist), decodes
6
+ // `{ inbox_path, learn_numbers }`, then injects the factory guidance via `pi.sendUserMessage` so
7
+ // the model reads the inbox, authors a code plan, and calls `plan_save` with `consumed_learn`. No
8
+ // model tool — the model uses the existing `plan_save` tool.
9
+ //
10
+ // Headless-safe: rich UI is guarded by `ctx.hasUI`; without a UI it logs to stderr and returns
11
+ // (the gather still runs so the inbox is materialized, but no turn is driven).
12
+
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { bindingSuffix } from "../substrate/bindingDelivery.ts";
15
+ import { type ColdJson, runColdDoor, stringField } from "../substrate/coldDoor.ts";
16
+ import { registerPerkCommand } from "../substrate/command.ts";
17
+ import { render } from "../substrate/prompts.ts";
18
+ import { report } from "../surfaces/report.ts";
19
+
20
+ /** The decoded `perk learn code --gather --json` payload slice the warm door consumes. */
21
+ interface LearnCodeGatherPayload {
22
+ inbox_path: string;
23
+ /** Opaque string learn-issue ids (GitHub "45", Linear "ENG-45") — §8.21. */
24
+ learn_numbers: string[];
25
+ }
26
+
27
+ /** Strict decode — the guidance dereferences both fields; `launched` is unconsumed. Exported for offline reject-branch tests. */
28
+ export function decodeGather(payload: ColdJson): LearnCodeGatherPayload | null {
29
+ const inboxPath = stringField(payload, "inbox_path");
30
+ const numbers = payload.learn_numbers;
31
+ if (inboxPath === undefined) return null;
32
+ // String ids are canonical (§8.21); numbers are tolerated + coerced (older envelopes).
33
+ if (
34
+ !Array.isArray(numbers) ||
35
+ !numbers.every((n) => typeof n === "string" || typeof n === "number")
36
+ ) {
37
+ return null;
38
+ }
39
+ return { inbox_path: inboxPath, learn_numbers: numbers.map((n) => String(n)) };
40
+ }
41
+
42
+ /**
43
+ * The seed guidance the warm `/learn-code` injects to start the factory loop (the perk-learn-code
44
+ * skill pointer rides the skill-binding suffix — not hardcoded here). Pure + exported
45
+ * for offline tests.
46
+ */
47
+ export function learnCodeGuidance(inboxPath: string, learnNumbers: string[]): string {
48
+ return render("stages/learn-code.md", {
49
+ inbox_path: inboxPath,
50
+ num_list: learnNumbers.join(", "),
51
+ });
52
+ }
53
+
54
+ /** Register the warm learn-code door: the `/learn-code` command (no model tool). */
55
+ export function registerLearnCode(pi: ExtensionAPI): void {
56
+ registerPerkCommand(pi, "learn-code", {
57
+ description:
58
+ "Start the learn-code plan factory: gather pre-stamped SHOULD_BE_CODE perk:learn issues into " +
59
+ "an inbox and author a plan routing each into its real code home.",
60
+ handler: async (_args, ctx: ExtensionContext) => {
61
+ // Report-only door (no Result type): branch on `errorType` directly (the coldDoor header
62
+ // convention). A clean "nothing to route" exits non-zero with error_type=no_learn_issues —
63
+ // the client's envelope-aware arm surfaces it gently.
64
+ const r = await runColdDoor<LearnCodeGatherPayload>(
65
+ pi,
66
+ ctx,
67
+ ["learn", "code", "--gather", "--json"],
68
+ { label: "perk learn code", decode: decodeGather },
69
+ );
70
+ if (!r.ok) {
71
+ if (r.errorType === "no_learn_issues") {
72
+ report(
73
+ ctx,
74
+ "learn-code",
75
+ "warning",
76
+ "nothing to route into code (no SHOULD_BE_CODE perk:learn issues).",
77
+ );
78
+ } else {
79
+ report(ctx, "learn-code", "error", `gather failed: ${r.message}`);
80
+ }
81
+ return;
82
+ }
83
+
84
+ if (!ctx.hasUI) {
85
+ // Headless can't drive a turn — the inbox is materialized; log and return (fail-safe).
86
+ console.error(
87
+ `perk: /learn-code invoked (headless) — gathered ${r.data.learn_numbers.length} ` +
88
+ `learn issue(s) into ${r.data.inbox_path}; run interactively to author the code plan.`,
89
+ );
90
+ return;
91
+ }
92
+
93
+ report(ctx, "learn-code", "info", `gathered ${r.data.learn_numbers.length} learn issue(s)`);
94
+ pi.sendUserMessage(
95
+ learnCodeGuidance(r.data.inbox_path, r.data.learn_numbers) +
96
+ bindingSuffix(ctx.cwd, "command:learn-code"),
97
+ );
98
+ },
99
+ });
100
+ }
@@ -13,6 +13,7 @@
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { bindingSuffix } from "../substrate/bindingDelivery.ts";
15
15
  import { type ColdJson, runColdDoor, stringField } from "../substrate/coldDoor.ts";
16
+ import { registerPerkCommand } from "../substrate/command.ts";
16
17
  import { render } from "../substrate/prompts.ts";
17
18
  import { report } from "../surfaces/report.ts";
18
19
 
@@ -23,8 +24,8 @@ interface LearnDocsGatherPayload {
23
24
  learn_numbers: string[];
24
25
  }
25
26
 
26
- /** Strict decode — the guidance dereferences both fields; `launched` is unconsumed. */
27
- function decodeGather(payload: ColdJson): LearnDocsGatherPayload | null {
27
+ /** Strict decode — the guidance dereferences both fields; `launched` is unconsumed. Exported for offline reject-branch tests. */
28
+ export function decodeGather(payload: ColdJson): LearnDocsGatherPayload | null {
28
29
  const inboxPath = stringField(payload, "inbox_path");
29
30
  const numbers = payload.learn_numbers;
30
31
  if (inboxPath === undefined) return null;
@@ -52,7 +53,7 @@ export function learnDocsGuidance(inboxPath: string, learnNumbers: string[]): st
52
53
 
53
54
  /** Register the warm learned-docs door: the `/learn-docs` command (no model tool). */
54
55
  export function registerLearnDocs(pi: ExtensionAPI): void {
55
- pi.registerCommand("learn-docs", {
56
+ registerPerkCommand(pi, "learn-docs", {
56
57
  description:
57
58
  "Start the learned-docs plan factory: gather open perk:learn issues into an inbox and author " +
58
59
  "a docs/learned consolidation plan.",
@@ -14,6 +14,7 @@ import type {
14
14
  ExtensionContext,
15
15
  } from "@earendil-works/pi-coding-agent";
16
16
  import type { PlanRef } from "../substrate/cache.ts";
17
+ import { registerPerkCommand } from "../substrate/command.ts";
17
18
  import { render } from "../substrate/prompts.ts";
18
19
  import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
19
20
  import { report } from "../surfaces/report.ts";
@@ -104,7 +105,7 @@ export function implementHandoffPrompt(ref: PlanRef): string {
104
105
  * either way), fail-safe-headless.
105
106
  */
106
107
  function registerImplementGuard(pi: ExtensionAPI): void {
107
- pi.registerCommand("implement", {
108
+ registerPerkCommand(pi, "implement", {
108
109
  description:
109
110
  "Refresh implement context (in-worktree handoff); cross-worktree is `perk implement`.",
110
111
  handler: async (_args, ctx) => {