@mgiles/perk 2.2.0 → 3.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 (129) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +132 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/draftReviewWaveTools.ts +384 -0
  9. package/extension/doors/harvestWaveTools.ts +279 -0
  10. package/extension/doors/land.ts +1 -0
  11. package/extension/doors/learn.ts +238 -24
  12. package/extension/doors/learnFactory.ts +33 -7
  13. package/extension/doors/lifecycleGates.ts +1 -1
  14. package/extension/doors/objectiveReviewBrowser.ts +514 -0
  15. package/extension/doors/objectiveStack.ts +1143 -0
  16. package/extension/doors/planReviewBrowser.ts +453 -0
  17. package/extension/doors/plannotatorHandoff.ts +120 -49
  18. package/extension/doors/prReview.ts +203 -22
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +256 -0
  21. package/extension/doors/prReviewTerminal.ts +6 -11
  22. package/extension/doors/reviewWaveTools.ts +374 -0
  23. package/extension/doors/selfcheck.ts +1 -1
  24. package/extension/doors/submit.ts +86 -10
  25. package/extension/factories/gistAuthor.ts +4 -4
  26. package/extension/factories/gistDraft.ts +1 -1
  27. package/extension/factories/gistSave.ts +2 -2
  28. package/extension/factories/objective.ts +7 -7
  29. package/extension/factories/objectiveAuthor.ts +5 -4
  30. package/extension/factories/objectiveDraft.ts +61 -6
  31. package/extension/factories/objectivePlan.ts +163 -8
  32. package/extension/factories/objectiveSave.ts +16 -1
  33. package/extension/factories/planMode.ts +5 -3
  34. package/extension/factories/planReview.ts +131 -36
  35. package/extension/factories/planTitle.ts +27 -9
  36. package/extension/hunkFeedback/inbox.ts +389 -0
  37. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  38. package/extension/hunkFeedback/receiver.ts +296 -0
  39. package/extension/hunkFeedback/store.ts +533 -0
  40. package/extension/index.ts +116 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +42 -14
  44. package/extension/substrate/git.ts +10 -0
  45. package/extension/substrate/providers.ts +4 -33
  46. package/extension/substrate/resources.ts +11 -0
  47. package/extension/substrate/result.ts +13 -5
  48. package/extension/substrate/sessionData.ts +3 -3
  49. package/extension/substrate/sessionPointers.ts +3 -3
  50. package/extension/substrate/structuredOutput.ts +78 -19
  51. package/extension/substrate/terminalLaunch.ts +1 -1
  52. package/extension/substrate/toolGating.ts +206 -26
  53. package/extension/substrate/workflowState.ts +6 -0
  54. package/extension/surfaces/footerProvider.ts +6 -6
  55. package/extension/surfaces/surfaces.ts +98 -282
  56. package/extension/vendor/btw/btw.ts +57 -12
  57. package/extension/waves/adversarialReviewWave.ts +164 -0
  58. package/extension/waves/auditWave.ts +312 -0
  59. package/extension/waves/draftReviewWave.ts +189 -0
  60. package/extension/waves/harvestWave.ts +399 -0
  61. package/extension/waves/learnWave.ts +155 -0
  62. package/extension/waves/memoryAdapter.ts +139 -0
  63. package/extension/waves/objectiveExplorerWave.ts +120 -0
  64. package/extension/waves/prReviewDynamicWave.ts +696 -0
  65. package/extension/waves/prReviewWave.ts +274 -0
  66. package/extension/waves/reportWave.ts +749 -0
  67. package/extension/waves/reviewClassifierWave.ts +109 -0
  68. package/extension/waves/rpcAdapter.ts +264 -0
  69. package/extension/worker/readOnlySession.ts +3 -3
  70. package/extension/worker/worker.ts +71 -93
  71. package/extension/workerMain.ts +4 -6
  72. package/package.json +20 -5
  73. package/prompts/README.md +19 -0
  74. package/prompts/_fixtures/live.yaml +71 -72
  75. package/prompts/common/resume-advisory.md +1 -0
  76. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  77. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  78. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  79. package/prompts/contexts/gist-authoring.md +6 -20
  80. package/prompts/contexts/objective-authoring.md +6 -18
  81. package/prompts/stages/address/action.md +4 -4
  82. package/prompts/stages/address/preview.md +3 -3
  83. package/prompts/stages/audit.md +18 -0
  84. package/prompts/stages/conflict-resolution.md +1 -1
  85. package/prompts/stages/gist-author/seed.md +4 -4
  86. package/prompts/stages/gist-save.md +1 -1
  87. package/prompts/stages/implement.md +3 -1
  88. package/prompts/stages/learn-code.md +1 -1
  89. package/prompts/stages/learn-docs.md +2 -2
  90. package/prompts/stages/learn-harvest.md +15 -0
  91. package/prompts/stages/learn-orchestrate.md +7 -5
  92. package/prompts/stages/objective-author/adopt.md +5 -3
  93. package/prompts/stages/objective-author/file.md +5 -4
  94. package/prompts/stages/objective-author/seed.md +5 -4
  95. package/prompts/stages/objective-land.md +7 -0
  96. package/prompts/stages/objective-plan/guidance.md +2 -2
  97. package/prompts/stages/objective-plan/seed.md +5 -1
  98. package/prompts/stages/objective-recover.md +8 -0
  99. package/prompts/stages/objective-replan.md +4 -2
  100. package/prompts/stages/objective-review-browser.md +9 -0
  101. package/prompts/stages/objective-sync.md +6 -0
  102. package/prompts/stages/plan-from/adopt.md +2 -2
  103. package/prompts/stages/plan-from/file.md +2 -2
  104. package/prompts/stages/plan-review-browser.md +9 -0
  105. package/prompts/stages/pr-review-browser/active.md +6 -6
  106. package/prompts/stages/pr-review-browser/foreign.md +6 -6
  107. package/prompts/stages/pr-review-dynamic.md +7 -0
  108. package/prompts/stages/pr-review-terminal/active.md +7 -7
  109. package/prompts/stages/pr-review-terminal/foreign.md +7 -7
  110. package/prompts/stages/pr-review.md +7 -6
  111. package/prompts/stages/replan.md +3 -3
  112. package/shared/README.md +2 -2
  113. package/shared/bindings.yaml +15 -0
  114. package/shared/contracts-history.md +11 -0
  115. package/shared/contracts.md +3713 -227
  116. package/shared/providers.yaml +27 -60
  117. package/shared/registry.yaml +32 -8
  118. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  119. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  120. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  121. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  122. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  123. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  124. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  125. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  126. package/extension/checkpoints/checkpoints.ts +0 -550
  127. package/extension/checkpoints/planSteps.ts +0 -108
  128. package/extension/doors/askUser.ts +0 -198
  129. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -4,25 +4,31 @@
4
4
  // `session_start` (verified-linkage), rebuild `perk:workflow-state` on `session_start` AND
5
5
  // `session_tree` (per-field LWW), and derive a child run_id on fork.
6
6
 
7
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync } from "node:fs";
8
8
  import { basename, join } from "node:path";
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { registerPlanAdapterPlannotator } from "./adapters/planAdapterPlannotator.ts";
11
11
  import { registerPlanAdapterTombell } from "./adapters/planAdapterTombell.ts";
12
- import { registerTodoAdapterJuicesharp } from "./adapters/todoAdapterJuicesharp.ts";
13
- import { registerCheckpoints } from "./checkpoints/checkpoints.ts";
14
12
  import { registerAddress } from "./doors/address.ts";
15
- import { registerAskUser } from "./doors/askUser.ts";
13
+ import { registerAnnotationPushTool } from "./doors/annotationPush.ts";
14
+ import { registerAuditWave } from "./doors/auditWaveTools.ts";
16
15
  import { registerCiExecutor } from "./doors/ciExecutor.ts";
17
16
  import { registerCommitAndCompact } from "./doors/commitCompact.ts";
17
+ import { registerDraftReviewWaveTools } from "./doors/draftReviewWaveTools.ts";
18
+ import { registerHarvestWave } from "./doors/harvestWaveTools.ts";
18
19
  import { registerLand } from "./doors/land.ts";
19
20
  import { registerLearn } from "./doors/learn.ts";
20
21
  import { CODE_DOOR, DOCS_DOOR, registerLearnFactoryDoor } from "./doors/learnFactory.ts";
21
22
  import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
23
+ import { registerObjectiveReviewBrowser } from "./doors/objectiveReviewBrowser.ts";
24
+ import { registerObjectiveStack } from "./doors/objectiveStack.ts";
25
+ import { registerPlanReviewBrowser } from "./doors/planReviewBrowser.ts";
22
26
  import { registerPrReview } from "./doors/prReview.ts";
23
27
  import { registerPrReviewBrowser } from "./doors/prReviewBrowser.ts";
28
+ import { registerPrReviewDynamic } from "./doors/prReviewDynamic.ts";
24
29
  import { registerPrReviewTerminal } from "./doors/prReviewTerminal.ts";
25
30
  import { registerReady } from "./doors/ready.ts";
31
+ import { registerReviewWaveTools } from "./doors/reviewWaveTools.ts";
26
32
  import { registerSelfcheck } from "./doors/selfcheck.ts";
27
33
  import { registerSubmit } from "./doors/submit.ts";
28
34
  import { registerSubmitPrReview } from "./doors/submitPrReview.ts";
@@ -39,8 +45,10 @@ import { registerPlanDraft } from "./factories/planDraft.ts";
39
45
  import { registerPlanMode } from "./factories/planMode.ts";
40
46
  import { registerPlanReview } from "./factories/planReview.ts";
41
47
  import { registerPlanSave } from "./factories/planSave.ts";
48
+ import { createHunkFeedbackReceiver } from "./hunkFeedback/receiver.ts";
42
49
  import { registerBindingDelivery } from "./substrate/bindingDelivery.ts";
43
50
  import {
51
+ atomicWriteFileSync,
44
52
  ensureRunScratch,
45
53
  markHandoffConsumed,
46
54
  readHandoff,
@@ -49,7 +57,7 @@ import {
49
57
  workflowDir,
50
58
  } from "./substrate/cache.ts";
51
59
  import { loadRegistry, type Registry, stageConsumesPlanRef } from "./substrate/registry.ts";
52
- import { perkVersion, sharedDir } from "./substrate/resources.ts";
60
+ import { perkVersion, sharedDir, versionStamp } from "./substrate/resources.ts";
53
61
  import { mintRunId } from "./substrate/runId.ts";
54
62
  import { captureSessionPointer } from "./substrate/sessionPointers.ts";
55
63
  import { registerToolGating } from "./substrate/toolGating.ts";
@@ -87,7 +95,7 @@ function writeT3Sentinel(
87
95
  try {
88
96
  const dir = workflowDir(cwd);
89
97
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
90
- writeFileSync(
98
+ atomicWriteFileSync(
91
99
  join(dir, ".perk-t3.json"),
92
100
  `${JSON.stringify({
93
101
  source,
@@ -101,7 +109,6 @@ function writeT3Sentinel(
101
109
  pi_session_id: state.pi_session_id ?? null,
102
110
  active_plan_ref: state.active_plan_ref ?? null,
103
111
  })}\n`,
104
- "utf8",
105
112
  );
106
113
  } catch {
107
114
  // never throw from a probe
@@ -178,21 +185,25 @@ export default function (pi: ExtensionAPI) {
178
185
  }
179
186
  const registryOk = registryStages > 0;
180
187
 
181
- // The composed `perk` status handle (charter D2): one slot, ordered objective
182
- // checkpoints segments. Created once here (no hidden module state) and threaded into the two
183
- // segment publishers below; the footer reads it back via get/subscribe.
188
+ // The single-value `perk` status handle (charter D2): one slot carrying the objective
189
+ // segment. Created once here (no hidden module state) and threaded into the objective
190
+ // publisher below; the footer reads it back via get/subscribe.
184
191
  const perkStatus = createPerkStatus();
185
192
 
186
- // Install the perk-owned footer once per session (charter D2/D7). Once-only: pi's
187
- // dispose contract for a REPLACED footer factory is unverified, so re-installing on every
188
- // session_start (reload) could leak the previous handle subscription.
189
- let footerInstalled = false;
190
-
191
193
  // Transcript marker for `perk:workflow-state` deltas (audit §2.3): the renderer body lives in
192
194
  // surfaces.ts, this registration is wiring, and the seam carries the typeof feature-detect
193
195
  // (pre-0.80.4 hosts stay inert). One registration covers every workflow-state appender.
194
196
  registerTranscriptRenderer(pi, WORKFLOW_STATE_TYPE, workflowStateEntryRenderer);
195
197
 
198
+ // The hunk watch feedback receiver controller (contracts §8.58) — factory-scoped (no module
199
+ // globals). Synced from session_start/session_tree below; closed on session_shutdown so the
200
+ // consumer lease releases with the session. A stale /reload predecessor instance is retired
201
+ // by the lease fencing (fresh token per same-identity reacquire + verify-before-inject).
202
+ const feedbackReceiver = createHunkFeedbackReceiver(pi);
203
+ pi.on("session_shutdown", async () => {
204
+ feedbackReceiver.close();
205
+ });
206
+
196
207
  pi.on("session_start", async (_event, ctx) => {
197
208
  const branchEntries = () => branchOf(ctx);
198
209
  const sessionFile = ctx.sessionManager.getSessionFile();
@@ -210,6 +221,11 @@ export default function (pi: ExtensionAPI) {
210
221
  cwd: ctx.cwd,
211
222
  });
212
223
 
224
+ // The session-audit exact-vintage stamp (§8.3), recorded by every run-identity arm below
225
+ // (claim/fork/adopt/mint); undefined on the perkVersion() failure sentinel, which drops the
226
+ // key on serialize and leaves the session on the timestamp-estimate arm.
227
+ const stamp = versionStamp(version);
228
+
213
229
  // `claim`/`adopt` carry no prior branch state (adopt's is written by its arm below).
214
230
  let resolved: WorkflowState =
215
231
  decision.action === "claim" || decision.action === "adopt" ? {} : decision.state;
@@ -225,6 +241,7 @@ export default function (pi: ExtensionAPI) {
225
241
  run_id: decision.runId,
226
242
  pi_session_id: currentSessionId ?? undefined,
227
243
  mode: handoff.mode,
244
+ perk_version: stamp,
228
245
  // Record the launched stage so the interior can tell e.g. objective-author from plan
229
246
  // (both are read-only) and inject the right authoring context (planMode vs objectiveAuthor).
230
247
  stage: handoff.stage,
@@ -253,6 +270,7 @@ export default function (pi: ExtensionAPI) {
253
270
  pi_session_id: currentSessionId ?? undefined,
254
271
  predecessor: decision.parentRunId,
255
272
  mode: decision.state.mode,
273
+ perk_version: stamp,
256
274
  };
257
275
  pi.appendEntry(WORKFLOW_STATE_TYPE, data);
258
276
  resolved = data;
@@ -269,6 +287,7 @@ export default function (pi: ExtensionAPI) {
269
287
  pi_session_id: currentSessionId ?? undefined,
270
288
  predecessor: decision.parentRunId,
271
289
  mode: decision.mode,
290
+ perk_version: stamp,
272
291
  };
273
292
  pi.appendEntry(WORKFLOW_STATE_TYPE, data);
274
293
  resolved = data;
@@ -278,7 +297,11 @@ export default function (pi: ExtensionAPI) {
278
297
  // dirs are the accessor's job; provenance is recorded separately. A failed cold claim above never
279
298
  // falls here (claim stays a loud unclaimed error).
280
299
  const runId = mintRunId();
281
- const data: WorkflowState = { run_id: runId, pi_session_id: currentSessionId ?? undefined };
300
+ const data: WorkflowState = {
301
+ run_id: runId,
302
+ pi_session_id: currentSessionId ?? undefined,
303
+ perk_version: stamp,
304
+ };
282
305
  const okAppend = appendWorkflowState(pi, ctx, {
283
306
  data,
284
307
  field: "run_id",
@@ -376,6 +399,20 @@ export default function (pi: ExtensionAPI) {
376
399
  });
377
400
  }
378
401
 
402
+ // The hunk watch feedback receiver (§8.58): sync strictly AFTER the run-identity claim and
403
+ // the plan-ref reconciliation above, so an unclaimed or mislinked session never touches the
404
+ // outbox. Eligibility (interactive TUI + implement stage + non-adopted + settled identity +
405
+ // plan-ref match against one fresh cache read) is evaluated inside sync; every ineligible
406
+ // shape closes any open inbox. Never throws (the controller contains its own failures).
407
+ feedbackReceiver.sync(ctx, {
408
+ stage: implStage,
409
+ adopted: decision.action === "adopt",
410
+ runId: resolved.run_id ?? null,
411
+ piSessionId: currentSessionId,
412
+ activePlanRef: resolved.active_plan_ref ?? null,
413
+ mode: ctx.mode ?? null,
414
+ });
415
+
379
416
  // Soft version-parity drift signal: pi can lazy-install / load a stale `npm:@mgiles/perk`, so the
380
417
  // extension actually running may differ from the `perk` CLI that launched it. The local launch
381
418
  // seam injects PERK_CLI_VERSION; compare it against this extension's own `perkVersion()`. Soft +
@@ -396,11 +433,16 @@ export default function (pi: ExtensionAPI) {
396
433
  // Charter D7: perk identity is standing footer state, not a transition — the
397
434
  // `v<version> loaded` toast (and its headless stderr mirror) is retired. D5 is rescinded:
398
435
  // perk keeps pi's default working indicator (no setWorkingIndicator call anywhere).
436
+ // Install on EVERY headful session_start: pi ≥ 0.84's `setExtensionFooter` explicitly
437
+ // disposes a replaced footer factory (verified at 0.84.1), and `resetExtensionUI` restores
438
+ // the built-in footer on /reload and before session replacement — both paths also re-run
439
+ // this extension factory, so repeated installs leak nothing and each install's deps
440
+ // closures capture the current event's ctx.
399
441
  // Footer-seam install-site vacating: under a foreign `[providers] footer` selection perk does
400
442
  // NOT install its own footer, leaving the foreign footer (`pi-powerline-footer` / `pi-bar`) as
401
- // the sole footer surface. perk's objective/checkpoints progress still reaches it via the
402
- // composed `perk` setStatus slot. Fail-safe: any config-read error resolves to install.
403
- if (ctx.hasUI && !footerInstalled && isPerkFooterReferenceSelected(ctx.cwd)) {
443
+ // the sole footer surface. perk's objective progress still reaches it via the
444
+ // single-value `perk` setStatus slot. Fail-safe: any config-read error resolves to install.
445
+ if (ctx.hasUI && isPerkFooterReferenceSelected(ctx.cwd)) {
404
446
  installPerkFooter(ctx, {
405
447
  identity: `perk v${version}`,
406
448
  status: perkStatus,
@@ -412,7 +454,6 @@ export default function (pi: ExtensionAPI) {
412
454
  return usage ? { percent: usage.percent, contextWindow: usage.contextWindow } : null;
413
455
  },
414
456
  });
415
- footerInstalled = true;
416
457
  }
417
458
 
418
459
  if (process.env.PERK_SELFCHECK) {
@@ -420,7 +461,7 @@ export default function (pi: ExtensionAPI) {
420
461
  const dir = workflowDir(ctx.cwd);
421
462
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
422
463
  // The gate sentinel (unchanged — those gates parse this line).
423
- writeFileSync(
464
+ atomicWriteFileSync(
424
465
  join(dir, ".perk-loaded"),
425
466
  `perk ${version} loaded; shared=${sharedOk ? "ok" : "miss"}; ` +
426
467
  `registry=${registryOk ? "ok" : "miss"} stages=${registryStages}; hasUI=${ctx.hasUI}\n`,
@@ -443,6 +484,17 @@ export default function (pi: ExtensionAPI) {
443
484
  } catch (error) {
444
485
  console.error(`perk: tool-gating sync failed on session_tree — ${error}`);
445
486
  }
487
+ // Re-sync the feedback receiver from the LWW-rebuilt state (§8.58). `adopted: false` is
488
+ // right here: an env-adopted child's fresh branch carries no stage, so the stage gate
489
+ // alone keeps it inert on tree navigation.
490
+ feedbackReceiver.sync(ctx, {
491
+ stage: state.stage ?? null,
492
+ adopted: false,
493
+ runId: state.run_id ?? null,
494
+ piSessionId: state.pi_session_id ?? null,
495
+ activePlanRef: state.active_plan_ref ?? null,
496
+ mode: ctx.mode ?? null,
497
+ });
446
498
  if (process.env.PERK_SELFCHECK) {
447
499
  writeT3Sentinel(ctx.cwd, "tree", state, ctx.mode ?? null);
448
500
  }
@@ -468,11 +520,6 @@ export default function (pi: ExtensionAPI) {
468
520
  // The `gist_draft` working-gist file tool (the third draft carve-out).
469
521
  registerGistDraft(pi);
470
522
 
471
- // The universal `ask_user_question` tool: lets a model interactively ask the human a
472
- // clarifying question (free-text or multiple-choice). Registered in the factory so it exists
473
- // before the gate snapshots tools; its name is in READ_ONLY_TOOLS so it survives plan mode.
474
- registerAskUser(pi);
475
-
476
523
  // Lifecycle gates: the dirty-repo switch/fork guard + the guard-only `/implement`.
477
524
  registerLifecycleGates(pi);
478
525
 
@@ -486,19 +533,48 @@ export default function (pi: ExtensionAPI) {
486
533
  registerLand(pi);
487
534
  registerLearn(pi);
488
535
 
489
- // The warm `/address` review loop: the `resolve_review_threads` tool + `/address`
536
+ // The warm stacked-delivery surface (§8.51): `/objective-stack` (read) +
537
+ // `/objective-sync`/`/objective-recover` (drives) + the four typed stack tools. Takes
538
+ // `gating` for the driving commands' gate-on soft refusal (stack sync/recovery mutates
539
+ // published branches; the stack tools never join READ_ONLY_TOOLS).
540
+ registerObjectiveStack(pi, gating);
541
+
542
+ // The warm `/address` review loop: the submit-then-resolve `finalize_address` tool + `/address`
490
543
  // command. Classify-then-act (the verbose feedback fetch + classification runs in an isolated
491
- // spawned child; the parent fixes actionable items and batch-resolves the threads).
544
+ // spawned child; the parent fixes actionable items and finalizes the committed repairs).
492
545
  registerAddress(pi);
493
546
 
494
547
  // The warm `/pr-review` door: automated code review in a FRESH, isolated subagent that
495
548
  // POSTS its review to the PR (the deliberate departure from /address's read-only-child rule).
496
549
  registerPrReview(pi);
497
550
 
551
+ // The EXPERIMENTAL warm `/pr-review-dynamic` door: the selector-driven sibling — angle
552
+ // selection delegated to a fresh perk.review-angle-selector lane, normalized in
553
+ // module-rendered code; posting shares /pr-review's post_pr_review + clean guard. The
554
+ // baseline /pr-review stays canonical; promotion/retire is a later dogfood's call.
555
+ registerPrReviewDynamic(pi);
556
+
498
557
  // The warm `submit_pr_review` tool: the human-gated curated-posting surface both review
499
558
  // doors ride (contracts §8.4) — neither door registers tools of its own.
500
559
  registerSubmitPrReview(pi);
501
560
 
561
+ // The flow-scoped review-wave pair (`start_review_wave`/`collect_review_wave`) both human
562
+ // review doors drive: non-blocking adversarial-review launch + the typed collect, flow-scoped
563
+ // via the session's pending-wave guard.
564
+ registerReviewWaveTools(pi);
565
+ registerAuditWave(pi);
566
+ registerHarvestWave(pi);
567
+
568
+ // The flow-scoped draft-review-wave pair (`start_draft_review_wave`/
569
+ // `collect_draft_review_wave`) the draft-review door drives: non-blocking draft-review
570
+ // launch over the door-primed context + the typed collect.
571
+ registerDraftReviewWaveTools(pi);
572
+
573
+ // The door-primed browser annotation tool (`push_annotations`): the browser door primes the
574
+ // surface handle on open and clears it on settle/degrade — the tool refuses outside a
575
+ // door-opened flow.
576
+ registerAnnotationPushTool(pi);
577
+
502
578
  // The warm `/pr-review-terminal` door: the terminal review entry — hunk always, no provider
503
579
  // dispatch (the command IS the selection); posting rides `submit_pr_review` above.
504
580
  registerPrReviewTerminal(pi);
@@ -508,26 +584,22 @@ export default function (pi: ExtensionAPI) {
508
584
  // human's own platform-post from the UI, with `submit_pr_review` for request-changes only.
509
585
  registerPrReviewBrowser(pi);
510
586
 
587
+ // The warm `/plan-review-browser` door: the summonable streaming draft review — the
588
+ // plannotator plan-review browser on the working plan draft, draft reviewers streaming
589
+ // phrase-anchored findings in; APPROVE auto-saves via the approvalSave seam, DENY returns a
590
+ // model-mediated revision round.
591
+ registerPlanReviewBrowser(pi, gating);
592
+
593
+ // The warm `/objective-review-browser` door: the summonable streaming objective-draft review
594
+ // — the plannotator plan-review browser on the RENDERED working objective draft, draft
595
+ // reviewers streaming phrase-anchored findings in; APPROVE auto-saves via the
596
+ // objectiveApprovalSave seam, Direct Edits = a model-mediated revise round (never auto-saved).
597
+ registerObjectiveReviewBrowser(pi, gating);
598
+
511
599
  // The read-only CI executor: the `run_ci` tool + `/ci` command + `--allow-project-ci`
512
600
  // flag. Runs the project's `[ci]` named checks deterministically and reports (never fixes/loops).
513
601
  registerCiExecutor(pi);
514
602
 
515
- // perk-owned checkpoints: seed from the plan body's `## Steps`, advance on `[DONE:n]`.
516
- // Inert when no step list is present (perk plans are prose). Own `session_start`/`session_tree`/
517
- // `turn_end` handlers (coexist with the others; pi.on supports multiple handlers per event).
518
- // Todo-seam deferral: perk is the reference todo provider (`perk-checkpoints`); these
519
- // runtime surfaces step aside when a foreign `[providers] todo` is selected (the todo-seam mirror
520
- // of planMode's plan-seam deferral) — silent on the event handlers, announced on `/checkpoints`.
521
- registerCheckpoints(pi, perkStatus);
522
-
523
- // The FIRST 3rd-party todo adapter (the todo-seam mirror of registerPlanAdapterTombell).
524
- // Injection-only: inert unless `[providers] todo = "juicesharp-todo"` is selected AND the session
525
- // is an active workflow. It carries perk's implement-progress discipline onto `@juicesharp/rpiv-
526
- // todo`'s checklist overlay (perk's own checkpoints deferred). No `gating` argument —
527
- // the shim NEVER arbitrates tools (Invariant 1); no registration-time vacating (no command-name
528
- // collision on the todo seam, unlike the plan seam); never writes `perk:checkpoint`.
529
- registerTodoAdapterJuicesharp(pi);
530
-
531
603
  // The objective substrate: `/objective` set/clear, budget accounting, threshold
532
604
  // compaction, all keyed off the now-live `active_objective`. Inert when no objective is active.
533
605
  // (The deterministic objective mechanics live in the Python plane: `perk objective …`.)
@@ -10,10 +10,54 @@
10
10
  // the read-only gate engages. The Python twins (src/perk/state/cache.py) deliberately keep
11
11
  // RAISING `CacheError` (exterior plane, launch-time fail-loud) — the cross-plane contract is
12
12
  // the *files*, not error semantics.
13
-
14
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ //
14
+ // Write discipline (contracts.md §8.1): every `.perk/workflow/` write goes through
15
+ // `atomicWriteFileSync` (temp file in the same directory + atomic rename) so a concurrent
16
+ // writer can never tear a file — a reader sees either the old bytes or the new bytes, never a
17
+ // mix (guard-tested by writeGuard.test.ts). The exemptions are the append-only NDJSON streams
18
+ // — the worker's `events.ndjson` (worker/worker.ts) and the §8.58 hunk-watch `outbox.ndjson` /
19
+ // `delivered.ndjson` (hunkFeedback/perkFeedback.ts / hunkFeedback/store.ts) — where O_APPEND
20
+ // appends cannot truncate-tear and whole-file replace would introduce a read-modify-write race
21
+ // between independent processes. Atomicity is not mutual exclusion — whole-file
22
+ // last-writer-wins between concurrent writers is the accepted residual.
23
+
24
+ import { randomBytes } from "node:crypto";
25
+ import {
26
+ existsSync,
27
+ mkdirSync,
28
+ readdirSync,
29
+ readFileSync,
30
+ renameSync,
31
+ rmSync,
32
+ writeFileSync,
33
+ } from "node:fs";
15
34
  import { join } from "node:path";
16
35
 
36
+ /**
37
+ * Atomically replace `path` with `content` (the interior atomic-write seam).
38
+ *
39
+ * Writes a temp file in the same directory (so the rename never crosses filesystems) then swaps
40
+ * it into place; a concurrent reader sees either the old bytes or the new bytes, never a torn
41
+ * mix. On failure the temp file is best-effort removed and the error re-thrown. Precondition:
42
+ * the parent directory exists (same contract as `writeFileSync`; call sites mkdir first).
43
+ * Deliberately no fsync — crash durability is out of scope; the target is inter-process tearing
44
+ * of regenerable, gitignored workflow state.
45
+ */
46
+ export function atomicWriteFileSync(path: string, content: string): void {
47
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
48
+ try {
49
+ writeFileSync(tmp, content, "utf8");
50
+ renameSync(tmp, path);
51
+ } catch (error) {
52
+ try {
53
+ rmSync(tmp, { force: true });
54
+ } catch {
55
+ // best-effort cleanup — a cleanup failure must never mask the original write/rename error
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+
17
61
  export interface Handoff {
18
62
  run_id: string;
19
63
  consumed: boolean;
@@ -67,7 +111,7 @@ export function markHandoffConsumed(
67
111
  if (data === null) return;
68
112
  data.consumed = true;
69
113
  if (opts.piSessionId !== undefined) data.pi_session_id = opts.piSessionId;
70
- writeFileSync(handoffPath(cwd, runId), `${JSON.stringify(data, null, 2)}\n`, "utf8");
114
+ atomicWriteFileSync(handoffPath(cwd, runId), `${JSON.stringify(data, null, 2)}\n`);
71
115
  }
72
116
 
73
117
  // --- scratch -----------------------------------------------------------------------------
@@ -142,29 +186,37 @@ export function readPlanRef(cwd: string): PlanRef | null {
142
186
 
143
187
  export function writePlanRef(cwd: string, ref: PlanRef): void {
144
188
  mkdirSync(workflowDir(cwd), { recursive: true });
145
- writeFileSync(planRefPath(cwd), `${JSON.stringify(ref, null, 2)}\n`, "utf8");
189
+ atomicWriteFileSync(planRefPath(cwd), `${JSON.stringify(ref, null, 2)}\n`);
146
190
  }
147
191
 
148
- // --- plan body cache (`cache.plan`) ------------------------------------------------------
192
+ // --- hunk-watch: the watch-feedback bridge family (contracts.md §8.58) ---------------------
193
+ //
194
+ // Worktree-local, disposable: append-only NDJSON streams plus the single-consumer lease dir.
195
+ // This module is the INTERIOR construction site for the family; the hunk-plane twin is the
196
+ // self-contained bundled publisher (extension/hunkFeedback/perkFeedback.ts), pinned to these
197
+ // helpers by a path-parity test.
149
198
 
150
- /**
151
- * The materialized plan-body cache (`cache.plan`, contracts §8.1). Written by the Python cold door
152
- * (`perk implement` → `launch.materialize_plan_body`) when it positions the worktree; read here so
153
- * in-session checkpoints seed from its `## Steps` list (inert when absent).
154
- */
155
- export function planBodyPath(cwd: string): string {
156
- return join(workflowDir(cwd), "plan.md");
199
+ export function hunkWatchDir(cwd: string): string {
200
+ return join(workflowDir(cwd), "hunk-watch");
157
201
  }
158
202
 
159
- export function readPlanBody(cwd: string): string | null {
160
- const path = planBodyPath(cwd);
161
- if (!existsSync(path)) return null;
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
- }
203
+ /** Append-only feedback records (the Hunk publisher writes; the Pi receiver reads). */
204
+ export function hunkOutboxPath(cwd: string): string {
205
+ return join(hunkWatchDir(cwd), "outbox.ndjson");
206
+ }
207
+
208
+ /** Append-only delivery acknowledgements (the Pi receiver writes). */
209
+ export function hunkDeliveredPath(cwd: string): string {
210
+ return join(hunkWatchDir(cwd), "delivered.ndjson");
211
+ }
212
+
213
+ /** The single-consumer lease dir (atomic mkdir is the acquisition primitive). */
214
+ export function hunkConsumerLockDir(cwd: string): string {
215
+ return join(hunkWatchDir(cwd), "consumer.lock");
216
+ }
217
+
218
+ export function hunkLeasePath(cwd: string): string {
219
+ return join(hunkConsumerLockDir(cwd), "lease.json");
168
220
  }
169
221
 
170
222
  // --- markers (existence-only) ------------------------------------------------------------
@@ -178,7 +230,8 @@ export function markerPath(cwd: string, name: string): string {
178
230
 
179
231
  export function setMarker(cwd: string, name: string): void {
180
232
  mkdirSync(join(workflowDir(cwd), "markers"), { recursive: true });
181
- writeFileSync(markerPath(cwd, name), "", "utf8");
233
+ // Routed through the atomic seam for uniformity (empty content is trivially safe either way).
234
+ atomicWriteFileSync(markerPath(cwd, name), "");
182
235
  }
183
236
 
184
237
  export function hasMarker(cwd: string, name: string): boolean {
@@ -14,10 +14,9 @@
14
14
  // });
15
15
  // if (!r.ok) return fail(r.message, r.errorType);
16
16
 
17
- import { writeFileSync } from "node:fs";
18
17
  import { join } from "node:path";
19
18
  import type { ExecOptions, ExecResult } from "@earendil-works/pi-coding-agent";
20
- import { ensureRunScratch } from "./cache.ts";
19
+ import { atomicWriteFileSync, ensureRunScratch } from "./cache.ts";
21
20
  import { type BranchSource, branchOf, rebuildWorkflowState } from "./workflowState.ts";
22
21
 
23
22
  /** Minimal exec surface — `ExtensionAPI` satisfies it (compile-checked in the test); tests fake it. */
@@ -134,7 +133,7 @@ export async function runColdDoor<T>(
134
133
  try {
135
134
  const dir = ensureRunScratch(ctx.cwd, activeRunId(ctx));
136
135
  const path = join(dir, opts.stdin.filename);
137
- writeFileSync(path, opts.stdin.content, "utf8");
136
+ atomicWriteFileSync(path, opts.stdin.content);
138
137
  fullArgv = [...argv, opts.stdin.flag, path];
139
138
  } catch (err) {
140
139
  return {
@@ -48,14 +48,20 @@ export interface PerkConfig {
48
48
  /**
49
49
  * The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
50
50
  * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
51
- * `learn-analyst`, `adversarial-reviewer`). Each configured
52
- * value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when
53
- * a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
51
+ * `learn-analyst`, `adversarial-reviewer`, `review-angle-selector`, `draft-reviewer`,
52
+ * `harvest-analyst` consumed by `run_harvest_wave` at execute time and the
53
+ * dev-only `session-auditor`, whose def is repo-local to perk's own repository
54
+ * (`.pi/agents/perk-dev/session-auditor.md`, never delivered by `perk init`), so the key is
55
+ * dormant in consumer repos). Each configured
56
+ * value is injected as the top-level workflow-level `model` on that agent's one `subagent`
57
+ * workflowScript call — a default flowing onto every lane, single-child runs included (as
58
+ * /pr-review does); when a key is absent the agent's frontmatter `model` (in
59
+ * `.pi/agents/perk/<name>.md`; the session-auditor's in its repo-local def) is the default.
54
60
  * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
55
- * `applyBuiltinOverrides` applies only to builtins — so this inline override is the mechanism.)
61
+ * `applyBuiltinOverrides` applies only to builtins — so this inline injection is the mechanism.)
56
62
  * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
57
63
  * `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
58
- * pi-subagents on the inline override (the last-colon segment counts as thinking only when it
64
+ * pi-subagents on the injected value (the last-colon segment counts as thinking only when it
59
65
  * is a pi level, so ollama-style tags stay part of the model id).
60
66
  * Always-present object; absent keys omitted (mirror of `providers`).
61
67
  */
@@ -66,6 +72,10 @@ export interface PerkConfig {
66
72
  "conflict-resolver"?: string;
67
73
  "learn-analyst"?: string;
68
74
  "adversarial-reviewer"?: string;
75
+ "review-angle-selector"?: string;
76
+ "draft-reviewer"?: string;
77
+ "harvest-analyst"?: string;
78
+ "session-auditor"?: string;
69
79
  };
70
80
  /**
71
81
  * Optional `[compaction] objective_threshold` — the context-usage fraction (0,1] that triggers
@@ -83,8 +93,6 @@ export interface PerkConfig {
83
93
  */
84
94
  providers: {
85
95
  plan?: string;
86
- todo?: string;
87
- askuser?: string;
88
96
  footer?: string;
89
97
  web?: string;
90
98
  };
@@ -265,6 +273,24 @@ export function loadPerkConfig(cwd: string): PerkConfig {
265
273
  };
266
274
  }
267
275
 
276
+ /**
277
+ * Resolve one `[models.subagents]` model at execute time — the flow-tool read for sessions that
278
+ * may run inside a linked worktree. Committed `.perk/config.toml` is read from `cwd` (the
279
+ * worktree's committed semantics), but the gitignored `.perk/local.toml` lives only in the MAIN
280
+ * checkout when `cwd` is a linked worktree (worktrees never materialize it), so the local
281
+ * overlay is additionally anchored via `mainCheckoutRoot` — a user's session-transient model
282
+ * override survives the cold worktree launch. A worktree-local `local.toml`, if one exists,
283
+ * still wins (most specific last); in the main checkout the two local reads are the same file
284
+ * (byte-identical behavior). Fail-open like everything here — missing/malformed files are empty.
285
+ */
286
+ export function subagentModel(cwd: string, agent: SubagentKey): string | undefined {
287
+ const merged = overlay(
288
+ overlay(readTomlFile(configFile(cwd)), readTomlFile(localConfigFile(mainCheckoutRoot(cwd)))),
289
+ readTomlFile(localConfigFile(cwd)),
290
+ );
291
+ return parseSubagentsSelection(merged.tables["models.subagents"])[agent];
292
+ }
293
+
268
294
  /**
269
295
  * Read the `[[ci.checks]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when
270
296
  * both `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string.
@@ -286,6 +312,9 @@ export function parseCiChecks(rows: Array<Record<string, TomlScalar>>): CiCheck[
286
312
  return checks;
287
313
  }
288
314
 
315
+ /** One perk-owned project agent name configurable via the `[models.subagents]` table. */
316
+ export type SubagentKey = (typeof SUBAGENT_KEYS)[number];
317
+
289
318
  /** The perk-owned project agents configurable via the `[models.subagents]` table. */
290
319
  const SUBAGENT_KEYS = [
291
320
  "pr-reviewer",
@@ -294,6 +323,11 @@ const SUBAGENT_KEYS = [
294
323
  "conflict-resolver",
295
324
  "learn-analyst",
296
325
  "adversarial-reviewer",
326
+ "review-angle-selector",
327
+ "draft-reviewer",
328
+ "harvest-analyst",
329
+ // Dev-only: the perk-dev session-audit judgment wave's auditor (dormant in consumer repos).
330
+ "session-auditor",
297
331
  ] as const;
298
332
 
299
333
  /**
@@ -312,24 +346,18 @@ function parseSubagentsSelection(
312
346
  return selection;
313
347
  }
314
348
 
315
- /** Read the flat `[providers]` table into a `{plan?, todo?, askuser?, footer?, web?}` selection (string values only). A retired `review` key is silently ignored (the TS fail-safe posture; the Python plane's tripwire is the loud surface). */
349
+ /** Read the flat `[providers]` table into a `{plan?, footer?, web?}` selection (string values only). Retired keys (`review`, `askuser`, `todo`) are silently ignored (the TS fail-safe posture; the Python plane's tripwire is the loud surface). */
316
350
  function parseProvidersSelection(table: Record<string, TomlScalar> | undefined): {
317
351
  plan?: string;
318
- todo?: string;
319
- askuser?: string;
320
352
  footer?: string;
321
353
  web?: string;
322
354
  } {
323
355
  const selection: {
324
356
  plan?: string;
325
- todo?: string;
326
- askuser?: string;
327
357
  footer?: string;
328
358
  web?: string;
329
359
  } = {};
330
360
  if (typeof table?.plan === "string") selection.plan = table.plan;
331
- if (typeof table?.todo === "string") selection.todo = table.todo;
332
- if (typeof table?.askuser === "string") selection.askuser = table.askuser;
333
361
  if (typeof table?.footer === "string") selection.footer = table.footer;
334
362
  if (typeof table?.web === "string") selection.web = table.web;
335
363
  return selection;
@@ -48,6 +48,16 @@ function git(cwd: string, args: string[], timeout?: number): string | null {
48
48
  }
49
49
  }
50
50
 
51
+ /**
52
+ * Tracked files under `pathspec` (repo-relative names), [] when none or on ANY failure (not a
53
+ * repo, git missing — the module's fail-open style). Callers deciding trust on the result must
54
+ * treat [] as "nothing PROVEN tracked", not proof of cleanliness.
55
+ */
56
+ export function lsFiles(cwd: string, pathspec: string): string[] {
57
+ const out = git(cwd, ["ls-files", "--", pathspec]);
58
+ return out === null ? [] : out.split("\n").filter((line) => line !== "");
59
+ }
60
+
51
61
  /** The bounded best-effort `git fetch` budget (ms) — see `sinceBaseSha` step 2. */
52
62
  const FETCH_TIMEOUT_MS = 15_000;
53
63