@mgiles/perk 2.3.0 → 3.1.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 (145) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +135 -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/commitCompact.ts +98 -10
  9. package/extension/doors/draftReviewWaveTools.ts +412 -0
  10. package/extension/doors/dreamWaveTools.ts +475 -0
  11. package/extension/doors/harvestWaveTools.ts +279 -0
  12. package/extension/doors/land.ts +1 -0
  13. package/extension/doors/learn.ts +24 -6
  14. package/extension/doors/learnFactory.ts +33 -7
  15. package/extension/doors/lifecycleGates.ts +1 -1
  16. package/extension/doors/objectiveReviewBrowser.ts +537 -0
  17. package/extension/doors/objectiveStack.ts +1143 -0
  18. package/extension/doors/planReviewBrowser.ts +475 -0
  19. package/extension/doors/plannotatorHandoff.ts +120 -49
  20. package/extension/doors/prReview.ts +180 -63
  21. package/extension/doors/prReviewBrowser.ts +31 -23
  22. package/extension/doors/prReviewDynamic.ts +57 -30
  23. package/extension/doors/prReviewTerminal.ts +6 -11
  24. package/extension/doors/reviewWaveTools.ts +397 -0
  25. package/extension/doors/selfcheck.ts +1 -1
  26. package/extension/doors/submit.ts +82 -7
  27. package/extension/factories/gistAuthor.ts +4 -4
  28. package/extension/factories/gistDraft.ts +1 -1
  29. package/extension/factories/gistSave.ts +2 -2
  30. package/extension/factories/objective.ts +7 -7
  31. package/extension/factories/objectiveAuthor.ts +5 -4
  32. package/extension/factories/objectiveDraft.ts +153 -30
  33. package/extension/factories/objectiveDreamReport.ts +347 -0
  34. package/extension/factories/objectivePlan.ts +163 -9
  35. package/extension/factories/objectiveSave.ts +90 -2
  36. package/extension/factories/planMode.ts +5 -3
  37. package/extension/factories/planReview.ts +304 -46
  38. package/extension/factories/planTitle.ts +27 -9
  39. package/extension/hunkFeedback/inbox.ts +389 -0
  40. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  41. package/extension/hunkFeedback/receiver.ts +296 -0
  42. package/extension/hunkFeedback/store.ts +533 -0
  43. package/extension/index.ts +169 -57
  44. package/extension/substrate/agentScratch.ts +171 -0
  45. package/extension/substrate/bindingDelivery.ts +9 -11
  46. package/extension/substrate/cache.ts +166 -23
  47. package/extension/substrate/coldDoor.ts +2 -3
  48. package/extension/substrate/command.ts +9 -6
  49. package/extension/substrate/config.ts +41 -12
  50. package/extension/substrate/git.ts +95 -2
  51. package/extension/substrate/providers.ts +4 -33
  52. package/extension/substrate/resources.ts +11 -0
  53. package/extension/substrate/result.ts +16 -7
  54. package/extension/substrate/sessionData.ts +8 -6
  55. package/extension/substrate/sessionPointers.ts +4 -5
  56. package/extension/substrate/structuredOutput.ts +78 -19
  57. package/extension/substrate/toolGating.ts +179 -23
  58. package/extension/substrate/workflowState.ts +50 -2
  59. package/extension/surfaces/footerProvider.ts +6 -6
  60. package/extension/surfaces/report.ts +38 -12
  61. package/extension/surfaces/surfaces.ts +213 -275
  62. package/extension/vendor/btw/btw.ts +94 -17
  63. package/extension/waves/adversarialReviewWave.ts +181 -0
  64. package/extension/waves/auditWave.ts +312 -0
  65. package/extension/waves/draftReviewWave.ts +205 -0
  66. package/extension/waves/dreamReducerWave.ts +700 -0
  67. package/extension/waves/dreamReport.ts +1494 -0
  68. package/extension/waves/dreamWave.ts +927 -0
  69. package/extension/waves/harvestWave.ts +399 -0
  70. package/extension/waves/memoryAdapter.ts +14 -1
  71. package/extension/waves/objectiveExplorerWave.ts +120 -0
  72. package/extension/waves/ponytail.ts +104 -0
  73. package/extension/waves/prReviewDynamicWave.ts +383 -72
  74. package/extension/waves/prReviewWave.ts +175 -25
  75. package/extension/waves/reportWave.ts +503 -107
  76. package/extension/waves/reviewClassifierWave.ts +109 -0
  77. package/extension/waves/rpcAdapter.ts +65 -2
  78. package/extension/worker/readOnlySession.ts +4 -5
  79. package/extension/worker/worker.ts +71 -93
  80. package/extension/workerMain.ts +4 -6
  81. package/package.json +18 -5
  82. package/prompts/README.md +19 -0
  83. package/prompts/_fixtures/live.yaml +115 -65
  84. package/prompts/commit-and-compact-continuation.md +13 -0
  85. package/prompts/common/resume-advisory.md +1 -0
  86. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  87. package/prompts/contexts/adapters/plannotator-objective.md +8 -3
  88. package/prompts/contexts/adapters/plannotator-plan.md +8 -3
  89. package/prompts/contexts/gist-authoring.md +6 -20
  90. package/prompts/contexts/objective-authoring.md +6 -18
  91. package/prompts/stages/address/action.md +3 -14
  92. package/prompts/stages/address/preview.md +2 -13
  93. package/prompts/stages/audit.md +18 -0
  94. package/prompts/stages/conflict-resolution.md +1 -1
  95. package/prompts/stages/gist-author/seed.md +4 -4
  96. package/prompts/stages/gist-save.md +1 -1
  97. package/prompts/stages/implement.md +3 -1
  98. package/prompts/stages/learn-code.md +1 -1
  99. package/prompts/stages/learn-docs.md +2 -2
  100. package/prompts/stages/learn-dream.md +10 -0
  101. package/prompts/stages/learn-harvest.md +15 -0
  102. package/prompts/stages/learn-orchestrate.md +1 -1
  103. package/prompts/stages/objective-author/adopt.md +5 -3
  104. package/prompts/stages/objective-author/file.md +5 -4
  105. package/prompts/stages/objective-author/seed.md +5 -4
  106. package/prompts/stages/objective-land.md +7 -0
  107. package/prompts/stages/objective-plan/guidance.md +2 -13
  108. package/prompts/stages/objective-plan/seed.md +5 -12
  109. package/prompts/stages/objective-recover.md +8 -0
  110. package/prompts/stages/objective-replan.md +4 -2
  111. package/prompts/stages/objective-review-browser.md +9 -0
  112. package/prompts/stages/objective-sync.md +6 -0
  113. package/prompts/stages/plan-from/adopt.md +2 -2
  114. package/prompts/stages/plan-from/file.md +2 -2
  115. package/prompts/stages/plan-review-browser.md +9 -0
  116. package/prompts/stages/pr-review-browser/active.md +6 -14
  117. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  118. package/prompts/stages/pr-review-dynamic.md +6 -6
  119. package/prompts/stages/pr-review-terminal/active.md +7 -15
  120. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  121. package/prompts/stages/pr-review-terminal/local.md +1 -1
  122. package/prompts/stages/pr-review.md +5 -5
  123. package/prompts/stages/replan.md +3 -3
  124. package/shared/README.md +2 -2
  125. package/shared/bindings.yaml +15 -0
  126. package/shared/contracts-history.md +11 -0
  127. package/shared/contracts.md +5373 -281
  128. package/shared/providers.yaml +27 -60
  129. package/shared/registry.yaml +42 -18
  130. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  131. package/shared/schemas/outputs/objective-doctor.schema.json +487 -0
  132. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  133. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  134. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  135. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  136. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  137. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  138. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  139. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  140. package/extension/checkpoints/checkpoints.ts +0 -550
  141. package/extension/checkpoints/planSteps.ts +0 -108
  142. package/extension/doors/askUser.ts +0 -198
  143. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  144. package/prompts/common/output-schemas/review-classifier.md +0 -47
  145. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -29,13 +29,26 @@
29
29
  // is always explicit) / backend-unavailable all soft-skip so plan authoring never wedges — those
30
30
  // arms keep the present-the-plan + human-`/plan-save` discipline (the manual failsafe).
31
31
  //
32
+ // THE LAUNCH CHOOSER (plannotator arms only, §8.23): on an eligible round (injected `WaveLaunch`
33
+ // deps present, plannotator loaded, the review source a validated draft artifact) the tool first
34
+ // asks the human — "Browser review + reviewer wave" vs "Browser review only" — before anything
35
+ // launches. The wave choice collects an optional trimmed custom angle, delegates to the door's
36
+ // guidance-returning open core (openPlanReviewSurface / openObjectiveReviewSurface, injected —
37
+ // never imported: doors value-import this module), and returns the NON-terminating
38
+ // `wave_launched` result carrying the door guidance verbatim; the browser decision then routes
39
+ // through the door's background decision task. Esc ⇒ the plain flavor (never a cancel); abort
40
+ // outranks every dialog result AND the awaited opener (an interrupted turn never reports a
41
+ // launched wave); a null opener return (synchronous port-pick failure, loudly reported in the
42
+ // core) falls open to the plain blocking review in the same call.
43
+ //
32
44
  // `ctx.ui.editor` takes NO AbortSignal (unlike select/confirm/input) — `signal?.aborted` is
33
45
  // checked between dialogs; an in-flight editor dialog survives a turn abort and its result is
34
46
  // discarded (the aborted arm wins). Enter submits in the editor dialog (Shift+Enter = newline),
35
47
  // so the dialog titles carry the key hints — pi renders no other affordance.
36
48
  //
37
- // THE OBJECTIVE ARM: an objective-author session (read-only, stage
38
- // `objective-author`) routes through `executeObjectiveReview` instead of the plan path the
49
+ // THE OBJECTIVE ARM: an objective-authoring session (read-only, stage `objective-author` or
50
+ // `objective-save` the two stages whose working draft IS the objective draft; neither carries
51
+ // `plan_draft`) routes through `executeObjectiveReview` instead of the plan path — the
39
52
  // reviewed bytes are the RENDERED objective draft (`readObjectiveDraft` + `renderObjectiveDraft`,
40
53
  // objectiveDraft.ts — never raw JSON, never the `plan` param, never the transcript; no draft
41
54
  // soft-skips with `reason: "no_objective_draft"`). Dispatch mirrors the plan path (plannotator
@@ -49,7 +62,11 @@
49
62
  // `executeGistReview` the same way — the reviewed bytes are the RENDERED gist draft
50
63
  // (`readGistDraft` + `renderGistDraft`, gistDraft.ts), first-party VIEW-ONLY, implement-here
51
64
  // never offered, APPROVED → the `gistApprovalSave` seam (gistSave.ts), no draft soft-skips with
52
- // `reason: "no_gist_draft"`.
65
+ // `reason: "no_gist_draft"`. Plannotator approve-with-Direct-Edits mirrors the objective arm:
66
+ // the save seam re-reads the STRUCTURED gist artifact, so rendered edits cannot be folded back
67
+ // mechanically — the arm SKIPS the save and returns one model-mediated revise round (fold each
68
+ // hunk into the matching `gist_draft` field — title heading → `title`, `Scope:` line → `scope`,
69
+ // prose → `prose` — then re-review to confirm).
53
70
  //
54
71
  // INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
55
72
  // restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
@@ -64,6 +81,7 @@ import {
64
81
  isPlannotatorPlanSelected,
65
82
  } from "../adapters/planAdapterPlannotator.ts";
66
83
  import type { Result } from "../substrate/result.ts";
84
+ import { readSessionArtifact } from "../substrate/sessionData.ts";
67
85
  import type { ToolGating } from "../substrate/toolGating.ts";
68
86
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
69
87
  import { applyUnifiedDiff } from "../substrate/unifiedDiff.ts";
@@ -73,9 +91,17 @@ import { readGistDraft, renderGistDraft } from "./gistDraft.ts";
73
91
  import { type GistApprovalSaveOutcome, gistApprovalSave } from "./gistSave.ts";
74
92
  import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
75
93
  import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
76
- import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
94
+ import {
95
+ OBJECTIVE_DRAFT_ARTIFACT,
96
+ readObjectiveDraft,
97
+ renderObjectiveDraft,
98
+ } from "./objectiveDraft.ts";
77
99
  import { readNodeClaim } from "./objectivePlan.ts";
78
- import { type ObjectiveApprovalSaveOutcome, objectiveApprovalSave } from "./objectiveSave.ts";
100
+ import {
101
+ OBJECTIVE_SAVE_STAGE,
102
+ type ObjectiveApprovalSaveOutcome,
103
+ objectiveApprovalSave,
104
+ } from "./objectiveSave.ts";
79
105
  import { writePlanDraft } from "./planDraft.ts";
80
106
  import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./planSave.ts";
81
107
 
@@ -410,9 +436,148 @@ export function implementHereResult(
410
436
  };
411
437
  }
412
438
 
439
+ // ------------------------------------------------------ the plannotator Direct-Edits apply
440
+
441
+ /**
442
+ * The shared plannotator APPROVE mechanical-apply path (contracts.md §8.23): inspect an
443
+ * APPROVED outcome's feedback for a `# Direct Edits` section and mechanically apply the
444
+ * reviewer's diff to the exact bytes reviewed (`basePlan`), writing the patched bytes back to
445
+ * the draft (reviewed bytes == artifact bytes == saved bytes). Consumed by `executePlanReview`'s
446
+ * plannotator arm AND the `/plan-review-browser` door — one apply path, byte-identical
447
+ * semantics:
448
+ *
449
+ * - only an `approved` outcome WITH feedback is inspected (anything else passes through
450
+ * verbatim);
451
+ * - a clean extract + apply + write-back swaps `reviewedPlan` to the patched bytes, sets
452
+ * `edited: true`, and strips the applied section from the returned outcome's feedback (only
453
+ * the annotation remainder survives — the applied diff must never render as "apply these
454
+ * exact changes" guidance);
455
+ * - a seen-but-unhonorable heading (or a failed apply / write-back) sets
456
+ * `directEditsFailed: true` with the plan left verbatim (the caller renders the loud warning;
457
+ * the diff stays in the surfaced feedback for a manual follow-up).
458
+ */
459
+ export function applyPlannotatorDirectEdits(
460
+ pi: ExtensionAPI,
461
+ ctx: ExtensionContext,
462
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
463
+ basePlan: string,
464
+ ): {
465
+ outcome: Extract<ReviewOutcome, { status: "completed" }>;
466
+ reviewedPlan: string;
467
+ edited: boolean;
468
+ directEditsFailed: boolean;
469
+ } {
470
+ if (!outcome.approved || outcome.feedback === undefined) {
471
+ return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: false };
472
+ }
473
+ const section = extractDirectEdits(outcome.feedback);
474
+ if (section !== null) {
475
+ const patched = applyUnifiedDiff(basePlan, section.diff);
476
+ if (patched !== null && writePlanDraft(pi, ctx, patched).details.ok) {
477
+ return {
478
+ outcome: { ...outcome, feedback: section.remainder },
479
+ reviewedPlan: patched,
480
+ edited: true,
481
+ directEditsFailed: false,
482
+ };
483
+ }
484
+ return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: true };
485
+ }
486
+ if (hasDirectEditsHeading(outcome.feedback)) {
487
+ return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: true };
488
+ }
489
+ return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: false };
490
+ }
491
+
492
+ // ------------------------------------------------------ the launch chooser (the wave arm)
493
+
494
+ /**
495
+ * The injected wave-launch deps (composed in index.ts from the door exports — structural on
496
+ * purpose: this module imports NOTHING from door modules, avoiding the value-import cycle;
497
+ * `planReviewBrowser.ts` already value-imports this module). `present` is the plannotator
498
+ * presence probe (`plannotatorPresent(pi)` at the call site); `plan`/`objective` are the
499
+ * guidance-returning door open cores (`openPlanReviewSurface` / `openObjectiveReviewSurface`) —
500
+ * one open path, byte-identical door semantics (contracts.md §8.23). `null` from an opener is
501
+ * the synchronous port-pick failure (already loudly reported inside the core) — the caller
502
+ * falls open to the plain blocking review.
503
+ */
504
+ export interface WaveLaunch {
505
+ present(): boolean;
506
+ plan(ctx: ExtensionContext, opts: { draft: string; custom?: string }): Promise<string | null>;
507
+ objective(
508
+ ctx: ExtensionContext,
509
+ opts: { rendered: string; artifactRaw: string; custom?: string },
510
+ ): Promise<string | null>;
511
+ }
512
+
513
+ /** The minimal structural `ctx.ui` subset the launch chooser needs (both dialogs signal-aware). */
514
+ export interface ReviewLaunchUI {
515
+ select(
516
+ title: string,
517
+ options: string[],
518
+ opts?: { signal?: AbortSignal },
519
+ ): Promise<string | undefined>;
520
+ input(
521
+ title: string,
522
+ placeholder?: string,
523
+ opts?: { signal?: AbortSignal },
524
+ ): Promise<string | undefined>;
525
+ }
526
+
527
+ /** The launch chooser's outcome: the review flavor (never a cancel), or the aborted turn. */
528
+ export type ReviewLaunchChoice =
529
+ | { launch: "plain" }
530
+ | { launch: "wave"; custom?: string }
531
+ | { launch: "aborted" };
532
+
533
+ const LAUNCH_WAVE = "Browser review + reviewer wave";
534
+ const LAUNCH_PLAIN = "Browser review only";
535
+ const CUSTOM_ANGLE_TITLE = "Custom review angle (optional — Enter to skip)";
536
+
537
+ /**
538
+ * The launch chooser (pure over the injected ui slice — offline-testable): every eligible
539
+ * plannotator round asks the human whether the browser review launches WITH the streamed
540
+ * reviewer wave; the wave choice then asks for an optional custom review angle. Esc/dismiss
541
+ * anywhere selects a FLAVOR, never cancels the review (Esc at the chooser ⇒ plain; Esc/blank at
542
+ * the angle input ⇒ wave with no custom lane — the input is `.trim()`'d before blank detection,
543
+ * the door handlers' exact discipline). ABORT OUTRANKS EVERYTHING: `signal?.aborted` is checked
544
+ * at entry and re-checked immediately after each awaited dialog, BEFORE interpreting its result
545
+ * (the `runFirstPartyReview` discipline) — a conforming caller can never launch a browser or
546
+ * enter a blocking review after the turn was interrupted. No other return paths exist.
547
+ */
548
+ export async function chooseReviewLaunch(
549
+ ui: ReviewLaunchUI,
550
+ subjectNoun: string,
551
+ signal?: AbortSignal,
552
+ ): Promise<ReviewLaunchChoice> {
553
+ if (signal?.aborted) return { launch: "aborted" };
554
+ const picked = await ui.select(`${subjectNoun} review launch`, [LAUNCH_WAVE, LAUNCH_PLAIN], {
555
+ signal,
556
+ });
557
+ if (signal?.aborted) return { launch: "aborted" }; // abort outranks Esc AND any selection
558
+ if (picked !== LAUNCH_WAVE) return { launch: "plain" }; // Esc/dismiss = the plain flavor
559
+ const raw = await ui.input(CUSTOM_ANGLE_TITLE, undefined, { signal });
560
+ if (signal?.aborted) return { launch: "aborted" }; // abort outranks the input result too
561
+ const custom = (raw ?? "").trim();
562
+ return custom.length > 0 ? { launch: "wave", custom } : { launch: "wave" };
563
+ }
564
+
565
+ /**
566
+ * The NON-terminating wave-launched result: the door core's guidance rides back verbatim as the
567
+ * tool text (same templates, same binding suffix — the model behaves identically whether the
568
+ * human summoned the door or chose the wave inside `plan_review`), and the human's browser
569
+ * decision routes through the door's background decision task — never through this call.
570
+ */
571
+ function waveLaunchedResult(subject: ReviewSubject, guidance: string): ToolResult {
572
+ return {
573
+ content: [{ type: "text", text: guidance }],
574
+ details: { ok: true, status: "wave_launched", ...subject.detailsExtra },
575
+ };
576
+ }
577
+
413
578
  // ----------------------------------------------------------------- the first-party review core
414
579
 
415
- /** The minimal structural `ctx.ui` subset the first-party review needs (the askUser.ts recipe). */
580
+ /** The minimal structural `ctx.ui` subset the first-party review needs (the ciExecutor.ts pure-core + injected-fakes recipe). */
416
581
  export interface PlanReviewUI {
417
582
  editor(title: string, prefill?: string): Promise<string | undefined>;
418
583
  select(
@@ -444,7 +609,7 @@ const REVIEW_EDITOR_TITLE =
444
609
  const DENY_FEEDBACK_TITLE = "Deny feedback (optional) — Enter to send";
445
610
 
446
611
  /**
447
- * The first-party in-TUI review core, pure over injected seams (the askUser.ts recipe) — fully
612
+ * The first-party in-TUI review core, pure over injected seams (the ciExecutor.ts pure-core + injected-fakes recipe) — fully
448
613
  * offline-testable. Flow: (1) display the plan in the editor dialog (Esc = dismissed; the human
449
614
  * may edit, incl. via Ctrl+G/$EDITOR); (2) a non-blank edit differing from the displayed plan is
450
615
  * written back to the draft BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes;
@@ -588,10 +753,19 @@ export async function executeObjectiveReview(
588
753
  gating: ToolGating,
589
754
  bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
590
755
  signal?: AbortSignal,
756
+ wave?: WaveLaunch,
591
757
  ): Promise<ToolResult> {
592
758
  // 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
593
759
  if (!ctx.hasUI) return skipResult();
594
- // 2. The draft artifact is the sole review source no draft soft skip with the
760
+ // 2. The wave arm's stale-guard baseline is captured BEFORE the validated read below (the
761
+ // objective door's fail-closed ordering): the rendered bytes always derive from a read at
762
+ // or after this baseline, so a concurrent objective_draft write between the two reads makes
763
+ // the browsed render NEWER than the baseline and routeObjectiveReviewDecision's existing
764
+ // guard refuses the approval — the reverse order would fail open (approve unreviewed
765
+ // bytes). Raw artifact bytes on purpose: the save-authoritative surface catches
766
+ // render-invisible changes.
767
+ const baseline = readSessionArtifact(ctx, OBJECTIVE_DRAFT_ARTIFACT);
768
+ // 3. The draft artifact is the sole review source — no draft → soft skip with the
595
769
  // objective_draft redirect.
596
770
  const draft = readObjectiveDraft(ctx);
597
771
  if (draft === null) {
@@ -613,13 +787,35 @@ export async function executeObjectiveReview(
613
787
  },
614
788
  };
615
789
  }
616
- // 3. The reviewed bytes are the RENDERED markdown (prose + roadmap table) — never raw JSON.
790
+ // 4. The reviewed bytes are the RENDERED markdown (prose + roadmap table) — never raw JSON.
617
791
  const rendered = renderObjectiveDraft(draft);
618
- // 4. Backend dispatch (mirrors the plan path): plannotator-selected → the bridge; ANY other
792
+ // 5. Backend dispatch (mirrors the plan path): plannotator-selected → the bridge; ANY other
619
793
  // selection → the first-party editor, view-only.
620
794
  const sig = signal ?? ctx.signal;
621
795
  let outcome: ReviewOutcome;
622
796
  if (isPlannotatorPlanSelected(ctx.cwd)) {
797
+ // The launch chooser (contracts.md §8.23): every eligible round the human picks with/without
798
+ // the streamed reviewer wave BEFORE anything launches. Eligibility is drafts-only — the wave
799
+ // door stale-guards the raw artifact baseline, so a null baseline keeps the plain path
800
+ // (silently: there is no forced mode to warn about).
801
+ if (wave?.present() && baseline !== null) {
802
+ const choice = await chooseReviewLaunch(ctx.ui, "Objective", sig);
803
+ if (choice.launch === "aborted") return objectiveReviewOutcomeResult({ status: "aborted" });
804
+ if (choice.launch === "wave") {
805
+ const guidance = await wave.objective(ctx, {
806
+ rendered,
807
+ artifactRaw: baseline.content,
808
+ ...(choice.custom !== undefined ? { custom: choice.custom } : {}),
809
+ });
810
+ // Abort outranks the opener result too: a turn interrupted during the awaited open must
811
+ // never report a successful launch (the door's own bridge abort handling settles the
812
+ // background tasks and clears the primed surfaces).
813
+ if (sig?.aborted) return objectiveReviewOutcomeResult({ status: "aborted" });
814
+ if (guidance !== null) return waveLaunchedResult(OBJECTIVE_SUBJECT, guidance);
815
+ // null = the synchronous port-pick failure (already loudly reported inside the core) —
816
+ // fall open to the plain blocking review in the same call: the review never wedges.
817
+ }
818
+ }
623
819
  outcome = await bridge.review(rendered, sig);
624
820
  // APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
625
821
  // approved-save routing (the approved-first discipline): the save seam re-reads the
@@ -669,7 +865,7 @@ export async function executeObjectiveReview(
669
865
  });
670
866
  outcome = fp.outcome;
671
867
  }
672
- // 5. An APPROVED decision (either backend) wires into the objectiveApprovalSave seam (the
868
+ // 6. An APPROVED decision (either backend) wires into the objectiveApprovalSave seam (the
673
869
  // STRUCTURED artifact is re-read at save time — never the rendered bytes; auto-save → D1a
674
870
  // gate exit → terminating result); everything else maps via objectiveReviewOutcomeResult.
675
871
  // Approved-first routing: objectiveReviewOutcomeResult's completed case renders DENIED.
@@ -721,7 +917,12 @@ export function approvedGistSaveResult(
721
917
  * run VIEW-ONLY (edits are never written back; deny+feedback is the change channel); the
722
918
  * implement-here verdict is never offered (a gist is not implementable — it has no strategy).
723
919
  * An APPROVED outcome wires into the `gistApprovalSave` seam (re-read the artifact → `saveGist`
724
- * → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`.
920
+ * → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`. ONE
921
+ * carve-out (plannotator only, mirroring the objective arm): an approval whose feedback opens a
922
+ * Direct Edits section SKIPS the save — rendered edits cannot be folded back into the
923
+ * structured draft mechanically — and returns a NON-terminating revise round with the gate
924
+ * untouched (fold each hunk into the matching `gist_draft` field, re-review to confirm); perk
925
+ * never saves a gist the reviewer explicitly edited away from.
725
926
  */
726
927
  export async function executeGistReview(
727
928
  pi: ExtensionAPI,
@@ -762,6 +963,42 @@ export async function executeGistReview(
762
963
  let outcome: ReviewOutcome;
763
964
  if (isPlannotatorPlanSelected(ctx.cwd)) {
764
965
  outcome = await bridge.review(rendered, sig);
966
+ // APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
967
+ // approved-save routing (the approved-first discipline; mirrors the objective arm): the
968
+ // save seam re-reads the STRUCTURED artifact, so rendered edits cannot be folded back
969
+ // without model judgment. Skip the save, keep the gate read-only, and route ONE revise
970
+ // round: the model folds each hunk into the matching `gist_draft` field, then re-reviews
971
+ // to confirm. The heading check suffices (extraction success is irrelevant here — the diff
972
+ // goes to the model verbatim either way).
973
+ if (
974
+ outcome.status === "completed" &&
975
+ outcome.approved &&
976
+ outcome.feedback !== undefined &&
977
+ hasDirectEditsHeading(outcome.feedback)
978
+ ) {
979
+ return {
980
+ content: [
981
+ {
982
+ type: "text",
983
+ text:
984
+ "gist APPROVED with direct browser edits — these cannot be auto-applied to the " +
985
+ "structured draft, so nothing was saved. Fold each Direct Edits hunk below into " +
986
+ "the matching gist_draft field (a `# <title>` heading hunk → title, a `Scope:` " +
987
+ "line hunk → scope, prose hunks → prose), then call plan_review again to " +
988
+ `confirm.\n\nReviewer feedback:\n${outcome.feedback}`,
989
+ },
990
+ ],
991
+ details: {
992
+ ok: true,
993
+ status: "revise",
994
+ reason: "direct_edits",
995
+ approved: true,
996
+ feedback: outcome.feedback,
997
+ reviewId: outcome.reviewId,
998
+ subject: "gist",
999
+ },
1000
+ };
1001
+ }
765
1002
  } else {
766
1003
  const fp = await runFirstPartyReview({
767
1004
  ui: ctx.ui,
@@ -790,8 +1027,9 @@ export async function executeGistReview(
790
1027
  /**
791
1028
  * The `plan_review` execute core, extracted pure-over-its-seams (the bridge, the gating, the
792
1029
  * ctx) so the resolution + dispatch + approved-save paths are unit-testable offline. Arm order:
793
- * param decode → the objective-author arm (`executeObjectiveReview` — the rendered objective
794
- * draft is the review subject there) → headless skip → file-first resolution → backend
1030
+ * param decode → the objective arm (`executeObjectiveReview` — the rendered objective draft is
1031
+ * the review subject in BOTH objective-authoring stages) → headless skip → file-first
1032
+ * resolution → backend
795
1033
  * dispatch (plannotator-selected → the event-bus bridge; ANY other selection → the first-party
796
1034
  * in-TUI editor review) → approved → `approvalSave`.
797
1035
  */
@@ -802,6 +1040,7 @@ export async function executePlanReview(
802
1040
  bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
803
1041
  params: unknown,
804
1042
  signal?: AbortSignal,
1043
+ wave?: WaveLaunch,
805
1044
  ): Promise<ToolResult> {
806
1045
  // Tool-boundary decode, in this tool's native fail-open vocabulary: a MISTYPED
807
1046
  // `plan` (or non-object params) skip-shapes (`reason: "bad_input"`) without reviewing; an
@@ -825,12 +1064,15 @@ export async function executePlanReview(
825
1064
  },
826
1065
  };
827
1066
  }
828
- // 1. Objective-author session the objective review arm: the rendered
829
- // objective draft is the sole review source; a well-typed `plan` param is ignored here.
830
- // A gist-author session likewise routes to the gist arm (the rendered gist draft).
1067
+ // 1. An objective-authoring session (objective-author OR objective-save both stages'
1068
+ // working draft is the objective draft, and neither carries `plan_draft`) the objective
1069
+ // review arm: the rendered objective draft is the sole review source; a well-typed `plan`
1070
+ // param is ignored here — the plan-arm fallthrough could otherwise review/save an
1071
+ // unrelated plan param from an objective session. A gist-author session likewise routes to
1072
+ // the gist arm (the rendered gist draft).
831
1073
  const launchedStage = rebuildWorkflowState(branchOf(ctx)).stage;
832
- if (launchedStage === OBJECTIVE_AUTHOR_STAGE) {
833
- return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
1074
+ if (launchedStage === OBJECTIVE_AUTHOR_STAGE || launchedStage === OBJECTIVE_SAVE_STAGE) {
1075
+ return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal, wave);
834
1076
  }
835
1077
  if (launchedStage === GIST_AUTHOR_STAGE) {
836
1078
  return executeGistReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
@@ -867,31 +1109,41 @@ export async function executePlanReview(
867
1109
  let edited = false;
868
1110
  let directEditsFailed = false;
869
1111
  if (isPlannotatorPlanSelected(ctx.cwd)) {
1112
+ // The launch chooser (contracts.md §8.23): every eligible round the human picks with/without
1113
+ // the streamed reviewer wave BEFORE anything launches. Eligibility is drafts-only — the wave
1114
+ // door reviews and stale-guards the validated artifact, so a param-tier source keeps the
1115
+ // plain path (silently: there is no forced mode to warn about; the `wave === undefined` arm
1116
+ // is defensive/test-only and behaves identically).
1117
+ if (wave?.present() && src.source === "plan-draft") {
1118
+ const choice = await chooseReviewLaunch(ctx.ui, "Plan", sig);
1119
+ if (choice.launch === "aborted") return reviewOutcomeResult({ status: "aborted" });
1120
+ if (choice.launch === "wave") {
1121
+ const guidance = await wave.plan(ctx, {
1122
+ draft: src.plan,
1123
+ ...(choice.custom !== undefined ? { custom: choice.custom } : {}),
1124
+ });
1125
+ // Abort outranks the opener result too: a turn interrupted during the awaited open must
1126
+ // never report a successful launch (the door's own bridge abort handling settles the
1127
+ // background tasks and clears the primed surfaces).
1128
+ if (sig?.aborted) return reviewOutcomeResult({ status: "aborted" });
1129
+ if (guidance !== null) return waveLaunchedResult(PLAN_SUBJECT, guidance);
1130
+ // null = the synchronous port-pick failure (already loudly reported inside the core) —
1131
+ // fall open to the plain blocking review in the same call: the review never wedges.
1132
+ }
1133
+ }
870
1134
  outcome = await bridge.review(src.plan, sig);
871
1135
  // APPROVE + Direct Edits (browser plan edits, contracts.md §8.23): mechanically apply the
872
- // reviewer's diff to the exact bytes reviewed, write it back to the draft (reviewed bytes ==
873
- // artifact bytes == saved bytes the first-party pre-verdict write-back, replayed here
874
- // post-verdict because the bridge only reports the diff), and save the EDITED bytes. Every
875
- // rung fails open to the verbatim path: no section untouched; a heading that cannot be
876
- // parsed / applied / written back → verbatim save + a loud warning (never save bytes the
877
- // artifact doesn't carry). DENY stays model-mediated — the feedback (diff included) passes
878
- // through for the plan_draft rewrite.
879
- if (outcome.status === "completed" && outcome.approved && outcome.feedback !== undefined) {
880
- const section = extractDirectEdits(outcome.feedback);
881
- if (section !== null) {
882
- const patched = applyUnifiedDiff(src.plan, section.diff);
883
- if (patched !== null && writePlanDraft(pi, ctx, patched).details.ok) {
884
- reviewedPlan = patched;
885
- edited = true;
886
- // The applied diff must NOT survive into the result as "apply these exact changes"
887
- // guidance — only the annotation remainder (when any) stays reviewer feedback.
888
- outcome = { ...outcome, feedback: section.remainder };
889
- } else {
890
- directEditsFailed = true;
891
- }
892
- } else if (hasDirectEditsHeading(outcome.feedback)) {
893
- directEditsFailed = true;
894
- }
1136
+ // reviewer's diff via the shared helper (the first-party pre-verdict write-back, replayed
1137
+ // here post-verdict because the bridge only reports the diff), and save the EDITED bytes.
1138
+ // Every rung fails open to the verbatim path (never save bytes the artifact doesn't carry).
1139
+ // DENY stays model-mediated the feedback (diff included) passes through for the
1140
+ // plan_draft rewrite.
1141
+ if (outcome.status === "completed") {
1142
+ const applied = applyPlannotatorDirectEdits(pi, ctx, outcome, src.plan);
1143
+ outcome = applied.outcome;
1144
+ reviewedPlan = applied.reviewedPlan;
1145
+ edited = applied.edited;
1146
+ directEditsFailed = applied.directEditsFailed;
895
1147
  }
896
1148
  } else {
897
1149
  // The 4th verdict (implement-here, the no-save exit) is offered UNLESS this is an
@@ -937,8 +1189,10 @@ export async function executePlanReview(
937
1189
  * Register `plan_review` — perk's universal review door. In READ_ONLY_TOOLS so it is callable
938
1190
  * INSIDE plan mode (the whole point — review happens before the gate ever comes off). Fail-open
939
1191
  * everywhere: headless / dismissed / backend-unavailable all soft-skip so authoring never wedges.
1192
+ * `wave` is the injected wave-launch deps (index.ts composes them from the door open cores);
1193
+ * absent ⇒ the chooser never appears and every path is byte-stable.
940
1194
  */
941
- export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
1195
+ export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating, wave?: WaveLaunch): void {
942
1196
  const bridge = createPlannotatorBridge(pi.events);
943
1197
 
944
1198
  pi.registerTool({
@@ -949,14 +1203,18 @@ export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
949
1203
  "selected, otherwise perk's in-TUI editor review — and wait for the human decision. " +
950
1204
  "Reviews the validated plan-draft artifact (keep it current with plan_draft); on approval " +
951
1205
  "the plan is auto-saved and the turn terminates. On deny, revise per the returned " +
952
- "feedback, rewrite the draft with plan_draft, and call again. No-op skip when the session " +
953
- "is headless or the review is dismissed.",
1206
+ "feedback, rewrite the draft with plan_draft, and call again. On the Plannotator surface " +
1207
+ "the human may first opt into a streamed reviewer wave — the call then returns immediately " +
1208
+ 'with wave guidance (status "wave_launched") to follow in the same turn, and the browser ' +
1209
+ "decision routes back automatically. No-op skip when the session is headless or the " +
1210
+ "review is dismissed.",
954
1211
  promptSnippet: "Request a human review of the working plan draft",
955
1212
  promptGuidelines: [
956
1213
  "Keep the working draft current with plan_draft — the validated plan-draft artifact is what plan_review reviews AND auto-saves; the plan param is only a fallback when no draft exists.",
957
1214
  "Call plan_review only when the plan is decision-complete.",
958
1215
  "On a DENIED review, revise per the feedback, rewrite the draft with plan_draft, then call plan_review again.",
959
1216
  "On an APPROVED plan_review, the plan is auto-saved and the turn ends — never re-dump the plan as a final message and never tell the user to run /plan-save; relay the save outcome instead.",
1217
+ "On a wave_launched result (the human opted into the reviewer wave), follow the returned guidance in the same turn — launch the wave and relay its findings; the human's browser decision routes back automatically, so never re-call plan_review while that browser review is open.",
960
1218
  "If plan_review reports it was skipped or unavailable (headless, dismissed), fall back to presenting the complete plan; the human runs /plan-save (the manual failsafe).",
961
1219
  ],
962
1220
  executionMode: "sequential",
@@ -973,7 +1231,7 @@ export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
973
1231
  },
974
1232
  },
975
1233
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
976
- return executePlanReview(pi, ctx, gating, bridge, params, signal);
1234
+ return executePlanReview(pi, ctx, gating, bridge, params, signal, wave);
977
1235
  },
978
1236
  });
979
1237
  }
@@ -92,16 +92,35 @@ export async function generatePlanTitle(
92
92
  ): Promise<string | null> {
93
93
  if (!llmTitlesEnabled()) return null;
94
94
 
95
- let auth: Awaited<ReturnType<typeof resolveModelAuth>>;
96
- try {
97
- auth = await resolveModelAuth(ctx);
98
- } catch {
99
- return null;
95
+ const model = ctx.model;
96
+ if (!model) return null; // silent: no model configured (fail-safe).
97
+
98
+ // Primary path (pi ≥ 0.84): registry dispatch — pi owns final request assembly (resolved auth,
99
+ // nullable headers, credential-resolved baseUrl, provider env). Feature-detected: the method is
100
+ // absent on older hosts, where the widened resolveModelAuth fallback keeps the old shape.
101
+ const registry = ctx.modelRegistry;
102
+ const registryComplete = registry.complete;
103
+ let authOptions: Pick<
104
+ Parameters<typeof completeStructured>[0],
105
+ "dispatch" | "apiKey" | "headers" | "env"
106
+ >;
107
+ if (typeof registryComplete === "function") {
108
+ authOptions = {
109
+ dispatch: (m, context, options) => registryComplete.call(registry, m, context, options),
110
+ };
111
+ } else {
112
+ let auth: Awaited<ReturnType<typeof resolveModelAuth>>;
113
+ try {
114
+ auth = await resolveModelAuth(ctx);
115
+ } catch {
116
+ return null;
117
+ }
118
+ if (!auth.ok) return null; // silent: no auth configured (fail-safe).
119
+ authOptions = { apiKey: auth.apiKey, headers: auth.headers, env: auth.env };
100
120
  }
101
- if (!auth.ok) return null; // silent: no model / no auth configured (fail-safe).
102
121
 
103
122
  const outcome = await completeStructured({
104
- model: auth.model,
123
+ model,
105
124
  schema: PlanTitleSchema,
106
125
  toolName: "set_plan_title",
107
126
  toolDescription: "Provide the chosen title and category for the plan.",
@@ -110,8 +129,7 @@ export async function generatePlanTitle(
110
129
  "Read this implementation plan and choose a title and category. The title must be a concise, " +
111
130
  "imperative phrase, at most ~70 characters, with no trailing period and no markdown.",
112
131
  input: planMarkdown.slice(0, TITLE_INPUT_CHAR_CAP),
113
- apiKey: auth.apiKey,
114
- headers: auth.headers,
132
+ ...authOptions,
115
133
  signal,
116
134
  });
117
135
  if (!outcome.ok || !outcome.value) {