@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.
Files changed (93) hide show
  1. package/README.md +68 -44
  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 +19 -12
  6. package/extension/doors/address.ts +4 -4
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +21 -14
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +31 -9
  11. package/extension/doors/learn.ts +2 -2
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/plannotatorHandoff.ts +509 -0
  14. package/extension/doors/prReview.ts +4 -4
  15. package/extension/doors/prReviewBrowser.ts +341 -0
  16. package/extension/doors/prReviewTerminal.ts +267 -0
  17. package/extension/doors/selfcheck.ts +238 -5
  18. package/extension/doors/submit.ts +20 -0
  19. package/extension/doors/submitPrReview.ts +408 -0
  20. package/extension/factories/objective.ts +15 -5
  21. package/extension/factories/objectiveAuthor.ts +15 -32
  22. package/extension/factories/objectiveDraft.ts +1 -1
  23. package/extension/factories/objectivePlan.ts +12 -10
  24. package/extension/factories/objectiveSave.ts +2 -2
  25. package/extension/factories/planMode.ts +22 -40
  26. package/extension/factories/planReview.ts +213 -191
  27. package/extension/factories/planSave.ts +7 -7
  28. package/extension/index.ts +83 -25
  29. package/extension/substrate/bindingDelivery.ts +32 -10
  30. package/extension/substrate/bindings.ts +4 -2
  31. package/extension/substrate/cache.ts +34 -7
  32. package/extension/substrate/clipboard.ts +81 -0
  33. package/extension/substrate/config.ts +88 -65
  34. package/extension/substrate/git.ts +43 -0
  35. package/extension/substrate/paths.ts +1 -1
  36. package/extension/substrate/prompts.ts +2 -2
  37. package/extension/substrate/providers.ts +62 -8
  38. package/extension/substrate/sessionPointers.ts +35 -6
  39. package/extension/substrate/structuredOutput.ts +3 -1
  40. package/extension/substrate/terminalLaunch.ts +178 -0
  41. package/extension/substrate/toolGating.ts +330 -79
  42. package/extension/substrate/toolParams.ts +7 -0
  43. package/extension/substrate/workflowState.ts +54 -2
  44. package/extension/surfaces/footerProvider.ts +8 -4
  45. package/extension/surfaces/surfaces.ts +330 -12
  46. package/extension/vendor/btw/btw.ts +10 -0
  47. package/extension/worker/readOnlySession.ts +19 -6
  48. package/extension/worker/worker.ts +77 -7
  49. package/extension/workerMain.ts +12 -13
  50. package/package.json +3 -3
  51. package/prompts/_fixtures/live.yaml +117 -2
  52. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  53. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  54. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  55. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  56. package/prompts/contexts/objective-authoring.md +20 -0
  57. package/prompts/contexts/plan-authoring.md +24 -0
  58. package/prompts/contexts/read-only.md +10 -0
  59. package/prompts/stages/conflict-resolution.md +1 -1
  60. package/prompts/stages/learn-code.md +1 -1
  61. package/prompts/stages/learn-docs.md +2 -2
  62. package/prompts/stages/learn-orchestrate.md +1 -1
  63. package/prompts/stages/objective-author/adopt.md +1 -1
  64. package/prompts/stages/objective-author/file.md +1 -1
  65. package/prompts/stages/objective-plan/guidance.md +1 -1
  66. package/prompts/stages/objective-plan/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile.md +1 -1
  68. package/prompts/stages/objective-replan.md +1 -1
  69. package/prompts/stages/plan-from/adopt.md +2 -2
  70. package/prompts/stages/plan-from/file.md +2 -2
  71. package/prompts/stages/pr-review-browser/active.md +11 -0
  72. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  73. package/prompts/stages/pr-review-terminal/active.md +12 -0
  74. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  75. package/prompts/stages/pr-review-terminal/local.md +4 -0
  76. package/prompts/stages/pr-review.md +1 -1
  77. package/prompts/stages/replan.md +2 -2
  78. package/prompts/stages/skills/create-from.md +1 -1
  79. package/prompts/stages/skills/create.md +1 -1
  80. package/prompts/stages/skills/refine.md +1 -1
  81. package/shared/README.md +22 -18
  82. package/shared/bindings.yaml +10 -2
  83. package/shared/contracts-history.md +24 -0
  84. package/shared/contracts.md +1442 -1787
  85. package/shared/providers.yaml +8 -1
  86. package/shared/registry.yaml +7 -8
  87. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  88. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  89. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  90. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  91. package/extension/doors/learnCode.ts +0 -100
  92. package/extension/doors/learnDocs.ts +0 -100
  93. package/extension/doors/prReviewLocal.ts +0 -229
@@ -1,7 +1,8 @@
1
1
  // The perk-owned, read-only CI executor (the Run→Report half of Run→Report→Fix→Verify).
2
2
  //
3
- // A deterministic, in-process check runner: it runs the project's configured `[ci]` named checks
4
- // via `pi.exec` and REPORTS pass/fail + failure output — it never edits, fixes, or loops. The
3
+ // A deterministic, in-process check runner: it runs the project's configured `[[ci.checks]]`
4
+ // named checks via `pi.exec` and REPORTS pass/fail + failure output — it never edits, fixes, or
5
+ // loops. The
5
6
  // parent agent (the normal read-write implement session) owns the entire fix loop and all
6
7
  // iteration state; this executor is a stateless oracle invoked once per `run_ci` call (the
7
8
  // `devrun` discipline: "run and report", never "run and fix").
@@ -13,14 +14,14 @@
13
14
  // does NOT call `runReadOnlyChild` (whose `success` means "ran", carrying no exit code).
14
15
  //
15
16
  // Threat model & the safety boundary (read first):
16
- // `pi.exec("bash", ["-lc", cmd])` runs whatever the `[ci]` command string says, with full
17
- // filesystem/network access, OUTSIDE perk's tool gate. The defenses, in order, are:
17
+ // `pi.exec("bash", ["-lc", cmd])` runs whatever the `[[ci.checks]]` command string says, with
18
+ // full filesystem/network access, OUTSIDE perk's tool gate. The defenses, in order, are:
18
19
  // 1. The model never authors the command — it picks a configured NAME (a persuaded model
19
20
  // cannot run `rm -rf` because it cannot supply a command).
20
21
  // 2. Untrusted-config scope gate (`decideCiScope`) — running a project-supplied command at
21
- // all requires `[trust] ci = "true"` (committed config), `--allow-project-ci`, an
22
+ // all requires `[ci] trusted = true` (committed config), `--allow-project-ci`, an
22
23
  // interactive confirm, or a per-session approval latch; headless with none REFUSES (fail
23
- // closed). This is the real defense against a malicious cloned-repo `[ci]`.
24
+ // closed). This is the real defense against malicious cloned-repo `[[ci.checks]]` rows.
24
25
  // 3. Output isolation — full output to scratch, capped + `<untrusted_ci_output>`-wrapped in
25
26
  // the parent's view (prompt-injection-in-stdout hygiene).
26
27
  // A true OS/tool sandbox around the check command is explicitly OUT OF SCOPE.
@@ -94,7 +95,7 @@ export type CiScope = "run" | "confirm" | "refuse";
94
95
 
95
96
  /**
96
97
  * Decide how to treat project-supplied CI. Pure (the load-bearing safety boundary):
97
- * - `[trust] ci` (committed config), `--allow-project-ci`, or a per-session latch ⇒ "run"
98
+ * - `[ci] trusted` (committed config), `--allow-project-ci`, or a per-session latch ⇒ "run"
98
99
  * (trust runs on EVERY surface, overriding the headless refuse below)
99
100
  * - else with UI ⇒ "confirm" (ask the human)
100
101
  * - else (headless, no trust/flag) ⇒ "refuse" (fail closed)
@@ -164,7 +165,9 @@ export async function runOneCheck(
164
165
  writeError = err instanceof Error ? err.message : String(err);
165
166
  }
166
167
 
167
- const capped = capForModel(outcome.output, cap, scratchPath);
168
+ // Tail-keep: pytest/tsc failure summaries live at the END of the output, so the model-visible
169
+ // slice keeps the last `cap` bytes; the scratch file still holds the full output.
170
+ const capped = capForModel(outcome.output, cap, scratchPath, "tail");
168
171
  return {
169
172
  name,
170
173
  command,
@@ -368,7 +371,7 @@ export function renderCiProse(report: CiReport): string {
368
371
  );
369
372
  }
370
373
  if (report.error_type === "no_checks_configured") {
371
- return "No CI checks configured ([[ci]] in .perk/config.toml is empty). Nothing to run.";
374
+ return "No CI checks configured ([[ci.checks]] in .perk/config.toml is empty). Nothing to run.";
372
375
  }
373
376
  if (report.error_type === "unknown_check") {
374
377
  return `perk CI: ${report.error}`;
@@ -398,6 +401,9 @@ export function renderCiProse(report: CiReport): string {
398
401
  lines.push(c.shown || "(no output captured)");
399
402
  lines.push("</untrusted_ci_output>");
400
403
  }
404
+ // Deliberately head-capped (unlike the per-check tail cap): the prose leads with the ✓/✗
405
+ // per-check summary and the scratch-path pointers — the actionable routing info a tail cap
406
+ // would drop.
401
407
  return capForModel(lines.join("\n"), DEFAULT_MODEL_VISIBLE_CAP).shown;
402
408
  }
403
409
 
@@ -433,8 +439,9 @@ interface ApprovalLatch {
433
439
  }
434
440
 
435
441
  /**
436
- * The single `run_ci`/`/ci` implementation. Loads `[ci]`, scopes the run (the untrusted-config
437
- * gate), runs the selected check(s) deterministically, and returns double-delivery. Never throws.
442
+ * The single `run_ci`/`/ci` implementation. Loads `[[ci.checks]]`, scopes the run (the
443
+ * untrusted-config gate), runs the selected check(s) deterministically, and returns
444
+ * double-delivery. Never throws.
438
445
  */
439
446
  async function runCiImpl(
440
447
  pi: ExtensionAPI,
@@ -444,7 +451,7 @@ async function runCiImpl(
444
451
  deps: RunCiDeps = {},
445
452
  ): Promise<CiResult> {
446
453
  const cfg = loadPerkConfig(ctx.cwd);
447
- const checks: CiCheck[] = cfg.ci;
454
+ const checks: CiCheck[] = cfg.ci.checks;
448
455
  const wrap = (report: CiReport): CiResult => ({
449
456
  content: [{ type: "text", text: renderCiProse(report) }],
450
457
  details: report,
@@ -456,7 +463,7 @@ async function runCiImpl(
456
463
  if (checks.length > 0) {
457
464
  const decideScope = deps.decideScope ?? decideCiScope;
458
465
  const allowFlag = pi.getFlag("allow-project-ci") === true;
459
- const trusted = cfg.trust.ci === true;
466
+ const trusted = cfg.ci.trusted;
460
467
  const scope = decideScope({ hasUI: ctx.hasUI, allowFlag, approved: latch.approved, trusted });
461
468
 
462
469
  if (scope === "refuse") {
@@ -507,7 +514,7 @@ async function runCiImpl(
507
514
  const TOOL_GUIDELINES = [
508
515
  "run_ci RUNS the configured CI checks and REPORTS results — it never edits, fixes, or loops.",
509
516
  "Analyze any failure yourself, fix it in your own turn, then call run_ci again to re-verify.",
510
- "Optionally pass a single configured check name; omit it to run all checks in declared order.",
517
+ "Pass run_ci a single configured check name to run just that check; omit it to run all checks in declared order.",
511
518
  "You own the Run→Report→Fix→Verify loop; run_ci is a stateless oracle, not an auto-fixer.",
512
519
  ];
513
520
 
@@ -0,0 +1,202 @@
1
+ // The hunk terminal-review substrate serving `/pr-review-terminal`: the strict
2
+ // `perk pr review checkout` decode, the `hunk --version` presence probe, and the R7 launch
3
+ // handoff (clipboard copy + terminal auto-launch raced against a soft deadline) — plus the
4
+ // door-common PR-token arg grammar (`parseReviewArgs`) that `/pr-review-browser` also consumes
5
+ // via `parseReviewDoorArgs`. Re-homing the door-common pieces to a neutral module is a deferred
6
+ // residual (accepted — no code moves yet).
7
+
8
+ import { copyToClipboard } from "../substrate/clipboard.ts";
9
+ import { type ColdJson, type ExecHost, numberField, stringField } from "../substrate/coldDoor.ts";
10
+ import { LAUNCH_SURFACE, launchInTerminal } from "../substrate/terminalLaunch.ts";
11
+ import { type ReportTarget, report } from "../surfaces/report.ts";
12
+
13
+ /** The install hint for the absent hunk binary — the exact `HUNK_INSTALL_HINT` wording
14
+ * (src/perk/convergence/init/review_cli.py). */
15
+ export const HUNK_INSTALL_HINT = "npm i -g hunkdiff (or brew install hunk)";
16
+
17
+ // ------------------------------------------------------------------------ the PR arg grammar
18
+
19
+ /** A parsed PR-review invocation: the PR number + the optional free-form focus directive. */
20
+ export interface ReviewArgs {
21
+ pr: number;
22
+ directive: string;
23
+ }
24
+
25
+ /** Extracts the PR number from a GitHub PR URL (with optional trailing /path, #fragment, ?query). */
26
+ export const PR_URL_RE = /\/pull\/(\d+)(?:\/|$|#|\?)/;
27
+
28
+ /**
29
+ * Parse a PR-review arg string: the first token is a bare PR number or a GitHub PR URL; the rest
30
+ * is the optional free-form focus directive. Null on a missing/unparseable PR (usage failure).
31
+ * Cross-repo URLs are NOT validated against the session repo — the number is extracted as-is.
32
+ */
33
+ export function parseReviewArgs(args: string): ReviewArgs | null {
34
+ const trimmed = args.trim();
35
+ if (trimmed.length === 0) return null;
36
+ const split = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
37
+ if (split === null) return null;
38
+ const first = split[1] ?? "";
39
+ const directive = (split[2] ?? "").trim();
40
+ let pr: number | null = null;
41
+ if (/^\d+$/.test(first)) {
42
+ pr = Number(first);
43
+ } else {
44
+ const url = first.match(PR_URL_RE);
45
+ if (url?.[1] !== undefined) pr = Number(url[1]);
46
+ }
47
+ if (pr === null || !Number.isSafeInteger(pr) || pr <= 0) return null;
48
+ return { pr, directive };
49
+ }
50
+
51
+ // ------------------------------------------------------------------------ checkout decode
52
+
53
+ /** The `perk pr review checkout --json` ok-arm (all six fields always emitted — strict). */
54
+ export interface CheckoutOk {
55
+ path: string;
56
+ pr: number;
57
+ url: string;
58
+ head_sha: string;
59
+ base_sha: string;
60
+ base_ref: string;
61
+ }
62
+
63
+ /** Strict decode of the checkout payload — the guidance dereferences `path`/`base_sha`/`url`. */
64
+ export function decodeCheckout(payload: ColdJson): CheckoutOk | null {
65
+ const path = stringField(payload, "path");
66
+ const pr = numberField(payload, "pr");
67
+ const url = stringField(payload, "url");
68
+ const headSha = stringField(payload, "head_sha");
69
+ const baseSha = stringField(payload, "base_sha");
70
+ const baseRef = stringField(payload, "base_ref");
71
+ if (
72
+ path === undefined ||
73
+ pr === undefined ||
74
+ url === undefined ||
75
+ headSha === undefined ||
76
+ baseSha === undefined ||
77
+ baseRef === undefined
78
+ ) {
79
+ return null;
80
+ }
81
+ return { path, pr, url, head_sha: headSha, base_sha: baseSha, base_ref: baseRef };
82
+ }
83
+
84
+ // ------------------------------------------------------------------------ the hunk probe
85
+
86
+ /**
87
+ * Whether the `hunk` review CLI is present — the `hunk --version` probe (`!killed && code === 0`;
88
+ * any throw ⇒ false). The terminal door refuses-at-start on a false, before any cold-door call.
89
+ */
90
+ export async function hunkPresent(
91
+ pi: ExecHost,
92
+ ctx: { cwd: string; signal?: AbortSignal },
93
+ ): Promise<boolean> {
94
+ try {
95
+ const probe = await pi.exec("hunk", ["--version"], { cwd: ctx.cwd, signal: ctx.signal });
96
+ return !probe.killed && probe.code === 0;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ // ------------------------------------------------------------------------ the R7 handoff
103
+
104
+ /** The minimal ctx slice the hunk launch handoff needs (report + the exec cwd/signal). */
105
+ interface ReviewLaunchCtx extends ReportTarget {
106
+ cwd: string;
107
+ signal?: AbortSignal;
108
+ }
109
+
110
+ /** The default soft deadline the launch is raced against (ms) — see `handleHunkLaunch`. */
111
+ const LAUNCH_SOFT_DEADLINE_MS = 2000;
112
+
113
+ /**
114
+ * The soft-deadline knob: `PERK_REVIEW_LAUNCH_DEADLINE_MS` (an internal test seam — documented
115
+ * in contracts §8.4 beside the two human-facing seams, never in user docs; tests drive the whole
116
+ * command handler, so an env knob is the only injectable surface). Falls back to the 2s default.
117
+ */
118
+ function softDeadlineMs(): number {
119
+ const raw = process.env.PERK_REVIEW_LAUNCH_DEADLINE_MS;
120
+ if (raw !== undefined) {
121
+ const n = Number(raw);
122
+ if (Number.isFinite(n) && n >= 0) return n;
123
+ }
124
+ return LAUNCH_SOFT_DEADLINE_MS;
125
+ }
126
+
127
+ /**
128
+ * A cancellable `null`-resolving timer — the soft-deadline arm of the launch race. The caller
129
+ * cancels it once the race settles so a won race never leaves a live timer behind (a leak in
130
+ * production; a hang/latency drag under test runners that wait for pending timers).
131
+ */
132
+ function delay(ms: number): { promise: Promise<null>; cancel: () => void } {
133
+ let timer: ReturnType<typeof setTimeout> | undefined;
134
+ const promise = new Promise<null>((resolve) => {
135
+ timer = setTimeout(() => resolve(null), ms);
136
+ });
137
+ return {
138
+ promise,
139
+ cancel: () => {
140
+ if (timer !== undefined) clearTimeout(timer);
141
+ },
142
+ };
143
+ }
144
+
145
+ /**
146
+ * The hunk R7 handoff (contracts §8.4). Copies the launch command to the clipboard, then
147
+ * auto-launches hunk in a terminal the human can see — raced against a soft deadline so a
148
+ * first-run macOS Automation/TCC dialog (which blocks `osascript` until answered) never inserts
149
+ * ~18s of silence before the guidance injection. Reports the outcome under `opts.scope` (the
150
+ * invoking door's report scope): an **info** "opened hunk" when the launch settled cleanly within
151
+ * the deadline, else a **warning** "ACTION NEEDED" (the launch failed, no rung matched, or it is
152
+ * still pending). For the pending case, a background follow-up info note lands if/when consent is
153
+ * granted and the launch succeeds. Non-blocking: the caller injects the guidance immediately
154
+ * either way. The `hunk session get` handshake (driven by the model per the template) — never a
155
+ * spawn success — remains the only verification hunk is up.
156
+ */
157
+ export async function handleHunkLaunch(
158
+ pi: ExecHost,
159
+ ctx: ReviewLaunchCtx,
160
+ opts: { cwd: string; hunkCmd: string; launchLine: string; scope: string },
161
+ ): Promise<void> {
162
+ const copied = await copyToClipboard(pi, ctx, opts.launchLine);
163
+ const clip = copied ? " (it's on your clipboard)" : "";
164
+ const settled = launchInTerminal(pi, ctx, { cwd: opts.cwd, command: opts.hunkCmd });
165
+ const deadline = delay(softDeadlineMs());
166
+ const quick = await Promise.race([settled, deadline.promise]);
167
+ deadline.cancel();
168
+ if (quick?.launched) {
169
+ report(
170
+ ctx,
171
+ opts.scope,
172
+ "info",
173
+ `opened hunk in a new ${LAUNCH_SURFACE[quick.via ?? "terminal-app"]} — if nothing ` +
174
+ `appeared, run this in another terminal:\n ${opts.launchLine}${clip}`,
175
+ );
176
+ return;
177
+ }
178
+ report(
179
+ ctx,
180
+ opts.scope,
181
+ "warning",
182
+ `ACTION NEEDED — run hunk in another terminal:\n ${opts.launchLine}${clip}`,
183
+ );
184
+ if (quick === null) {
185
+ // Still pending past the soft deadline (a first-run TCC dialog): proceed now, and note it if
186
+ // consent is granted and the launch lands so the human can ignore the manual step.
187
+ // `launchInTerminal` never rejects by contract — the `.catch` is belt-and-braces.
188
+ void settled
189
+ .then((r) => {
190
+ if (r.launched) {
191
+ report(
192
+ ctx,
193
+ opts.scope,
194
+ "info",
195
+ `hunk opened in a new ${LAUNCH_SURFACE[r.via ?? "terminal-app"]} — ignore the ` +
196
+ "manual step above",
197
+ );
198
+ }
199
+ })
200
+ .catch(() => {});
201
+ }
202
+ }
@@ -1,13 +1,16 @@
1
1
  // The warm `/land` door. The in-session twin of the Python cold door
2
2
  // (`perk pr land`): a terminating tool + command that DELEGATE the GitHub merge (mutations
3
- // canonical in Python), then set the `pending-learn` marker for the in-session path (the worker
4
- // sets it too on the cold path; the marker is an idempotent existence-semaphore). Never throws.
3
+ // canonical in Python), then mirror the envelope's `pending_learn` for the in-session path
4
+ // setting the `pending-learn` marker (an idempotent existence-semaphore; the worker sets it too
5
+ // on the cold path) unless the cold door reports the learn-docs exemption (`pending_learn:
6
+ // false` — no marker, no /learn nudge). Never throws.
5
7
 
6
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
9
  import { reconcileGuidance } from "../factories/objectivePlan.ts";
8
10
  import { bindingSuffix } from "../substrate/bindingDelivery.ts";
9
11
  import { PENDING_LEARN, setMarker } from "../substrate/cache.ts";
10
12
  import {
13
+ booleanField,
11
14
  type ColdJson,
12
15
  nullableStringField,
13
16
  numberField,
@@ -37,7 +40,8 @@ export interface LearnConsumeUpdate {
37
40
  skipped_reason: string | null;
38
41
  }
39
42
 
40
- /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
43
+ /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state.
44
+ * `pending_learn` mirrors the cold envelope: `false` is the learn-docs exemption (no marker). */
41
45
  export interface LandOk {
42
46
  pr: { number: number; state: string };
43
47
  branch?: string;
@@ -51,11 +55,13 @@ export interface LandOk {
51
55
  export type LandResult = Result<LandOk>;
52
56
  export type LandDetails = LandResult["details"];
53
57
 
54
- /** The decoded `perk pr land --json` payload — `LandOk` minus the warm-door-owned `pending_learn`. */
58
+ /** The decoded `perk pr land --json` payload — the cold door owns `pending_learn` (the
59
+ * learn-docs-exemption decision point); decoded leniently so skew degrades to legacy. */
55
60
  interface LandPayload {
56
61
  pr: { number: number; state: string };
57
62
  branch?: string;
58
63
  issue?: string;
64
+ pending_learn: boolean;
59
65
  objective?: ObjectiveLandUpdate;
60
66
  learn?: LearnConsumeUpdate;
61
67
  }
@@ -108,6 +114,10 @@ function decodeLand(payload: ColdJson): LandPayload | null {
108
114
  pr: { number, state },
109
115
  branch: stringField(payload, "branch"),
110
116
  issue: stringField(payload, "issue"),
117
+ // Lenient: a missing/mistyped `pending_learn` (an older cold CLI under version skew)
118
+ // defaults to the legacy behavior (marker + /learn nudge) — never a silently-unreleased
119
+ // marker with no visible nudge.
120
+ pending_learn: booleanField(payload, "pending_learn") ?? true,
111
121
  objective: decodeObjective(payload),
112
122
  learn: decodeLearn(payload),
113
123
  };
@@ -115,7 +125,9 @@ function decodeLand(payload: ColdJson): LandPayload | null {
115
125
 
116
126
  /**
117
127
  * The single land implementation both surfaces call. Delegates the merge to the Python cold door,
118
- * then sets `pending-learn` (in-session path). Returns a soft result (never throws).
128
+ * then mirrors the envelope's `pending_learn` (in-session path): marker + /learn nudge on the
129
+ * ordinary arm; no marker, no nudge on the learn-docs exemption. Returns a soft result
130
+ * (never throws).
119
131
  */
120
132
  export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
121
133
  const fail = failFor(ctx, "land");
@@ -126,10 +138,16 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
126
138
  });
127
139
  if (!r.ok) return fail(r.message, r.errorType);
128
140
 
129
- // Set the semaphore for the in-session path (idempotent; the worker also set it on disk).
130
- setMarker(ctx.cwd, PENDING_LEARN);
141
+ if (r.data.pending_learn) {
142
+ // Set the semaphore for the in-session path (idempotent; the cold door also set it on disk).
143
+ setMarker(ctx.cwd, PENDING_LEARN);
144
+ }
131
145
 
132
- const lines = [`Landed PR #${r.data.pr.number}; run /learn to release the worktree.`];
146
+ const lines = [
147
+ r.data.pending_learn
148
+ ? `Landed PR #${r.data.pr.number}; run /learn to release the worktree.`
149
+ : `Landed PR #${r.data.pr.number}; learn-docs plan — no learn pass needed; the worktree is releasable.`,
150
+ ];
133
151
  const obj = r.data.objective;
134
152
  if (obj?.nodes_marked.length && obj.id !== null) {
135
153
  // The reconcile pass is auto-driven after land (see driveReconcileAfterLand); just report it.
@@ -156,7 +174,11 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
156
174
  lines.push(`Warning: learn consume incomplete — ${learn.skipped_reason}.`);
157
175
  }
158
176
 
159
- return ok(lines.join("\n"), { ...r.data, pending_learn: true }, { terminate: true });
177
+ return ok(
178
+ lines.join("\n"),
179
+ { ...r.data, pending_learn: r.data.pending_learn },
180
+ { terminate: true },
181
+ );
160
182
  }
161
183
 
162
184
  /**
@@ -24,7 +24,7 @@
24
24
  // `/learn <text>` / `/learn skip` stay the existing verbatim-capture / skip-recording paths
25
25
  // (decision-less escape hatches). Cold `perk learn` launch stays the simple investigate+capture.
26
26
  //
27
- // The analyst model is configurable via `[subagents] learn-analyst` in `.perk/config.toml`; because
27
+ // The analyst model is configurable via `[models.subagents] learn-analyst` in `.perk/config.toml`; because
28
28
  // `subagents.agentOverrides` does NOT reach project agents, the orchestration seed injects that
29
29
  // model as a per-call inline `model` override on every analyst spawn.
30
30
 
@@ -226,7 +226,7 @@ export async function learnDone(
226
226
 
227
227
  const TOOL_GUIDELINES = [
228
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
- "The summary is captured verbatim — write the learnings as markdown (what changed vs. the plan, deviations, residual risks).",
229
+ "learn captures the summary verbatim — write the learnings as markdown (what changed vs. the plan, deviations, residual risks).",
230
230
  ];
231
231
 
232
232
  /** Resolve the active plan-ref (worktree first, then the rebuilt workflow-state). */
@@ -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
+ }