@mgiles/perk 3.0.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 (62) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +12 -9
  2. package/extension/doors/commitCompact.ts +98 -10
  3. package/extension/doors/draftReviewWaveTools.ts +43 -15
  4. package/extension/doors/dreamWaveTools.ts +475 -0
  5. package/extension/doors/objectiveReviewBrowser.ts +36 -13
  6. package/extension/doors/objectiveStack.ts +1 -1
  7. package/extension/doors/planReviewBrowser.ts +30 -8
  8. package/extension/doors/prReview.ts +156 -49
  9. package/extension/doors/prReviewDynamic.ts +33 -13
  10. package/extension/doors/reviewWaveTools.ts +37 -14
  11. package/extension/factories/objectiveDraft.ts +95 -27
  12. package/extension/factories/objectiveDreamReport.ts +347 -0
  13. package/extension/factories/objectiveSave.ts +74 -1
  14. package/extension/factories/planReview.ts +173 -10
  15. package/extension/index.ts +62 -15
  16. package/extension/substrate/agentScratch.ts +171 -0
  17. package/extension/substrate/bindingDelivery.ts +9 -11
  18. package/extension/substrate/cache.ts +92 -2
  19. package/extension/substrate/command.ts +9 -6
  20. package/extension/substrate/config.ts +6 -1
  21. package/extension/substrate/git.ts +85 -2
  22. package/extension/substrate/result.ts +3 -2
  23. package/extension/substrate/sessionData.ts +6 -4
  24. package/extension/substrate/sessionPointers.ts +3 -4
  25. package/extension/substrate/toolGating.ts +9 -0
  26. package/extension/substrate/workflowState.ts +44 -2
  27. package/extension/surfaces/report.ts +38 -12
  28. package/extension/surfaces/surfaces.ts +129 -7
  29. package/extension/vendor/btw/btw.ts +38 -6
  30. package/extension/waves/adversarialReviewWave.ts +19 -2
  31. package/extension/waves/draftReviewWave.ts +17 -1
  32. package/extension/waves/dreamReducerWave.ts +700 -0
  33. package/extension/waves/dreamReport.ts +1494 -0
  34. package/extension/waves/dreamWave.ts +927 -0
  35. package/extension/waves/harvestWave.ts +1 -1
  36. package/extension/waves/ponytail.ts +104 -0
  37. package/extension/waves/prReviewDynamicWave.ts +115 -34
  38. package/extension/waves/prReviewWave.ts +122 -17
  39. package/extension/waves/reportWave.ts +103 -7
  40. package/extension/worker/readOnlySession.ts +2 -3
  41. package/package.json +6 -3
  42. package/prompts/_fixtures/live.yaml +49 -0
  43. package/prompts/commit-and-compact-continuation.md +13 -0
  44. package/prompts/contexts/adapters/plannotator-objective.md +7 -1
  45. package/prompts/contexts/adapters/plannotator-plan.md +7 -1
  46. package/prompts/stages/conflict-resolution.md +1 -1
  47. package/prompts/stages/learn-dream.md +10 -0
  48. package/prompts/stages/objective-review-browser.md +1 -1
  49. package/prompts/stages/plan-review-browser.md +1 -1
  50. package/prompts/stages/pr-review-browser/active.md +1 -1
  51. package/prompts/stages/pr-review-browser/foreign.md +1 -1
  52. package/prompts/stages/pr-review-dynamic.md +5 -5
  53. package/prompts/stages/pr-review-terminal/active.md +1 -1
  54. package/prompts/stages/pr-review-terminal/foreign.md +1 -1
  55. package/prompts/stages/pr-review-terminal/local.md +1 -1
  56. package/prompts/stages/pr-review.md +5 -5
  57. package/shared/bindings.yaml +3 -0
  58. package/shared/contracts.md +2176 -500
  59. package/shared/registry.yaml +12 -12
  60. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  61. package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
  62. package/shared/schemas/outputs/pr-land.schema.json +3 -3
@@ -15,8 +15,10 @@
15
15
  // FAILSAFE invocation of the same seam, keeping the legacy drive-the-session behavior as the
16
16
  // no-draft fallback (objectives have no transcript scrape by design).
17
17
 
18
+ import { join } from "node:path";
18
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
20
  import { bindingSuffix } from "../substrate/bindingDelivery.ts";
21
+ import { atomicWriteFileSync, ensureRunScratch } from "../substrate/cache.ts";
20
22
  import {
21
23
  booleanField,
22
24
  type ColdJson,
@@ -34,14 +36,24 @@ import { OBJECTIVE_BUDGET_TYPE } from "./objective.ts";
34
36
  import {
35
37
  DELIVERY_PARAM_SCHEMA,
36
38
  type DeliveryChoice,
39
+ DREAM_REPORT_PARAM_SCHEMA,
37
40
  decodeObjectiveSaveParams,
38
41
  ROADMAP_PARAM_SCHEMA,
39
42
  readObjectiveDraft,
40
43
  } from "./objectiveDraft.ts";
44
+ import { resolveDreamReportGate } from "./objectiveDreamReport.ts";
41
45
 
42
46
  /** The `objective-save` registry stage id (the objectiveAuthor.ts constant's sibling). */
43
47
  export const OBJECTIVE_SAVE_STAGE = "objective-save";
44
48
 
49
+ /**
50
+ * The run-scoped dream-report transfer filename (contracts §8.64) — the extension→door handoff
51
+ * carrying the reviewed CANONICAL parts. The Python mirror is
52
+ * `perk.learn.dream_companion.DREAM_REPORT_TRANSFER_FILENAME` (parity-pinned by test), beside
53
+ * the existing `DREAM_MANIFEST_FILENAME` mirror pair.
54
+ */
55
+ export const DREAM_REPORT_TRANSFER_FILENAME = "dream-report-transfer.json";
56
+
45
57
  /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
46
58
  export interface ObjectiveSaveOk {
47
59
  /** `id` is the opaque string objective id (GitHub "7", Linear "ENG-7") — §8.21. */
@@ -70,6 +82,13 @@ function decodeObjectiveCreate(payload: ColdJson): ObjectiveCreatePayload | null
70
82
  * The single save implementation both surfaces call. Delegates the GitHub write to the Python cold
71
83
  * door, then links the live session (`active_objective` + budget marker). Returns a soft result
72
84
  * (never throws); failures set `details.ok = false` and append no linkage.
85
+ *
86
+ * `dream_report` is ONE carrier with two sources (§8.63): the direct tool path supplies
87
+ * `{input}` and the save stamps `generated_at`; the approval path passes the artifact block
88
+ * through with its stored stamp AND stored parts — the stored parts are byte-compared against
89
+ * the freshly re-rendered ones, so run-scratch drift or artifact tamper between draft-write
90
+ * and save refuses `bad_state` (nothing saved, the gate stays on). The parts do NOT cross to
91
+ * the Python plane yet — companion persistence is deferred (explicit in §8.63, not silent).
73
92
  */
74
93
  export async function saveObjective(
75
94
  pi: ExtensionAPI,
@@ -80,6 +99,7 @@ export async function saveObjective(
80
99
  roadmap?: unknown[];
81
100
  base?: string;
82
101
  delivery?: DeliveryChoice;
102
+ dream_report?: { input: unknown; generated_at?: string; parts?: string[] };
83
103
  },
84
104
  ): Promise<ObjectiveSaveResult> {
85
105
  const fail = failFor(ctx, "objective-save");
@@ -91,9 +111,52 @@ export async function saveObjective(
91
111
  return fail("roadmap must be a JSON array of nodes", "invalid_input");
92
112
  }
93
113
 
114
+ // The §8.63 fail-closed re-validation, before anything reaches the cold door. Presence is
115
+ // the `opts.dream_report === undefined` boundary — an `{input: undefined}` carrier is never
116
+ // constructed (the execute wraps only a present decoded value; the approval path passes the
117
+ // validated artifact block).
118
+ const generatedAt = opts.dream_report?.generated_at ?? new Date().toISOString();
119
+ const gate =
120
+ opts.dream_report === undefined
121
+ ? resolveDreamReportGate(ctx, undefined, generatedAt)
122
+ : resolveDreamReportGate(ctx, opts.dream_report.input, generatedAt);
123
+ if (gate.kind === "refuse") {
124
+ return fail(gate.detail, gate.errorType);
125
+ }
126
+ if (gate.kind === "block" && opts.dream_report?.parts !== undefined) {
127
+ // The approval path: the reviewed (stored) parts must byte-match the re-render against
128
+ // freshly recovered context — the same stored `generated_at` stamp keeps the comparison
129
+ // deterministic.
130
+ if (JSON.stringify(gate.block.parts) !== JSON.stringify(opts.dream_report.parts)) {
131
+ return fail(
132
+ "the reviewed report no longer matches the wave state — re-draft and re-review",
133
+ "bad_state",
134
+ );
135
+ }
136
+ }
137
+
94
138
  const branch = () => branchOf(ctx);
95
139
  const runId = rebuildWorkflowState(branch()).run_id ?? "";
96
140
 
141
+ if (gate.kind === "block") {
142
+ // The §8.64 transfer write (the dream arm only): the reviewed CANONICAL parts cross to the
143
+ // Python save door through the run-scoped scratch handoff — written atomically BEFORE the
144
+ // cold door is invoked. A write failure is the soft `scratch_failed` failure (the
145
+ // runColdDoor stdin-staging precedent): the cold door is NOT invoked, nothing activates,
146
+ // and the read-only gate stays on. Non-dream saves write nothing (byte-identical).
147
+ try {
148
+ const dir = ensureRunScratch(ctx.cwd, runId);
149
+ const content = `${JSON.stringify(
150
+ { schema_version: "1", run_id: runId, parts: gate.block.parts },
151
+ null,
152
+ 2,
153
+ )}\n`;
154
+ atomicWriteFileSync(join(dir, DREAM_REPORT_TRANSFER_FILENAME), content);
155
+ } catch (err) {
156
+ return fail(`could not stage the dream-report transfer: ${String(err)}`, "scratch_failed");
157
+ }
158
+ }
159
+
97
160
  const args = ["objective", "create", "--json"];
98
161
  if (opts.title) args.push("--title", opts.title);
99
162
  if (opts.base) args.push("--base", opts.base);
@@ -175,6 +238,9 @@ export async function objectiveApprovalSave(
175
238
  roadmap: draft.roadmap,
176
239
  base: draft.base,
177
240
  delivery: draft.delivery,
241
+ // The approval path passes the artifact block through whole — stored stamp + stored parts
242
+ // (the save re-validates and byte-compares, §8.63).
243
+ ...(draft.dream_report !== undefined ? { dream_report: draft.dream_report } : {}),
178
244
  });
179
245
  let gateExited = false;
180
246
  if (result.details.ok && wasReadOnly) {
@@ -231,6 +297,7 @@ export function registerObjectiveSave(pi: ExtensionAPI, gating: ToolGating): voi
231
297
  "Optional target branch for this objective's plans (omit to use the repo default).",
232
298
  },
233
299
  delivery: DELIVERY_PARAM_SCHEMA,
300
+ dream_report: DREAM_REPORT_PARAM_SCHEMA,
234
301
  roadmap: {
235
302
  type: "array",
236
303
  description:
@@ -251,7 +318,13 @@ export function registerObjectiveSave(pi: ExtensionAPI, gating: ToolGating): voi
251
318
  "bad_input",
252
319
  );
253
320
  }
254
- return saveObjective(pi, ctx, decoded);
321
+ // The direct tool path wraps ONLY a present decoded value as the `{input}` carrier (the
322
+ // save stamps generated_at); no stored parts, so no byte-compare on this path.
323
+ const { dream_report, ...rest } = decoded;
324
+ return saveObjective(pi, ctx, {
325
+ ...rest,
326
+ ...(dream_report !== undefined ? { dream_report: { input: dream_report } } : {}),
327
+ });
255
328
  },
256
329
  });
257
330
 
@@ -29,6 +29,18 @@
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),
@@ -69,6 +81,7 @@ import {
69
81
  isPlannotatorPlanSelected,
70
82
  } from "../adapters/planAdapterPlannotator.ts";
71
83
  import type { Result } from "../substrate/result.ts";
84
+ import { readSessionArtifact } from "../substrate/sessionData.ts";
72
85
  import type { ToolGating } from "../substrate/toolGating.ts";
73
86
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
74
87
  import { applyUnifiedDiff } from "../substrate/unifiedDiff.ts";
@@ -78,7 +91,11 @@ import { readGistDraft, renderGistDraft } from "./gistDraft.ts";
78
91
  import { type GistApprovalSaveOutcome, gistApprovalSave } from "./gistSave.ts";
79
92
  import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
80
93
  import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
81
- import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
94
+ import {
95
+ OBJECTIVE_DRAFT_ARTIFACT,
96
+ readObjectiveDraft,
97
+ renderObjectiveDraft,
98
+ } from "./objectiveDraft.ts";
82
99
  import { readNodeClaim } from "./objectivePlan.ts";
83
100
  import {
84
101
  OBJECTIVE_SAVE_STAGE,
@@ -472,6 +489,92 @@ export function applyPlannotatorDirectEdits(
472
489
  return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: false };
473
490
  }
474
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
+
475
578
  // ----------------------------------------------------------------- the first-party review core
476
579
 
477
580
  /** The minimal structural `ctx.ui` subset the first-party review needs (the ciExecutor.ts pure-core + injected-fakes recipe). */
@@ -650,10 +753,19 @@ export async function executeObjectiveReview(
650
753
  gating: ToolGating,
651
754
  bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
652
755
  signal?: AbortSignal,
756
+ wave?: WaveLaunch,
653
757
  ): Promise<ToolResult> {
654
758
  // 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
655
759
  if (!ctx.hasUI) return skipResult();
656
- // 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
657
769
  // objective_draft redirect.
658
770
  const draft = readObjectiveDraft(ctx);
659
771
  if (draft === null) {
@@ -675,13 +787,35 @@ export async function executeObjectiveReview(
675
787
  },
676
788
  };
677
789
  }
678
- // 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.
679
791
  const rendered = renderObjectiveDraft(draft);
680
- // 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
681
793
  // selection → the first-party editor, view-only.
682
794
  const sig = signal ?? ctx.signal;
683
795
  let outcome: ReviewOutcome;
684
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
+ }
685
819
  outcome = await bridge.review(rendered, sig);
686
820
  // APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
687
821
  // approved-save routing (the approved-first discipline): the save seam re-reads the
@@ -731,7 +865,7 @@ export async function executeObjectiveReview(
731
865
  });
732
866
  outcome = fp.outcome;
733
867
  }
734
- // 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
735
869
  // STRUCTURED artifact is re-read at save time — never the rendered bytes; auto-save → D1a
736
870
  // gate exit → terminating result); everything else maps via objectiveReviewOutcomeResult.
737
871
  // Approved-first routing: objectiveReviewOutcomeResult's completed case renders DENIED.
@@ -906,6 +1040,7 @@ export async function executePlanReview(
906
1040
  bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
907
1041
  params: unknown,
908
1042
  signal?: AbortSignal,
1043
+ wave?: WaveLaunch,
909
1044
  ): Promise<ToolResult> {
910
1045
  // Tool-boundary decode, in this tool's native fail-open vocabulary: a MISTYPED
911
1046
  // `plan` (or non-object params) skip-shapes (`reason: "bad_input"`) without reviewing; an
@@ -937,7 +1072,7 @@ export async function executePlanReview(
937
1072
  // the gist arm (the rendered gist draft).
938
1073
  const launchedStage = rebuildWorkflowState(branchOf(ctx)).stage;
939
1074
  if (launchedStage === OBJECTIVE_AUTHOR_STAGE || launchedStage === OBJECTIVE_SAVE_STAGE) {
940
- return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
1075
+ return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal, wave);
941
1076
  }
942
1077
  if (launchedStage === GIST_AUTHOR_STAGE) {
943
1078
  return executeGistReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
@@ -974,6 +1109,28 @@ export async function executePlanReview(
974
1109
  let edited = false;
975
1110
  let directEditsFailed = false;
976
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
+ }
977
1134
  outcome = await bridge.review(src.plan, sig);
978
1135
  // APPROVE + Direct Edits (browser plan edits, contracts.md §8.23): mechanically apply the
979
1136
  // reviewer's diff via the shared helper (the first-party pre-verdict write-back, replayed
@@ -1032,8 +1189,10 @@ export async function executePlanReview(
1032
1189
  * Register `plan_review` — perk's universal review door. In READ_ONLY_TOOLS so it is callable
1033
1190
  * INSIDE plan mode (the whole point — review happens before the gate ever comes off). Fail-open
1034
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.
1035
1194
  */
1036
- export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
1195
+ export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating, wave?: WaveLaunch): void {
1037
1196
  const bridge = createPlannotatorBridge(pi.events);
1038
1197
 
1039
1198
  pi.registerTool({
@@ -1044,14 +1203,18 @@ export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
1044
1203
  "selected, otherwise perk's in-TUI editor review — and wait for the human decision. " +
1045
1204
  "Reviews the validated plan-draft artifact (keep it current with plan_draft); on approval " +
1046
1205
  "the plan is auto-saved and the turn terminates. On deny, revise per the returned " +
1047
- "feedback, rewrite the draft with plan_draft, and call again. No-op skip when the session " +
1048
- "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.",
1049
1211
  promptSnippet: "Request a human review of the working plan draft",
1050
1212
  promptGuidelines: [
1051
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.",
1052
1214
  "Call plan_review only when the plan is decision-complete.",
1053
1215
  "On a DENIED review, revise per the feedback, rewrite the draft with plan_draft, then call plan_review again.",
1054
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.",
1055
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).",
1056
1219
  ],
1057
1220
  executionMode: "sequential",
@@ -1068,7 +1231,7 @@ export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
1068
1231
  },
1069
1232
  },
1070
1233
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1071
- return executePlanReview(pi, ctx, gating, bridge, params, signal);
1234
+ return executePlanReview(pi, ctx, gating, bridge, params, signal, wave);
1072
1235
  },
1073
1236
  });
1074
1237
  }
@@ -15,14 +15,19 @@ import { registerAuditWave } from "./doors/auditWaveTools.ts";
15
15
  import { registerCiExecutor } from "./doors/ciExecutor.ts";
16
16
  import { registerCommitAndCompact } from "./doors/commitCompact.ts";
17
17
  import { registerDraftReviewWaveTools } from "./doors/draftReviewWaveTools.ts";
18
+ import { registerDreamWave } from "./doors/dreamWaveTools.ts";
18
19
  import { registerHarvestWave } from "./doors/harvestWaveTools.ts";
19
20
  import { registerLand } from "./doors/land.ts";
20
21
  import { registerLearn } from "./doors/learn.ts";
21
22
  import { CODE_DOOR, DOCS_DOOR, registerLearnFactoryDoor } from "./doors/learnFactory.ts";
22
23
  import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
23
- import { registerObjectiveReviewBrowser } from "./doors/objectiveReviewBrowser.ts";
24
+ import {
25
+ openObjectiveReviewSurface,
26
+ registerObjectiveReviewBrowser,
27
+ } from "./doors/objectiveReviewBrowser.ts";
24
28
  import { registerObjectiveStack } from "./doors/objectiveStack.ts";
25
- import { registerPlanReviewBrowser } from "./doors/planReviewBrowser.ts";
29
+ import { plannotatorPresent } from "./doors/plannotatorHandoff.ts";
30
+ import { openPlanReviewSurface, registerPlanReviewBrowser } from "./doors/planReviewBrowser.ts";
26
31
  import { registerPrReview } from "./doors/prReview.ts";
27
32
  import { registerPrReviewBrowser } from "./doors/prReviewBrowser.ts";
28
33
  import { registerPrReviewDynamic } from "./doors/prReviewDynamic.ts";
@@ -46,6 +51,7 @@ import { registerPlanMode } from "./factories/planMode.ts";
46
51
  import { registerPlanReview } from "./factories/planReview.ts";
47
52
  import { registerPlanSave } from "./factories/planSave.ts";
48
53
  import { createHunkFeedbackReceiver } from "./hunkFeedback/receiver.ts";
54
+ import { createAgentScratchProvisioner, registerAgentScratch } from "./substrate/agentScratch.ts";
49
55
  import { registerBindingDelivery } from "./substrate/bindingDelivery.ts";
50
56
  import {
51
57
  atomicWriteFileSync,
@@ -77,7 +83,9 @@ import {
77
83
  createPerkStatus,
78
84
  installPerkFooter,
79
85
  latestCacheHitRate,
86
+ REPORT_DETAIL_TYPE,
80
87
  registerTranscriptRenderer,
88
+ reportDetailEntryRenderer,
81
89
  workflowStateEntryRenderer,
82
90
  } from "./surfaces/surfaces.ts";
83
91
  import { registerBtw } from "./vendor/btw/btw.ts";
@@ -122,12 +130,18 @@ export default function (pi: ExtensionAPI) {
122
130
  // both session_start AND session_tree below. enter/exit are the surface the gated stages consume.
123
131
  const gating = registerToolGating(pi);
124
132
 
133
+ // Run-owned disposable scratch guidance for every eligible write-capable model turn. One
134
+ // activation-scoped provisioner shares retry/warning suppression with the isolated /btw side
135
+ // session; no model tool or process-global temp environment is introduced.
136
+ const agentScratch = createAgentScratchProvisioner();
137
+ registerAgentScratch(pi, agentScratch);
138
+
125
139
  // Vendored `btw`: a `/btw` human-only side-chat popover backed by an isolated in-memory
126
140
  // AgentSession. Takes `gating` for the gate-mirror — its side-session toolset + cache key follow
127
141
  // perk's read-only gate (`sideSessionTools`), so the isolated session never bypasses the read-only
128
142
  // guarantee. Its `ctx.ui.custom` overlay is the ONE sanctioned charter exception (§6 D6): human-
129
143
  // invoked only, `hasUI`-gated, no model tool, not a stage/door — never machine-reachable.
130
- registerBtw(pi, gating);
144
+ registerBtw(pi, gating, agentScratch);
131
145
 
132
146
  // Vendored `whimsical`: flavors pi's default working-message label with a random phrase per
133
147
  // turn, via the headless-no-op `setWorkingMessage` surfaces seam. Always on, no config toggle.
@@ -156,8 +170,15 @@ export default function (pi: ExtensionAPI) {
156
170
  // `plan_review`, perk's UNIVERSAL review door: plannotator-selected → the event-bus
157
171
  // bridge; ANY other selection → the first-party in-TUI editor review. It takes `gating` only to
158
172
  // COMPOSE the approvalSave seam on an APPROVED review (auto-save → D1a gate exit) — Invariant 1
159
- // holds: the door composes the gate through the seam, never owns it.
160
- registerPlanReview(pi, gating);
173
+ // holds: the door composes the gate through the seam, never owns it. The injected wave-launch
174
+ // deps power the plannotator launch chooser (§8.23): the presence probe + the two door open
175
+ // cores are composed HERE so planReview.ts imports nothing from door modules (the value-import
176
+ // cycle break — planReviewBrowser.ts already value-imports planReview.ts).
177
+ registerPlanReview(pi, gating, {
178
+ present: () => plannotatorPresent(pi),
179
+ plan: (ctx, opts) => openPlanReviewSurface(pi, ctx, gating, opts),
180
+ objective: (ctx, opts) => openObjectiveReviewSurface(pi, ctx, gating, opts),
181
+ });
161
182
 
162
183
  // Objective-author context injection (the objective mirror of plan mode's authoring
163
184
  // half). Keyed off (read-only gate AND stage === objective-author); planMode defers to it.
@@ -190,9 +211,10 @@ export default function (pi: ExtensionAPI) {
190
211
  // publisher below; the footer reads it back via get/subscribe.
191
212
  const perkStatus = createPerkStatus();
192
213
 
193
- // Transcript marker for `perk:workflow-state` deltas (audit §2.3): the renderer body lives in
194
- // surfaces.ts, this registration is wiring, and the seam carries the typeof feature-detect
195
- // (pre-0.80.4 hosts stay inert). One registration covers every workflow-state appender.
214
+ // The generic full report-detail entry and the `perk:workflow-state` transition marker. Renderer
215
+ // bodies live in surfaces.ts; registration is wiring through the pre-0.80.4-safe seam. The report
216
+ // family is appended by command-attached sinks; one workflow registration covers every appender.
217
+ registerTranscriptRenderer(pi, REPORT_DETAIL_TYPE, reportDetailEntryRenderer);
196
218
  registerTranscriptRenderer(pi, WORKFLOW_STATE_TYPE, workflowStateEntryRenderer);
197
219
 
198
220
  // The hunk watch feedback receiver controller (contracts §8.58) — factory-scoped (no module
@@ -209,7 +231,8 @@ export default function (pi: ExtensionAPI) {
209
231
  const sessionFile = ctx.sessionManager.getSessionFile();
210
232
  const currentSessionId = sessionFile ? basename(sessionFile) : null;
211
233
 
212
- // Headless-safe linkage failure: loud (notify if UI + stderr), non-fatal, leaves unclaimed.
234
+ // Terminal-safe linkage failure: managed headline when headful, complete stderr when headless
235
+ // (plus the explicit RPC mirror); non-fatal and leaves the run unclaimed.
213
236
  const reportError = (message: string) => {
214
237
  report(ctx, "workflow-state linkage error", "error", message, { alsoLog: true });
215
238
  };
@@ -263,8 +286,20 @@ export default function (pi: ExtensionAPI) {
263
286
  }
264
287
  }
265
288
  } else if (decision.action === "fork") {
266
- // Inherited a run_id from a different session file → isolate the child's scratch.
267
- ensureRunScratch(ctx.cwd, decision.childRunId);
289
+ // Inherited a run_id from a different session file → isolate the child's scratch. A static
290
+ // redirect or filesystem failure is loud but does not prevent the derived workflow identity
291
+ // from settling; later eligible turns retry through the agent-scratch resolver.
292
+ try {
293
+ ensureRunScratch(ctx.cwd, decision.childRunId);
294
+ } catch (error) {
295
+ report(
296
+ ctx,
297
+ "run scratch",
298
+ "warning",
299
+ `could not create fork run root for ${decision.childRunId}: ${String(error)}`,
300
+ { alsoLog: true },
301
+ );
302
+ }
268
303
  const data: WorkflowState = {
269
304
  run_id: decision.childRunId,
270
305
  pi_session_id: currentSessionId ?? undefined,
@@ -281,7 +316,17 @@ export default function (pi: ExtensionAPI) {
281
316
  // the launched session: never re-consume the handoff (its pi_session_id keeps the true
282
317
  // claimer), no `stage` (no stage impersonation / stage-binding injection), and no
283
318
  // implementation/main pointer capture (resolveRunStage stays null for adopt).
284
- ensureRunScratch(ctx.cwd, decision.childRunId);
319
+ try {
320
+ ensureRunScratch(ctx.cwd, decision.childRunId);
321
+ } catch (error) {
322
+ report(
323
+ ctx,
324
+ "run scratch",
325
+ "warning",
326
+ `could not create adopted run root for ${decision.childRunId}: ${String(error)}`,
327
+ { alsoLog: true },
328
+ );
329
+ }
285
330
  const data: WorkflowState = {
286
331
  run_id: decision.childRunId,
287
332
  pi_session_id: currentSessionId ?? undefined,
@@ -564,6 +609,7 @@ export default function (pi: ExtensionAPI) {
564
609
  registerReviewWaveTools(pi);
565
610
  registerAuditWave(pi);
566
611
  registerHarvestWave(pi);
612
+ registerDreamWave(pi);
567
613
 
568
614
  // The flow-scoped draft-review-wave pair (`start_draft_review_wave`/
569
615
  // `collect_draft_review_wave`) the draft-review door drives: non-blocking draft-review
@@ -605,9 +651,10 @@ export default function (pi: ExtensionAPI) {
605
651
  // (The deterministic objective mechanics live in the Python plane: `perk objective …`.)
606
652
  registerObjective(pi, perkStatus);
607
653
 
608
- // The warm `/commit-and-compact` utility door: drive a commit of the work so far, then
609
- // compact the session once HEAD has actually advanced (clean/read-only trees compact
610
- // immediately; no commit → compaction skipped, loudly). Human-only — no tool twin.
654
+ // The warm `/commit-and-compact` utility door: drive a commit of the work so far, compact once
655
+ // a successful outcome is known, then completion-gate an automatic evidence-first continuation
656
+ // (clean/read-only trees compact immediately; no commit → no compaction or continuation).
657
+ // Human-only — no tool twin.
611
658
  registerCommitAndCompact(pi, gating);
612
659
 
613
660
  // The warm `objective_save` door: the `objective_save` tool + `/objective-save` command