@mgiles/perk 3.1.0 → 3.2.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/doors/address.ts +11 -0
- package/extension/doors/dreamWaveTools.ts +29 -15
- package/extension/doors/land.ts +6 -0
- package/extension/doors/learn.ts +16 -3
- package/extension/doors/lifecycleGates.ts +36 -1
- package/extension/doors/objectiveStack.ts +423 -23
- package/extension/doors/plannotatorHandoff.ts +80 -8
- package/extension/doors/prReview.ts +2 -1
- package/extension/doors/prReviewBrowser.ts +75 -27
- package/extension/doors/ready.ts +209 -17
- package/extension/doors/reviewWaveTools.ts +24 -3
- package/extension/doors/stackReviewBrowser.ts +573 -0
- package/extension/doors/submit.ts +36 -10
- package/extension/doors/submitPrReview.ts +116 -19
- package/extension/factories/objectivePlan.ts +12 -6
- package/extension/factories/objectiveSave.ts +5 -2
- package/extension/index.ts +26 -1
- package/extension/substrate/config.ts +4 -2
- package/extension/substrate/paths.ts +2 -7
- package/extension/substrate/resolverLease.ts +363 -0
- package/extension/substrate/toolGating.ts +16 -0
- package/extension/substrate/workflowState.ts +13 -3
- package/extension/waves/adversarialReviewWave.ts +16 -2
- package/package.json +1 -1
- package/prompts/_fixtures/live.yaml +63 -0
- package/prompts/contexts/adapters/tombell-plan.md +4 -0
- package/prompts/contexts/plan-authoring.md +6 -5
- package/prompts/stages/conflict-resolution-continuation.md +6 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-author/seed.md +1 -1
- package/prompts/stages/objective-reconcile-ready.md +7 -0
- package/prompts/stages/objective-sync.md +1 -1
- package/prompts/stages/stack-review/cold.md +1 -0
- package/prompts/stages/stack-review-browser/stack.md +23 -0
- package/shared/README.md +0 -3
- package/shared/bindings.yaml +3 -0
- package/shared/contracts.md +2010 -1753
- package/shared/registry.yaml +16 -1
- package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
- package/shared/schemas/outputs/pr-ready.schema.json +110 -2
- package/shared/contracts-history.md +0 -605
|
@@ -22,7 +22,13 @@
|
|
|
22
22
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { subagentModel } from "../substrate/config.ts";
|
|
24
24
|
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
booleanParam,
|
|
27
|
+
numberParam,
|
|
28
|
+
paramsOf,
|
|
29
|
+
stringArrayParam,
|
|
30
|
+
stringParam,
|
|
31
|
+
} from "../substrate/toolParams.ts";
|
|
26
32
|
import { type ReportTarget, report } from "../surfaces/report.ts";
|
|
27
33
|
import {
|
|
28
34
|
type AdversarialReviewAngle,
|
|
@@ -51,6 +57,7 @@ export interface StartReviewWaveParams {
|
|
|
51
57
|
pr: number;
|
|
52
58
|
worktree: string;
|
|
53
59
|
directive?: string;
|
|
60
|
+
stack?: boolean;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
/**
|
|
@@ -58,7 +65,8 @@ export interface StartReviewWaveParams {
|
|
|
58
65
|
* tool-boundary seam; the `decodeWaveParams` whole-refusal posture): `angles` an array of 2–3
|
|
59
66
|
* unique slugs from the four-slug allowlist with `claimed-intent` mandatory; `pr` a positive
|
|
60
67
|
* integer; `worktree` a non-empty string; `directive` optional — decoded trimmed,
|
|
61
|
-
* present-but-not-a-string or blank (empty/whitespace-only) ⇒ null
|
|
68
|
+
* present-but-not-a-string or blank (empty/whitespace-only) ⇒ null; `stack` an optional
|
|
69
|
+
* boolean (anything else ⇒ whole refusal). Any violation ⇒ null.
|
|
62
70
|
*/
|
|
63
71
|
export function decodeStartReviewWaveParams(params: unknown): StartReviewWaveParams | null {
|
|
64
72
|
const p = paramsOf(params);
|
|
@@ -83,11 +91,14 @@ export function decodeStartReviewWaveParams(params: unknown): StartReviewWavePar
|
|
|
83
91
|
// dangling, contentless operator-focus suffix.
|
|
84
92
|
const directive = rawDirective?.trim();
|
|
85
93
|
if (directive !== undefined && directive.length === 0) return null;
|
|
94
|
+
const stack = booleanParam(p, "stack");
|
|
95
|
+
if (stack === null) return null;
|
|
86
96
|
return {
|
|
87
97
|
angles,
|
|
88
98
|
pr,
|
|
89
99
|
worktree,
|
|
90
100
|
...(directive !== undefined ? { directive } : {}),
|
|
101
|
+
...(stack !== undefined ? { stack } : {}),
|
|
91
102
|
};
|
|
92
103
|
}
|
|
93
104
|
|
|
@@ -143,6 +154,7 @@ export async function executeStartReviewWave(
|
|
|
143
154
|
pr: number;
|
|
144
155
|
worktree: string;
|
|
145
156
|
directive?: string;
|
|
157
|
+
stack?: boolean;
|
|
146
158
|
model?: string;
|
|
147
159
|
/** Test seam; production validates the exact source-bound Ponytail review skill. */
|
|
148
160
|
requiredSkillPreflight?: WaveSpec["requiredSkillPreflight"];
|
|
@@ -161,6 +173,7 @@ export async function executeStartReviewWave(
|
|
|
161
173
|
pr: opts.pr,
|
|
162
174
|
worktree: opts.worktree,
|
|
163
175
|
...(opts.directive !== undefined ? { directive: opts.directive } : {}),
|
|
176
|
+
...(opts.stack !== undefined ? { stack: opts.stack } : {}),
|
|
164
177
|
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
165
178
|
...(opts.requiredSkillPreflight !== undefined
|
|
166
179
|
? { requiredSkillPreflight: opts.requiredSkillPreflight }
|
|
@@ -349,6 +362,13 @@ export function registerReviewWaveTools(pi: ExtensionAPI): void {
|
|
|
349
362
|
"The operator's free-form focus note, threaded to every reviewer as DATA " +
|
|
350
363
|
"(emphasis within the assigned angle only).",
|
|
351
364
|
},
|
|
365
|
+
stack: {
|
|
366
|
+
type: "boolean",
|
|
367
|
+
description:
|
|
368
|
+
"Stack mode (the /stack-review-browser flow): the lanes review the combined diff " +
|
|
369
|
+
"of the PR stack topped by `pr` at `worktree`, fetching membership via " +
|
|
370
|
+
"`perk pr review-context --pr <pr> --stack`.",
|
|
371
|
+
},
|
|
352
372
|
},
|
|
353
373
|
},
|
|
354
374
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -357,7 +377,8 @@ export function registerReviewWaveTools(pi: ExtensionAPI): void {
|
|
|
357
377
|
return failFor(ctx, "start_review_wave")(
|
|
358
378
|
"start_review_wave needs { angles: 2–3 unique slugs among " +
|
|
359
379
|
"claimed-intent|correctness|tests|quality (claimed-intent mandatory), pr: positive " +
|
|
360
|
-
"integer, worktree: non-empty string, directive?: non-empty string
|
|
380
|
+
"integer, worktree: non-empty string, directive?: non-empty string, " +
|
|
381
|
+
"stack?: boolean }",
|
|
361
382
|
"bad_input",
|
|
362
383
|
);
|
|
363
384
|
}
|
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// The warm `/stack-review-browser` door + the `open_stack_review` cold-launch tool: the BROWSER
|
|
2
|
+
// entry into human-in-the-loop adversarial review of an ENTIRE PR stack (contracts §8.4) — one
|
|
3
|
+
// plannotator session over the combined diff (stack base → top head), one reviewer wave over
|
|
4
|
+
// that combined diff (`start_review_wave` with `stack: true`), and the judgment-routed per-PR
|
|
5
|
+
// posting protocol through `submit_pr_review`.
|
|
6
|
+
//
|
|
7
|
+
// TARGET GRAMMAR (explicit, no error-conditioned fallback probing):
|
|
8
|
+
// /stack-review-browser [target] [focus note]
|
|
9
|
+
// where target is an objective id (`77` / `#77` / an issue URL — bare numbers are objective ids
|
|
10
|
+
// BY DEFINITION of the grammar), `pr:<n>` or a PR URL (the non-perk chain arm), or absent. The
|
|
11
|
+
// no-target ladder: the session's rebuilt workflow-state `active_objective` (passed explicitly
|
|
12
|
+
// as the objective id) → else the checkout worker with no id (its `cache.plan-ref` arm) → a
|
|
13
|
+
// `no_objective` failure is a typed usage refusal naming the explicit forms.
|
|
14
|
+
//
|
|
15
|
+
// THE COMBINED DIFF is rendered by plannotator itself: the cold checkout worker materializes a
|
|
16
|
+
// detached checkout of the TOP stack head, and the door opens plannotator in local mode with
|
|
17
|
+
// `{diffType: "since-base", defaultBranch: "origin/<stack base>"}` — the REMOTE-TRACKING ref the
|
|
18
|
+
// checkout actually materializes (plannotator trusts an explicit base verbatim and degrades a
|
|
19
|
+
// failed merge-base to HEAD, which would render an empty review — a bare branch name that only
|
|
20
|
+
// exists on the remote would do exactly that).
|
|
21
|
+
//
|
|
22
|
+
// THE POSTING CONTRACT (the delta from /pr-review-browser): a local-diff session has NO attached
|
|
23
|
+
// PR, so the browser has no platform-posting path — ALL GitHub posting is perk-side after the
|
|
24
|
+
// human triage, judgment-routed per member PR (dry-run ALL batches first, bottom→top, per-PR
|
|
25
|
+
// confirm for formal events). The stack respond mapper (`stackRespondMessage`) and the stack
|
|
26
|
+
// degrade notice both carry that framing.
|
|
27
|
+
//
|
|
28
|
+
// `open_stack_review` is the cold-launch twin (the `run_audit_wave` posture): NO parameters —
|
|
29
|
+
// the pinned stack snapshot comes ONLY from the `perk objective stack review` launch handoff
|
|
30
|
+
// (`stack_review`, recovered via the rebuilt workflow-state run_id), so no model-relayed path
|
|
31
|
+
// can aim the flow anywhere. Single-use per session; it runs the SAME extracted lifecycle core
|
|
32
|
+
// and returns the rendered stack.md guidance as its ok text.
|
|
33
|
+
|
|
34
|
+
import { existsSync } from "node:fs";
|
|
35
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
37
|
+
import { readHandoff } from "../substrate/cache.ts";
|
|
38
|
+
import { type ColdJson, runColdDoor } from "../substrate/coldDoor.ts";
|
|
39
|
+
import { registerPerkCommand } from "../substrate/command.ts";
|
|
40
|
+
import { render } from "../substrate/prompts.ts";
|
|
41
|
+
import { failFor, ok } from "../substrate/result.ts";
|
|
42
|
+
import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
43
|
+
import { report } from "../surfaces/report.ts";
|
|
44
|
+
import { type CheckoutOk, decodeCheckout, PR_URL_RE } from "./hunkHandoff.ts";
|
|
45
|
+
import {
|
|
46
|
+
LOCAL_REVIEW_DIFF_TYPE,
|
|
47
|
+
plannotatorPresent,
|
|
48
|
+
stackRespondMessage,
|
|
49
|
+
} from "./plannotatorHandoff.ts";
|
|
50
|
+
import { openReviewBrowserCore } from "./prReviewBrowser.ts";
|
|
51
|
+
|
|
52
|
+
/** The door's report scope — also the `command:<id>` binding trigger id. */
|
|
53
|
+
const SCOPE = "stack-review-browser";
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------------------ the target grammar
|
|
56
|
+
|
|
57
|
+
/** A parsed `/stack-review-browser` target: objective arm, chain arm, or the no-target ladder. */
|
|
58
|
+
export type StackReviewTarget =
|
|
59
|
+
| { kind: "objective"; id: string }
|
|
60
|
+
| { kind: "pr"; pr: number }
|
|
61
|
+
| { kind: "auto" };
|
|
62
|
+
|
|
63
|
+
export interface StackReviewArgs {
|
|
64
|
+
target: StackReviewTarget;
|
|
65
|
+
directive: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Extracts the issue number from a GitHub issue URL (the objective-id URL form). */
|
|
69
|
+
const ISSUE_URL_RE = /\/issues\/(\d+)(?:\/|$|#|\?)/;
|
|
70
|
+
|
|
71
|
+
/** A backend-native objective id (Linear's `ENG-123` shape — the Python `parse_objective_id`
|
|
72
|
+
* ident grammar, mirrored so an explicit target never silently degrades to a focus note). */
|
|
73
|
+
const NATIVE_ID_RE = /^[A-Za-z0-9]+-\d+$/;
|
|
74
|
+
|
|
75
|
+
/** Peel a Linear issue/project URL down to its opaque objective id (null = not one). */
|
|
76
|
+
function linearIdFromUrl(token: string): string | null {
|
|
77
|
+
let url: URL;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(token);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
84
|
+
const host = url.hostname;
|
|
85
|
+
if (host !== "linear.app" && !host.endsWith(".linear.app")) return null;
|
|
86
|
+
const segments = url.pathname.split("/").filter((s) => s !== "");
|
|
87
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
88
|
+
const seg = segments[i];
|
|
89
|
+
const next = segments[i + 1];
|
|
90
|
+
if (next === undefined) break;
|
|
91
|
+
if (seg === "issue" && NATIVE_ID_RE.test(next)) return next;
|
|
92
|
+
if (seg === "project") return next;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse the explicit target grammar (pure, offline-tested). Bare numbers (and `#n`,
|
|
99
|
+
* backend-native ids like `ENG-123`, GitHub issue URLs, and Linear issue/project URLs — the
|
|
100
|
+
* Python `parse_objective_id` grammar) are OBJECTIVE ids by definition; the chain arm is
|
|
101
|
+
* `pr:<n>` or a PR URL. A first token that is none of these makes the WHOLE string the focus
|
|
102
|
+
* note (target absent — the ladder). Null only on a malformed `pr:` token (a usage failure,
|
|
103
|
+
* never silently a focus note).
|
|
104
|
+
*/
|
|
105
|
+
export function parseStackReviewArgs(args: string): StackReviewArgs | null {
|
|
106
|
+
const trimmed = args.trim();
|
|
107
|
+
if (trimmed.length === 0) return { target: { kind: "auto" }, directive: "" };
|
|
108
|
+
const split = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
|
|
109
|
+
const first = split?.[1] ?? "";
|
|
110
|
+
const rest = (split?.[2] ?? "").trim();
|
|
111
|
+
if (/^pr:/i.test(first)) {
|
|
112
|
+
const prToken = first.match(/^pr:(\d+)$/i);
|
|
113
|
+
if (prToken?.[1] === undefined) return null;
|
|
114
|
+
return { target: { kind: "pr", pr: Number(prToken[1]) }, directive: rest };
|
|
115
|
+
}
|
|
116
|
+
const prUrl = first.match(PR_URL_RE);
|
|
117
|
+
if (prUrl?.[1] !== undefined) {
|
|
118
|
+
return { target: { kind: "pr", pr: Number(prUrl[1]) }, directive: rest };
|
|
119
|
+
}
|
|
120
|
+
const bare = first.match(/^#?(\d+)$/);
|
|
121
|
+
if (bare?.[1] !== undefined) {
|
|
122
|
+
return { target: { kind: "objective", id: bare[1] }, directive: rest };
|
|
123
|
+
}
|
|
124
|
+
const issueUrl = first.match(ISSUE_URL_RE);
|
|
125
|
+
if (issueUrl?.[1] !== undefined) {
|
|
126
|
+
return { target: { kind: "objective", id: issueUrl[1] }, directive: rest };
|
|
127
|
+
}
|
|
128
|
+
const linearId = linearIdFromUrl(first);
|
|
129
|
+
if (linearId !== null) {
|
|
130
|
+
return { target: { kind: "objective", id: linearId }, directive: rest };
|
|
131
|
+
}
|
|
132
|
+
if (NATIVE_ID_RE.test(first)) {
|
|
133
|
+
return { target: { kind: "objective", id: first }, directive: rest };
|
|
134
|
+
}
|
|
135
|
+
return { target: { kind: "auto" }, directive: trimmed };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ------------------------------------------------------------------------ the snapshot decode
|
|
139
|
+
|
|
140
|
+
/** One pinned stack-snapshot row (the checkout envelope's `stack[]` / the handoff's rows). */
|
|
141
|
+
export interface StackSnapshotRow {
|
|
142
|
+
pr: number;
|
|
143
|
+
url: string;
|
|
144
|
+
branch: string;
|
|
145
|
+
head_sha: string;
|
|
146
|
+
base_ref: string;
|
|
147
|
+
node_id: string | null;
|
|
148
|
+
plan_id: string | null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The `perk pr review checkout --stack --json` ok-arm: the single-PR fields + the snapshot
|
|
152
|
+
* (`base_ref` IS the combined-diff/stack base on the stack envelope — no separate field). */
|
|
153
|
+
export interface StackCheckoutOk extends CheckoutOk {
|
|
154
|
+
stack: StackSnapshotRow[];
|
|
155
|
+
stack_notes: string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function decodeSnapshotRow(item: unknown): StackSnapshotRow | null {
|
|
159
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) return null;
|
|
160
|
+
const raw = item as Record<string, unknown>;
|
|
161
|
+
const { pr, url, branch, head_sha, base_ref, node_id, plan_id } = raw;
|
|
162
|
+
if (typeof pr !== "number" || !Number.isInteger(pr)) return null;
|
|
163
|
+
if (typeof url !== "string" || typeof branch !== "string") return null;
|
|
164
|
+
if (typeof head_sha !== "string" || typeof base_ref !== "string") return null;
|
|
165
|
+
if (node_id !== null && typeof node_id !== "string") return null;
|
|
166
|
+
if (plan_id !== null && typeof plan_id !== "string") return null;
|
|
167
|
+
return { pr, url, branch, head_sha, base_ref, node_id, plan_id };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function decodeSnapshotRows(raw: unknown): StackSnapshotRow[] | null {
|
|
171
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
172
|
+
const rows: StackSnapshotRow[] = [];
|
|
173
|
+
for (const item of raw) {
|
|
174
|
+
const row = decodeSnapshotRow(item);
|
|
175
|
+
if (row === null) return null;
|
|
176
|
+
rows.push(row);
|
|
177
|
+
}
|
|
178
|
+
return rows;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function decodeStringArray(raw: unknown): string[] | null {
|
|
182
|
+
if (!Array.isArray(raw)) return null;
|
|
183
|
+
return raw.every((n) => typeof n === "string") ? (raw as string[]) : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Strict decode of the `--stack` checkout envelope (the pinned snapshot the door reads). */
|
|
187
|
+
export function decodeStackCheckout(payload: ColdJson): StackCheckoutOk | null {
|
|
188
|
+
const base = decodeCheckout(payload);
|
|
189
|
+
if (base === null) return null;
|
|
190
|
+
const stack = decodeSnapshotRows(payload.stack);
|
|
191
|
+
const stackNotes = decodeStringArray(payload.stack_notes);
|
|
192
|
+
if (stack === null || stackNotes === null) return null;
|
|
193
|
+
return { ...base, stack, stack_notes: stackNotes };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ------------------------------------------------------------------------ guidance
|
|
197
|
+
|
|
198
|
+
/** The guidance inputs (the pinned snapshot slice both entry paths render from). */
|
|
199
|
+
export interface StackReviewGuidanceOpts {
|
|
200
|
+
topPr: number;
|
|
201
|
+
checkout: string;
|
|
202
|
+
stackBase: string;
|
|
203
|
+
/** Ordered bottom→top. */
|
|
204
|
+
members: StackSnapshotRow[];
|
|
205
|
+
notes: string[];
|
|
206
|
+
directive?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The seed guidance both entry paths share verbatim (the warm door injects it; the cold-launch
|
|
211
|
+
* tool returns it as ok text). Pure + exported for offline tests. The member table and notes
|
|
212
|
+
* are pre-rendered here (the frozen mini-jinja subset has no loops).
|
|
213
|
+
*/
|
|
214
|
+
export function stackReviewGuidance(opts: StackReviewGuidanceOpts): string {
|
|
215
|
+
const table = opts.members
|
|
216
|
+
.map((member, index) => {
|
|
217
|
+
const node = member.node_id === null ? "" : ` · node ${member.node_id}`;
|
|
218
|
+
const plan = member.plan_id === null ? "" : ` · plan #${member.plan_id}`;
|
|
219
|
+
return (
|
|
220
|
+
`${index + 1}. PR #${member.pr} \`${member.branch}\` ← \`${member.base_ref}\`` +
|
|
221
|
+
`${node}${plan} — ${member.url}`
|
|
222
|
+
);
|
|
223
|
+
})
|
|
224
|
+
.join("\n");
|
|
225
|
+
const notes = opts.notes.map((note) => `- ${note}`).join("\n");
|
|
226
|
+
return render("stages/stack-review-browser/stack.md", {
|
|
227
|
+
top_pr: String(opts.topPr),
|
|
228
|
+
checkout: opts.checkout,
|
|
229
|
+
stack_base: opts.stackBase,
|
|
230
|
+
member_count: String(opts.members.length),
|
|
231
|
+
stack_table: table,
|
|
232
|
+
notes,
|
|
233
|
+
directive: opts.directive ?? "",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ------------------------------------------------------------------------ the degrade notice
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The stack degrade notice (browser never ready): findings render in-session and the triage
|
|
241
|
+
* runs conversationally; the routing + per-PR posting protocol is unchanged — it never
|
|
242
|
+
* depended on the browser.
|
|
243
|
+
*/
|
|
244
|
+
export const STACK_DEGRADE_NOTICE =
|
|
245
|
+
"The plannotator browser review is unavailable (the review server never became ready) — " +
|
|
246
|
+
"degrade in-session: render the reviewers' reconciled findings as a table in your reply and " +
|
|
247
|
+
"run the same triage loop conversationally. The annotation surface is cleared — " +
|
|
248
|
+
"`push_annotations` now refuses (`no_surface`); render findings in-session. The routing + " +
|
|
249
|
+
"per-PR posting protocol is unchanged (it never depended on the browser): dry-run ALL " +
|
|
250
|
+
"per-PR batches first, then post bottom→top via `submit_pr_review` — only what the human " +
|
|
251
|
+
"approves.";
|
|
252
|
+
|
|
253
|
+
// ------------------------------------------------------------------------ the shared open
|
|
254
|
+
|
|
255
|
+
/** Open the stack browser session through the extracted lifecycle core (both entry paths). */
|
|
256
|
+
async function openStackBrowser(
|
|
257
|
+
pi: ExtensionAPI,
|
|
258
|
+
ctx: ExtensionContext,
|
|
259
|
+
opts: {
|
|
260
|
+
checkoutPath: string;
|
|
261
|
+
stackBaseRef: string;
|
|
262
|
+
guidance: string;
|
|
263
|
+
injectGuidance: boolean;
|
|
264
|
+
},
|
|
265
|
+
): Promise<boolean> {
|
|
266
|
+
return await openReviewBrowserCore(pi, ctx, {
|
|
267
|
+
scope: SCOPE,
|
|
268
|
+
browserOpts: {
|
|
269
|
+
cwd: opts.checkoutPath,
|
|
270
|
+
diffType: LOCAL_REVIEW_DIFF_TYPE,
|
|
271
|
+
// The remote-tracking ref the checkout materialized — an explicit base plannotator
|
|
272
|
+
// trusts verbatim (a bare branch name would degrade to an empty HEAD diff).
|
|
273
|
+
defaultBranch: `origin/${opts.stackBaseRef}`,
|
|
274
|
+
},
|
|
275
|
+
guidance: opts.guidance,
|
|
276
|
+
degradeNotice: STACK_DEGRADE_NOTICE,
|
|
277
|
+
respondMessageFor: stackRespondMessage,
|
|
278
|
+
injectGuidance: opts.injectGuidance,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ------------------------------------------------------------------------ the warm door
|
|
283
|
+
|
|
284
|
+
/** Register the warm `/stack-review-browser` command (posting rides submit_pr_review). */
|
|
285
|
+
export function registerStackReviewBrowser(pi: ExtensionAPI): void {
|
|
286
|
+
registerPerkCommand(pi, SCOPE, {
|
|
287
|
+
description:
|
|
288
|
+
"Review a whole PR stack human-in-the-loop in the plannotator browser UI over the " +
|
|
289
|
+
"combined diff: no arg reviews the session/plan-ref objective's delivery train; an " +
|
|
290
|
+
"objective id (42, #42, ENG-123) or issue/project URL targets that objective; pr:<n> or " +
|
|
291
|
+
"a PR URL walks the base-ref chain. Any other text is a focus note. Posting is " +
|
|
292
|
+
"perk-side, judgment-routed per member PR.",
|
|
293
|
+
handler: async (args, ctx: ExtensionContext) => {
|
|
294
|
+
// Entry gates, in order — nothing executed on refusal, each a loud error.
|
|
295
|
+
const parsed = parseStackReviewArgs(args ?? "");
|
|
296
|
+
if (parsed === null) {
|
|
297
|
+
report(
|
|
298
|
+
ctx,
|
|
299
|
+
SCOPE,
|
|
300
|
+
"error",
|
|
301
|
+
"usage: /stack-review-browser [objective id|issue URL|pr:<n>|PR URL] [focus note]",
|
|
302
|
+
);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!ctx.hasUI) {
|
|
306
|
+
report(
|
|
307
|
+
ctx,
|
|
308
|
+
SCOPE,
|
|
309
|
+
"error",
|
|
310
|
+
"/stack-review-browser requires an interactive session — the plannotator browser " +
|
|
311
|
+
"surface and the human are constitutive",
|
|
312
|
+
);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (!plannotatorPresent(pi)) {
|
|
316
|
+
report(
|
|
317
|
+
ctx,
|
|
318
|
+
SCOPE,
|
|
319
|
+
"error",
|
|
320
|
+
"the plannotator extension is not loaded (its /plannotator-review command was not " +
|
|
321
|
+
"found) — select the plannotator plan provider (`[providers] plan = " +
|
|
322
|
+
'"plannotator-plan"`), run `perk init`, then restart pi',
|
|
323
|
+
);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const argv = ["pr", "review", "checkout", "--stack"];
|
|
328
|
+
if (parsed.target.kind === "pr") {
|
|
329
|
+
argv.push("--pr", String(parsed.target.pr));
|
|
330
|
+
} else if (parsed.target.kind === "objective") {
|
|
331
|
+
argv.push("--objective", parsed.target.id);
|
|
332
|
+
} else {
|
|
333
|
+
// The no-target ladder: the session's active objective, passed EXPLICITLY; else the
|
|
334
|
+
// worker's own cache.plan-ref arm (bare --stack).
|
|
335
|
+
const active = rebuildWorkflowState(branchOf(ctx)).active_objective;
|
|
336
|
+
if (typeof active === "string" && active.trim() !== "") {
|
|
337
|
+
argv.push("--objective", active.trim());
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
argv.push("--json");
|
|
341
|
+
|
|
342
|
+
const checkout = await runColdDoor<StackCheckoutOk>(pi, ctx, argv, {
|
|
343
|
+
label: "perk pr review checkout --stack",
|
|
344
|
+
decode: decodeStackCheckout,
|
|
345
|
+
});
|
|
346
|
+
if (!checkout.ok) {
|
|
347
|
+
if (checkout.errorType === "no_objective") {
|
|
348
|
+
report(
|
|
349
|
+
ctx,
|
|
350
|
+
SCOPE,
|
|
351
|
+
"error",
|
|
352
|
+
"no stack target: pass an objective id / issue URL, pr:<n> / a PR URL, or run " +
|
|
353
|
+
"from a session/worktree linked to a stacked objective",
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
report(
|
|
358
|
+
ctx,
|
|
359
|
+
SCOPE,
|
|
360
|
+
"error",
|
|
361
|
+
`perk pr review checkout --stack failed (${checkout.errorType}): ${checkout.message}`,
|
|
362
|
+
{ alsoLog: true },
|
|
363
|
+
);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const data = checkout.data;
|
|
368
|
+
report(
|
|
369
|
+
ctx,
|
|
370
|
+
SCOPE,
|
|
371
|
+
"info",
|
|
372
|
+
`stack of ${data.stack.length} PRs (base ${data.base_ref}, top #${data.pr})` +
|
|
373
|
+
(parsed.directive
|
|
374
|
+
? ` → adversarial reviewers (focus: ${parsed.directive})`
|
|
375
|
+
: " → adversarial reviewers") +
|
|
376
|
+
" → plannotator browser triage → judgment-routed per-PR posting",
|
|
377
|
+
);
|
|
378
|
+
await openStackBrowser(pi, ctx, {
|
|
379
|
+
checkoutPath: data.path,
|
|
380
|
+
stackBaseRef: data.base_ref,
|
|
381
|
+
guidance:
|
|
382
|
+
stackReviewGuidance({
|
|
383
|
+
topPr: data.pr,
|
|
384
|
+
checkout: data.path,
|
|
385
|
+
stackBase: data.base_ref,
|
|
386
|
+
members: data.stack,
|
|
387
|
+
notes: data.stack_notes,
|
|
388
|
+
...(parsed.directive ? { directive: parsed.directive } : {}),
|
|
389
|
+
}) + bindingSuffix(ctx.cwd, `command:${SCOPE}`),
|
|
390
|
+
injectGuidance: true,
|
|
391
|
+
});
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ------------------------------------------------------------------------ the cold-launch tool
|
|
397
|
+
|
|
398
|
+
/** The decoded `stack_review` launch binding (the launcher's `handoff_extra` blob) — exactly
|
|
399
|
+
* what the tool consumes: the pinned snapshot rows, the checkout path, the notes, and the
|
|
400
|
+
* focus. The top PR and the stack base are DERIVED from the ordered rows (last row's `pr`;
|
|
401
|
+
* first row's `base_ref`), never carried redundantly. */
|
|
402
|
+
export interface StackReviewBinding {
|
|
403
|
+
stack: StackSnapshotRow[];
|
|
404
|
+
checkout_path: string;
|
|
405
|
+
notes: string[];
|
|
406
|
+
focus: string | null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The derived stack endpoints (the binding's rows are ordered bottom→top, never empty). */
|
|
410
|
+
export function bindingTopPr(binding: StackReviewBinding): number {
|
|
411
|
+
const top = binding.stack[binding.stack.length - 1];
|
|
412
|
+
return top === undefined ? 0 : top.pr;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function bindingBaseRef(binding: StackReviewBinding): string {
|
|
416
|
+
return binding.stack[0]?.base_ref ?? "";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Strict decode of the handoff's `stack_review` blob; null on ANY drift (⇒ bad_state). Every
|
|
421
|
+
* field is REQUIRED — `stack` a non-empty row array, `checkout_path` a non-empty string,
|
|
422
|
+
* `notes` a string array, `focus` present as a string or null (the one normalization: a
|
|
423
|
+
* blank/whitespace-only focus string decodes to null — "no focus", matching the launcher's
|
|
424
|
+
* no-flag arm).
|
|
425
|
+
*/
|
|
426
|
+
export function decodeStackReviewBinding(raw: unknown): StackReviewBinding | null {
|
|
427
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
428
|
+
const b = raw as Record<string, unknown>;
|
|
429
|
+
const stack = decodeSnapshotRows(b.stack);
|
|
430
|
+
const notes = decodeStringArray(b.notes);
|
|
431
|
+
if (stack === null || notes === null) return null;
|
|
432
|
+
if (typeof b.checkout_path !== "string" || b.checkout_path === "") return null;
|
|
433
|
+
if (!("focus" in b)) return null;
|
|
434
|
+
if (b.focus !== null && typeof b.focus !== "string") return null;
|
|
435
|
+
return {
|
|
436
|
+
stack,
|
|
437
|
+
checkout_path: b.checkout_path,
|
|
438
|
+
notes,
|
|
439
|
+
focus: typeof b.focus === "string" && b.focus.trim() !== "" ? b.focus : null,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Recover the launch binding: rebuilt workflow-state run_id → the run's handoff blob (the
|
|
444
|
+
* `audit_bundle_dir` recovery seam). Null when absent — i.e. in every session that is not a
|
|
445
|
+
* claimed `perk objective stack review` launch. */
|
|
446
|
+
export function stackReviewBindingOf(ctx: ExtensionContext): StackReviewBinding | null {
|
|
447
|
+
const runId = rebuildWorkflowState(branchOf(ctx)).run_id;
|
|
448
|
+
if (runId === undefined || runId === "") return null;
|
|
449
|
+
const raw = readHandoff(ctx.cwd, runId)?.stack_review;
|
|
450
|
+
if (raw === undefined) return null;
|
|
451
|
+
return decodeStackReviewBinding(raw);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const TOOL_GUIDELINES = [
|
|
455
|
+
"Call open_stack_review ONCE, with no arguments, inside the perk objective stack review session — the stack snapshot is bound to the session by the cold door (launch handoff), never passed by you.",
|
|
456
|
+
"Follow the returned guidance exactly: launch the reviewer wave with stack: true, stream findings via push_annotations, and run the judgment-routed per-PR posting protocol through submit_pr_review (dry-run ALL batches first, bottom→top, only what the human approves).",
|
|
457
|
+
"The tool is single-use per session; a bad_state failure means this session is not a stack-review launch (or the checkout is gone) — re-run perk objective stack review.",
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
/** The single-use latch (registration-scoped state, injectable for the execute-core tests). */
|
|
461
|
+
export interface OpenLatch {
|
|
462
|
+
opened: boolean;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The injectable browser-open seam (the execute-core tests force the failure arm). */
|
|
466
|
+
type StackBrowserOpen = typeof openStackBrowser;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The `open_stack_review` execute core (exported for direct tests — the `executeStartReviewWave`
|
|
470
|
+
* posture): every gate in registration order, then the shared browser open.
|
|
471
|
+
*/
|
|
472
|
+
export async function executeOpenStackReview(
|
|
473
|
+
pi: ExtensionAPI,
|
|
474
|
+
ctx: ExtensionContext,
|
|
475
|
+
latch: OpenLatch,
|
|
476
|
+
open: StackBrowserOpen = openStackBrowser,
|
|
477
|
+
): Promise<ReturnType<typeof ok> | ReturnType<ReturnType<typeof failFor>>> {
|
|
478
|
+
const fail = failFor(ctx, "open_stack_review");
|
|
479
|
+
if (!ctx.hasUI) {
|
|
480
|
+
return fail(
|
|
481
|
+
"open_stack_review requires an interactive session — the plannotator browser " +
|
|
482
|
+
"surface and the human are constitutive",
|
|
483
|
+
"headless",
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
if (latch.opened) {
|
|
487
|
+
return fail(
|
|
488
|
+
"the stack review browser was already opened in this session (single-use) — " +
|
|
489
|
+
"continue the flow from the earlier guidance",
|
|
490
|
+
"bad_state",
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
// The structural binding: no param exists, so the ONLY reachable snapshot is the one the
|
|
494
|
+
// cold door bound into this session's launch handoff.
|
|
495
|
+
const binding = stackReviewBindingOf(ctx);
|
|
496
|
+
if (binding === null) {
|
|
497
|
+
return fail(
|
|
498
|
+
"no stack_review binding in this session's launch state — open_stack_review runs " +
|
|
499
|
+
"only inside a perk objective stack review session",
|
|
500
|
+
"bad_state",
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (!existsSync(binding.checkout_path)) {
|
|
504
|
+
return fail(
|
|
505
|
+
`the stack checkout is missing at '${binding.checkout_path}' — re-run perk ` +
|
|
506
|
+
"objective stack review",
|
|
507
|
+
"bad_state",
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
if (!plannotatorPresent(pi)) {
|
|
511
|
+
return fail(
|
|
512
|
+
"the plannotator extension is not loaded (its /plannotator-review command was not " +
|
|
513
|
+
"found) — select the plannotator plan provider, run `perk init`, then restart pi",
|
|
514
|
+
"plannotator_missing",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
const guidance = stackReviewGuidance({
|
|
518
|
+
topPr: bindingTopPr(binding),
|
|
519
|
+
checkout: binding.checkout_path,
|
|
520
|
+
stackBase: bindingBaseRef(binding),
|
|
521
|
+
members: binding.stack,
|
|
522
|
+
notes: binding.notes,
|
|
523
|
+
...(binding.focus !== null ? { directive: binding.focus } : {}),
|
|
524
|
+
});
|
|
525
|
+
const started = await open(pi, ctx, {
|
|
526
|
+
checkoutPath: binding.checkout_path,
|
|
527
|
+
stackBaseRef: bindingBaseRef(binding),
|
|
528
|
+
guidance,
|
|
529
|
+
injectGuidance: false,
|
|
530
|
+
});
|
|
531
|
+
if (!started) {
|
|
532
|
+
return fail(
|
|
533
|
+
"could not start the plannotator review server (no free local port) — see the " +
|
|
534
|
+
"error report",
|
|
535
|
+
"browser_failed",
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
latch.opened = true;
|
|
539
|
+
return ok(guidance, {
|
|
540
|
+
top_pr: bindingTopPr(binding),
|
|
541
|
+
checkout_path: binding.checkout_path,
|
|
542
|
+
member_count: binding.stack.length,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Register the parameterless `open_stack_review` tool (the `run_audit_wave` posture) and reset
|
|
548
|
+
* its single-use latch (a fresh registration is a fresh session).
|
|
549
|
+
*/
|
|
550
|
+
export function registerOpenStackReview(pi: ExtensionAPI): void {
|
|
551
|
+
const latch: OpenLatch = { opened: false };
|
|
552
|
+
|
|
553
|
+
pi.registerTool({
|
|
554
|
+
name: "open_stack_review",
|
|
555
|
+
label: "Open stack review",
|
|
556
|
+
description:
|
|
557
|
+
"Open the launch-bound stacked-PR browser review (the perk objective stack review " +
|
|
558
|
+
"session's ONE opener): starts the plannotator browser over the combined stack diff, " +
|
|
559
|
+
"primes the annotation surface, and returns the full flow guidance. No parameters: the " +
|
|
560
|
+
"stack snapshot comes only from the launch handoff. Single-use per session.",
|
|
561
|
+
promptSnippet: "Open the launch-bound stacked-PR browser review",
|
|
562
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
563
|
+
executionMode: "sequential",
|
|
564
|
+
parameters: {
|
|
565
|
+
type: "object",
|
|
566
|
+
additionalProperties: false,
|
|
567
|
+
properties: {},
|
|
568
|
+
},
|
|
569
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
570
|
+
return await executeOpenStackReview(pi, ctx, latch);
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
}
|