@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.
- package/extension/adapters/planAdapterPlannotator.ts +64 -1
- package/extension/doors/address.ts +3 -3
- package/extension/doors/commitCompact.ts +163 -0
- package/extension/doors/learn.ts +219 -23
- package/extension/doors/prReview.ts +189 -18
- package/extension/doors/prReviewDynamic.ts +249 -0
- package/extension/doors/submit.ts +4 -3
- package/extension/factories/gistAuthor.ts +94 -0
- package/extension/factories/gistDraft.ts +265 -0
- package/extension/factories/gistSave.ts +251 -0
- package/extension/factories/objectivePlan.ts +3 -2
- package/extension/factories/planMode.ts +8 -5
- package/extension/factories/planReview.ts +233 -12
- package/extension/index.ts +26 -0
- package/extension/substrate/config.ts +8 -4
- package/extension/substrate/git.ts +38 -0
- package/extension/substrate/terminalLaunch.ts +1 -1
- package/extension/substrate/toolGating.ts +44 -3
- package/extension/substrate/unifiedDiff.ts +224 -0
- package/extension/waves/learnWave.ts +155 -0
- package/extension/waves/memoryAdapter.ts +126 -0
- package/extension/waves/prReviewDynamicWave.ts +466 -0
- package/extension/waves/prReviewWave.ts +229 -0
- package/extension/waves/reportWave.ts +449 -0
- package/extension/waves/rpcAdapter.ts +201 -0
- package/package.json +7 -1
- package/prompts/_fixtures/live.yaml +22 -11
- package/prompts/commit-and-compact.md +7 -0
- package/prompts/common/output-schemas/objective-explorer.md +36 -0
- package/prompts/common/output-schemas/review-classifier.md +47 -0
- package/prompts/contexts/adapters/plannotator-objective.md +8 -1
- package/prompts/contexts/adapters/plannotator-plan.md +6 -1
- package/prompts/contexts/gist-authoring.md +22 -0
- package/prompts/stages/address/action.md +15 -4
- package/prompts/stages/address/preview.md +14 -3
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/gist-author/seed.md +10 -0
- package/prompts/stages/gist-save.md +9 -0
- package/prompts/stages/learn-orchestrate.md +7 -5
- package/prompts/stages/objective-plan/guidance.md +12 -1
- package/prompts/stages/objective-plan/seed.md +12 -1
- package/prompts/stages/pr-review-browser/active.md +11 -3
- package/prompts/stages/pr-review-browser/foreign.md +11 -3
- package/prompts/stages/pr-review-dynamic.md +7 -0
- package/prompts/stages/pr-review-terminal/active.md +11 -3
- package/prompts/stages/pr-review-terminal/foreign.md +11 -3
- package/prompts/stages/pr-review.md +7 -6
- package/shared/bindings.yaml +6 -0
- package/shared/contracts.md +221 -45
- 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.
|
|
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
|
|
248
|
-
* objective delegator never passes opts, so the suffixes render empty and
|
|
249
|
-
* its details.
|
|
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
|
-
*
|
|
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
|
-
|
|
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, {
|
|
925
|
+
return approvedSaveResult(outcome, save, {
|
|
926
|
+
paramMismatch: src.paramMismatch,
|
|
927
|
+
edited,
|
|
928
|
+
directEditsFailed,
|
|
929
|
+
});
|
|
709
930
|
}
|
|
710
931
|
return reviewOutcomeResult(outcome);
|
|
711
932
|
}
|
package/extension/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
|
208
|
-
* stage session (authoring and worktree alike) and
|
|
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[] = [
|
|
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",
|