@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,4 +1,4 @@
1
- // The warm `/plan-save` door (turn-3 §5/§6). The in-session twin of the Python cold
1
+ // The warm `/plan-save` door. The in-session twin of the Python cold
2
2
  // door (`perk plan save`): a deterministic, terminating tool + command that WRAP the existing
3
3
  // storage — they do NOT reimplement the GitHub write. `savePlan()` delegates to `perk plan save
4
4
  // --json` via the shared cold-door client (`runColdDoor` — the plan markdown rides the run-scratch
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // APPROVAL→SAVE ORCHESTRATION. The exported `approvalSave` seam is the shared
15
15
  // APPROVED-review → save flow: artifact-first resolution → `savePlan` (warm node-link recovery
16
- // inside, from the `objective_node_claim` carrier) → gate exit on a successful save (D1a). The
16
+ // inside, from the `objective_node_claim` carrier) → gate exit on a successful save. The
17
17
  // `/plan-save` command is the MANUAL FAILSAFE invocation of the same seam; the review backends
18
18
  // (plannotator / first-party / tombell) wire their APPROVED outcome into it.
19
19
  //
@@ -34,8 +34,10 @@ import {
34
34
  runColdDoor,
35
35
  stringField,
36
36
  } from "../substrate/coldDoor.ts";
37
+ import { registerPerkCommand } from "../substrate/command.ts";
37
38
  import { failFor, ok, type Result } from "../substrate/result.ts";
38
39
  import { readSessionArtifact, type SessionDataCtx } from "../substrate/sessionData.ts";
40
+ import { captureSessionPointer } from "../substrate/sessionPointers.ts";
39
41
  import type { ToolGating } from "../substrate/toolGating.ts";
40
42
  import { idArrayParam, paramsOf, stringParam } from "../substrate/toolParams.ts";
41
43
  import {
@@ -50,7 +52,7 @@ import { nodeClaimsEqual, readNodeClaim } from "./objectivePlan.ts";
50
52
  import { PLAN_DRAFT_ARTIFACT } from "./planDraft.ts";
51
53
  import { generatePlanTitle } from "./planTitle.ts";
52
54
 
53
- /** The ok-arm fields (turn-3 D6) — the `details` surface doubles as branch-safe persisted state. */
55
+ /** The ok-arm fields — the `details` surface doubles as branch-safe persisted state. */
54
56
  export interface PlanSaveOk {
55
57
  /** `issue.id` is the opaque string issue id (GitHub "42", Linear "ENG-123") — §8.21. */
56
58
  issue: { id: string; url: string };
@@ -312,7 +314,20 @@ export async function savePlan(
312
314
  });
313
315
  if (!r.ok) return fail(r.message, r.errorType);
314
316
 
315
- // Link the live session (turn-3 D4): append iff the rebuilt ref differs, with a strict read-back.
317
+ // Capture the planning session pointer (contracts.md §8.35): this planning run self-keys by its
318
+ // own run_id into the shared main checkout, so a later/other session can resolve it cross-run.
319
+ // Best-effort + non-fatal (the carrier warns + returns false; a successful save must stand).
320
+ // Covers /plan-save, the plan_save tool, and approvalSave (all flow through savePlan).
321
+ captureSessionPointer({
322
+ cwd: ctx.cwd,
323
+ runId,
324
+ klass: "planning",
325
+ site: "main",
326
+ // Optional-chained: best-effort, and some side-session fakes have no getSessionFile.
327
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
328
+ });
329
+
330
+ // Link the live session: append iff the rebuilt ref differs, with a strict read-back.
316
331
  const ref = r.data.plan_ref;
317
332
  if (!planRefsEqual(rebuildWorkflowState(branch()).active_plan_ref ?? null, ref)) {
318
333
  appendWorkflowState(pi, ctx, {
@@ -346,7 +361,7 @@ export async function savePlan(
346
361
  }
347
362
  // Render all THREE node-link outcomes (the silent-partial-failure fix). A failed advance
348
363
  // (`linked: false`) is a non-fatal sub-step — the plan genuinely saved — but it must be VISIBLE
349
- // (the §8.4 "warn + retriable" intent), not swallowed. Both surfaces render content[0].text, so
364
+ // (the §8.3 "surfaced, never swallowed" intent), not swallowed. Both surfaces render content[0].text, so
350
365
  // this one site fixes the tool path (the model relays it) and the command path (the user sees the
351
366
  // notify) at once.
352
367
  let linkSuffix = "";
@@ -466,8 +481,8 @@ export function decodePlanSaveParams(params: unknown): PlanSaveParams | null {
466
481
  const TOOL_GUIDELINES = [
467
482
  "Use plan_save only after the plan is decision-complete and the user has agreed; it creates the canonical GitHub plan and ends the turn.",
468
483
  "Keep the working draft current with plan_draft — the validated plan-draft artifact is what plan_save saves; the `plan` parameter is only a fallback when no draft exists. Never reference line numbers — use durable anchors (function names, behavioral descriptions, structural locations).",
469
- "Pass consumed_learn (the gathered perk:learn issue ids) only from the learned-docs factory — it links the issues the docs plan consolidates so /land closes + labels them.",
470
- "When saving an objective-factory plan, pass BOTH objective_id and node_id — this links the node to the plan and advances it planning → in_progress (no separate backlink call).",
484
+ "Pass plan_save's consumed_learn (the gathered perk:learn issue ids) only from the learned-docs factory — it links the issues the docs plan consolidates so /land closes + labels them.",
485
+ "When saving an objective-factory plan, pass plan_save BOTH objective_id and node_id — this links the node to the plan and advances it planning → in_progress (no separate backlink call).",
471
486
  ];
472
487
 
473
488
  /** Register the warm door: the `plan_save` tool (canonical) + the `/plan-save` command twin. */
@@ -552,7 +567,7 @@ export function registerPlanSave(pi: ExtensionAPI, gating: ToolGating): void {
552
567
  },
553
568
  });
554
569
 
555
- pi.registerCommand("plan-save", {
570
+ registerPerkCommand(pi, "plan-save", {
556
571
  description:
557
572
  "Save the latest proposed plan to GitHub — the manual failsafe for the approval→save flow " +
558
573
  "(the read-only → read-write boundary).",
@@ -16,12 +16,16 @@ import { registerAskUser } from "./doors/askUser.ts";
16
16
  import { registerCiExecutor } from "./doors/ciExecutor.ts";
17
17
  import { registerLand } from "./doors/land.ts";
18
18
  import { registerLearn } from "./doors/learn.ts";
19
- import { registerLearnDocs } from "./doors/learnDocs.ts";
19
+ import { CODE_DOOR, DOCS_DOOR, registerLearnFactoryDoor } from "./doors/learnFactory.ts";
20
20
  import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
21
21
  import { registerPrReview } from "./doors/prReview.ts";
22
+ import { registerPrReviewBrowser } from "./doors/prReviewBrowser.ts";
23
+ import { registerPrReviewTerminal } from "./doors/prReviewTerminal.ts";
22
24
  import { registerReady } from "./doors/ready.ts";
23
25
  import { registerSelfcheck } from "./doors/selfcheck.ts";
24
26
  import { registerSubmit } from "./doors/submit.ts";
27
+ import { registerSubmitPrReview } from "./doors/submitPrReview.ts";
28
+ import { registerImplementHere } from "./factories/implementHere.ts";
25
29
  import { registerObjective } from "./factories/objective.ts";
26
30
  import { registerObjectiveAuthor } from "./factories/objectiveAuthor.ts";
27
31
  import { registerObjectiveDraft } from "./factories/objectiveDraft.ts";
@@ -38,10 +42,12 @@ import {
38
42
  readHandoff,
39
43
  readPlanRef,
40
44
  setMarker,
45
+ workflowDir,
41
46
  } from "./substrate/cache.ts";
42
47
  import { loadRegistry, type Registry, stageConsumesPlanRef } from "./substrate/registry.ts";
43
48
  import { perkVersion, sharedDir } from "./substrate/resources.ts";
44
49
  import { mintRunId } from "./substrate/runId.ts";
50
+ import { captureSessionPointer } from "./substrate/sessionPointers.ts";
45
51
  import { registerToolGating } from "./substrate/toolGating.ts";
46
52
  import {
47
53
  appendWorkflowState,
@@ -55,7 +61,13 @@ import {
55
61
  } from "./substrate/workflowState.ts";
56
62
  import { isPerkFooterReferenceSelected } from "./surfaces/footerProvider.ts";
57
63
  import { report } from "./surfaces/report.ts";
58
- import { createPerkStatus, installPerkFooter } from "./surfaces/surfaces.ts";
64
+ import {
65
+ createPerkStatus,
66
+ installPerkFooter,
67
+ latestCacheHitRate,
68
+ registerTranscriptRenderer,
69
+ workflowStateEntryRenderer,
70
+ } from "./surfaces/surfaces.ts";
59
71
  import { registerBtw } from "./vendor/btw/btw.ts";
60
72
  import { registerWhimsical } from "./vendor/whimsical/whimsical.ts";
61
73
 
@@ -69,7 +81,7 @@ function writeT3Sentinel(
69
81
  runMode: string | null,
70
82
  ): void {
71
83
  try {
72
- const dir = join(cwd, ".pi", "workflow");
84
+ const dir = workflowDir(cwd);
73
85
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
74
86
  writeFileSync(
75
87
  join(dir, ".perk-t3.json"),
@@ -168,6 +180,11 @@ export default function (pi: ExtensionAPI) {
168
180
  // session_start (reload) could leak the previous handle subscription.
169
181
  let footerInstalled = false;
170
182
 
183
+ // Transcript marker for `perk:workflow-state` deltas (audit §2.3): the renderer body lives in
184
+ // surfaces.ts, this registration is wiring, and the seam carries the typeof feature-detect
185
+ // (pre-0.80.4 hosts stay inert). One registration covers every workflow-state appender.
186
+ registerTranscriptRenderer(pi, WORKFLOW_STATE_TYPE, workflowStateEntryRenderer);
187
+
171
188
  pi.on("session_start", async (_event, ctx) => {
172
189
  const branchEntries = () => branchOf(ctx);
173
190
  const sessionFile = ctx.sessionManager.getSessionFile();
@@ -185,7 +202,9 @@ export default function (pi: ExtensionAPI) {
185
202
  cwd: ctx.cwd,
186
203
  });
187
204
 
188
- let resolved: WorkflowState = decision.action === "claim" ? {} : decision.state;
205
+ // `claim`/`adopt` carry no prior branch state (adopt's is written by its arm below).
206
+ let resolved: WorkflowState =
207
+ decision.action === "claim" || decision.action === "adopt" ? {} : decision.state;
189
208
  let minted = false;
190
209
 
191
210
  if (decision.action === "claim") {
@@ -229,6 +248,22 @@ export default function (pi: ExtensionAPI) {
229
248
  };
230
249
  pi.appendEntry(WORKFLOW_STATE_TYPE, data);
231
250
  resolved = data;
251
+ } else if (decision.action === "adopt") {
252
+ // An env-inherited run id whose handoff was already consumed by a different session: a
253
+ // spawned child (contracts §8.2). Mirror the fork arm — derived child identity, isolated
254
+ // scratch, inherited mode (read-only gating survives) — minus everything that belongs to
255
+ // the launched session: never re-consume the handoff (its pi_session_id keeps the true
256
+ // claimer), no `stage` (no stage impersonation / stage-binding injection), and no
257
+ // implementation/main pointer capture (resolveRunStage stays null for adopt).
258
+ ensureRunScratch(ctx.cwd, decision.childRunId);
259
+ const data: WorkflowState = {
260
+ run_id: decision.childRunId,
261
+ pi_session_id: currentSessionId ?? undefined,
262
+ predecessor: decision.parentRunId,
263
+ mode: decision.mode,
264
+ };
265
+ pi.appendEntry(WORKFLOW_STATE_TYPE, data);
266
+ resolved = data;
232
267
  } else if (decision.action === "none") {
233
268
  // A warm session with no identity mints its own run_id so
234
269
  // per-run state (the session data dir) can key off it. No disk artifacts —
@@ -249,7 +284,28 @@ export default function (pi: ExtensionAPI) {
249
284
  }
250
285
  }
251
286
 
252
- // Plan-ref linkage (turn-2b §6, stage-gated): reconcile the cache.plan-ref file into
287
+ // Reapply the read-only allowlist + stage scoping from the resolved mode/stage — FIRST,
288
+ // before the plan-ref/stage reconciliation below. `resolved.mode` is final once the
289
+ // claim/fork/none arms settle (the later blocks only touch `active_plan_ref` / capture
290
+ // pointers), and ordering the sync ahead of them guarantees no cache read or reconciliation
291
+ // failure can leave the gate unsynced (defense in depth on top of the total cache readers).
292
+ // The scope stage is the workflow-state `stage` key (§8.40): claim → the handoff-recorded
293
+ // stage just appended; keep/none → the branch-LWW stage; fork INHERITS the parent's stage (a
294
+ // forked implement session is an implement session); adopt NEVER impersonates (subagent
295
+ // children stay unscoped — their fresh branch carries no stage, so session_tree agrees). A
296
+ // failed claim leaves `resolved` empty → no stage → unscoped (stage scoping is fail-open).
297
+ // Fail-closed on the gate: if the sync throws, leave it as-is (a failed sync never opens it).
298
+ const scopeStage =
299
+ decision.action === "adopt"
300
+ ? undefined
301
+ : (resolved.stage ?? (decision.action === "fork" ? decision.state.stage : undefined));
302
+ try {
303
+ gating.syncFromState(resolved.mode, scopeStage);
304
+ } catch (error) {
305
+ console.error(`perk: tool-gating sync failed on session_start — ${error}`);
306
+ }
307
+
308
+ // Plan-ref linkage (stage-gated): reconcile the cache.plan-ref file into
253
309
  // active_plan_ref — but ONLY when the launched stage *consumes* the ref (its registry
254
310
  // `requires`/`reads` list `cache.plan-ref`). That is the worktree binding stages
255
311
  // (implement/submit/address/land/learn); the root `worktree: none` stages
@@ -290,12 +346,26 @@ export default function (pi: ExtensionAPI) {
290
346
  resolved = { ...resolved, active_plan_ref: linked };
291
347
  }
292
348
 
293
- // Reapply the read-only allowlist from the resolved mode. Fail-closed: if the sync throws,
294
- // leave the gate as-is (a failed sync never opens it).
295
- try {
296
- gating.syncFromState(resolved.mode);
297
- } catch (error) {
298
- console.error(`perk: tool-gating sync failed on session_start${error}`);
349
+ // Implementation session pointer (contracts.md §8.35): an implement session self-keys its own
350
+ // session file into the shared main checkout so a later/other session resolves it cross-run.
351
+ // The headless worker's inner session lands here too (.main); driveStage records the matching
352
+ // .worker. A forked implement session inherits the parent's launched stage + threads the
353
+ // inherited parent session id as fork provenance. Best-effort + non-fatal (carrier warns).
354
+ // First-write-wins (`preserveForeign`): this is the corroborated shadowing defect site the
355
+ // claimer's original capture stays authoritative, and any future shadow vector warns loudly
356
+ // instead of silently corrupting /learn evidence.
357
+ const implStage =
358
+ runStage ?? (decision.action === "fork" ? (decision.state.stage ?? null) : null);
359
+ if (resolved.run_id && implStage === "implement") {
360
+ captureSessionPointer({
361
+ cwd: ctx.cwd,
362
+ runId: resolved.run_id,
363
+ klass: "implementation",
364
+ site: "main",
365
+ sessionFile,
366
+ parentSessionId: decision.action === "fork" ? (decision.state.pi_session_id ?? null) : null,
367
+ preserveForeign: true,
368
+ });
299
369
  }
300
370
 
301
371
  // Soft version-parity drift signal: pi can lazy-install / load a stale `npm:@mgiles/perk`, so the
@@ -327,6 +397,8 @@ export default function (pi: ExtensionAPI) {
327
397
  identity: `perk v${version}`,
328
398
  status: perkStatus,
329
399
  getModelId: () => ctx.model?.id ?? null,
400
+ getThinkingLevel: () => (ctx.model ? pi.getThinkingLevel() : null),
401
+ getCacheHitRate: () => latestCacheHitRate(ctx.sessionManager.getEntries()),
330
402
  getContext: () => {
331
403
  const usage = ctx.getContextUsage();
332
404
  return usage ? { percent: usage.percent, contextWindow: usage.contextWindow } : null;
@@ -337,7 +409,7 @@ export default function (pi: ExtensionAPI) {
337
409
 
338
410
  if (process.env.PERK_SELFCHECK) {
339
411
  try {
340
- const dir = join(ctx.cwd, ".pi", "workflow");
412
+ const dir = workflowDir(ctx.cwd);
341
413
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
342
414
  // The gate sentinel (unchanged — those gates parse this line).
343
415
  writeFileSync(
@@ -356,9 +428,10 @@ export default function (pi: ExtensionAPI) {
356
428
  // Non-negotiable: rebuild on branch navigation too, or state goes stale after /tree (§8.3).
357
429
  pi.on("session_tree", async (_event, ctx) => {
358
430
  const state = rebuildWorkflowState(branchOf(ctx));
359
- // Non-negotiable: re-sync the gate on tree navigation too (mode is per-field LWW). Fail-closed.
431
+ // Non-negotiable: re-sync the gate + stage scoping on tree navigation too (mode and stage are
432
+ // per-field LWW — the branch-rebuilt stage is the §8.40 key). Fail-closed on the gate.
360
433
  try {
361
- gating.syncFromState(state.mode);
434
+ gating.syncFromState(state.mode, state.stage);
362
435
  } catch (error) {
363
436
  console.error(`perk: tool-gating sync failed on session_tree — ${error}`);
364
437
  }
@@ -367,10 +440,15 @@ export default function (pi: ExtensionAPI) {
367
440
  }
368
441
  });
369
442
 
370
- // Warm door: the `plan_save` tool + `/plan-save` command (turn-3). Takes `gating` for D1a:
443
+ // Warm door: the `plan_save` tool + `/plan-save` command. Takes `gating`:
371
444
  // a successful command-path save exits read-only mode (the read-only → read-write boundary).
372
445
  registerPlanSave(pi, gating);
373
446
 
447
+ // The `/implement-here` command: the human-only no-save exit from plan mode (§8.23) —
448
+ // implement the reviewed draft in-session, no issue created. Composes the gate through the
449
+ // implementHereExit seam; no model tool is registered (machine-unreachable by construction).
450
+ registerImplementHere(pi, gating);
451
+
374
452
  // The `plan_draft` working-draft file tool. Registered in the factory so it
375
453
  // exists before the gate snapshots tools; its name is in READ_ONLY_TOOLS (the structural
376
454
  // session-data carve-out), so it survives plan mode.
@@ -384,16 +462,16 @@ export default function (pi: ExtensionAPI) {
384
462
  // before the gate snapshots tools; its name is in READ_ONLY_TOOLS so it survives plan mode.
385
463
  registerAskUser(pi);
386
464
 
387
- // Lifecycle gates: the dirty-repo switch/fork guard + the guard-only `/implement` (turn-4b).
465
+ // Lifecycle gates: the dirty-repo switch/fork guard + the guard-only `/implement`.
388
466
  registerLifecycleGates(pi);
389
467
 
390
- // Warm door: the `submit` tool + `/submit` command (turn-5a).
468
+ // Warm door: the `submit` tool + `/submit` command.
391
469
  registerSubmit(pi);
392
470
 
393
471
  // The warm `ready` door: the deliberate draft→ready review gate (submit keeps draft).
394
472
  registerReady(pi);
395
473
 
396
- // Warm doors: `land` (turn-5b) merges + sets pending-learn; `learn` clears it (TS-only).
474
+ // Warm doors: `land` merges + sets pending-learn; `learn` clears it (TS-only).
397
475
  registerLand(pi);
398
476
  registerLearn(pi);
399
477
 
@@ -406,6 +484,19 @@ export default function (pi: ExtensionAPI) {
406
484
  // POSTS its review to the PR (the deliberate departure from /address's read-only-child rule).
407
485
  registerPrReview(pi);
408
486
 
487
+ // The warm `submit_pr_review` tool: the human-gated curated-posting surface both review
488
+ // doors ride (contracts §8.4) — neither door registers tools of its own.
489
+ registerSubmitPrReview(pi);
490
+
491
+ // The warm `/pr-review-terminal` door: the terminal review entry — hunk always, no provider
492
+ // dispatch (the command IS the selection); posting rides `submit_pr_review` above.
493
+ registerPrReviewTerminal(pi);
494
+
495
+ // The warm `/pr-review-browser` door: the browser review entry — plannotator always, opened
496
+ // in the background (pre-PR it absorbs the since-base local browser review); posting is the
497
+ // human's own platform-post from the UI, with `submit_pr_review` for request-changes only.
498
+ registerPrReviewBrowser(pi);
499
+
409
500
  // The read-only CI executor: the `run_ci` tool + `/ci` command + `--allow-project-ci`
410
501
  // flag. Runs the project's `[ci]` named checks deterministically and reports (never fixes/loops).
411
502
  registerCiExecutor(pi);
@@ -445,11 +536,17 @@ export default function (pi: ExtensionAPI) {
445
536
  // The learned-docs plan factory's warm surface: the `/learn-docs` command gathers open
446
537
  // perk:learn issues into an inbox (via the `perk learn docs --gather` cold door) and injects the
447
538
  // factory guidance so the model authors a docs/learned consolidation plan (no model tool).
448
- registerLearnDocs(pi);
539
+ registerLearnFactoryDoor(pi, DOCS_DOOR);
540
+
541
+ // The learn-code plan factory's warm surface: the `/learn-code` command gathers pre-stamped
542
+ // SHOULD_BE_CODE perk:learn issues into an inbox (via the `perk learn code --gather` cold door)
543
+ // and injects the factory guidance so the model authors a code-routing plan (no model tool).
544
+ registerLearnFactoryDoor(pi, CODE_DOOR);
449
545
 
450
546
  // Warm-door skill-binding delivery: Mechanism A's `before_agent_start` injection of
451
547
  // the launched stage's user-originated bindings (+ the stale-context strip). Mechanism B (the
452
- // `command:<id>` suffix) is wired into the `/objective-reconcile` + `/learn-docs` guidance.
548
+ // `command:<id>` suffix) is wired into the `/objective-reconcile` + `/learn-docs` +
549
+ // `/learn-code` guidance.
453
550
  registerBindingDelivery(pi);
454
551
 
455
552
  // `/perk-selfcheck` — the session-wiring verifier (turned from a liveness ping into a real check
@@ -14,8 +14,11 @@
14
14
  //
15
15
  // This is the SINGLE delivery path for perk's own nudges. Delivery NEVER double-delivers: the
16
16
  // cold↔warm dedup marker is `BINDING_HEADER` itself — the cold door's initial prompt and every warm
17
- // injection carry it, so Mechanism A injects ONLY when nothing on the branch already carries the
18
- // header (idempotent across turns/reloads; after compaction drops the original it re-delivers).
17
+ // injection carry it, so Mechanism A injects ONLY when neither the branch NOR the submitting
18
+ // turn's prompt already carries the header (idempotent across turns/reloads; after compaction
19
+ // drops the original it re-delivers). The prompt scan is load-bearing on the launch turn: at
20
+ // `before_agent_start` the just-submitted prompt is NOT yet on the branch, so the branch scan
21
+ // alone would miss a cold seed's binding suffix and double-deliver.
19
22
  //
20
23
  // LBYL throughout: a missing/unreadable transclude target degrades to the nudge pointer with a
21
24
  // loud-but-non-fatal warning, never throws, never blocks a turn. Resolver shape `issues` are NOT
@@ -26,7 +29,12 @@ import { join } from "node:path";
26
29
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
30
  import { loadDefaultBindings, resolveBindings, type SkillBinding } from "./bindings.ts";
28
31
  import { loadPerkConfig } from "./config.ts";
29
- import { type BranchEntry, branchOf, rebuildWorkflowState } from "./workflowState.ts";
32
+ import {
33
+ type BranchEntry,
34
+ branchCarries,
35
+ branchOf,
36
+ rebuildWorkflowState,
37
+ } from "./workflowState.ts";
30
38
 
31
39
  /**
32
40
  * The cross-plane dedup marker AND render header. MUST stay byte-identical to the Python cold
@@ -58,7 +66,10 @@ export function resolvedBindings(cwd: string): SkillBinding[] {
58
66
 
59
67
  /**
60
68
  * Render the resolved bindings matching `trigger` into a header-joined fragment (or `null` when
61
- * none match). `nudge` renders a `Follow the \`<skill>\` skill.` pointer; `transclude` inlines
69
+ * none match). `nudge` renders a `Follow the \`<skill>\` skill (read
70
+ * \`.agents/skills/<skill>/SKILL.md\`).` pointer — the read path is unconditional, so a skill
71
+ * hidden from the ambient prompt (`disable-model-invocation: true`) stays reachable; `transclude`
72
+ * inlines
62
73
  * `.agents/skills/<skill>/SKILL.md` (frontmatter stripped), degrading to the nudge pointer with a
63
74
  * loud-but-non-fatal warning when the file is absent/unreadable. Pure but for the LBYL file read.
64
75
  */
@@ -86,7 +97,10 @@ export function renderBindings(cwd: string, trigger: string): BindingRender {
86
97
  `under ${SKILLS_SUBDIR}/${binding.skill}/${SKILL_FILENAME} — the pointer may dangle.`,
87
98
  );
88
99
  }
89
- parts.push(`Follow the \`${binding.skill}\` skill.`);
100
+ parts.push(
101
+ `Follow the \`${binding.skill}\` skill ` +
102
+ `(read \`${SKILLS_SUBDIR}/${binding.skill}/${SKILL_FILENAME}\`).`,
103
+ );
90
104
  }
91
105
  const text = parts.length > 0 ? [BINDING_HEADER, ...parts].join("\n\n") : null;
92
106
  return { text, warnings };
@@ -116,7 +130,11 @@ function readSkillBody(cwd: string, skill: string): string | null {
116
130
  const path = join(cwd, SKILLS_SUBDIR, skill, SKILL_FILENAME);
117
131
  if (!existsSync(path)) return null;
118
132
  try {
119
- return stripFrontmatter(readFileSync(path, "utf8"));
133
+ // Normalize CRLF/CR before stripping (the miniJinja.ts pattern): Node's readFileSync keeps
134
+ // `\r\n` where Python's read_text() normalizes, so without this a CRLF checkout would defeat
135
+ // the `---\n` frontmatter check AND break the cross-plane byte parity pinned by
136
+ // tests/test_binding_render_parity.py.
137
+ return stripFrontmatter(readFileSync(path, "utf8").replace(/\r\n?/g, "\n"));
120
138
  } catch {
121
139
  return null;
122
140
  }
@@ -142,7 +160,7 @@ function stripFrontmatter(text: string): string {
142
160
  * header is a distinctive literal, so a substring hit means "already delivered on this branch".
143
161
  */
144
162
  function branchHasHeader(branch: readonly BranchEntry[]): boolean {
145
- return branch.some((entry) => JSON.stringify(entry).includes(BINDING_HEADER));
163
+ return branchCarries(branch, BINDING_HEADER);
146
164
  }
147
165
 
148
166
  /** The launched stage's `stage:<id>` render, or `null` when there is no stage / nothing matches. */
@@ -160,13 +178,17 @@ function activeStageRender(cwd: string, branch: readonly BranchEntry[]): Binding
160
178
  */
161
179
  export function registerBindingDelivery(pi: ExtensionAPI): void {
162
180
  // Mechanism A — inject the launched stage's resolved bindings as a hidden context message,
163
- // but ONLY when no entry on the branch already carries BINDING_HEADER (the cold door's initial
164
- // prompt or a prior warm inject) — the cold↔warm idempotency guard.
165
- pi.on("before_agent_start", async (_event, ctx) => {
181
+ // but ONLY when no entry on the branch AND not the submitting turn's prompt already carries
182
+ // BINDING_HEADER (the cold door's initial prompt or a prior warm inject) — the cold↔warm
183
+ // idempotency guard. The `event.prompt` scan covers the launch turn, where the just-submitted
184
+ // prompt is not yet on the branch; a worker prompt carries no header, so Mechanism A still
185
+ // fires there (contracts.md §8.38).
186
+ pi.on("before_agent_start", async (event, ctx) => {
166
187
  const branch = branchOf(ctx);
167
188
  const rendered = activeStageRender(ctx.cwd, branch);
168
189
  if (rendered === null || rendered.text === null) return;
169
190
  if (branchHasHeader(branch)) return;
191
+ if (event.prompt.includes(BINDING_HEADER)) return;
170
192
  for (const warning of rendered.warnings) console.error(`perk: ${warning}`);
171
193
  return {
172
194
  message: {
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
+ // Type-only import (config.ts value-imports this module; a value import here would cycle).
16
+ import type { TomlScalar } from "./config.ts";
15
17
  import { parse } from "./miniYaml.ts";
16
18
  import { sharedDir } from "./resources.ts";
17
19
 
@@ -74,10 +76,10 @@ export function loadDefaultBindings(): SkillBinding[] {
74
76
  }
75
77
 
76
78
  /**
77
- * Parse `.pi/perk.toml` `[[bindings]]` rows (string tables) into `SkillBinding`s. Tolerant like
79
+ * Parse `.perk/config.toml` `[[bindings]]` rows (scalar tables) into `SkillBinding`s. Tolerant like
78
80
  * the YAML reader: absent/ill-typed fields become empty strings so the *resolver* reports them.
79
81
  */
80
- export function parseUserBindings(rows: Array<Record<string, string>>): SkillBinding[] {
82
+ export function parseUserBindings(rows: Array<Record<string, TomlScalar>>): SkillBinding[] {
81
83
  return rows.map((row) => {
82
84
  const trigger = typeof row.trigger === "string" ? row.trigger : "";
83
85
  const [kind, targetId] = splitTrigger(trigger);
@@ -1,8 +1,15 @@
1
- // `.pi/workflow/` cache-tier I/O — the TS twin of perk/state/cache.py (contracts.md §8.1).
1
+ // `.perk/workflow/` cache-tier I/O — the TS twin of perk/state/cache.py (contracts.md §8.1).
2
2
  //
3
3
  // Both planes read and write the SAME files; the cross-plane contract is the *files*, not a
4
4
  // shared module. State-tiering primitives only — no workflow semantics. Imports use no
5
5
  // relative paths (only node builtins), so this module loads cleanly under `node --test`.
6
+ //
7
+ // Readers are TOTAL: a corrupt/unreadable file is reported loudly on stderr (`console.error` —
8
+ // the report() seam is intentionally unavailable here, and stderr is headless-safe) and treated
9
+ // as absent (`null`), so a bad cache blob can never crash a caller mid-`session_start` before
10
+ // the read-only gate engages. The Python twins (src/perk/state/cache.py) deliberately keep
11
+ // RAISING `CacheError` (exterior plane, launch-time fail-loud) — the cross-plane contract is
12
+ // the *files*, not error semantics.
6
13
 
7
14
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
15
  import { join } from "node:path";
@@ -18,7 +25,26 @@ export interface Handoff {
18
25
  }
19
26
 
20
27
  export function workflowDir(cwd: string): string {
21
- return join(cwd, ".pi", "workflow");
28
+ return join(cwd, ".perk", "workflow");
29
+ }
30
+
31
+ /**
32
+ * Read + parse a JSON cache blob, totally: a missing file is a silent `null` (absence is the
33
+ * normal state); an unreadable/corrupt/wrong-shape file is a LOUD `null` (one stderr line naming
34
+ * the file kind + path + error) — treated as absent by every caller.
35
+ */
36
+ function readJsonOrNull<T>(path: string, what: string): T | null {
37
+ if (!existsSync(path)) return null;
38
+ try {
39
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
40
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
41
+ throw new Error(`expected a JSON object, got ${JSON.stringify(parsed)}`);
42
+ }
43
+ return parsed as T;
44
+ } catch (error) {
45
+ console.error(`perk: unreadable ${what} at ${path} — treating as absent (${error})`);
46
+ return null;
47
+ }
22
48
  }
23
49
 
24
50
  // --- handoff -----------------------------------------------------------------------------
@@ -28,9 +54,7 @@ export function handoffPath(cwd: string, runId: string): string {
28
54
  }
29
55
 
30
56
  export function readHandoff(cwd: string, runId: string): Handoff | null {
31
- const path = handoffPath(cwd, runId);
32
- if (!existsSync(path)) return null;
33
- return JSON.parse(readFileSync(path, "utf8")) as Handoff;
57
+ return readJsonOrNull<Handoff>(handoffPath(cwd, runId), "handoff");
34
58
  }
35
59
 
36
60
  /** Mark a handoff consumed (idempotent); a no-op when absent. Keeps the file (audit + GC). */
@@ -113,9 +137,7 @@ export function planRefPath(cwd: string): string {
113
137
  }
114
138
 
115
139
  export function readPlanRef(cwd: string): PlanRef | null {
116
- const path = planRefPath(cwd);
117
- if (!existsSync(path)) return null;
118
- return JSON.parse(readFileSync(path, "utf8")) as PlanRef;
140
+ return readJsonOrNull<PlanRef>(planRefPath(cwd), "plan-ref");
119
141
  }
120
142
 
121
143
  export function writePlanRef(cwd: string, ref: PlanRef): void {
@@ -137,7 +159,12 @@ export function planBodyPath(cwd: string): string {
137
159
  export function readPlanBody(cwd: string): string | null {
138
160
  const path = planBodyPath(cwd);
139
161
  if (!existsSync(path)) return null;
140
- return readFileSync(path, "utf8");
162
+ try {
163
+ return readFileSync(path, "utf8");
164
+ } catch (error) {
165
+ console.error(`perk: unreadable plan body at ${path} — treating as absent (${error})`);
166
+ return null;
167
+ }
141
168
  }
142
169
 
143
170
  // --- markers (existence-only) ------------------------------------------------------------
@@ -0,0 +1,81 @@
1
+ // A fail-soft "copy this text to the OS clipboard" seam for the extension interior. Used by the
2
+ // terminal review door's R7 hunk handoff so the human always has the launch command one paste
3
+ // away — belt-and-braces beside the loud print and the terminal auto-launch.
4
+ //
5
+ // Two functions: a pure `resolveClipboardScript` (platform + env → a POSIX `sh` script, or `null`
6
+ // for "don't copy") and a thin `copyToClipboard` runner that stages the text in a temp file and
7
+ // shells the script. Never throws, never notifies — the caller owns all messaging. The
8
+ // `PERK_CLIPBOARD_CMD` env seam: unset → the platform default; empty → disabled (the test seam);
9
+ // non-empty → a custom copier.
10
+
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import type { ExecHost } from "./coldDoor.ts";
15
+
16
+ /**
17
+ * Resolve the clipboard-copy shell script for `platform` + the `PERK_CLIPBOARD_CMD` override. The
18
+ * script copies the file named by `$1`; returns `null` for "don't copy".
19
+ *
20
+ * - `envCmd === ""` → `null` (the test seam / explicit opt-out);
21
+ * - `envCmd` non-empty → `<envCmd> < "$1"` (custom override — receives the file path as `$1`);
22
+ * - `darwin` → `pbcopy < "$1"`;
23
+ * - `linux`/other POSIX → a `command -v` chain (`wl-copy` → `xclip` → `xsel`), else `exit 127`;
24
+ * - `win32` → `null` (no POSIX `sh`; the print+launch fallback covers it).
25
+ */
26
+ export function resolveClipboardScript(
27
+ platform: string,
28
+ envCmd: string | undefined,
29
+ ): string | null {
30
+ if (envCmd !== undefined) {
31
+ if (envCmd === "") return null;
32
+ return `${envCmd} < "$1"`;
33
+ }
34
+ if (platform === "darwin") return `pbcopy < "$1"`;
35
+ if (platform === "win32") return null;
36
+ return (
37
+ 'if command -v wl-copy >/dev/null 2>&1; then wl-copy < "$1"; ' +
38
+ 'elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard < "$1"; ' +
39
+ 'elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input < "$1"; ' +
40
+ "else exit 127; fi"
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Copy `text` to the OS clipboard, best-effort. Resolves the script from `process.platform` +
46
+ * `process.env.PERK_CLIPBOARD_CMD`; a `null` script (win32, or the disabled seam) short-circuits
47
+ * to `false` without shelling. Otherwise stages `text` in a temp file and runs
48
+ * `sh -c <script> sh <file>` with a 3s cap (guards a display-less `xclip` hanging). Returns
49
+ * `true` iff the copier exited 0 and was not killed. Any throw → `false`. Never rejects, never
50
+ * notifies.
51
+ */
52
+ export async function copyToClipboard(
53
+ pi: ExecHost,
54
+ ctx: { cwd: string; signal?: AbortSignal },
55
+ text: string,
56
+ ): Promise<boolean> {
57
+ const script = resolveClipboardScript(process.platform, process.env.PERK_CLIPBOARD_CMD);
58
+ if (script === null) return false;
59
+ let dir: string | undefined;
60
+ try {
61
+ dir = mkdtempSync(join(tmpdir(), "perk-clip-"));
62
+ const file = join(dir, "clip.txt");
63
+ writeFileSync(file, text, "utf8");
64
+ const res = await pi.exec("sh", ["-c", script, "sh", file], {
65
+ cwd: ctx.cwd,
66
+ signal: ctx.signal,
67
+ timeout: 3000,
68
+ });
69
+ return res.code === 0 && !res.killed;
70
+ } catch {
71
+ return false;
72
+ } finally {
73
+ if (dir !== undefined) {
74
+ try {
75
+ rmSync(dir, { recursive: true, force: true });
76
+ } catch {
77
+ // best-effort cleanup — a leaked temp file is harmless
78
+ }
79
+ }
80
+ }
81
+ }