@mgiles/perk 2.3.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 (128) 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 +24 -6
  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 +26 -16
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +25 -18
  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 +82 -7
  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 -9
  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 +109 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +36 -12
  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/toolGating.ts +170 -23
  52. package/extension/substrate/workflowState.ts +6 -0
  53. package/extension/surfaces/footerProvider.ts +6 -6
  54. package/extension/surfaces/surfaces.ts +98 -282
  55. package/extension/vendor/btw/btw.ts +57 -12
  56. package/extension/waves/adversarialReviewWave.ts +164 -0
  57. package/extension/waves/auditWave.ts +312 -0
  58. package/extension/waves/draftReviewWave.ts +189 -0
  59. package/extension/waves/harvestWave.ts +399 -0
  60. package/extension/waves/memoryAdapter.ts +14 -1
  61. package/extension/waves/objectiveExplorerWave.ts +120 -0
  62. package/extension/waves/prReviewDynamicWave.ts +291 -61
  63. package/extension/waves/prReviewWave.ts +58 -13
  64. package/extension/waves/reportWave.ts +406 -106
  65. package/extension/waves/reviewClassifierWave.ts +109 -0
  66. package/extension/waves/rpcAdapter.ts +65 -2
  67. package/extension/worker/readOnlySession.ts +3 -3
  68. package/extension/worker/worker.ts +71 -93
  69. package/extension/workerMain.ts +4 -6
  70. package/package.json +15 -5
  71. package/prompts/README.md +19 -0
  72. package/prompts/_fixtures/live.yaml +66 -65
  73. package/prompts/common/resume-advisory.md +1 -0
  74. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  75. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  76. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  77. package/prompts/contexts/gist-authoring.md +6 -20
  78. package/prompts/contexts/objective-authoring.md +6 -18
  79. package/prompts/stages/address/action.md +3 -14
  80. package/prompts/stages/address/preview.md +2 -13
  81. package/prompts/stages/audit.md +18 -0
  82. package/prompts/stages/gist-author/seed.md +4 -4
  83. package/prompts/stages/gist-save.md +1 -1
  84. package/prompts/stages/implement.md +3 -1
  85. package/prompts/stages/learn-code.md +1 -1
  86. package/prompts/stages/learn-docs.md +2 -2
  87. package/prompts/stages/learn-harvest.md +15 -0
  88. package/prompts/stages/learn-orchestrate.md +1 -1
  89. package/prompts/stages/objective-author/adopt.md +5 -3
  90. package/prompts/stages/objective-author/file.md +5 -4
  91. package/prompts/stages/objective-author/seed.md +5 -4
  92. package/prompts/stages/objective-land.md +7 -0
  93. package/prompts/stages/objective-plan/guidance.md +2 -13
  94. package/prompts/stages/objective-plan/seed.md +5 -12
  95. package/prompts/stages/objective-recover.md +8 -0
  96. package/prompts/stages/objective-replan.md +4 -2
  97. package/prompts/stages/objective-review-browser.md +9 -0
  98. package/prompts/stages/objective-sync.md +6 -0
  99. package/prompts/stages/plan-from/adopt.md +2 -2
  100. package/prompts/stages/plan-from/file.md +2 -2
  101. package/prompts/stages/plan-review-browser.md +9 -0
  102. package/prompts/stages/pr-review-browser/active.md +6 -14
  103. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  104. package/prompts/stages/pr-review-dynamic.md +3 -3
  105. package/prompts/stages/pr-review-terminal/active.md +7 -15
  106. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  107. package/prompts/stages/pr-review.md +2 -2
  108. package/prompts/stages/replan.md +3 -3
  109. package/shared/README.md +2 -2
  110. package/shared/bindings.yaml +12 -0
  111. package/shared/contracts-history.md +11 -0
  112. package/shared/contracts.md +3652 -236
  113. package/shared/providers.yaml +27 -60
  114. package/shared/registry.yaml +32 -8
  115. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  116. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  117. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  118. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  119. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  120. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  121. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  122. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  123. package/extension/checkpoints/checkpoints.ts +0 -550
  124. package/extension/checkpoints/planSteps.ts +0 -108
  125. package/extension/doors/askUser.ts +0 -198
  126. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  127. package/prompts/common/output-schemas/review-classifier.md +0 -47
  128. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -4,26 +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";
24
28
  import { registerPrReviewDynamic } from "./doors/prReviewDynamic.ts";
25
29
  import { registerPrReviewTerminal } from "./doors/prReviewTerminal.ts";
26
30
  import { registerReady } from "./doors/ready.ts";
31
+ import { registerReviewWaveTools } from "./doors/reviewWaveTools.ts";
27
32
  import { registerSelfcheck } from "./doors/selfcheck.ts";
28
33
  import { registerSubmit } from "./doors/submit.ts";
29
34
  import { registerSubmitPrReview } from "./doors/submitPrReview.ts";
@@ -40,8 +45,10 @@ import { registerPlanDraft } from "./factories/planDraft.ts";
40
45
  import { registerPlanMode } from "./factories/planMode.ts";
41
46
  import { registerPlanReview } from "./factories/planReview.ts";
42
47
  import { registerPlanSave } from "./factories/planSave.ts";
48
+ import { createHunkFeedbackReceiver } from "./hunkFeedback/receiver.ts";
43
49
  import { registerBindingDelivery } from "./substrate/bindingDelivery.ts";
44
50
  import {
51
+ atomicWriteFileSync,
45
52
  ensureRunScratch,
46
53
  markHandoffConsumed,
47
54
  readHandoff,
@@ -50,7 +57,7 @@ import {
50
57
  workflowDir,
51
58
  } from "./substrate/cache.ts";
52
59
  import { loadRegistry, type Registry, stageConsumesPlanRef } from "./substrate/registry.ts";
53
- import { perkVersion, sharedDir } from "./substrate/resources.ts";
60
+ import { perkVersion, sharedDir, versionStamp } from "./substrate/resources.ts";
54
61
  import { mintRunId } from "./substrate/runId.ts";
55
62
  import { captureSessionPointer } from "./substrate/sessionPointers.ts";
56
63
  import { registerToolGating } from "./substrate/toolGating.ts";
@@ -88,7 +95,7 @@ function writeT3Sentinel(
88
95
  try {
89
96
  const dir = workflowDir(cwd);
90
97
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
91
- writeFileSync(
98
+ atomicWriteFileSync(
92
99
  join(dir, ".perk-t3.json"),
93
100
  `${JSON.stringify({
94
101
  source,
@@ -102,7 +109,6 @@ function writeT3Sentinel(
102
109
  pi_session_id: state.pi_session_id ?? null,
103
110
  active_plan_ref: state.active_plan_ref ?? null,
104
111
  })}\n`,
105
- "utf8",
106
112
  );
107
113
  } catch {
108
114
  // never throw from a probe
@@ -179,21 +185,25 @@ export default function (pi: ExtensionAPI) {
179
185
  }
180
186
  const registryOk = registryStages > 0;
181
187
 
182
- // The composed `perk` status handle (charter D2): one slot, ordered objective
183
- // checkpoints segments. Created once here (no hidden module state) and threaded into the two
184
- // 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.
185
191
  const perkStatus = createPerkStatus();
186
192
 
187
- // Install the perk-owned footer once per session (charter D2/D7). Once-only: pi's
188
- // dispose contract for a REPLACED footer factory is unverified, so re-installing on every
189
- // session_start (reload) could leak the previous handle subscription.
190
- let footerInstalled = false;
191
-
192
193
  // Transcript marker for `perk:workflow-state` deltas (audit §2.3): the renderer body lives in
193
194
  // surfaces.ts, this registration is wiring, and the seam carries the typeof feature-detect
194
195
  // (pre-0.80.4 hosts stay inert). One registration covers every workflow-state appender.
195
196
  registerTranscriptRenderer(pi, WORKFLOW_STATE_TYPE, workflowStateEntryRenderer);
196
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
+
197
207
  pi.on("session_start", async (_event, ctx) => {
198
208
  const branchEntries = () => branchOf(ctx);
199
209
  const sessionFile = ctx.sessionManager.getSessionFile();
@@ -211,6 +221,11 @@ export default function (pi: ExtensionAPI) {
211
221
  cwd: ctx.cwd,
212
222
  });
213
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
+
214
229
  // `claim`/`adopt` carry no prior branch state (adopt's is written by its arm below).
215
230
  let resolved: WorkflowState =
216
231
  decision.action === "claim" || decision.action === "adopt" ? {} : decision.state;
@@ -226,6 +241,7 @@ export default function (pi: ExtensionAPI) {
226
241
  run_id: decision.runId,
227
242
  pi_session_id: currentSessionId ?? undefined,
228
243
  mode: handoff.mode,
244
+ perk_version: stamp,
229
245
  // Record the launched stage so the interior can tell e.g. objective-author from plan
230
246
  // (both are read-only) and inject the right authoring context (planMode vs objectiveAuthor).
231
247
  stage: handoff.stage,
@@ -254,6 +270,7 @@ export default function (pi: ExtensionAPI) {
254
270
  pi_session_id: currentSessionId ?? undefined,
255
271
  predecessor: decision.parentRunId,
256
272
  mode: decision.state.mode,
273
+ perk_version: stamp,
257
274
  };
258
275
  pi.appendEntry(WORKFLOW_STATE_TYPE, data);
259
276
  resolved = data;
@@ -270,6 +287,7 @@ export default function (pi: ExtensionAPI) {
270
287
  pi_session_id: currentSessionId ?? undefined,
271
288
  predecessor: decision.parentRunId,
272
289
  mode: decision.mode,
290
+ perk_version: stamp,
273
291
  };
274
292
  pi.appendEntry(WORKFLOW_STATE_TYPE, data);
275
293
  resolved = data;
@@ -279,7 +297,11 @@ export default function (pi: ExtensionAPI) {
279
297
  // dirs are the accessor's job; provenance is recorded separately. A failed cold claim above never
280
298
  // falls here (claim stays a loud unclaimed error).
281
299
  const runId = mintRunId();
282
- 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
+ };
283
305
  const okAppend = appendWorkflowState(pi, ctx, {
284
306
  data,
285
307
  field: "run_id",
@@ -377,6 +399,20 @@ export default function (pi: ExtensionAPI) {
377
399
  });
378
400
  }
379
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
+
380
416
  // Soft version-parity drift signal: pi can lazy-install / load a stale `npm:@mgiles/perk`, so the
381
417
  // extension actually running may differ from the `perk` CLI that launched it. The local launch
382
418
  // seam injects PERK_CLI_VERSION; compare it against this extension's own `perkVersion()`. Soft +
@@ -397,11 +433,16 @@ export default function (pi: ExtensionAPI) {
397
433
  // Charter D7: perk identity is standing footer state, not a transition — the
398
434
  // `v<version> loaded` toast (and its headless stderr mirror) is retired. D5 is rescinded:
399
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.
400
441
  // Footer-seam install-site vacating: under a foreign `[providers] footer` selection perk does
401
442
  // NOT install its own footer, leaving the foreign footer (`pi-powerline-footer` / `pi-bar`) as
402
- // the sole footer surface. perk's objective/checkpoints progress still reaches it via the
403
- // composed `perk` setStatus slot. Fail-safe: any config-read error resolves to install.
404
- 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)) {
405
446
  installPerkFooter(ctx, {
406
447
  identity: `perk v${version}`,
407
448
  status: perkStatus,
@@ -413,7 +454,6 @@ export default function (pi: ExtensionAPI) {
413
454
  return usage ? { percent: usage.percent, contextWindow: usage.contextWindow } : null;
414
455
  },
415
456
  });
416
- footerInstalled = true;
417
457
  }
418
458
 
419
459
  if (process.env.PERK_SELFCHECK) {
@@ -421,7 +461,7 @@ export default function (pi: ExtensionAPI) {
421
461
  const dir = workflowDir(ctx.cwd);
422
462
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
423
463
  // The gate sentinel (unchanged — those gates parse this line).
424
- writeFileSync(
464
+ atomicWriteFileSync(
425
465
  join(dir, ".perk-loaded"),
426
466
  `perk ${version} loaded; shared=${sharedOk ? "ok" : "miss"}; ` +
427
467
  `registry=${registryOk ? "ok" : "miss"} stages=${registryStages}; hasUI=${ctx.hasUI}\n`,
@@ -444,6 +484,17 @@ export default function (pi: ExtensionAPI) {
444
484
  } catch (error) {
445
485
  console.error(`perk: tool-gating sync failed on session_tree — ${error}`);
446
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
+ });
447
498
  if (process.env.PERK_SELFCHECK) {
448
499
  writeT3Sentinel(ctx.cwd, "tree", state, ctx.mode ?? null);
449
500
  }
@@ -469,11 +520,6 @@ export default function (pi: ExtensionAPI) {
469
520
  // The `gist_draft` working-gist file tool (the third draft carve-out).
470
521
  registerGistDraft(pi);
471
522
 
472
- // The universal `ask_user_question` tool: lets a model interactively ask the human a
473
- // clarifying question (free-text or multiple-choice). Registered in the factory so it exists
474
- // before the gate snapshots tools; its name is in READ_ONLY_TOOLS so it survives plan mode.
475
- registerAskUser(pi);
476
-
477
523
  // Lifecycle gates: the dirty-repo switch/fork guard + the guard-only `/implement`.
478
524
  registerLifecycleGates(pi);
479
525
 
@@ -487,9 +533,15 @@ export default function (pi: ExtensionAPI) {
487
533
  registerLand(pi);
488
534
  registerLearn(pi);
489
535
 
490
- // 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`
491
543
  // command. Classify-then-act (the verbose feedback fetch + classification runs in an isolated
492
- // 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).
493
545
  registerAddress(pi);
494
546
 
495
547
  // The warm `/pr-review` door: automated code review in a FRESH, isolated subagent that
@@ -506,6 +558,23 @@ export default function (pi: ExtensionAPI) {
506
558
  // doors ride (contracts §8.4) — neither door registers tools of its own.
507
559
  registerSubmitPrReview(pi);
508
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
+
509
578
  // The warm `/pr-review-terminal` door: the terminal review entry — hunk always, no provider
510
579
  // dispatch (the command IS the selection); posting rides `submit_pr_review` above.
511
580
  registerPrReviewTerminal(pi);
@@ -515,26 +584,22 @@ export default function (pi: ExtensionAPI) {
515
584
  // human's own platform-post from the UI, with `submit_pr_review` for request-changes only.
516
585
  registerPrReviewBrowser(pi);
517
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
+
518
599
  // The read-only CI executor: the `run_ci` tool + `/ci` command + `--allow-project-ci`
519
600
  // flag. Runs the project's `[ci]` named checks deterministically and reports (never fixes/loops).
520
601
  registerCiExecutor(pi);
521
602
 
522
- // perk-owned checkpoints: seed from the plan body's `## Steps`, advance on `[DONE:n]`.
523
- // Inert when no step list is present (perk plans are prose). Own `session_start`/`session_tree`/
524
- // `turn_end` handlers (coexist with the others; pi.on supports multiple handlers per event).
525
- // Todo-seam deferral: perk is the reference todo provider (`perk-checkpoints`); these
526
- // runtime surfaces step aside when a foreign `[providers] todo` is selected (the todo-seam mirror
527
- // of planMode's plan-seam deferral) — silent on the event handlers, announced on `/checkpoints`.
528
- registerCheckpoints(pi, perkStatus);
529
-
530
- // The FIRST 3rd-party todo adapter (the todo-seam mirror of registerPlanAdapterTombell).
531
- // Injection-only: inert unless `[providers] todo = "juicesharp-todo"` is selected AND the session
532
- // is an active workflow. It carries perk's implement-progress discipline onto `@juicesharp/rpiv-
533
- // todo`'s checklist overlay (perk's own checkpoints deferred). No `gating` argument —
534
- // the shim NEVER arbitrates tools (Invariant 1); no registration-time vacating (no command-name
535
- // collision on the todo seam, unlike the plan seam); never writes `perk:checkpoint`.
536
- registerTodoAdapterJuicesharp(pi);
537
-
538
603
  // The objective substrate: `/objective` set/clear, budget accounting, threshold
539
604
  // compaction, all keyed off the now-live `active_objective`. Inert when no objective is active.
540
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,11 +48,15 @@ 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`, `review-angle-selector`). Each configured
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
52
56
  * value is injected as the top-level workflow-level `model` on that agent's one `subagent`
53
57
  * workflowScript call — a default flowing onto every lane, single-child runs included (as
54
- * /pr-review does); when
55
- * a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
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.
56
60
  * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
57
61
  * `applyBuiltinOverrides` applies only to builtins — so this inline injection is the mechanism.)
58
62
  * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
@@ -69,6 +73,9 @@ export interface PerkConfig {
69
73
  "learn-analyst"?: string;
70
74
  "adversarial-reviewer"?: string;
71
75
  "review-angle-selector"?: string;
76
+ "draft-reviewer"?: string;
77
+ "harvest-analyst"?: string;
78
+ "session-auditor"?: string;
72
79
  };
73
80
  /**
74
81
  * Optional `[compaction] objective_threshold` — the context-usage fraction (0,1] that triggers
@@ -86,8 +93,6 @@ export interface PerkConfig {
86
93
  */
87
94
  providers: {
88
95
  plan?: string;
89
- todo?: string;
90
- askuser?: string;
91
96
  footer?: string;
92
97
  web?: string;
93
98
  };
@@ -268,6 +273,24 @@ export function loadPerkConfig(cwd: string): PerkConfig {
268
273
  };
269
274
  }
270
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
+
271
294
  /**
272
295
  * Read the `[[ci.checks]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when
273
296
  * both `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string.
@@ -289,6 +312,9 @@ export function parseCiChecks(rows: Array<Record<string, TomlScalar>>): CiCheck[
289
312
  return checks;
290
313
  }
291
314
 
315
+ /** One perk-owned project agent name configurable via the `[models.subagents]` table. */
316
+ export type SubagentKey = (typeof SUBAGENT_KEYS)[number];
317
+
292
318
  /** The perk-owned project agents configurable via the `[models.subagents]` table. */
293
319
  const SUBAGENT_KEYS = [
294
320
  "pr-reviewer",
@@ -298,6 +324,10 @@ const SUBAGENT_KEYS = [
298
324
  "learn-analyst",
299
325
  "adversarial-reviewer",
300
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",
301
331
  ] as const;
302
332
 
303
333
  /**
@@ -316,24 +346,18 @@ function parseSubagentsSelection(
316
346
  return selection;
317
347
  }
318
348
 
319
- /** 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). */
320
350
  function parseProvidersSelection(table: Record<string, TomlScalar> | undefined): {
321
351
  plan?: string;
322
- todo?: string;
323
- askuser?: string;
324
352
  footer?: string;
325
353
  web?: string;
326
354
  } {
327
355
  const selection: {
328
356
  plan?: string;
329
- todo?: string;
330
- askuser?: string;
331
357
  footer?: string;
332
358
  web?: string;
333
359
  } = {};
334
360
  if (typeof table?.plan === "string") selection.plan = table.plan;
335
- if (typeof table?.todo === "string") selection.todo = table.todo;
336
- if (typeof table?.askuser === "string") selection.askuser = table.askuser;
337
361
  if (typeof table?.footer === "string") selection.footer = table.footer;
338
362
  if (typeof table?.web === "string") selection.web = table.web;
339
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