@mgiles/perk 1.0.1 → 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.
Files changed (157) hide show
  1. package/README.md +67 -57
  2. package/extension/adapters/planAdapterPlannotator.ts +27 -41
  3. package/extension/adapters/planAdapterTombell.ts +15 -28
  4. package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
  5. package/extension/checkpoints/checkpoints.ts +21 -13
  6. package/extension/doors/address.ts +6 -5
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +23 -15
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +33 -10
  11. package/extension/doors/learn.ts +240 -34
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/lifecycleGates.ts +2 -1
  14. package/extension/doors/plannotatorHandoff.ts +509 -0
  15. package/extension/doors/prReview.ts +22 -37
  16. package/extension/doors/prReviewBrowser.ts +341 -0
  17. package/extension/doors/prReviewTerminal.ts +267 -0
  18. package/extension/doors/ready.ts +2 -1
  19. package/extension/doors/selfcheck.ts +240 -6
  20. package/extension/doors/submit.ts +37 -19
  21. package/extension/doors/submitPrReview.ts +408 -0
  22. package/extension/factories/implementHere.ts +116 -0
  23. package/extension/factories/objective.ts +17 -6
  24. package/extension/factories/objectiveAuthor.ts +15 -32
  25. package/extension/factories/objectiveDraft.ts +1 -1
  26. package/extension/factories/objectivePlan.ts +16 -34
  27. package/extension/factories/objectiveSave.ts +7 -17
  28. package/extension/factories/planMode.ts +24 -38
  29. package/extension/factories/planReview.ts +291 -177
  30. package/extension/factories/planSave.ts +23 -8
  31. package/extension/index.ts +117 -20
  32. package/extension/substrate/bindingDelivery.ts +32 -10
  33. package/extension/substrate/bindings.ts +4 -2
  34. package/extension/substrate/cache.ts +36 -9
  35. package/extension/substrate/clipboard.ts +81 -0
  36. package/extension/substrate/command.ts +25 -0
  37. package/extension/substrate/config.ts +98 -76
  38. package/extension/substrate/consoleCapture.ts +90 -0
  39. package/extension/substrate/git.ts +77 -0
  40. package/extension/substrate/miniJinja.ts +480 -0
  41. package/extension/substrate/paths.ts +38 -0
  42. package/extension/substrate/prompts.ts +15 -24
  43. package/extension/substrate/providers.ts +62 -8
  44. package/extension/substrate/sessionData.ts +1 -1
  45. package/extension/substrate/sessionPointers.ts +184 -0
  46. package/extension/substrate/structuredOutput.ts +3 -1
  47. package/extension/substrate/terminalLaunch.ts +178 -0
  48. package/extension/substrate/toolGating.ts +332 -73
  49. package/extension/substrate/toolParams.ts +7 -0
  50. package/extension/substrate/workflowState.ts +54 -2
  51. package/extension/surfaces/footerProvider.ts +8 -4
  52. package/extension/surfaces/surfaces.ts +338 -13
  53. package/extension/vendor/btw/btw.ts +10 -0
  54. package/extension/worker/readOnlySession.ts +19 -6
  55. package/extension/worker/worker.ts +187 -31
  56. package/extension/workerMain.ts +14 -13
  57. package/package.json +3 -7
  58. package/prompts/README.md +56 -5
  59. package/prompts/_fixtures/cases.yaml +52 -131
  60. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  61. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  62. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  63. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  64. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  65. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  66. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  67. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  68. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  69. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  70. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  71. package/prompts/_fixtures/live.yaml +396 -0
  72. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  73. package/prompts/_fixtures/templates/cond_if.md +7 -0
  74. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  75. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  76. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  77. package/prompts/_fixtures/templates/trim_block.md +5 -0
  78. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  79. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  80. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  81. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  82. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  83. package/prompts/contexts/objective-authoring.md +20 -0
  84. package/prompts/contexts/plan-authoring.md +24 -0
  85. package/prompts/contexts/read-only.md +10 -0
  86. package/prompts/stages/conflict-resolution.md +4 -0
  87. package/prompts/stages/learn-code.md +8 -0
  88. package/prompts/stages/learn-docs.md +7 -6
  89. package/prompts/stages/learn-orchestrate.md +6 -0
  90. package/prompts/stages/learn.md +1 -1
  91. package/prompts/stages/objective-author/adopt.md +12 -0
  92. package/prompts/stages/objective-author/file.md +9 -0
  93. package/prompts/stages/objective-author/seed.md +9 -0
  94. package/prompts/stages/objective-plan/guidance.md +1 -1
  95. package/prompts/stages/objective-plan/seed.md +3 -2
  96. package/prompts/stages/objective-reconcile.md +7 -0
  97. package/prompts/stages/objective-replan.md +14 -0
  98. package/prompts/stages/objective-save.md +9 -0
  99. package/prompts/stages/plan-from/adopt.md +10 -0
  100. package/prompts/stages/plan-from/file.md +9 -0
  101. package/prompts/stages/pr-review-browser/active.md +11 -0
  102. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  103. package/prompts/stages/pr-review-terminal/active.md +12 -0
  104. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  105. package/prompts/stages/pr-review-terminal/local.md +4 -0
  106. package/prompts/stages/pr-review.md +6 -0
  107. package/prompts/stages/replan.md +13 -0
  108. package/prompts/stages/skills/create-from.md +15 -0
  109. package/prompts/stages/skills/create.md +9 -0
  110. package/prompts/stages/skills/refine.md +9 -0
  111. package/shared/README.md +23 -13
  112. package/shared/bindings.yaml +22 -2
  113. package/shared/contracts-history.md +191 -0
  114. package/shared/contracts.md +2258 -1638
  115. package/shared/providers.yaml +8 -1
  116. package/shared/registry.yaml +10 -11
  117. package/shared/schemas/contracts/bindings.schema.json +38 -0
  118. package/shared/schemas/contracts/providers.schema.json +89 -0
  119. package/shared/schemas/contracts/registry.schema.json +98 -0
  120. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  121. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  122. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  123. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  124. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  125. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  126. package/shared/schemas/outputs/init-report.schema.json +419 -0
  127. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  128. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  129. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  130. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  131. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  132. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  133. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  134. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  135. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  136. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  137. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  138. package/extension/doors/learnDocs.ts +0 -99
  139. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  140. package/prompts/_fixtures/golden/address-action.txt +0 -10
  141. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  142. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  143. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  144. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  145. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  146. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  147. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  148. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  149. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  150. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  151. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  152. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  153. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  154. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  155. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  156. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  157. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
@@ -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 `[models.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,8 +225,8 @@ 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.",
132
- "The summary is captured verbatim — write the learnings as markdown (what changed vs. the plan, deviations, residual risks).",
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.",
229
+ "learn captures the summary verbatim — write the learnings as markdown (what changed vs. the plan, deviations, residual risks).",
133
230
  ];
134
231
 
135
232
  /** Resolve the active plan-ref (worktree first, then the rebuilt workflow-state). */
@@ -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,144 @@
1
+ // hop-2 — the two learn plan factories' warm transition surfaces: the `/learn-docs` and
2
+ // `/learn-code` commands (the warm twins of the `perk learn docs` / `perk learn code` cold
3
+ // doors). One shared register parameterized by a kind config — mirroring the Python plane's
4
+ // `factory_common.py` (`LearnFactoryKind` + `DOCS_FACTORY`/`CODE_FACTORY` + `run_factory`).
5
+ //
6
+ // Each door DELEGATES the gather to the Python plane (`perk learn <kind> --gather --json` via the
7
+ // shared cold-door client `runColdDoor` — gate-safe, not subject to the read-only bash allowlist),
8
+ // decodes `{ inbox_path, learn_numbers }`, then injects the factory guidance via
9
+ // `pi.sendUserMessage` so the model reads the inbox, authors the plan, and saves it. The save is
10
+ // surface-dependent (the seed spells this out): where `plan_save` is active (a read-write session,
11
+ // the warm doors' usual host) the model passes `consumed_learn` explicitly — load-bearing here,
12
+ // because the warm gather is side-effect-free and writes NO handoff carrier; in a gated read-only
13
+ // session `plan_save` is hidden (toolGating.ts), so the save lands review-first via `plan_review`
14
+ // and the COLD doors' handoff carrier supplies `consumed_learn`. No model tool is registered here.
15
+ //
16
+ // Headless-safe: rich UI is guarded by `ctx.hasUI`; without a UI it logs to stderr and returns
17
+ // (the gather still runs so the inbox is materialized, but no turn is driven).
18
+
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { bindingSuffix } from "../substrate/bindingDelivery.ts";
21
+ import { type ColdJson, runColdDoor, stringField } from "../substrate/coldDoor.ts";
22
+ import { registerPerkCommand } from "../substrate/command.ts";
23
+ import { render } from "../substrate/prompts.ts";
24
+ import { report } from "../surfaces/report.ts";
25
+
26
+ /**
27
+ * The per-door parameter bundle shared by the two warm learn-factory doors (the TS twin of the
28
+ * frozen `LearnFactoryKind` dataclass). `subcommand` derives the cold argv, the `runColdDoor`
29
+ * label, and the headless log tail; `seedTemplate` and `bindingTrigger` stay explicit so the
30
+ * strings remain greppable against `prompts/stages/` and `shared/bindings.yaml`.
31
+ */
32
+ export interface LearnFactoryDoorKind {
33
+ /** The command id and `report()` scope. */
34
+ readonly name: string;
35
+ /** The cold-door verb under `perk learn`. */
36
+ readonly subcommand: string;
37
+ readonly seedTemplate: string;
38
+ readonly bindingTrigger: string;
39
+ /** The `registerPerkCommand` description. */
40
+ readonly description: string;
41
+ /** The gentle `no_learn_issues` warning. */
42
+ readonly emptyMessage: string;
43
+ }
44
+
45
+ export const DOCS_DOOR: LearnFactoryDoorKind = {
46
+ name: "learn-docs",
47
+ subcommand: "docs",
48
+ seedTemplate: "stages/learn-docs.md",
49
+ bindingTrigger: "command:learn-docs",
50
+ description:
51
+ "Start the learned-docs plan factory: gather open perk:learn issues into an inbox and author " +
52
+ "a docs/learned consolidation plan.",
53
+ emptyMessage: "nothing to consolidate (no open perk:learn issues).",
54
+ };
55
+
56
+ export const CODE_DOOR: LearnFactoryDoorKind = {
57
+ name: "learn-code",
58
+ subcommand: "code",
59
+ seedTemplate: "stages/learn-code.md",
60
+ bindingTrigger: "command:learn-code",
61
+ description:
62
+ "Start the learn-code plan factory: gather pre-stamped SHOULD_BE_CODE perk:learn issues into " +
63
+ "an inbox and author a plan routing each into its real code home.",
64
+ emptyMessage: "nothing to route into code (no SHOULD_BE_CODE perk:learn issues).",
65
+ };
66
+
67
+ /** The decoded `perk learn <kind> --gather --json` payload slice the warm door consumes. */
68
+ export interface LearnGatherPayload {
69
+ inbox_path: string;
70
+ /** Opaque string learn-issue ids (GitHub "45", Linear "ENG-45") — §8.21. */
71
+ learn_numbers: string[];
72
+ }
73
+
74
+ /** Strict decode — the guidance dereferences both fields; `launched` is unconsumed. Exported for offline reject-branch tests. */
75
+ export function decodeGather(payload: ColdJson): LearnGatherPayload | null {
76
+ const inboxPath = stringField(payload, "inbox_path");
77
+ const numbers = payload.learn_numbers;
78
+ if (inboxPath === undefined) return null;
79
+ // String ids are canonical (§8.21); numbers are tolerated + coerced (older envelopes).
80
+ if (
81
+ !Array.isArray(numbers) ||
82
+ !numbers.every((n) => typeof n === "string" || typeof n === "number")
83
+ ) {
84
+ return null;
85
+ }
86
+ return { inbox_path: inboxPath, learn_numbers: numbers.map((n) => String(n)) };
87
+ }
88
+
89
+ /**
90
+ * The seed guidance the warm door injects to start the factory loop (the per-kind skill pointer
91
+ * rides the skill-binding suffix — not hardcoded here). Pure + exported for offline tests.
92
+ */
93
+ export function learnFactoryGuidance(
94
+ kind: LearnFactoryDoorKind,
95
+ inboxPath: string,
96
+ learnNumbers: string[],
97
+ ): string {
98
+ return render(kind.seedTemplate, {
99
+ inbox_path: inboxPath,
100
+ num_list: learnNumbers.join(", "),
101
+ });
102
+ }
103
+
104
+ /** Register one warm learn-factory door: the `/<kind.name>` command (no model tool). */
105
+ export function registerLearnFactoryDoor(pi: ExtensionAPI, kind: LearnFactoryDoorKind): void {
106
+ registerPerkCommand(pi, kind.name, {
107
+ description: kind.description,
108
+ handler: async (_args, ctx: ExtensionContext) => {
109
+ // Report-only door (no Result type): branch on `errorType` directly (the coldDoor header
110
+ // convention). A clean empty inbox exits non-zero with error_type=no_learn_issues — the
111
+ // client's envelope-aware arm surfaces it gently.
112
+ const r = await runColdDoor<LearnGatherPayload>(
113
+ pi,
114
+ ctx,
115
+ ["learn", kind.subcommand, "--gather", "--json"],
116
+ { label: `perk learn ${kind.subcommand}`, decode: decodeGather },
117
+ );
118
+ if (!r.ok) {
119
+ if (r.errorType === "no_learn_issues") {
120
+ report(ctx, kind.name, "warning", kind.emptyMessage);
121
+ } else {
122
+ report(ctx, kind.name, "error", `gather failed: ${r.message}`);
123
+ }
124
+ return;
125
+ }
126
+
127
+ if (!ctx.hasUI) {
128
+ // Headless can't drive a turn — the inbox is materialized; log and return (fail-safe).
129
+ console.error(
130
+ `perk: /${kind.name} invoked (headless) — gathered ${r.data.learn_numbers.length} ` +
131
+ `learn issue(s) into ${r.data.inbox_path}; run interactively to author the ` +
132
+ `${kind.subcommand} plan.`,
133
+ );
134
+ return;
135
+ }
136
+
137
+ report(ctx, kind.name, "info", `gathered ${r.data.learn_numbers.length} learn issue(s)`);
138
+ pi.sendUserMessage(
139
+ learnFactoryGuidance(kind, r.data.inbox_path, r.data.learn_numbers) +
140
+ bindingSuffix(ctx.cwd, kind.bindingTrigger),
141
+ );
142
+ },
143
+ });
144
+ }
@@ -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) => {