@mgiles/perk 1.0.1
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/README.md +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- package/shared/registry.yaml +199 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// The backend-neutral `plan_review` review door. perk's UNIVERSAL
|
|
2
|
+
// plan-review surface: the model calls ONE tool; this module dispatches to the configured review
|
|
3
|
+
// backend. Plannotator-selected → the event-bus bridge (`createPlannotatorBridge`,
|
|
4
|
+
// planAdapterPlannotator.ts — the AUGMENT-posture path, byte-stable); ANY other
|
|
5
|
+
// selection (perk-plan, tombell, unknown ids) → the FIRST-PARTY in-TUI editor review
|
|
6
|
+
// (`runFirstPartyReview`): display the draft in pi's built-in `ctx.ui.editor` dialog (scrollable,
|
|
7
|
+
// Ctrl+G opens the user's external $EDITOR), write optional human edits back to the draft via
|
|
8
|
+
// `writePlanDraft` BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes — a failed
|
|
9
|
+
// write-back ABORTS the review fail-open, nothing saved), then a 3-option approve/deny/skip
|
|
10
|
+
// `ctx.ui.select` verdict, with deny feedback via a second editor dialog.
|
|
11
|
+
//
|
|
12
|
+
// REVIEW SEMANTICS (file-first, approval auto-saves): the review runs while the session is still
|
|
13
|
+
// read-only (the tool is in READ_ONLY_TOOLS — review happens before the gate ever comes off).
|
|
14
|
+
// The reviewed plan resolves FILE-FIRST via `resolvePlanSource` (the validated `plan-draft.md`
|
|
15
|
+
// artifact wins; the `plan` param is the fallback; the transcript scrape is NEVER reviewed — an
|
|
16
|
+
// approval would auto-save scraped conversation bytes, so no draft + no param soft-skips with a
|
|
17
|
+
// `plan_draft` redirect). An APPROVED outcome (either backend) wires into the shared
|
|
18
|
+
// `approvalSave` seam (planSave.ts): auto-save → D1a gate exit → terminating result,
|
|
19
|
+
// node link recovered from the `objective_node_claim` carrier inside `savePlan`. A DENY returns
|
|
20
|
+
// feedback and directs a `plan_draft` rewrite + re-review. Strict on deny, FAIL-OPEN everywhere
|
|
21
|
+
// else: headless / dismissed (Esc anywhere = skip, mirroring ask_user_question's dismissal — deny
|
|
22
|
+
// is always explicit) / backend-unavailable all soft-skip so plan authoring never wedges — those
|
|
23
|
+
// arms keep the present-the-plan + human-`/plan-save` discipline (the manual failsafe).
|
|
24
|
+
//
|
|
25
|
+
// `ctx.ui.editor` takes NO AbortSignal (unlike select/confirm/input) — `signal?.aborted` is
|
|
26
|
+
// checked between dialogs; an in-flight editor dialog survives a turn abort and its result is
|
|
27
|
+
// discarded (the aborted arm wins). Enter submits in the editor dialog (Shift+Enter = newline),
|
|
28
|
+
// so the dialog titles carry the key hints — pi renders no other affordance.
|
|
29
|
+
//
|
|
30
|
+
// THE OBJECTIVE ARM: an objective-author session (read-only, stage
|
|
31
|
+
// `objective-author`) routes through `executeObjectiveReview` instead of the plan path — the
|
|
32
|
+
// reviewed bytes are the RENDERED objective draft (`readObjectiveDraft` + `renderObjectiveDraft`,
|
|
33
|
+
// objectiveDraft.ts — never raw JSON, never the `plan` param, never the transcript; no draft
|
|
34
|
+
// soft-skips with `reason: "no_objective_draft"`). Dispatch mirrors the plan path (plannotator
|
|
35
|
+
// bridge or the first-party editor, VIEW-ONLY — edits are never written back; deny+feedback is
|
|
36
|
+
// the change channel). An APPROVED outcome wires into the `objectiveApprovalSave` seam
|
|
37
|
+
// (objectiveSave.ts): re-read the STRUCTURED artifact → `saveObjective` → D1a gate
|
|
38
|
+
// exit → a TERMINATING result; a failed save is non-terminating, leaves the gate read-only, and
|
|
39
|
+
// directs the human `/objective-save` failsafe.
|
|
40
|
+
//
|
|
41
|
+
// INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
|
|
42
|
+
// restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
|
|
43
|
+
// through the `approvalSave` seam (Invariant 1: composes, never owns).
|
|
44
|
+
|
|
45
|
+
import { randomUUID } from "node:crypto";
|
|
46
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
47
|
+
import {
|
|
48
|
+
createPlannotatorBridge,
|
|
49
|
+
isPlannotatorPlanSelected,
|
|
50
|
+
} from "../adapters/planAdapterPlannotator.ts";
|
|
51
|
+
import type { ToolGating } from "../substrate/toolGating.ts";
|
|
52
|
+
import { paramsOf, stringParam } from "../substrate/toolParams.ts";
|
|
53
|
+
import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
54
|
+
import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
|
|
55
|
+
import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
|
|
56
|
+
import { type ObjectiveApprovalSaveOutcome, objectiveApprovalSave } from "./objectiveSave.ts";
|
|
57
|
+
import { writePlanDraft } from "./planDraft.ts";
|
|
58
|
+
import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./planSave.ts";
|
|
59
|
+
|
|
60
|
+
// ----------------------------------------------------------------------------- review outcomes
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The review outcome a backend produces, mapped into a tool result below (also `details.status`).
|
|
64
|
+
* The `dismissed` arm is FIRST-PARTY ONLY (Esc anywhere = fail-open skip; the plannotator bridge
|
|
65
|
+
* never produces it).
|
|
66
|
+
*/
|
|
67
|
+
export type ReviewOutcome =
|
|
68
|
+
| { status: "unavailable"; warning: string }
|
|
69
|
+
| { status: "aborted" }
|
|
70
|
+
| { status: "dismissed" }
|
|
71
|
+
| { status: "completed"; approved: boolean; feedback?: string; reviewId: string };
|
|
72
|
+
|
|
73
|
+
interface ToolResult {
|
|
74
|
+
content: { type: "text"; text: string }[];
|
|
75
|
+
details: Record<string, unknown>;
|
|
76
|
+
terminate?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SKIP_TEXT =
|
|
80
|
+
"no interactive review surface available — present the complete plan to the user in your next message.";
|
|
81
|
+
|
|
82
|
+
function skipResult(): ToolResult {
|
|
83
|
+
return { content: [{ type: "text", text: SKIP_TEXT }], details: { status: "skipped" } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Map a non-approved review outcome into the model-facing tool result (exported for the offline
|
|
88
|
+
* tests). The `completed` case renders the DENIED text — the execute path routes approved
|
|
89
|
+
* outcomes to `approvedSaveResult` first, so callers only reach `completed` here with
|
|
90
|
+
* `approved: false` (kept total for safety). The `dismissed` arm renders as a skip — the human
|
|
91
|
+
* declined to decide, so the present-plan + `/plan-save` manual-failsafe discipline applies.
|
|
92
|
+
*/
|
|
93
|
+
export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
|
|
94
|
+
switch (outcome.status) {
|
|
95
|
+
case "unavailable":
|
|
96
|
+
return {
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
type: "text",
|
|
100
|
+
text:
|
|
101
|
+
`WARNING: ${outcome.warning} — no review performed. ` +
|
|
102
|
+
"Present the complete plan to the user in your next message instead.",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
details: { status: "unavailable" },
|
|
106
|
+
};
|
|
107
|
+
case "aborted":
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: "plan review aborted (turn interrupted)." }],
|
|
110
|
+
details: { status: "aborted" },
|
|
111
|
+
};
|
|
112
|
+
case "dismissed":
|
|
113
|
+
return {
|
|
114
|
+
content: [
|
|
115
|
+
{
|
|
116
|
+
type: "text",
|
|
117
|
+
text:
|
|
118
|
+
"plan review dismissed — present the complete plan to the user; the human runs " +
|
|
119
|
+
"/plan-save (the manual failsafe).",
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
details: { status: "skipped", reason: "dismissed" },
|
|
123
|
+
};
|
|
124
|
+
case "completed": {
|
|
125
|
+
const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
|
|
126
|
+
const text =
|
|
127
|
+
"plan DENIED — revise per this feedback, rewrite the working draft with plan_draft, " +
|
|
128
|
+
`then call plan_review again.${feedback}`;
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: "text", text }],
|
|
131
|
+
details: {
|
|
132
|
+
status: "completed",
|
|
133
|
+
approved: outcome.approved,
|
|
134
|
+
feedback: outcome.feedback ?? null,
|
|
135
|
+
reviewId: outcome.reviewId,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
|
|
144
|
+
* (exported for the offline tests). A successful save TERMINATES the turn (propagating the
|
|
145
|
+
* seam's `terminate: true` intent); a failed save is non-terminating, leaves the gate read-only,
|
|
146
|
+
* and directs the human `/plan-save` failsafe. Reviewer feedback is surfaced loudly as
|
|
147
|
+
* implementation guidance — the approved bytes were saved verbatim, never post-edited. `edited`
|
|
148
|
+
* (first-party only) flags that human edits were written back to the draft pre-verdict, so the
|
|
149
|
+
* saved bytes carry them. The `no-plan` arm is defensively unreachable (the reviewed plan is
|
|
150
|
+
* always non-blank) but maps to the save-failed shape rather than throwing.
|
|
151
|
+
*/
|
|
152
|
+
export function approvedSaveResult(
|
|
153
|
+
outcome: Extract<ReviewOutcome, { status: "completed" }>,
|
|
154
|
+
save: ApprovalSaveOutcome,
|
|
155
|
+
opts: { paramMismatch: boolean; edited?: boolean },
|
|
156
|
+
): ToolResult {
|
|
157
|
+
const feedback = outcome.feedback
|
|
158
|
+
? "\n\nReviewer feedback (implementation guidance — the approved plan was saved verbatim):\n" +
|
|
159
|
+
outcome.feedback
|
|
160
|
+
: "";
|
|
161
|
+
const base = {
|
|
162
|
+
status: "completed",
|
|
163
|
+
approved: true,
|
|
164
|
+
reviewId: outcome.reviewId,
|
|
165
|
+
feedback: outcome.feedback ?? null,
|
|
166
|
+
...(opts.edited === true ? { edited: true } : {}),
|
|
167
|
+
};
|
|
168
|
+
if (save.status === "saved") {
|
|
169
|
+
const saveText = save.result.content[0]?.text ?? "";
|
|
170
|
+
const mismatch = opts.paramMismatch
|
|
171
|
+
? "\n\n⚠ differing plan param ignored — the validated draft was reviewed and saved."
|
|
172
|
+
: "";
|
|
173
|
+
const edited =
|
|
174
|
+
opts.edited === true ? " · human edits were written back to the draft and saved" : "";
|
|
175
|
+
return {
|
|
176
|
+
content: [
|
|
177
|
+
{
|
|
178
|
+
type: "text",
|
|
179
|
+
text: `plan APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}`,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
details: { ...base, saved: true, gateExited: save.gateExited, save: save.result.details },
|
|
183
|
+
terminate: true,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const error =
|
|
187
|
+
save.status === "no-plan"
|
|
188
|
+
? "no plan source resolved"
|
|
189
|
+
: save.result.details.ok
|
|
190
|
+
? "unknown save failure"
|
|
191
|
+
: save.result.details.error;
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text",
|
|
196
|
+
text:
|
|
197
|
+
`plan APPROVED by reviewer, but the auto-save FAILED (${error}) — the session stays ` +
|
|
198
|
+
`read-only. Ask the user to run /plan-save (the manual failsafe) to retry.${feedback}`,
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
details: {
|
|
202
|
+
...base,
|
|
203
|
+
saved: false,
|
|
204
|
+
save: save.status === "no-plan" ? null : save.result.details,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ----------------------------------------------------------------- the first-party review core
|
|
210
|
+
|
|
211
|
+
/** The minimal structural `ctx.ui` subset the first-party review needs (the askUser.ts recipe). */
|
|
212
|
+
export interface PlanReviewUI {
|
|
213
|
+
editor(title: string, prefill?: string): Promise<string | undefined>;
|
|
214
|
+
select(
|
|
215
|
+
title: string,
|
|
216
|
+
options: string[],
|
|
217
|
+
opts?: { signal?: AbortSignal },
|
|
218
|
+
): Promise<string | undefined>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The verdict options (plain text — charter D3: no emoji outside the footer). */
|
|
222
|
+
const VERDICT_APPROVE = "Approve — auto-save to GitHub";
|
|
223
|
+
const VERDICT_DENY = "Deny — send feedback for revision";
|
|
224
|
+
const VERDICT_SKIP = "Skip — decide later (manual /plan-save)";
|
|
225
|
+
|
|
226
|
+
const REVIEW_EDITOR_TITLE =
|
|
227
|
+
"Plan review — Enter: continue to verdict · Esc: skip · Ctrl+G: $EDITOR";
|
|
228
|
+
const DENY_FEEDBACK_TITLE = "Deny feedback (optional) — Enter to send";
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The first-party in-TUI review core, pure over injected seams (the askUser.ts recipe) — fully
|
|
232
|
+
* offline-testable. Flow: (1) display the plan in the editor dialog (Esc = dismissed; the human
|
|
233
|
+
* may edit, incl. via Ctrl+G/$EDITOR); (2) a non-blank edit differing from the displayed plan is
|
|
234
|
+
* written back to the draft BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes;
|
|
235
|
+
* a failed write-back aborts the review fail-open — never let an approval save a stale artifact;
|
|
236
|
+
* a blank edit result is treated as no-edit); (3) the 3-option verdict select (Esc/Skip =
|
|
237
|
+
* dismissed); (4) on deny, optional feedback via a second editor dialog. `ctx.ui.editor` takes no
|
|
238
|
+
* AbortSignal — `signal?.aborted` is checked before each dialog (the aborted arm wins).
|
|
239
|
+
*
|
|
240
|
+
* Presentation options (defaults preserve the plan-path behavior byte-for-byte):
|
|
241
|
+
* `editorTitle`/`verdicts` swap the displayed strings; `viewOnly: true` skips the write-back
|
|
242
|
+
* branch entirely — the editor output is used only for Esc/dismissed detection, `plan` is
|
|
243
|
+
* returned unchanged and `edited` stays false (deny+feedback is the change channel).
|
|
244
|
+
*/
|
|
245
|
+
export async function runFirstPartyReview(args: {
|
|
246
|
+
ui: PlanReviewUI;
|
|
247
|
+
plan: string;
|
|
248
|
+
/** Bound to `writePlanDraft(pi, ctx, …).details.ok` by the execute path. */
|
|
249
|
+
writeDraft(plan: string): boolean;
|
|
250
|
+
signal?: AbortSignal;
|
|
251
|
+
editorTitle?: string;
|
|
252
|
+
verdicts?: { approve: string; deny: string; skip: string };
|
|
253
|
+
viewOnly?: boolean;
|
|
254
|
+
}): Promise<{ outcome: ReviewOutcome; plan: string; edited: boolean }> {
|
|
255
|
+
const { ui, writeDraft, signal } = args;
|
|
256
|
+
const editorTitle = args.editorTitle ?? REVIEW_EDITOR_TITLE;
|
|
257
|
+
const verdicts = args.verdicts ?? {
|
|
258
|
+
approve: VERDICT_APPROVE,
|
|
259
|
+
deny: VERDICT_DENY,
|
|
260
|
+
skip: VERDICT_SKIP,
|
|
261
|
+
};
|
|
262
|
+
let plan = args.plan;
|
|
263
|
+
let edited = false;
|
|
264
|
+
const result = (
|
|
265
|
+
outcome: ReviewOutcome,
|
|
266
|
+
): { outcome: ReviewOutcome; plan: string; edited: boolean } => ({
|
|
267
|
+
outcome,
|
|
268
|
+
plan,
|
|
269
|
+
edited,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (signal?.aborted) return result({ status: "aborted" });
|
|
273
|
+
const reviewed = await ui.editor(editorTitle, plan);
|
|
274
|
+
if (signal?.aborted) return result({ status: "aborted" });
|
|
275
|
+
if (reviewed === undefined) return result({ status: "dismissed" });
|
|
276
|
+
|
|
277
|
+
// Write human edits back to the draft BEFORE the verdict (blank = no-edit, review the original
|
|
278
|
+
// bytes). A failed write-back aborts the review fail-open — nothing saved. View-only reviews
|
|
279
|
+
// skip the branch entirely (the editor is display-only; deny+feedback is the change channel).
|
|
280
|
+
if (args.viewOnly !== true && reviewed !== plan && reviewed.trim().length > 0) {
|
|
281
|
+
if (!writeDraft(reviewed)) {
|
|
282
|
+
return result({
|
|
283
|
+
status: "unavailable",
|
|
284
|
+
warning:
|
|
285
|
+
"could not write the edited draft back to the session data dir — review aborted, " +
|
|
286
|
+
"nothing saved",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
plan = reviewed;
|
|
290
|
+
edited = true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (signal?.aborted) return result({ status: "aborted" });
|
|
294
|
+
const verdict = await ui.select(
|
|
295
|
+
"Plan review verdict",
|
|
296
|
+
[verdicts.approve, verdicts.deny, verdicts.skip],
|
|
297
|
+
{ signal },
|
|
298
|
+
);
|
|
299
|
+
if (signal?.aborted) return result({ status: "aborted" });
|
|
300
|
+
if (verdict === verdicts.approve) {
|
|
301
|
+
return result({ status: "completed", approved: true, reviewId: randomUUID() });
|
|
302
|
+
}
|
|
303
|
+
if (verdict === verdicts.deny) {
|
|
304
|
+
const feedback = await ui.editor(DENY_FEEDBACK_TITLE, "");
|
|
305
|
+
if (signal?.aborted) return result({ status: "aborted" });
|
|
306
|
+
return result({
|
|
307
|
+
status: "completed",
|
|
308
|
+
approved: false,
|
|
309
|
+
feedback: feedback?.trim() ? feedback : undefined,
|
|
310
|
+
reviewId: randomUUID(),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
// Skip option, or the select dismissed (Esc) — fail-open skip.
|
|
314
|
+
return result({ status: "dismissed" });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ------------------------------------------------------------------- the objective review arm
|
|
318
|
+
|
|
319
|
+
/** The objective-flavored verdict options (approval auto-saves). */
|
|
320
|
+
const OBJECTIVE_VERDICTS = {
|
|
321
|
+
approve: "Approve — auto-save to GitHub",
|
|
322
|
+
deny: "Deny — send feedback for revision",
|
|
323
|
+
skip: "Skip — decide later (manual /objective-save)",
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const OBJECTIVE_REVIEW_EDITOR_TITLE =
|
|
327
|
+
"Objective review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
|
|
328
|
+
"Ctrl+G: $EDITOR";
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Map a non-approved objective review outcome into the model-facing tool result (exported for
|
|
332
|
+
* the offline tests) — the objective-flavored sibling of `reviewOutcomeResult`. Every arm
|
|
333
|
+
* carries `details.subject: "objective"`; the texts redirect to `objective_draft` /
|
|
334
|
+
* `/objective-save`. The `completed` case renders the DENIED text — the execute path routes
|
|
335
|
+
* approved outcomes to `approvedObjectiveSaveResult` first, so callers only reach `completed`
|
|
336
|
+
* here with `approved: false` (kept total for safety).
|
|
337
|
+
*/
|
|
338
|
+
export function objectiveReviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
|
|
339
|
+
switch (outcome.status) {
|
|
340
|
+
case "unavailable":
|
|
341
|
+
return {
|
|
342
|
+
content: [
|
|
343
|
+
{
|
|
344
|
+
type: "text",
|
|
345
|
+
text:
|
|
346
|
+
`WARNING: ${outcome.warning} — no review performed. ` +
|
|
347
|
+
"Present the complete objective + structured roadmap to the user instead.",
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
details: { status: "unavailable", subject: "objective" },
|
|
351
|
+
};
|
|
352
|
+
case "aborted":
|
|
353
|
+
return {
|
|
354
|
+
content: [{ type: "text", text: "objective review aborted (turn interrupted)." }],
|
|
355
|
+
details: { status: "aborted", subject: "objective" },
|
|
356
|
+
};
|
|
357
|
+
case "dismissed":
|
|
358
|
+
return {
|
|
359
|
+
content: [
|
|
360
|
+
{
|
|
361
|
+
type: "text",
|
|
362
|
+
text:
|
|
363
|
+
"objective review dismissed — present the complete objective + structured roadmap " +
|
|
364
|
+
"to the user; the human runs /objective-save (the manual failsafe).",
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
details: { status: "skipped", reason: "dismissed", subject: "objective" },
|
|
368
|
+
};
|
|
369
|
+
case "completed": {
|
|
370
|
+
const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
|
|
371
|
+
return {
|
|
372
|
+
content: [
|
|
373
|
+
{
|
|
374
|
+
type: "text",
|
|
375
|
+
text:
|
|
376
|
+
"objective DENIED — revise per this feedback, rewrite the working draft with " +
|
|
377
|
+
`objective_draft, then call plan_review again.${feedback}`,
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
details: {
|
|
381
|
+
status: "completed",
|
|
382
|
+
approved: outcome.approved,
|
|
383
|
+
feedback: outcome.feedback ?? null,
|
|
384
|
+
reviewId: outcome.reviewId,
|
|
385
|
+
subject: "objective",
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Map an APPROVED objective review outcome + the `objectiveApprovalSave` outcome into the
|
|
394
|
+
* model-facing tool result (exported for the offline tests) — the objective sibling of
|
|
395
|
+
* `approvedSaveResult` (no `paramMismatch`/`edited` opts: the objective path reviews only the
|
|
396
|
+
* rendered draft, view-only). A successful save TERMINATES the turn; a failed save is
|
|
397
|
+
* non-terminating, leaves the gate read-only, and directs the human `/objective-save` failsafe.
|
|
398
|
+
* The `no-draft` arm is defensively unreachable (the review just read the draft) but maps to
|
|
399
|
+
* the save-failed shape rather than throwing.
|
|
400
|
+
*/
|
|
401
|
+
export function approvedObjectiveSaveResult(
|
|
402
|
+
outcome: Extract<ReviewOutcome, { status: "completed" }>,
|
|
403
|
+
save: ObjectiveApprovalSaveOutcome,
|
|
404
|
+
): ToolResult {
|
|
405
|
+
const feedback = outcome.feedback
|
|
406
|
+
? "\n\nReviewer feedback (implementation guidance — the approved objective was saved " +
|
|
407
|
+
`verbatim):\n${outcome.feedback}`
|
|
408
|
+
: "";
|
|
409
|
+
const base = {
|
|
410
|
+
status: "completed",
|
|
411
|
+
approved: true,
|
|
412
|
+
reviewId: outcome.reviewId,
|
|
413
|
+
feedback: outcome.feedback ?? null,
|
|
414
|
+
subject: "objective",
|
|
415
|
+
};
|
|
416
|
+
if (save.status === "saved") {
|
|
417
|
+
const saveText = save.result.content[0]?.text ?? "";
|
|
418
|
+
return {
|
|
419
|
+
content: [
|
|
420
|
+
{ type: "text", text: `objective APPROVED by reviewer.${feedback}\n\n${saveText}` },
|
|
421
|
+
],
|
|
422
|
+
details: { ...base, saved: true, gateExited: save.gateExited, save: save.result.details },
|
|
423
|
+
terminate: true,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const error =
|
|
427
|
+
save.status === "no-draft"
|
|
428
|
+
? "no objective draft resolved"
|
|
429
|
+
: save.result.details.ok
|
|
430
|
+
? "unknown save failure"
|
|
431
|
+
: save.result.details.error;
|
|
432
|
+
return {
|
|
433
|
+
content: [
|
|
434
|
+
{
|
|
435
|
+
type: "text",
|
|
436
|
+
text:
|
|
437
|
+
`objective APPROVED by reviewer, but the auto-save FAILED (${error}) — the session ` +
|
|
438
|
+
"stays read-only. Ask the user to run /objective-save (the manual failsafe) to " +
|
|
439
|
+
`retry.${feedback}`,
|
|
440
|
+
},
|
|
441
|
+
],
|
|
442
|
+
details: {
|
|
443
|
+
...base,
|
|
444
|
+
saved: false,
|
|
445
|
+
save: save.status === "no-draft" ? null : save.result.details,
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The objective review arm, mirroring `executePlanReview`'s shape but
|
|
452
|
+
* with the rendered objective draft as the SOLE review source (never the `plan` param, never
|
|
453
|
+
* the transcript). First-party reviews run VIEW-ONLY (edits are never written back;
|
|
454
|
+
* deny+feedback is the change channel). An APPROVED outcome wires into the
|
|
455
|
+
* `objectiveApprovalSave` seam (re-read the STRUCTURED artifact → `saveObjective` → D1a gate
|
|
456
|
+
* exit → terminating); every other outcome maps via `objectiveReviewOutcomeResult`.
|
|
457
|
+
*/
|
|
458
|
+
export async function executeObjectiveReview(
|
|
459
|
+
pi: ExtensionAPI,
|
|
460
|
+
ctx: ExtensionContext,
|
|
461
|
+
gating: ToolGating,
|
|
462
|
+
bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
|
|
463
|
+
signal?: AbortSignal,
|
|
464
|
+
): Promise<ToolResult> {
|
|
465
|
+
// 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
|
|
466
|
+
if (!ctx.hasUI) return skipResult();
|
|
467
|
+
// 2. The draft artifact is the sole review source — no draft → soft skip with the
|
|
468
|
+
// objective_draft redirect.
|
|
469
|
+
const draft = readObjectiveDraft(ctx);
|
|
470
|
+
if (draft === null) {
|
|
471
|
+
return {
|
|
472
|
+
content: [
|
|
473
|
+
{
|
|
474
|
+
type: "text",
|
|
475
|
+
text:
|
|
476
|
+
"no objective draft to review — write the working objective with objective_draft " +
|
|
477
|
+
"(prose + the structured roadmap), then call plan_review again.",
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
details: { status: "skipped", reason: "no_objective_draft" },
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// 3. The reviewed bytes are the RENDERED markdown (prose + roadmap table) — never raw JSON.
|
|
484
|
+
const rendered = renderObjectiveDraft(draft);
|
|
485
|
+
// 4. Backend dispatch (mirrors the plan path): plannotator-selected → the bridge; ANY other
|
|
486
|
+
// selection → the first-party editor, view-only.
|
|
487
|
+
const sig = signal ?? ctx.signal;
|
|
488
|
+
let outcome: ReviewOutcome;
|
|
489
|
+
if (isPlannotatorPlanSelected(ctx.cwd)) {
|
|
490
|
+
outcome = await bridge.review(rendered, sig);
|
|
491
|
+
} else {
|
|
492
|
+
const fp = await runFirstPartyReview({
|
|
493
|
+
ui: ctx.ui,
|
|
494
|
+
plan: rendered,
|
|
495
|
+
writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
|
|
496
|
+
signal: sig,
|
|
497
|
+
editorTitle: OBJECTIVE_REVIEW_EDITOR_TITLE,
|
|
498
|
+
verdicts: OBJECTIVE_VERDICTS,
|
|
499
|
+
viewOnly: true,
|
|
500
|
+
});
|
|
501
|
+
outcome = fp.outcome;
|
|
502
|
+
}
|
|
503
|
+
// 5. An APPROVED decision (either backend) wires into the objectiveApprovalSave seam (the
|
|
504
|
+
// STRUCTURED artifact is re-read at save time — never the rendered bytes; auto-save → D1a
|
|
505
|
+
// gate exit → terminating result); everything else maps via objectiveReviewOutcomeResult.
|
|
506
|
+
// Approved-first routing: objectiveReviewOutcomeResult's completed case renders DENIED.
|
|
507
|
+
if (outcome.status === "completed" && outcome.approved) {
|
|
508
|
+
const save = await objectiveApprovalSave(pi, ctx, gating);
|
|
509
|
+
return approvedObjectiveSaveResult(outcome, save);
|
|
510
|
+
}
|
|
511
|
+
return objectiveReviewOutcomeResult(outcome);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ------------------------------------------------------------------------- the execute core
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The `plan_review` execute core, extracted pure-over-its-seams (the bridge, the gating, the
|
|
518
|
+
* ctx) so the resolution + dispatch + approved-save paths are unit-testable offline. Arm order:
|
|
519
|
+
* param decode → the objective-author arm (`executeObjectiveReview` — the rendered objective
|
|
520
|
+
* draft is the review subject there) → headless skip → file-first resolution → backend
|
|
521
|
+
* dispatch (plannotator-selected → the event-bus bridge; ANY other selection → the first-party
|
|
522
|
+
* in-TUI editor review) → approved → `approvalSave`.
|
|
523
|
+
*/
|
|
524
|
+
export async function executePlanReview(
|
|
525
|
+
pi: ExtensionAPI,
|
|
526
|
+
ctx: ExtensionContext,
|
|
527
|
+
gating: ToolGating,
|
|
528
|
+
bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
|
|
529
|
+
params: unknown,
|
|
530
|
+
signal?: AbortSignal,
|
|
531
|
+
): Promise<ToolResult> {
|
|
532
|
+
// Tool-boundary decode, in this tool's native fail-open vocabulary: a MISTYPED
|
|
533
|
+
// `plan` (or non-object params) skip-shapes (`reason: "bad_input"`) without reviewing; an
|
|
534
|
+
// ABSENT `plan` proceeds — the validated draft artifact is the preferred source.
|
|
535
|
+
const p = paramsOf(params);
|
|
536
|
+
const plan = p === null ? null : stringParam(p, "plan");
|
|
537
|
+
if (plan === null) {
|
|
538
|
+
return {
|
|
539
|
+
content: [
|
|
540
|
+
{
|
|
541
|
+
type: "text",
|
|
542
|
+
text: "plan_review takes { plan?: string } — omit it (the plan-draft artifact is preferred) or pass a string.",
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
details: { status: "skipped", reason: "bad_input" },
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
// 1. Objective-author session → the objective review arm: the rendered
|
|
549
|
+
// objective draft is the sole review source; a well-typed `plan` param is ignored here.
|
|
550
|
+
if (rebuildWorkflowState(branchOf(ctx)).stage === OBJECTIVE_AUTHOR_STAGE) {
|
|
551
|
+
return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
|
|
552
|
+
}
|
|
553
|
+
// 2. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
|
|
554
|
+
if (!ctx.hasUI) return skipResult();
|
|
555
|
+
// 3. File-first resolution: artifact → param, NEVER transcript — an approval
|
|
556
|
+
// auto-saves the reviewed bytes, and scraped conversation bytes must never be those.
|
|
557
|
+
const src = resolvePlanSource(ctx, plan);
|
|
558
|
+
if (src === null || src.source === "transcript") {
|
|
559
|
+
return {
|
|
560
|
+
content: [
|
|
561
|
+
{
|
|
562
|
+
type: "text",
|
|
563
|
+
text:
|
|
564
|
+
"no plan to review — write the working draft with plan_draft (or pass the plan " +
|
|
565
|
+
"param), then call plan_review again.",
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
details: { status: "skipped", reason: "no_plan" },
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
// 4. Backend dispatch: plannotator-selected → the event-bus bridge; ANY other selection
|
|
572
|
+
// (perk-plan, tombell, unknown ids) → the first-party in-TUI editor review.
|
|
573
|
+
const sig = signal ?? ctx.signal;
|
|
574
|
+
let outcome: ReviewOutcome;
|
|
575
|
+
let reviewedPlan = src.plan;
|
|
576
|
+
let edited = false;
|
|
577
|
+
if (isPlannotatorPlanSelected(ctx.cwd)) {
|
|
578
|
+
outcome = await bridge.review(src.plan, sig);
|
|
579
|
+
} else {
|
|
580
|
+
const fp = await runFirstPartyReview({
|
|
581
|
+
ui: ctx.ui,
|
|
582
|
+
plan: src.plan,
|
|
583
|
+
writeDraft: (text) => writePlanDraft(pi, ctx, text).details.ok,
|
|
584
|
+
signal: sig,
|
|
585
|
+
});
|
|
586
|
+
outcome = fp.outcome;
|
|
587
|
+
reviewedPlan = fp.plan;
|
|
588
|
+
edited = fp.edited;
|
|
589
|
+
}
|
|
590
|
+
// 5. An APPROVED decision (either backend) wires into the approvalSave seam (auto-save → D1a
|
|
591
|
+
// gate exit → terminating result); everything else maps via reviewOutcomeResult.
|
|
592
|
+
if (outcome.status === "completed" && outcome.approved) {
|
|
593
|
+
const save = await approvalSave(pi, ctx, gating, { reviewedPlan });
|
|
594
|
+
return approvedSaveResult(outcome, save, { paramMismatch: src.paramMismatch, edited });
|
|
595
|
+
}
|
|
596
|
+
return reviewOutcomeResult(outcome);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ----------------------------------------------------------------------------- registration
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Register `plan_review` — perk's universal review door. In READ_ONLY_TOOLS so it is callable
|
|
603
|
+
* INSIDE plan mode (the whole point — review happens before the gate ever comes off). Fail-open
|
|
604
|
+
* everywhere: headless / dismissed / backend-unavailable all soft-skip so authoring never wedges.
|
|
605
|
+
*/
|
|
606
|
+
export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
|
|
607
|
+
const bridge = createPlannotatorBridge(pi.events);
|
|
608
|
+
|
|
609
|
+
pi.registerTool({
|
|
610
|
+
name: "plan_review",
|
|
611
|
+
label: "Plan review",
|
|
612
|
+
description:
|
|
613
|
+
"Present the plan to the configured review surface — the Plannotator browser UI when " +
|
|
614
|
+
"selected, otherwise perk's in-TUI editor review — and wait for the human decision. " +
|
|
615
|
+
"Reviews the validated plan-draft artifact (keep it current with plan_draft); on approval " +
|
|
616
|
+
"the plan is auto-saved and the turn terminates. On deny, revise per the returned " +
|
|
617
|
+
"feedback, rewrite the draft with plan_draft, and call again. No-op skip when the session " +
|
|
618
|
+
"is headless or the review is dismissed.",
|
|
619
|
+
promptSnippet: "Request a human review of the working plan draft",
|
|
620
|
+
promptGuidelines: [
|
|
621
|
+
"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.",
|
|
622
|
+
"Call plan_review only when the plan is decision-complete.",
|
|
623
|
+
"On a DENIED review, revise per the feedback, rewrite the draft with plan_draft, then call plan_review again.",
|
|
624
|
+
"On an APPROVED 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.",
|
|
625
|
+
"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).",
|
|
626
|
+
],
|
|
627
|
+
executionMode: "sequential",
|
|
628
|
+
parameters: {
|
|
629
|
+
type: "object",
|
|
630
|
+
additionalProperties: false,
|
|
631
|
+
properties: {
|
|
632
|
+
plan: {
|
|
633
|
+
type: "string",
|
|
634
|
+
description:
|
|
635
|
+
"Optional — the validated plan-draft.md artifact is preferred when present; this " +
|
|
636
|
+
"param is the fallback for sessions that never wrote a draft.",
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
641
|
+
return executePlanReview(pi, ctx, gating, bridge, params, signal);
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
}
|