@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
@@ -16,14 +16,15 @@ 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 { registerLearnCode } from "./doors/learnCode.ts";
20
- import { registerLearnDocs } from "./doors/learnDocs.ts";
19
+ import { CODE_DOOR, DOCS_DOOR, registerLearnFactoryDoor } from "./doors/learnFactory.ts";
21
20
  import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
22
21
  import { registerPrReview } from "./doors/prReview.ts";
23
- import { registerPrReviewLocal } from "./doors/prReviewLocal.ts";
22
+ import { registerPrReviewBrowser } from "./doors/prReviewBrowser.ts";
23
+ import { registerPrReviewTerminal } from "./doors/prReviewTerminal.ts";
24
24
  import { registerReady } from "./doors/ready.ts";
25
25
  import { registerSelfcheck } from "./doors/selfcheck.ts";
26
26
  import { registerSubmit } from "./doors/submit.ts";
27
+ import { registerSubmitPrReview } from "./doors/submitPrReview.ts";
27
28
  import { registerImplementHere } from "./factories/implementHere.ts";
28
29
  import { registerObjective } from "./factories/objective.ts";
29
30
  import { registerObjectiveAuthor } from "./factories/objectiveAuthor.ts";
@@ -60,7 +61,13 @@ import {
60
61
  } from "./substrate/workflowState.ts";
61
62
  import { isPerkFooterReferenceSelected } from "./surfaces/footerProvider.ts";
62
63
  import { report } from "./surfaces/report.ts";
63
- import { createPerkStatus, installPerkFooter } from "./surfaces/surfaces.ts";
64
+ import {
65
+ createPerkStatus,
66
+ installPerkFooter,
67
+ latestCacheHitRate,
68
+ registerTranscriptRenderer,
69
+ workflowStateEntryRenderer,
70
+ } from "./surfaces/surfaces.ts";
64
71
  import { registerBtw } from "./vendor/btw/btw.ts";
65
72
  import { registerWhimsical } from "./vendor/whimsical/whimsical.ts";
66
73
 
@@ -173,6 +180,11 @@ export default function (pi: ExtensionAPI) {
173
180
  // session_start (reload) could leak the previous handle subscription.
174
181
  let footerInstalled = false;
175
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
+
176
188
  pi.on("session_start", async (_event, ctx) => {
177
189
  const branchEntries = () => branchOf(ctx);
178
190
  const sessionFile = ctx.sessionManager.getSessionFile();
@@ -190,7 +202,9 @@ export default function (pi: ExtensionAPI) {
190
202
  cwd: ctx.cwd,
191
203
  });
192
204
 
193
- 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;
194
208
  let minted = false;
195
209
 
196
210
  if (decision.action === "claim") {
@@ -234,6 +248,22 @@ export default function (pi: ExtensionAPI) {
234
248
  };
235
249
  pi.appendEntry(WORKFLOW_STATE_TYPE, data);
236
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;
237
267
  } else if (decision.action === "none") {
238
268
  // A warm session with no identity mints its own run_id so
239
269
  // per-run state (the session data dir) can key off it. No disk artifacts —
@@ -254,7 +284,28 @@ export default function (pi: ExtensionAPI) {
254
284
  }
255
285
  }
256
286
 
257
- // 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
258
309
  // active_plan_ref — but ONLY when the launched stage *consumes* the ref (its registry
259
310
  // `requires`/`reads` list `cache.plan-ref`). That is the worktree binding stages
260
311
  // (implement/submit/address/land/learn); the root `worktree: none` stages
@@ -300,6 +351,9 @@ export default function (pi: ExtensionAPI) {
300
351
  // The headless worker's inner session lands here too (.main); driveStage records the matching
301
352
  // .worker. A forked implement session inherits the parent's launched stage + threads the
302
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.
303
357
  const implStage =
304
358
  runStage ?? (decision.action === "fork" ? (decision.state.stage ?? null) : null);
305
359
  if (resolved.run_id && implStage === "implement") {
@@ -310,17 +364,10 @@ export default function (pi: ExtensionAPI) {
310
364
  site: "main",
311
365
  sessionFile,
312
366
  parentSessionId: decision.action === "fork" ? (decision.state.pi_session_id ?? null) : null,
367
+ preserveForeign: true,
313
368
  });
314
369
  }
315
370
 
316
- // Reapply the read-only allowlist from the resolved mode. Fail-closed: if the sync throws,
317
- // leave the gate as-is (a failed sync never opens it).
318
- try {
319
- gating.syncFromState(resolved.mode);
320
- } catch (error) {
321
- console.error(`perk: tool-gating sync failed on session_start — ${error}`);
322
- }
323
-
324
371
  // Soft version-parity drift signal: pi can lazy-install / load a stale `npm:@mgiles/perk`, so the
325
372
  // extension actually running may differ from the `perk` CLI that launched it. The local launch
326
373
  // seam injects PERK_CLI_VERSION; compare it against this extension's own `perkVersion()`. Soft +
@@ -351,6 +398,7 @@ export default function (pi: ExtensionAPI) {
351
398
  status: perkStatus,
352
399
  getModelId: () => ctx.model?.id ?? null,
353
400
  getThinkingLevel: () => (ctx.model ? pi.getThinkingLevel() : null),
401
+ getCacheHitRate: () => latestCacheHitRate(ctx.sessionManager.getEntries()),
354
402
  getContext: () => {
355
403
  const usage = ctx.getContextUsage();
356
404
  return usage ? { percent: usage.percent, contextWindow: usage.contextWindow } : null;
@@ -380,9 +428,10 @@ export default function (pi: ExtensionAPI) {
380
428
  // Non-negotiable: rebuild on branch navigation too, or state goes stale after /tree (§8.3).
381
429
  pi.on("session_tree", async (_event, ctx) => {
382
430
  const state = rebuildWorkflowState(branchOf(ctx));
383
- // 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.
384
433
  try {
385
- gating.syncFromState(state.mode);
434
+ gating.syncFromState(state.mode, state.stage);
386
435
  } catch (error) {
387
436
  console.error(`perk: tool-gating sync failed on session_tree — ${error}`);
388
437
  }
@@ -391,7 +440,7 @@ export default function (pi: ExtensionAPI) {
391
440
  }
392
441
  });
393
442
 
394
- // 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`:
395
444
  // a successful command-path save exits read-only mode (the read-only → read-write boundary).
396
445
  registerPlanSave(pi, gating);
397
446
 
@@ -413,16 +462,16 @@ export default function (pi: ExtensionAPI) {
413
462
  // before the gate snapshots tools; its name is in READ_ONLY_TOOLS so it survives plan mode.
414
463
  registerAskUser(pi);
415
464
 
416
- // 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`.
417
466
  registerLifecycleGates(pi);
418
467
 
419
- // Warm door: the `submit` tool + `/submit` command (turn-5a).
468
+ // Warm door: the `submit` tool + `/submit` command.
420
469
  registerSubmit(pi);
421
470
 
422
471
  // The warm `ready` door: the deliberate draft→ready review gate (submit keeps draft).
423
472
  registerReady(pi);
424
473
 
425
- // 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).
426
475
  registerLand(pi);
427
476
  registerLearn(pi);
428
477
 
@@ -435,9 +484,18 @@ export default function (pi: ExtensionAPI) {
435
484
  // POSTS its review to the PR (the deliberate departure from /address's read-only-child rule).
436
485
  registerPrReview(pi);
437
486
 
438
- // The warm `/pr-review-local` command: open the plannotator browser code review on the active
439
- // PR (URL filled in implicitly) by bridging to plannotator's published `code-review` event.
440
- registerPrReviewLocal(pi);
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);
441
499
 
442
500
  // The read-only CI executor: the `run_ci` tool + `/ci` command + `--allow-project-ci`
443
501
  // flag. Runs the project's `[ci]` named checks deterministically and reports (never fixes/loops).
@@ -478,12 +536,12 @@ export default function (pi: ExtensionAPI) {
478
536
  // The learned-docs plan factory's warm surface: the `/learn-docs` command gathers open
479
537
  // perk:learn issues into an inbox (via the `perk learn docs --gather` cold door) and injects the
480
538
  // factory guidance so the model authors a docs/learned consolidation plan (no model tool).
481
- registerLearnDocs(pi);
539
+ registerLearnFactoryDoor(pi, DOCS_DOOR);
482
540
 
483
541
  // The learn-code plan factory's warm surface: the `/learn-code` command gathers pre-stamped
484
542
  // SHOULD_BE_CODE perk:learn issues into an inbox (via the `perk learn code --gather` cold door)
485
543
  // and injects the factory guidance so the model authors a code-routing plan (no model tool).
486
- registerLearnCode(pi);
544
+ registerLearnFactoryDoor(pi, CODE_DOOR);
487
545
 
488
546
  // Warm-door skill-binding delivery: Mechanism A's `before_agent_start` injection of
489
547
  // the launched stage's user-originated bindings (+ the stale-context strip). Mechanism B (the
@@ -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 `.perk/config.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);
@@ -3,6 +3,13 @@
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";
@@ -21,6 +28,25 @@ export function workflowDir(cwd: string): string {
21
28
  return join(cwd, ".perk", "workflow");
22
29
  }
23
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
+ }
48
+ }
49
+
24
50
  // --- handoff -----------------------------------------------------------------------------
25
51
 
26
52
  export function handoffPath(cwd: string, runId: string): string {
@@ -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
+ }