@mgiles/perk 2.1.0 → 2.3.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 (50) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +64 -1
  2. package/extension/doors/address.ts +3 -3
  3. package/extension/doors/commitCompact.ts +163 -0
  4. package/extension/doors/learn.ts +219 -23
  5. package/extension/doors/prReview.ts +189 -18
  6. package/extension/doors/prReviewDynamic.ts +249 -0
  7. package/extension/doors/submit.ts +4 -3
  8. package/extension/factories/gistAuthor.ts +94 -0
  9. package/extension/factories/gistDraft.ts +265 -0
  10. package/extension/factories/gistSave.ts +251 -0
  11. package/extension/factories/objectivePlan.ts +3 -2
  12. package/extension/factories/planMode.ts +8 -5
  13. package/extension/factories/planReview.ts +233 -12
  14. package/extension/index.ts +26 -0
  15. package/extension/substrate/config.ts +8 -4
  16. package/extension/substrate/git.ts +38 -0
  17. package/extension/substrate/terminalLaunch.ts +1 -1
  18. package/extension/substrate/toolGating.ts +44 -3
  19. package/extension/substrate/unifiedDiff.ts +224 -0
  20. package/extension/waves/learnWave.ts +155 -0
  21. package/extension/waves/memoryAdapter.ts +126 -0
  22. package/extension/waves/prReviewDynamicWave.ts +466 -0
  23. package/extension/waves/prReviewWave.ts +229 -0
  24. package/extension/waves/reportWave.ts +449 -0
  25. package/extension/waves/rpcAdapter.ts +201 -0
  26. package/package.json +7 -1
  27. package/prompts/_fixtures/live.yaml +22 -11
  28. package/prompts/commit-and-compact.md +7 -0
  29. package/prompts/common/output-schemas/objective-explorer.md +36 -0
  30. package/prompts/common/output-schemas/review-classifier.md +47 -0
  31. package/prompts/contexts/adapters/plannotator-objective.md +8 -1
  32. package/prompts/contexts/adapters/plannotator-plan.md +6 -1
  33. package/prompts/contexts/gist-authoring.md +22 -0
  34. package/prompts/stages/address/action.md +15 -4
  35. package/prompts/stages/address/preview.md +14 -3
  36. package/prompts/stages/conflict-resolution.md +1 -1
  37. package/prompts/stages/gist-author/seed.md +10 -0
  38. package/prompts/stages/gist-save.md +9 -0
  39. package/prompts/stages/learn-orchestrate.md +7 -5
  40. package/prompts/stages/objective-plan/guidance.md +12 -1
  41. package/prompts/stages/objective-plan/seed.md +12 -1
  42. package/prompts/stages/pr-review-browser/active.md +11 -3
  43. package/prompts/stages/pr-review-browser/foreign.md +11 -3
  44. package/prompts/stages/pr-review-dynamic.md +7 -0
  45. package/prompts/stages/pr-review-terminal/active.md +11 -3
  46. package/prompts/stages/pr-review-terminal/foreign.md +11 -3
  47. package/prompts/stages/pr-review.md +7 -6
  48. package/shared/bindings.yaml +6 -0
  49. package/shared/contracts.md +221 -45
  50. package/shared/registry.yaml +31 -1
@@ -19,7 +19,12 @@
19
19
  // `plan_draft` redirect). An APPROVED outcome (either backend) wires into the shared
20
20
  // `approvalSave` seam (planSave.ts): auto-save → D1a gate exit → terminating result,
21
21
  // node link recovered from the `objective_node_claim` carrier inside `savePlan`. A DENY returns
22
- // feedback and directs a `plan_draft` rewrite + re-review. Strict on deny, FAIL-OPEN everywhere
22
+ // feedback and directs a `plan_draft` rewrite + re-review. Plannotator's browser "Direct Edits"
23
+ // (a `# Direct Edits` unified diff opening the feedback) are handled asymmetrically per arm: the
24
+ // PLAN arm mechanically applies an approved diff (strict apply → draft write-back → save the
25
+ // edited bytes; any failure falls open to the verbatim save + a loud warning); the OBJECTIVE arm
26
+ // cannot fold rendered-markdown edits into the structured draft, so an approve-with-edits SKIPS
27
+ // the save and returns one model-mediated revise round; DENY stays model-mediated on both arms. Strict on deny, FAIL-OPEN everywhere
23
28
  // else: headless / dismissed (Esc anywhere = skip, mirroring ask_user_question's dismissal — deny
24
29
  // is always explicit) / backend-unavailable all soft-skip so plan authoring never wedges — those
25
30
  // arms keep the present-the-plan + human-`/plan-save` discipline (the manual failsafe).
@@ -40,6 +45,12 @@
40
45
  // exit → a TERMINATING result; a failed save is non-terminating, leaves the gate read-only, and
41
46
  // directs the human `/objective-save` failsafe.
42
47
  //
48
+ // THE GIST ARM: a gist-author session (read-only, stage `gist-author`) routes through
49
+ // `executeGistReview` the same way — the reviewed bytes are the RENDERED gist draft
50
+ // (`readGistDraft` + `renderGistDraft`, gistDraft.ts), first-party VIEW-ONLY, implement-here
51
+ // never offered, APPROVED → the `gistApprovalSave` seam (gistSave.ts), no draft soft-skips with
52
+ // `reason: "no_gist_draft"`.
53
+ //
43
54
  // INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
44
55
  // restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
45
56
  // through the `approvalSave` seam (Invariant 1: composes, never owns).
@@ -48,12 +59,18 @@ import { randomUUID } from "node:crypto";
48
59
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
49
60
  import {
50
61
  createPlannotatorBridge,
62
+ extractDirectEdits,
63
+ hasDirectEditsHeading,
51
64
  isPlannotatorPlanSelected,
52
65
  } from "../adapters/planAdapterPlannotator.ts";
53
66
  import type { Result } from "../substrate/result.ts";
54
67
  import type { ToolGating } from "../substrate/toolGating.ts";
55
68
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
69
+ import { applyUnifiedDiff } from "../substrate/unifiedDiff.ts";
56
70
  import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
71
+ import { GIST_AUTHOR_STAGE } from "./gistAuthor.ts";
72
+ import { readGistDraft, renderGistDraft } from "./gistDraft.ts";
73
+ import { type GistApprovalSaveOutcome, gistApprovalSave } from "./gistSave.ts";
57
74
  import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
58
75
  import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
59
76
  import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
@@ -131,6 +148,17 @@ const OBJECTIVE_SUBJECT: ReviewSubject = {
131
148
  noSourceError: "no objective draft resolved",
132
149
  };
133
150
 
151
+ const GIST_SUBJECT: ReviewSubject = {
152
+ noun: "gist",
153
+ present: "the complete gist to the user",
154
+ presentUnavailable: "the complete gist to the user",
155
+ implementHereWhere: "on the gist path",
156
+ draftTool: "gist_draft",
157
+ failsafeCmd: "/gist-save",
158
+ detailsExtra: { subject: "gist" },
159
+ noSourceError: "no gist draft resolved",
160
+ };
161
+
134
162
  const SKIP_TEXT =
135
163
  "no interactive review surface available — present the complete plan to the user in your next message.";
136
164
 
@@ -244,16 +272,19 @@ type SubjectSaveOutcome =
244
272
  * (propagating the seam's `terminate: true` intent); a failed save is non-terminating, leaves
245
273
  * the gate read-only, and directs the human manual failsafe. Reviewer feedback is surfaced
246
274
  * loudly as implementation guidance — the approved bytes were saved verbatim, never post-edited.
247
- * The `paramMismatch`/`edited` opts are plan-arm-only (their literals name "plan"/"draft"): the
248
- * objective delegator never passes opts, so the suffixes render empty and `edited` never reaches
249
- * its details. The `no-source` arm is defensively unreachable (the reviewed source is always
275
+ * The `paramMismatch`/`edited`/`directEditsFailed` opts are plan-arm-only (their literals name
276
+ * "plan"/"draft"): the objective delegator never passes opts, so the suffixes render empty and
277
+ * `edited` never reaches its details. `directEditsFailed` (plannotator-only) flags that a Direct
278
+ * Edits section was seen but could not be honored — the saved arm gains a loud warning that the
279
+ * plan was saved WITHOUT the reviewer's edits, and details carry `direct_edits_applied: false`.
280
+ * The `no-source` arm is defensively unreachable (the reviewed source is always
250
281
  * non-blank) but maps to the save-failed shape rather than throwing.
251
282
  */
252
283
  function approvedSubjectSaveResult(
253
284
  subject: ReviewSubject,
254
285
  outcome: Extract<ReviewOutcome, { status: "completed" }>,
255
286
  save: SubjectSaveOutcome,
256
- opts?: { paramMismatch?: boolean; edited?: boolean },
287
+ opts?: { paramMismatch?: boolean; edited?: boolean; directEditsFailed?: boolean },
257
288
  ): ToolResult {
258
289
  const feedback = outcome.feedback
259
290
  ? `\n\nReviewer feedback (implementation guidance — the approved ${subject.noun} was saved ` +
@@ -266,6 +297,7 @@ function approvedSubjectSaveResult(
266
297
  feedback: outcome.feedback ?? null,
267
298
  ...subject.detailsExtra,
268
299
  ...(opts?.edited === true ? { edited: true } : {}),
300
+ ...(opts?.directEditsFailed === true ? { direct_edits_applied: false } : {}),
269
301
  };
270
302
  if (save.status === "saved") {
271
303
  const saveText = save.result.content[0]?.text ?? "";
@@ -275,11 +307,17 @@ function approvedSubjectSaveResult(
275
307
  opts?.paramMismatch === true
276
308
  ? "\n\n⚠ differing plan param ignored — the validated draft was reviewed and saved."
277
309
  : "";
310
+ const editsWarning =
311
+ opts?.directEditsFailed === true
312
+ ? "\n\n⚠ WARNING: the reviewer's Direct Edits could NOT be auto-applied — the plan was " +
313
+ "saved WITHOUT them. The diff remains in the reviewer feedback above; apply it to the " +
314
+ "plan issue manually or via a follow-up."
315
+ : "";
278
316
  return {
279
317
  content: [
280
318
  {
281
319
  type: "text",
282
- text: `${subject.noun} APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}`,
320
+ text: `${subject.noun} APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}${editsWarning}`,
283
321
  },
284
322
  ],
285
323
  // `ok` sits per-branch, NOT in `base` — `base` is spread into the fail branch too.
@@ -323,13 +361,15 @@ function approvedSubjectSaveResult(
323
361
  /**
324
362
  * Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
325
363
  * (exported for the offline tests) — the plan flavor of `approvedSubjectSaveResult`. `edited`
326
- * (first-party only) flags that human edits were written back to the draft pre-verdict, so the
327
- * saved bytes carry them.
364
+ * flags that human edits were written back to the draft pre-verdict (the first-party editor, or
365
+ * the plannotator Direct Edits auto-apply), so the saved bytes carry them. `directEditsFailed`
366
+ * (plannotator-only, optional — absent keeps every existing call site byte-stable) flags a
367
+ * Direct Edits section that could not be honored: the plan saved verbatim, a loud warning added.
328
368
  */
329
369
  export function approvedSaveResult(
330
370
  outcome: Extract<ReviewOutcome, { status: "completed" }>,
331
371
  save: ApprovalSaveOutcome,
332
- opts: { paramMismatch: boolean; edited?: boolean },
372
+ opts: { paramMismatch: boolean; edited?: boolean; directEditsFailed?: boolean },
333
373
  ): ToolResult {
334
374
  return approvedSubjectSaveResult(
335
375
  PLAN_SUBJECT,
@@ -535,7 +575,12 @@ export function approvedObjectiveSaveResult(
535
575
  * the transcript). First-party reviews run VIEW-ONLY (edits are never written back;
536
576
  * deny+feedback is the change channel). An APPROVED outcome wires into the
537
577
  * `objectiveApprovalSave` seam (re-read the STRUCTURED artifact → `saveObjective` → D1a gate
538
- * exit → terminating); every other outcome maps via `objectiveReviewOutcomeResult`.
578
+ * exit → terminating); every other outcome maps via `objectiveReviewOutcomeResult`. ONE
579
+ * carve-out (plannotator only): an approval whose feedback opens a Direct Edits section SKIPS
580
+ * the save — rendered-markdown edits cannot be folded back into the structured draft
581
+ * mechanically — and returns a NON-terminating revise round with the gate untouched (fold the
582
+ * diff in via `objective_draft`, re-review to confirm); perk never saves an objective the
583
+ * reviewer explicitly edited away from.
539
584
  */
540
585
  export async function executeObjectiveReview(
541
586
  pi: ExtensionAPI,
@@ -576,6 +621,42 @@ export async function executeObjectiveReview(
576
621
  let outcome: ReviewOutcome;
577
622
  if (isPlannotatorPlanSelected(ctx.cwd)) {
578
623
  outcome = await bridge.review(rendered, sig);
624
+ // APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
625
+ // approved-save routing (the approved-first discipline): the save seam re-reads the
626
+ // STRUCTURED artifact, so rendered-markdown edits — roadmap-table rows included — cannot be
627
+ // folded back without model judgment. Skip the save, keep the gate read-only, and route ONE
628
+ // revise round: the model folds the diff into `objective_draft`, then re-reviews to confirm.
629
+ // The heading check suffices (extraction success is irrelevant here — the diff goes to the
630
+ // model verbatim either way).
631
+ if (
632
+ outcome.status === "completed" &&
633
+ outcome.approved &&
634
+ outcome.feedback !== undefined &&
635
+ hasDirectEditsHeading(outcome.feedback)
636
+ ) {
637
+ return {
638
+ content: [
639
+ {
640
+ type: "text",
641
+ text:
642
+ "objective APPROVED with direct browser edits — these cannot be auto-applied to " +
643
+ "the structured draft, so nothing was saved. Fold the Direct Edits diff below into " +
644
+ "the working draft with objective_draft (prose hunks → the prose; roadmap-table " +
645
+ "hunks → the matching node fields), then call plan_review again to confirm.\n\n" +
646
+ `Reviewer feedback:\n${outcome.feedback}`,
647
+ },
648
+ ],
649
+ details: {
650
+ ok: true,
651
+ status: "revise",
652
+ reason: "direct_edits",
653
+ approved: true,
654
+ feedback: outcome.feedback,
655
+ reviewId: outcome.reviewId,
656
+ subject: "objective",
657
+ },
658
+ };
659
+ }
579
660
  } else {
580
661
  const fp = await runFirstPartyReview({
581
662
  ui: ctx.ui,
@@ -599,6 +680,111 @@ export async function executeObjectiveReview(
599
680
  return objectiveReviewOutcomeResult(outcome);
600
681
  }
601
682
 
683
+ // ------------------------------------------------------------------------ the gist review arm
684
+
685
+ const GIST_REVIEW_EDITOR_TITLE =
686
+ "Gist review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
687
+ "Ctrl+G: $EDITOR";
688
+
689
+ /**
690
+ * Map a non-approved gist review outcome into the model-facing tool result (exported for the
691
+ * offline tests) — the gist-flavored sibling of `objectiveReviewOutcomeResult`, delegating to
692
+ * `subjectReviewOutcomeResult` with `GIST_SUBJECT`. Every arm carries `details.subject: "gist"`;
693
+ * the texts redirect to `gist_draft` / `/gist-save`. The execute path routes approved outcomes
694
+ * to `approvedGistSaveResult` first, so `completed` renders DENIED here.
695
+ */
696
+ export function gistReviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
697
+ return subjectReviewOutcomeResult(GIST_SUBJECT, outcome);
698
+ }
699
+
700
+ /**
701
+ * Map an APPROVED gist review outcome + the `gistApprovalSave` outcome into the model-facing
702
+ * tool result (exported for the offline tests) — the gist sibling of
703
+ * `approvedObjectiveSaveResult`, delegating to `approvedSubjectSaveResult` with `GIST_SUBJECT`
704
+ * and no opts (the gist path reviews only the rendered draft, view-only — no
705
+ * `paramMismatch`/`edited`).
706
+ */
707
+ export function approvedGistSaveResult(
708
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
709
+ save: GistApprovalSaveOutcome,
710
+ ): ToolResult {
711
+ return approvedSubjectSaveResult(
712
+ GIST_SUBJECT,
713
+ outcome,
714
+ save.status === "no-draft" ? { status: "no-source" } : save,
715
+ );
716
+ }
717
+
718
+ /**
719
+ * The gist review arm, mirroring `executeObjectiveReview`'s shape with the rendered gist draft
720
+ * as the SOLE review source (never the `plan` param, never the transcript). First-party reviews
721
+ * run VIEW-ONLY (edits are never written back; deny+feedback is the change channel); the
722
+ * implement-here verdict is never offered (a gist is not implementable — it has no strategy).
723
+ * An APPROVED outcome wires into the `gistApprovalSave` seam (re-read the artifact → `saveGist`
724
+ * → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`.
725
+ */
726
+ export async function executeGistReview(
727
+ pi: ExtensionAPI,
728
+ ctx: ExtensionContext,
729
+ gating: ToolGating,
730
+ bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
731
+ signal?: AbortSignal,
732
+ ): Promise<ToolResult> {
733
+ // 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
734
+ if (!ctx.hasUI) return skipResult();
735
+ // 2. The draft artifact is the sole review source — no draft → soft skip with the gist_draft
736
+ // redirect.
737
+ const draft = readGistDraft(ctx);
738
+ if (draft === null) {
739
+ return {
740
+ content: [
741
+ {
742
+ type: "text",
743
+ text:
744
+ "no gist draft to review — write the working gist with gist_draft (the " +
745
+ "statement-of-intent prose), then call plan_review again.",
746
+ },
747
+ ],
748
+ details: {
749
+ ok: false,
750
+ error: "no gist draft to review — write it with gist_draft first",
751
+ error_type: "no_gist_draft",
752
+ status: "skipped",
753
+ reason: "no_gist_draft",
754
+ },
755
+ };
756
+ }
757
+ // 3. The reviewed bytes are the RENDERED markdown (title + scope + prose) — never raw JSON.
758
+ const rendered = renderGistDraft(draft);
759
+ // 4. Backend dispatch (mirrors the objective path): plannotator-selected → the bridge; ANY
760
+ // other selection → the first-party editor, view-only.
761
+ const sig = signal ?? ctx.signal;
762
+ let outcome: ReviewOutcome;
763
+ if (isPlannotatorPlanSelected(ctx.cwd)) {
764
+ outcome = await bridge.review(rendered, sig);
765
+ } else {
766
+ const fp = await runFirstPartyReview({
767
+ ui: ctx.ui,
768
+ plan: rendered,
769
+ writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
770
+ signal: sig,
771
+ editorTitle: GIST_REVIEW_EDITOR_TITLE,
772
+ verdicts: verdictsFor(GIST_SUBJECT),
773
+ viewOnly: true,
774
+ });
775
+ outcome = fp.outcome;
776
+ }
777
+ // 5. An APPROVED decision (either backend) wires into the gistApprovalSave seam (the artifact
778
+ // is re-read at save time — never the rendered bytes; auto-save → D1a gate exit →
779
+ // terminating result); everything else maps via gistReviewOutcomeResult. Approved-first
780
+ // routing: gistReviewOutcomeResult's completed case renders DENIED.
781
+ if (outcome.status === "completed" && outcome.approved) {
782
+ const save = await gistApprovalSave(pi, ctx, gating);
783
+ return approvedGistSaveResult(outcome, save);
784
+ }
785
+ return gistReviewOutcomeResult(outcome);
786
+ }
787
+
602
788
  // ------------------------------------------------------------------------- the execute core
603
789
 
604
790
  /**
@@ -641,9 +827,14 @@ export async function executePlanReview(
641
827
  }
642
828
  // 1. Objective-author session → the objective review arm: the rendered
643
829
  // objective draft is the sole review source; a well-typed `plan` param is ignored here.
644
- if (rebuildWorkflowState(branchOf(ctx)).stage === OBJECTIVE_AUTHOR_STAGE) {
830
+ // A gist-author session likewise routes to the gist arm (the rendered gist draft).
831
+ const launchedStage = rebuildWorkflowState(branchOf(ctx)).stage;
832
+ if (launchedStage === OBJECTIVE_AUTHOR_STAGE) {
645
833
  return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
646
834
  }
835
+ if (launchedStage === GIST_AUTHOR_STAGE) {
836
+ return executeGistReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
837
+ }
647
838
  // 2. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
648
839
  if (!ctx.hasUI) return skipResult();
649
840
  // 3. File-first resolution: artifact → param, NEVER transcript — an approval
@@ -674,8 +865,34 @@ export async function executePlanReview(
674
865
  let outcome: ReviewOutcome;
675
866
  let reviewedPlan = src.plan;
676
867
  let edited = false;
868
+ let directEditsFailed = false;
677
869
  if (isPlannotatorPlanSelected(ctx.cwd)) {
678
870
  outcome = await bridge.review(src.plan, sig);
871
+ // 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
+ }
895
+ }
679
896
  } else {
680
897
  // The 4th verdict (implement-here, the no-save exit) is offered UNLESS this is an
681
898
  // objective-node planning session — a node-linked plan must save (the node advance and
@@ -705,7 +922,11 @@ export async function executePlanReview(
705
922
  // gate exit → terminating result); everything else maps via reviewOutcomeResult.
706
923
  if (outcome.status === "completed" && outcome.approved) {
707
924
  const save = await approvalSave(pi, ctx, gating, { reviewedPlan });
708
- return approvedSaveResult(outcome, save, { paramMismatch: src.paramMismatch, edited });
925
+ return approvedSaveResult(outcome, save, {
926
+ paramMismatch: src.paramMismatch,
927
+ edited,
928
+ directEditsFailed,
929
+ });
709
930
  }
710
931
  return reviewOutcomeResult(outcome);
711
932
  }
@@ -14,17 +14,22 @@ import { registerCheckpoints } from "./checkpoints/checkpoints.ts";
14
14
  import { registerAddress } from "./doors/address.ts";
15
15
  import { registerAskUser } from "./doors/askUser.ts";
16
16
  import { registerCiExecutor } from "./doors/ciExecutor.ts";
17
+ import { registerCommitAndCompact } from "./doors/commitCompact.ts";
17
18
  import { registerLand } from "./doors/land.ts";
18
19
  import { registerLearn } from "./doors/learn.ts";
19
20
  import { CODE_DOOR, DOCS_DOOR, registerLearnFactoryDoor } from "./doors/learnFactory.ts";
20
21
  import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
21
22
  import { registerPrReview } from "./doors/prReview.ts";
22
23
  import { registerPrReviewBrowser } from "./doors/prReviewBrowser.ts";
24
+ import { registerPrReviewDynamic } from "./doors/prReviewDynamic.ts";
23
25
  import { registerPrReviewTerminal } from "./doors/prReviewTerminal.ts";
24
26
  import { registerReady } from "./doors/ready.ts";
25
27
  import { registerSelfcheck } from "./doors/selfcheck.ts";
26
28
  import { registerSubmit } from "./doors/submit.ts";
27
29
  import { registerSubmitPrReview } from "./doors/submitPrReview.ts";
30
+ import { registerGistAuthor } from "./factories/gistAuthor.ts";
31
+ import { registerGistDraft } from "./factories/gistDraft.ts";
32
+ import { registerGistSave } from "./factories/gistSave.ts";
28
33
  import { registerImplementHere } from "./factories/implementHere.ts";
29
34
  import { registerObjective } from "./factories/objective.ts";
30
35
  import { registerObjectiveAuthor } from "./factories/objectiveAuthor.ts";
@@ -151,6 +156,10 @@ export default function (pi: ExtensionAPI) {
151
156
  // Objective-author context injection (the objective mirror of plan mode's authoring
152
157
  // half). Keyed off (read-only gate AND stage === objective-author); planMode defers to it.
153
158
  registerObjectiveAuthor(pi, gating);
159
+
160
+ // Gist-author context injection (the gist mirror). Keyed off (read-only gate AND
161
+ // stage === gist-author); planMode defers to it too.
162
+ registerGistAuthor(pi, gating);
154
163
  let sharedOk = false;
155
164
  try {
156
165
  sharedDir();
@@ -457,6 +466,9 @@ export default function (pi: ExtensionAPI) {
457
466
  // The `objective_draft` working-objective file tool (the plan_draft twin).
458
467
  registerObjectiveDraft(pi);
459
468
 
469
+ // The `gist_draft` working-gist file tool (the third draft carve-out).
470
+ registerGistDraft(pi);
471
+
460
472
  // The universal `ask_user_question` tool: lets a model interactively ask the human a
461
473
  // clarifying question (free-text or multiple-choice). Registered in the factory so it exists
462
474
  // before the gate snapshots tools; its name is in READ_ONLY_TOOLS so it survives plan mode.
@@ -484,6 +496,12 @@ export default function (pi: ExtensionAPI) {
484
496
  // POSTS its review to the PR (the deliberate departure from /address's read-only-child rule).
485
497
  registerPrReview(pi);
486
498
 
499
+ // The EXPERIMENTAL warm `/pr-review-dynamic` door: the selector-driven sibling — angle
500
+ // selection delegated to a fresh perk.review-angle-selector lane, normalized in
501
+ // module-rendered code; posting shares /pr-review's post_pr_review + clean guard. The
502
+ // baseline /pr-review stays canonical; promotion/retire is a later dogfood's call.
503
+ registerPrReviewDynamic(pi);
504
+
487
505
  // The warm `submit_pr_review` tool: the human-gated curated-posting surface both review
488
506
  // doors ride (contracts §8.4) — neither door registers tools of its own.
489
507
  registerSubmitPrReview(pi);
@@ -522,10 +540,18 @@ export default function (pi: ExtensionAPI) {
522
540
  // (The deterministic objective mechanics live in the Python plane: `perk objective …`.)
523
541
  registerObjective(pi, perkStatus);
524
542
 
543
+ // The warm `/commit-and-compact` utility door: drive a commit of the work so far, then
544
+ // compact the session once HEAD has actually advanced (clean/read-only trees compact
545
+ // immediately; no commit → compaction skipped, loudly). Human-only — no tool twin.
546
+ registerCommitAndCompact(pi, gating);
547
+
525
548
  // The warm `objective_save` door: the `objective_save` tool + `/objective-save` command
526
549
  // (the objective mirror of plan-save). Takes `gating` for the read-only → read-write boundary.
527
550
  registerObjectiveSave(pi, gating);
528
551
 
552
+ // The warm `gist_save` door: the `gist_save` tool + `/gist-save` command (the gist mirror).
553
+ registerGistSave(pi, gating);
554
+
529
555
  // The objective plan factory's warm transition surface: the `objective_node` bounded
530
556
  // tool (delegates to the Python cold door; `status:"done"` requires a completion audit) + the
531
557
  // `/objective-plan` command (select the next node and author a bounded plan). The command now
@@ -48,14 +48,16 @@ export interface PerkConfig {
48
48
  /**
49
49
  * The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
50
50
  * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
51
- * `learn-analyst`, `adversarial-reviewer`). Each configured
52
- * value is injected as a per-call inline `model` override on that agent's `subagent` spawn; when
51
+ * `learn-analyst`, `adversarial-reviewer`, `review-angle-selector`). Each configured
52
+ * value is injected as the top-level workflow-level `model` on that agent's one `subagent`
53
+ * workflowScript call — a default flowing onto every lane, single-child runs included (as
54
+ * /pr-review does); when
53
55
  * a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
54
56
  * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
55
- * `applyBuiltinOverrides` applies only to builtins — so this inline override is the mechanism.)
57
+ * `applyBuiltinOverrides` applies only to builtins — so this inline injection is the mechanism.)
56
58
  * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
57
59
  * `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
58
- * pi-subagents on the inline override (the last-colon segment counts as thinking only when it
60
+ * pi-subagents on the injected value (the last-colon segment counts as thinking only when it
59
61
  * is a pi level, so ollama-style tags stay part of the model id).
60
62
  * Always-present object; absent keys omitted (mirror of `providers`).
61
63
  */
@@ -66,6 +68,7 @@ export interface PerkConfig {
66
68
  "conflict-resolver"?: string;
67
69
  "learn-analyst"?: string;
68
70
  "adversarial-reviewer"?: string;
71
+ "review-angle-selector"?: string;
69
72
  };
70
73
  /**
71
74
  * Optional `[compaction] objective_threshold` — the context-usage fraction (0,1] that triggers
@@ -294,6 +297,7 @@ const SUBAGENT_KEYS = [
294
297
  "conflict-resolver",
295
298
  "learn-analyst",
296
299
  "adversarial-reviewer",
300
+ "review-angle-selector",
297
301
  ] as const;
298
302
 
299
303
  /**
@@ -75,3 +75,41 @@ export function sinceBaseSha(cwd: string, base: string | null | undefined): stri
75
75
  git(cwd, ["fetch", "origin", branch], FETCH_TIMEOUT_MS);
76
76
  return git(cwd, ["merge-base", "HEAD", `origin/${branch}`]);
77
77
  }
78
+
79
+ /**
80
+ * The current HEAD sha. **Fail-open**: null on any failure — not a repo, git missing, or an
81
+ * unborn HEAD (no commits yet), which callers treat as "no before-point to diff from".
82
+ */
83
+ export function headSha(cwd: string): string | null {
84
+ return git(cwd, ["rev-parse", "HEAD"]);
85
+ }
86
+
87
+ /**
88
+ * Whether the working tree has anything uncommitted (`git status --porcelain`). Untracked files
89
+ * count as dirty — deliberate: the model decides whether they belong in a commit. **Fail-open to
90
+ * null** on any failure (not a repo, git missing) — callers must NOT conflate null with clean.
91
+ * Own `execFileSync` rather than the `git()` helper: `git()` conflates empty output (a clean
92
+ * tree — meaningful here) with failure.
93
+ */
94
+ export function worktreeDirty(cwd: string): boolean | null {
95
+ try {
96
+ const out = execFileSync("git", ["status", "--porcelain"], {
97
+ cwd,
98
+ encoding: "utf8",
99
+ stdio: ["ignore", "pipe", "ignore"],
100
+ });
101
+ return out.trim() !== "";
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The `git log --oneline <fromSha>..HEAD` listing of commits made since `fromSha` — or every
109
+ * commit (`git log --oneline HEAD`) when `fromSha` is null (HEAD was unborn at capture time).
110
+ * **Fail-open**: null on failure or when the range is empty.
111
+ */
112
+ export function commitsSince(cwd: string, fromSha: string | null): string | null {
113
+ const range = fromSha === null ? "HEAD" : `${fromSha}..HEAD`;
114
+ return git(cwd, ["log", "--oneline", range]);
115
+ }
@@ -51,7 +51,7 @@ function interactiveShellWrap(
51
51
  command: string,
52
52
  ): string {
53
53
  const shell =
54
- env.SHELL !== undefined && env.SHELL.startsWith("/")
54
+ env.SHELL?.startsWith("/") === true
55
55
  ? env.SHELL
56
56
  : platform === "darwin"
57
57
  ? "/bin/zsh"
@@ -99,6 +99,22 @@ export const SUBAGENT_TOOLS: readonly string[] = [
99
99
  "intercom",
100
100
  ];
101
101
 
102
+ /**
103
+ * @ff-labs/pi-fff's search tools. BOTH mode name-sets are enumerated (static names, inert
104
+ * when absent — the code_search version-tolerance precedent): warm sessions run pi-fff's
105
+ * default tools-and-ui mode (fffind/ffgrep [+ fff-multi-grep when enabled upstream]);
106
+ * perk cold launches inject PI_FFF_MODE=override, where FFF registers under the builtin
107
+ * names find/grep (already allowlisted/pass-through) plus multi_grep. All register at
108
+ * load time. Frecency/history state lives under ~/.pi/agent/fff/ — outside the worktree
109
+ * (the fetch_content cache-write precedent), so the read-only bar holds.
110
+ */
111
+ export const FFF_SEARCH_TOOLS: readonly string[] = [
112
+ "fffind",
113
+ "ffgrep",
114
+ "fff-multi-grep",
115
+ "multi_grep",
116
+ ];
117
+
102
118
  /**
103
119
  * The enumerated borrowed-package tool census (contracts.md §8.40): every foreign tool name perk
104
120
  * wires — via `BORROWED_PACKAGES`, a provider package, or the linear issue backend — joins the
@@ -118,12 +134,15 @@ export const SUBAGENT_TOOLS: readonly string[] = [
118
134
  * - Single-governance rule: `ask_user_question` must stay OUT of this census — the
119
135
  * @juicesharp/rpiv-ask-user-question provider registers the IDENTICAL name perk does, so the
120
136
  * name-keyed PERK_TOOLS entry already governs both registrations (hygiene-tested).
137
+ * - @ff-labs/pi-fff (FFF_SEARCH_TOOLS): registration timing load-time (both modes); no
138
+ * `setFooter` (only a keyed optional-chained `setStatus`); zero bundled skills.
121
139
  */
122
140
  export const BORROWED_TOOLS: readonly string[] = [
123
141
  ...WEB_RESEARCH_TOOLS,
124
142
  ...LINEAR_READ_TOOLS,
125
143
  ...LINEAR_MUTATING_TOOLS,
126
144
  ...SUBAGENT_TOOLS,
145
+ ...FFF_SEARCH_TOOLS,
127
146
  "todo", // @juicesharp/rpiv-todo (the juicesharp-todo provider) — load-time
128
147
  // @plannotator/pi-extension: perk never drives its plan phases (the adapter bridges
129
148
  // `plan_review` to its event API), so the submit tool is dead weight in stage sessions.
@@ -152,6 +171,10 @@ export const READ_ONLY_TOOLS = [
152
171
  // working-objective artifact in the session data dir (fixed artifact name, seam-derived
153
172
  // path); the gate's edit/write/bash blocking is unchanged.
154
173
  "objective_draft",
174
+ // The gist_draft third of the draft carve-out family: gist_draft writes only the one
175
+ // working-gist artifact in the session data dir (fixed artifact name, seam-derived path);
176
+ // the gate's edit/write/bash blocking is unchanged.
177
+ "gist_draft",
155
178
  // The objective_node carve-out: it never touches the worktree — it delegates a bounded,
156
179
  // workflow-owned node transition to the canonical Python plane (`perk objective node`). Both
157
180
  // objective-plan factory paths run gated (the cold door hands off `mode: read-only`; the warm
@@ -163,6 +186,9 @@ export const READ_ONLY_TOOLS = [
163
186
  // The borrowed research families (extracted to family constants; set + order byte-identical).
164
187
  ...WEB_RESEARCH_TOOLS,
165
188
  ...LINEAR_READ_TOOLS,
189
+ // FFF local search belongs in read-only exploration (the override names find/grep are
190
+ // already present above; these are the additive tools-and-ui names + multi_grep).
191
+ ...FFF_SEARCH_TOOLS,
166
192
  // The delegation carve-in: the gated objective-plan seed/guidance names the
167
193
  // `perk.objective-explorer` spawn, so `subagent`/`wait` (+ the parent supervisor pair, which
168
194
  // already leaks active into cold-door gated sessions via late registration — keeping
@@ -192,22 +218,32 @@ export const PERK_TOOLS: readonly string[] = [
192
218
  "reconcile_objective",
193
219
  "add_objective_node",
194
220
  "objective_draft",
221
+ "gist_draft",
222
+ "gist_save",
195
223
  "learn",
224
+ "run_learn_wave",
196
225
  "ask_user_question",
197
226
  "land",
198
227
  "post_pr_review",
199
228
  "ready",
200
229
  "resolve_review_threads",
230
+ "run_pr_review_wave",
231
+ "run_pr_review_dynamic_wave",
201
232
  "submit_pr_review",
202
233
  "run_ci",
203
234
  "submit",
204
235
  ];
205
236
 
206
237
  /**
207
- * The research bundle EVERY stage list carries: web research + Linear reads are useful in every
208
- * stage session (authoring and worktree alike) and mutate nothing.
238
+ * The universal non-mutating bundle EVERY stage list carries: web research + Linear reads +
239
+ * FFF local search are useful in every stage session (authoring and worktree alike) and
240
+ * mutate nothing (FFF's frecency state lives under ~/.pi/agent/fff/, outside the worktree).
209
241
  */
210
- const RESEARCH_TOOLS: readonly string[] = [...WEB_RESEARCH_TOOLS, ...LINEAR_READ_TOOLS];
242
+ const RESEARCH_TOOLS: readonly string[] = [
243
+ ...WEB_RESEARCH_TOOLS,
244
+ ...LINEAR_READ_TOOLS,
245
+ ...FFF_SEARCH_TOOLS,
246
+ ];
211
247
 
212
248
  /**
213
249
  * The PR-loop family shared by ALL FIVE worktree stages (implement/submit/address/land/learn) —
@@ -229,8 +265,11 @@ const WORKTREE_STAGE_TOOLS: readonly string[] = [
229
265
  "run_ci",
230
266
  "land",
231
267
  "learn",
268
+ "run_learn_wave",
232
269
  "resolve_review_threads",
233
270
  "post_pr_review",
271
+ "run_pr_review_wave",
272
+ "run_pr_review_dynamic_wave",
234
273
  "submit_pr_review",
235
274
  // The reconcile trio: `/land` auto-drives the objective-reconcile pass inside the CURRENT
236
275
  // worktree session (driveReconcileAfterLand), and the manual `/objective-reconcile` gesture is
@@ -259,6 +298,8 @@ const WORKTREE_STAGE_TOOLS: readonly string[] = [
259
298
  * gesture — its guidance names all three).
260
299
  */
261
300
  export const STAGE_TOOLS: Readonly<Record<string, readonly string[]>> = {
301
+ "gist-author": ["ask_user_question", "gist_draft", "gist_save", ...RESEARCH_TOOLS],
302
+ "gist-save": ["ask_user_question", "gist_draft", "gist_save", ...RESEARCH_TOOLS],
262
303
  "objective-author": [
263
304
  "ask_user_question",
264
305
  "objective_draft",