@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,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.
@@ -29,6 +30,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
29
30
  import { join } from "node:path";
30
31
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
31
32
  import { ensureRunScratch, scratchDir } from "../substrate/cache.ts";
33
+ import { registerPerkCommand } from "../substrate/command.ts";
32
34
  import { type CiCheck, loadPerkConfig } from "../substrate/config.ts";
33
35
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
34
36
  import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
@@ -93,7 +95,7 @@ export type CiScope = "run" | "confirm" | "refuse";
93
95
 
94
96
  /**
95
97
  * Decide how to treat project-supplied CI. Pure (the load-bearing safety boundary):
96
- * - `[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"
97
99
  * (trust runs on EVERY surface, overriding the headless refuse below)
98
100
  * - else with UI ⇒ "confirm" (ask the human)
99
101
  * - else (headless, no trust/flag) ⇒ "refuse" (fail closed)
@@ -163,7 +165,9 @@ export async function runOneCheck(
163
165
  writeError = err instanceof Error ? err.message : String(err);
164
166
  }
165
167
 
166
- 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");
167
171
  return {
168
172
  name,
169
173
  command,
@@ -367,7 +371,7 @@ export function renderCiProse(report: CiReport): string {
367
371
  );
368
372
  }
369
373
  if (report.error_type === "no_checks_configured") {
370
- return "No CI checks configured ([[ci]] in .pi/perk.toml is empty). Nothing to run.";
374
+ return "No CI checks configured ([[ci.checks]] in .perk/config.toml is empty). Nothing to run.";
371
375
  }
372
376
  if (report.error_type === "unknown_check") {
373
377
  return `perk CI: ${report.error}`;
@@ -397,6 +401,9 @@ export function renderCiProse(report: CiReport): string {
397
401
  lines.push(c.shown || "(no output captured)");
398
402
  lines.push("</untrusted_ci_output>");
399
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.
400
407
  return capForModel(lines.join("\n"), DEFAULT_MODEL_VISIBLE_CAP).shown;
401
408
  }
402
409
 
@@ -432,8 +439,9 @@ interface ApprovalLatch {
432
439
  }
433
440
 
434
441
  /**
435
- * The single `run_ci`/`/ci` implementation. Loads `[ci]`, scopes the run (the untrusted-config
436
- * 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.
437
445
  */
438
446
  async function runCiImpl(
439
447
  pi: ExtensionAPI,
@@ -443,7 +451,7 @@ async function runCiImpl(
443
451
  deps: RunCiDeps = {},
444
452
  ): Promise<CiResult> {
445
453
  const cfg = loadPerkConfig(ctx.cwd);
446
- const checks: CiCheck[] = cfg.ci;
454
+ const checks: CiCheck[] = cfg.ci.checks;
447
455
  const wrap = (report: CiReport): CiResult => ({
448
456
  content: [{ type: "text", text: renderCiProse(report) }],
449
457
  details: report,
@@ -455,7 +463,7 @@ async function runCiImpl(
455
463
  if (checks.length > 0) {
456
464
  const decideScope = deps.decideScope ?? decideCiScope;
457
465
  const allowFlag = pi.getFlag("allow-project-ci") === true;
458
- const trusted = cfg.trust.ci === true;
466
+ const trusted = cfg.ci.trusted;
459
467
  const scope = decideScope({ hasUI: ctx.hasUI, allowFlag, approved: latch.approved, trusted });
460
468
 
461
469
  if (scope === "refuse") {
@@ -506,7 +514,7 @@ async function runCiImpl(
506
514
  const TOOL_GUIDELINES = [
507
515
  "run_ci RUNS the configured CI checks and REPORTS results — it never edits, fixes, or loops.",
508
516
  "Analyze any failure yourself, fix it in your own turn, then call run_ci again to re-verify.",
509
- "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.",
510
518
  "You own the Run→Report→Fix→Verify loop; run_ci is a stateless oracle, not an auto-fixer.",
511
519
  ];
512
520
 
@@ -565,7 +573,7 @@ export function registerCiExecutor(pi: ExtensionAPI): void {
565
573
  },
566
574
  });
567
575
 
568
- pi.registerCommand("ci", {
576
+ registerPerkCommand(pi, "ci", {
569
577
  description: "Run the project's configured CI checks and report results (never auto-fixes).",
570
578
  handler: async (args, ctx) => {
571
579
  const check = args.trim() === "" ? undefined : args.trim();
@@ -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,
@@ -15,6 +18,7 @@ import {
15
18
  runColdDoor,
16
19
  stringField,
17
20
  } from "../substrate/coldDoor.ts";
21
+ import { registerPerkCommand } from "../substrate/command.ts";
18
22
  import { failFor, ok, type Result } from "../substrate/result.ts";
19
23
  import { report } from "../surfaces/report.ts";
20
24
 
@@ -36,7 +40,8 @@ export interface LearnConsumeUpdate {
36
40
  skipped_reason: string | null;
37
41
  }
38
42
 
39
- /** 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). */
40
45
  export interface LandOk {
41
46
  pr: { number: number; state: string };
42
47
  branch?: string;
@@ -50,11 +55,13 @@ export interface LandOk {
50
55
  export type LandResult = Result<LandOk>;
51
56
  export type LandDetails = LandResult["details"];
52
57
 
53
- /** 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. */
54
60
  interface LandPayload {
55
61
  pr: { number: number; state: string };
56
62
  branch?: string;
57
63
  issue?: string;
64
+ pending_learn: boolean;
58
65
  objective?: ObjectiveLandUpdate;
59
66
  learn?: LearnConsumeUpdate;
60
67
  }
@@ -107,6 +114,10 @@ function decodeLand(payload: ColdJson): LandPayload | null {
107
114
  pr: { number, state },
108
115
  branch: stringField(payload, "branch"),
109
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,
110
121
  objective: decodeObjective(payload),
111
122
  learn: decodeLearn(payload),
112
123
  };
@@ -114,7 +125,9 @@ function decodeLand(payload: ColdJson): LandPayload | null {
114
125
 
115
126
  /**
116
127
  * The single land implementation both surfaces call. Delegates the merge to the Python cold door,
117
- * 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).
118
131
  */
119
132
  export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
120
133
  const fail = failFor(ctx, "land");
@@ -125,10 +138,16 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
125
138
  });
126
139
  if (!r.ok) return fail(r.message, r.errorType);
127
140
 
128
- // Set the semaphore for the in-session path (idempotent; the worker also set it on disk).
129
- 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
+ }
130
145
 
131
- 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
+ ];
132
151
  const obj = r.data.objective;
133
152
  if (obj?.nodes_marked.length && obj.id !== null) {
134
153
  // The reconcile pass is auto-driven after land (see driveReconcileAfterLand); just report it.
@@ -155,7 +174,11 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
155
174
  lines.push(`Warning: learn consume incomplete — ${learn.skipped_reason}.`);
156
175
  }
157
176
 
158
- 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
+ );
159
182
  }
160
183
 
161
184
  /**
@@ -208,7 +231,7 @@ export function registerLand(pi: ExtensionAPI): void {
208
231
  },
209
232
  });
210
233
 
211
- pi.registerCommand("land", {
234
+ registerPerkCommand(pi, "land", {
212
235
  description: "Merge the active plan's PR and set pending-learn (submit → land).",
213
236
  handler: async (_args, ctx) => {
214
237
  const result = await landPr(pi, ctx);