@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
|
@@ -372,6 +372,57 @@ export function respondMessage(outcome: CodeReviewOutcome): string | null {
|
|
|
372
372
|
return parts.join("\n\n");
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
+
/**
|
|
376
|
+
* The stack-flow respond → injection mapping (`/stack-review-browser`): the same arms as
|
|
377
|
+
* `respondMessage`, re-worded for the stack posting policy — a local-diff session has NO
|
|
378
|
+
* attached PR, so the browser posted nothing and ALL GitHub posting is perk-side after triage
|
|
379
|
+
* (per-PR, judgment-routed, human-approved). Returned annotations are treated as COMBINED-DIFF
|
|
380
|
+
* coordinates (stack base → top head).
|
|
381
|
+
*/
|
|
382
|
+
export function stackRespondMessage(outcome: CodeReviewOutcome): string | null {
|
|
383
|
+
if (outcome.status !== "handled") return null;
|
|
384
|
+
if (outcome.exit) {
|
|
385
|
+
return (
|
|
386
|
+
"The human closed the plannotator review without submitting — ask them how they want " +
|
|
387
|
+
"to proceed."
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (outcome.approved && outcome.annotations.length === 0) {
|
|
391
|
+
return (
|
|
392
|
+
"The human approved the stack review in plannotator (no annotations) — the review is " +
|
|
393
|
+
"complete. This local-diff session has no attached PR, so nothing was posted from the " +
|
|
394
|
+
"browser: ask the human whether they want per-PR COMMENT reviews posted (the routing + " +
|
|
395
|
+
"per-PR posting protocol via `submit_pr_review`) or nothing — perk posts only what the " +
|
|
396
|
+
"human approves."
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
const parts: string[] = [outcome.feedback ?? "The plannotator stack review returned."];
|
|
400
|
+
if (outcome.annotations.length > 0) {
|
|
401
|
+
parts.push(`\`\`\`json\n${JSON.stringify(outcome.annotations, null, 2)}\n\`\`\``);
|
|
402
|
+
parts.push(
|
|
403
|
+
"These annotations are in COMBINED-DIFF coordinates (stack base → top head): " +
|
|
404
|
+
"source-less ones are human-authored; `perk:*`-badged ones are your own findings " +
|
|
405
|
+
"returning. This local-diff session has no attached PR — nothing was posted from the " +
|
|
406
|
+
"browser, so ALL GitHub posting is perk-side: run the routing + per-PR posting " +
|
|
407
|
+
"protocol from the guidance (route each finding to the PR that introduced it over the " +
|
|
408
|
+
"per-PR diffs, sanity-check each quoted context against the target PR's diff, dry-run " +
|
|
409
|
+
"ALL per-PR batches first, then post bottom→top via `submit_pr_review`) — posting only " +
|
|
410
|
+
"what the human approves.",
|
|
411
|
+
);
|
|
412
|
+
} else {
|
|
413
|
+
// Feedback without annotations still needs the stack posting framing — the human may
|
|
414
|
+
// expect their words to reach GitHub, and nothing was posted from the browser.
|
|
415
|
+
parts.push(
|
|
416
|
+
"No annotations came back with this feedback. This local-diff session has no attached " +
|
|
417
|
+
"PR — nothing was posted from the browser, so any GitHub posting stays perk-side: if " +
|
|
418
|
+
"the feedback warrants per-PR reviews, run the guidance's routing + per-PR posting " +
|
|
419
|
+
"protocol (dry-run ALL per-PR batches first, then post bottom→top via " +
|
|
420
|
+
"`submit_pr_review`) — posting only what the human approves.",
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return parts.join("\n\n");
|
|
424
|
+
}
|
|
425
|
+
|
|
375
426
|
/** The minimal message sink `routeBrowserRespond` needs (an `ExtensionAPI` slice). */
|
|
376
427
|
export interface RespondSink {
|
|
377
428
|
sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
|
|
@@ -379,20 +430,24 @@ export interface RespondSink {
|
|
|
379
430
|
|
|
380
431
|
/**
|
|
381
432
|
* Route a settled PR-mode respond into the session (the idle-vs-streaming injection route),
|
|
382
|
-
* shared by `/pr-review-browser`'s PR modes
|
|
433
|
+
* shared by `/pr-review-browser`'s PR modes and `/stack-review-browser`. `scope` is the
|
|
434
|
+
* invoking surface's report scope; `messageFor` is the injectable respond → message mapper
|
|
435
|
+
* (default `respondMessage` — the single-PR posting contract; the stack flow supplies
|
|
436
|
+
* `stackRespondMessage`).
|
|
383
437
|
*/
|
|
384
438
|
export function routeBrowserRespond(
|
|
385
439
|
pi: RespondSink,
|
|
386
440
|
ctx: ReportTarget & Pick<ExtensionContext, "isIdle">,
|
|
387
441
|
out: CodeReviewOutcome,
|
|
388
442
|
scope: string,
|
|
443
|
+
messageFor: (outcome: CodeReviewOutcome) => string | null = respondMessage,
|
|
389
444
|
): void {
|
|
390
445
|
if (out.status === "unavailable" || out.status === "error") {
|
|
391
446
|
// Degrade-mid-flow: the flow continues in-session (findings table; posting unchanged).
|
|
392
447
|
report(ctx, scope, "error", out.warning, { alsoLog: true });
|
|
393
448
|
return;
|
|
394
449
|
}
|
|
395
|
-
const message =
|
|
450
|
+
const message = messageFor(out);
|
|
396
451
|
if (message === null) return; // aborted: the turn was interrupted — no-op
|
|
397
452
|
if (ctx.isIdle()) {
|
|
398
453
|
pi.sendUserMessage(message);
|
|
@@ -539,18 +594,35 @@ async function startPlannotatorSurface<T>(
|
|
|
539
594
|
|
|
540
595
|
/**
|
|
541
596
|
* The composable code-review browser open: the engine with launch = the `code-review` bridge
|
|
542
|
-
* request
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
597
|
+
* request and the `/api/diff` readiness route (`bridgePromise` is the single respond —
|
|
598
|
+
* code-review has no handshake). PR mode passes `{prUrl, cwd}` — the payload stays
|
|
599
|
+
* byte-identical to the original shape because the optional local-mode fields (`diffType`,
|
|
600
|
+
* `defaultBranch`) render ONLY when defined (`requestPlannotatorCodeReview` builds the payload
|
|
601
|
+
* conditionally). The stack door supplies the local-mode trio
|
|
602
|
+
* `{cwd, diffType: "since-base", defaultBranch: "origin/<stack base>"}` instead of a PR URL.
|
|
603
|
+
* Plannotator's defaults are otherwise untouched (deliberately NOT `useLocal: false` — the
|
|
604
|
+
* human chose the full surface).
|
|
546
605
|
*/
|
|
547
606
|
export async function startPlannotatorBrowser(
|
|
548
607
|
bus: PlannotatorBus,
|
|
549
|
-
opts: {
|
|
608
|
+
opts: {
|
|
609
|
+
cwd: string;
|
|
610
|
+
prUrl?: string;
|
|
611
|
+
diffType?: string;
|
|
612
|
+
defaultBranch?: string;
|
|
613
|
+
signal?: AbortSignal;
|
|
614
|
+
},
|
|
550
615
|
deps: StartBrowserDeps = {},
|
|
551
616
|
): Promise<StartedBrowser> {
|
|
552
617
|
return await startPlannotatorSurface(
|
|
553
|
-
(signal) =>
|
|
618
|
+
(signal) =>
|
|
619
|
+
requestPlannotatorCodeReview(bus, {
|
|
620
|
+
cwd: opts.cwd,
|
|
621
|
+
...(opts.prUrl !== undefined ? { prUrl: opts.prUrl } : {}),
|
|
622
|
+
...(opts.diffType !== undefined ? { diffType: opts.diffType } : {}),
|
|
623
|
+
...(opts.defaultBranch !== undefined ? { defaultBranch: opts.defaultBranch } : {}),
|
|
624
|
+
...(signal !== undefined ? { signal } : {}),
|
|
625
|
+
}),
|
|
554
626
|
CODE_REVIEW_READINESS_PROBE_PATH,
|
|
555
627
|
opts.signal,
|
|
556
628
|
deps,
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// (soft `details.ok`, mirrors resolveReviewThreads). This is documented in shared/contracts.md §8.3.
|
|
22
22
|
//
|
|
23
23
|
// The review model is configurable via `[models.subagents] pr-reviewer` in `.perk/config.toml`; because
|
|
24
|
-
// `
|
|
24
|
+
// an `agentOverrides` model can never displace a perk def's frontmatter-pinned `model:` (the ≥0.52
|
|
25
|
+
// custom-agent override path is a frontmatter-sensitive fill), `run_pr_review_wave` applies that model
|
|
25
26
|
// as the wave's workflow-level `model` default applied to every lane (the agent's frontmatter model
|
|
26
27
|
// is the default).
|
|
27
28
|
//
|
|
@@ -45,6 +45,7 @@ import { type ReportTarget, report } from "../surfaces/report.ts";
|
|
|
45
45
|
import { clearAnnotationSurface, primeAnnotationSurface } from "./annotationPush.ts";
|
|
46
46
|
import { type CheckoutOk, decodeCheckout } from "./hunkHandoff.ts";
|
|
47
47
|
import {
|
|
48
|
+
type CodeReviewOutcome,
|
|
48
49
|
decodePrUrl,
|
|
49
50
|
LOCAL_REVIEW_DIFF_TYPE,
|
|
50
51
|
plannotatorPresent,
|
|
@@ -95,8 +96,9 @@ export function prReviewBrowserGuidance(opts: PrReviewBrowserGuidanceOpts): stri
|
|
|
95
96
|
/**
|
|
96
97
|
* The degrade notice injected when the browser never comes up — the model renders the findings
|
|
97
98
|
* in-session and runs the same triage conversationally; the posting contract is unchanged.
|
|
99
|
+
* Exported as the `openReviewBrowserCore` default (the stack door supplies its own).
|
|
98
100
|
*/
|
|
99
|
-
const DEGRADE_NOTICE =
|
|
101
|
+
export const DEGRADE_NOTICE =
|
|
100
102
|
"The plannotator browser review is unavailable (the review server never became ready) — " +
|
|
101
103
|
"degrade in-session: render the reviewers' reconciled findings as a table in your reply and " +
|
|
102
104
|
"run the same triage loop conversationally. The annotation surface is cleared — " +
|
|
@@ -116,10 +118,13 @@ export async function observeBrowserReadiness(
|
|
|
116
118
|
pi: RespondSink,
|
|
117
119
|
ctx: ReportTarget & Pick<ExtensionContext, "isIdle">,
|
|
118
120
|
started: StartedBrowser,
|
|
121
|
+
opts?: { scope?: string; degradeNotice?: string },
|
|
119
122
|
): Promise<void> {
|
|
123
|
+
const scope = opts?.scope ?? SCOPE;
|
|
124
|
+
const degradeNotice = opts?.degradeNotice ?? DEGRADE_NOTICE;
|
|
120
125
|
const state = await started.readiness;
|
|
121
126
|
if (state === "ready") {
|
|
122
|
-
report(ctx,
|
|
127
|
+
report(ctx, scope, "info", `plannotator is up at ${started.url} — browser opening`);
|
|
123
128
|
return;
|
|
124
129
|
}
|
|
125
130
|
if (state === "aborted") return; // the turn was interrupted — no-op
|
|
@@ -129,71 +134,95 @@ export async function observeBrowserReadiness(
|
|
|
129
134
|
}
|
|
130
135
|
report(
|
|
131
136
|
ctx,
|
|
132
|
-
|
|
137
|
+
scope,
|
|
133
138
|
"error",
|
|
134
139
|
`the plannotator review server did not become ready at ${started.url} — the browser ` +
|
|
135
140
|
"review is unavailable",
|
|
136
141
|
{ alsoLog: true },
|
|
137
142
|
);
|
|
138
143
|
if (ctx.isIdle()) {
|
|
139
|
-
pi.sendUserMessage(
|
|
144
|
+
pi.sendUserMessage(degradeNotice);
|
|
140
145
|
} else {
|
|
141
|
-
pi.sendUserMessage(
|
|
146
|
+
pi.sendUserMessage(degradeNotice, { deliverAs: "followUp" });
|
|
142
147
|
}
|
|
143
148
|
// Consistent with "render findings in-session": a post-degrade push_annotations refuses
|
|
144
149
|
// loudly (`no_surface`). Idempotent beside the bridge-settle clear.
|
|
145
150
|
clearAnnotationSurface();
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
/** The parameterized browser-lifecycle core's options (see `openReviewBrowserCore`). */
|
|
154
|
+
export interface ReviewBrowserCoreOpts {
|
|
155
|
+
/** The invoking door's report scope. */
|
|
156
|
+
scope: string;
|
|
157
|
+
/** The `startPlannotatorBrowser` opts minus `signal` (the core threads `ctx.signal`). */
|
|
158
|
+
browserOpts: { cwd: string; prUrl?: string; diffType?: string; defaultBranch?: string };
|
|
159
|
+
/** The fully composed guidance to inject (binding suffix included by the caller). */
|
|
160
|
+
guidance: string;
|
|
161
|
+
/** The degrade notice for the browser-never-ready arm (default: the PR-mode notice). */
|
|
162
|
+
degradeNotice?: string;
|
|
163
|
+
/** The respond → injection mapper (default: `respondMessage` — the single-PR contract). */
|
|
164
|
+
respondMessageFor?: (outcome: CodeReviewOutcome) => string | null;
|
|
165
|
+
/**
|
|
166
|
+
* Whether the core injects `guidance` as a user message at the end (default true — the warm
|
|
167
|
+
* doors). The `open_stack_review` tool passes false and returns the guidance as its ok text
|
|
168
|
+
* instead (the tool result is the seeded session's delivery channel).
|
|
169
|
+
*/
|
|
170
|
+
injectGuidance?: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
148
173
|
/**
|
|
149
|
-
* The
|
|
150
|
-
*
|
|
151
|
-
* the
|
|
152
|
-
*
|
|
153
|
-
*
|
|
174
|
+
* The full browser-lifecycle core, extracted from the PR-mode arm and parameterized for the
|
|
175
|
+
* stack door: start the browser open in the background, prime the annotation surface the
|
|
176
|
+
* moment the port is picked (push_annotations now serves this browser session), observe
|
|
177
|
+
* readiness in the background (degrade notice on never-ready), route the bridge respond
|
|
178
|
+
* through the injectable mapper, clear the surface on settle, and inject the guidance
|
|
179
|
+
* immediately (the URL is deterministic once the port is picked). While plannotator sets up,
|
|
180
|
+
* its in-process `console.error` chatter re-routes through the TUI-safe report() seam (the
|
|
181
|
+
* debounce restores once setup goes quiet, with the `finally` as a backstop).
|
|
182
|
+
*
|
|
183
|
+
* Accepted stale-clear edge (unchanged from the pre-extraction arm): a second browser door
|
|
184
|
+
* while this browser is still open re-primes (a new browser session supersedes everything),
|
|
185
|
+
* and THIS bridge's later settle would clear the second session's surface — rare and loud
|
|
186
|
+
* (the fixed-port EADDRINUSE caveat, contracts §8.4), noted, not engineered around.
|
|
154
187
|
*/
|
|
155
|
-
async function
|
|
188
|
+
export async function openReviewBrowserCore(
|
|
156
189
|
pi: ExtensionAPI,
|
|
157
190
|
ctx: ExtensionContext,
|
|
158
|
-
opts:
|
|
159
|
-
): Promise<
|
|
191
|
+
opts: ReviewBrowserCoreOpts,
|
|
192
|
+
): Promise<boolean> {
|
|
160
193
|
let started: StartedBrowser;
|
|
161
194
|
try {
|
|
162
195
|
started = await startPlannotatorBrowser(pi.events, {
|
|
163
|
-
|
|
164
|
-
cwd: ctx.cwd,
|
|
196
|
+
...opts.browserOpts,
|
|
165
197
|
signal: ctx.signal,
|
|
166
198
|
});
|
|
167
199
|
} catch (error) {
|
|
168
200
|
const detail = error instanceof Error ? error.message : String(error);
|
|
169
201
|
report(
|
|
170
202
|
ctx,
|
|
171
|
-
|
|
203
|
+
opts.scope,
|
|
172
204
|
"error",
|
|
173
205
|
`could not pick a free local port for the plannotator review server: ${detail}`,
|
|
174
206
|
{ alsoLog: true },
|
|
175
207
|
);
|
|
176
|
-
return;
|
|
208
|
+
return false;
|
|
177
209
|
}
|
|
178
210
|
|
|
179
|
-
// Prime the annotation surface the moment the port is picked (the URL is deterministic — see
|
|
180
|
-
// the background-open header note): push_annotations now serves this browser session. Accepted
|
|
181
|
-
// stale-clear edge: a second /pr-review-browser while this browser is still open re-primes (a
|
|
182
|
-
// new browser session supersedes everything), and THIS bridge's later settle would clear the
|
|
183
|
-
// second session's surface — the overlap is already rare and loud (the fixed-port EADDRINUSE
|
|
184
|
-
// caveat, contracts §8.4), so it is noted, not engineered around.
|
|
185
211
|
primeAnnotationSurface({ mode: "review", url: started.url });
|
|
186
212
|
|
|
187
|
-
void observeBrowserReadiness(pi, ctx, started
|
|
213
|
+
void observeBrowserReadiness(pi, ctx, started, {
|
|
214
|
+
scope: opts.scope,
|
|
215
|
+
...(opts.degradeNotice !== undefined ? { degradeNotice: opts.degradeNotice } : {}),
|
|
216
|
+
});
|
|
188
217
|
|
|
189
218
|
void (async () => {
|
|
190
|
-
const interceptor = interceptConsoleError((line) => report(ctx,
|
|
219
|
+
const interceptor = interceptConsoleError((line) => report(ctx, opts.scope, "info", line), {
|
|
191
220
|
// plannotator can pause up to ~4s between setup lines — keep the quiet window above that.
|
|
192
221
|
quietMs: 6000,
|
|
193
222
|
});
|
|
194
223
|
try {
|
|
195
224
|
const out = await started.bridgePromise;
|
|
196
|
-
routeBrowserRespond(pi, ctx, out,
|
|
225
|
+
routeBrowserRespond(pi, ctx, out, opts.scope, opts.respondMessageFor);
|
|
197
226
|
} finally {
|
|
198
227
|
// The browser session is over — drop the surface so a later push refuses (`no_surface`).
|
|
199
228
|
clearAnnotationSurface();
|
|
@@ -201,7 +230,26 @@ async function openBrowserAndGuide(
|
|
|
201
230
|
}
|
|
202
231
|
})();
|
|
203
232
|
|
|
204
|
-
pi.sendUserMessage(
|
|
233
|
+
if (opts.injectGuidance !== false) pi.sendUserMessage(opts.guidance);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The shared PR-mode arm (foreign + active): the extracted core with this door's values —
|
|
239
|
+
* PR-mode browser opts, the mode guidance + binding suffix, and the default degrade notice /
|
|
240
|
+
* respond mapper (the byte-stability of the pre-extraction behavior is proven by this door's
|
|
241
|
+
* untouched tests).
|
|
242
|
+
*/
|
|
243
|
+
async function openBrowserAndGuide(
|
|
244
|
+
pi: ExtensionAPI,
|
|
245
|
+
ctx: ExtensionContext,
|
|
246
|
+
opts: PrReviewBrowserGuidanceOpts,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
await openReviewBrowserCore(pi, ctx, {
|
|
249
|
+
scope: SCOPE,
|
|
250
|
+
browserOpts: { prUrl: opts.prUrl, cwd: ctx.cwd },
|
|
251
|
+
guidance: prReviewBrowserGuidance(opts) + bindingSuffix(ctx.cwd, `command:${SCOPE}`),
|
|
252
|
+
});
|
|
205
253
|
}
|
|
206
254
|
|
|
207
255
|
// ------------------------------------------------------------------------ registration
|
package/extension/doors/ready.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
// The warm `/ready` door: the deliberate
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// submit; `/ready` is the explicit gesture that opens the
|
|
5
|
-
//
|
|
1
|
+
// The warm `/ready` door: the deliberate ready gesture. The in-session twin of the Python
|
|
2
|
+
// cold door (`perk pr ready`): a terminating tool + command that DELEGATE the mechanics
|
|
3
|
+
// (mutations canonical in Python). For an incremental plan this is the review gate — perk
|
|
4
|
+
// deliberately does NOT auto-publish on submit; `/ready` is the explicit gesture that opens the
|
|
5
|
+
// draft PR for review. For a STACKED layer it is the deliberate HUMAN handoff made after review +
|
|
6
|
+
// address: it stamps the exact verified published head into the delivery journal (draft AND
|
|
7
|
+
// non-draft PRs — mark-ready mechanics first, then the journal append), and every successful
|
|
8
|
+
// stacked stamp continues into the ready-time reconcile pass (`driveReadyReconcile` — the warm
|
|
9
|
+
// continuation, contracts.md §8.66). Mirrors `submit.ts`: write nothing, delegate via `pi.exec`,
|
|
10
|
+
// surface the structured result, never throw.
|
|
6
11
|
|
|
7
12
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { fetchObjectiveUrl, objectiveReadInstruction } from "../factories/objectivePlan.ts";
|
|
14
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
8
15
|
import {
|
|
9
16
|
booleanField,
|
|
10
17
|
type ColdJson,
|
|
@@ -14,25 +21,92 @@ import {
|
|
|
14
21
|
stringField,
|
|
15
22
|
} from "../substrate/coldDoor.ts";
|
|
16
23
|
import { registerPerkCommand } from "../substrate/command.ts";
|
|
24
|
+
import { resolveIssueBackendId } from "../substrate/config.ts";
|
|
25
|
+
import { render } from "../substrate/prompts.ts";
|
|
17
26
|
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
27
|
+
import type { ToolGating } from "../substrate/toolGating.ts";
|
|
18
28
|
import { report } from "../surfaces/report.ts";
|
|
19
29
|
|
|
20
|
-
|
|
30
|
+
// The drive's strict evidence vocabulary (contracts.md §8.66), local on purpose: this is
|
|
31
|
+
// exact-evidence validation at the continuation boundary, NOT the lenient render vocabulary
|
|
32
|
+
// other stack surfaces use. Both diff-range endpoints must be the full 40-hex lowercase
|
|
33
|
+
// object id; ids must be marker-safe segments.
|
|
34
|
+
const READY_EVIDENCE_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
35
|
+
const READY_FULL_SHA_RE = /^[0-9a-f]{40}$/;
|
|
36
|
+
|
|
37
|
+
/** The stacked handoff cohort — decoded all-or-nothing (advisory detail, never half-rendered).
|
|
38
|
+
* Deliberately facts-only: the worker envelope's `reconcile_notice`/`reconcile_retry` are cold
|
|
39
|
+
* presentation strings — the warm door derives its own retry gesture from `plan`, so missing
|
|
40
|
+
* presentation data can never suppress an otherwise valid continuation. */
|
|
41
|
+
export interface ReadyHandoff {
|
|
42
|
+
objective: string;
|
|
43
|
+
node: string;
|
|
44
|
+
stamped_head: string;
|
|
45
|
+
stamp_advanced: boolean;
|
|
46
|
+
plan: string;
|
|
47
|
+
parent_checkpoint: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state.
|
|
51
|
+
* `stacked` is the worker's own routing fact, passed through so a malformed cohort
|
|
52
|
+
* (`stacked === true`, `handoff` absent) is distinguishable from an incremental result. */
|
|
21
53
|
export interface ReadyOk {
|
|
22
54
|
pr: { number: number; url: string };
|
|
23
55
|
was_draft?: boolean;
|
|
56
|
+
stacked?: boolean;
|
|
57
|
+
handoff?: ReadyHandoff;
|
|
24
58
|
}
|
|
25
59
|
|
|
26
60
|
export type ReadyResult = Result<ReadyOk>;
|
|
61
|
+
export type ReadyDetails = ReadyResult["details"];
|
|
27
62
|
|
|
28
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Narrow the `perk pr ready --json` success payload; strict on `pr`, lenient on the rest.
|
|
65
|
+
*
|
|
66
|
+
* The stacked continuation decodes as ONE cohort: the handoff augmentation is attached only when
|
|
67
|
+
* `stacked === true` AND every cohort field decodes (the two continuation fields `plan` /
|
|
68
|
+
* `parent_checkpoint` included); a partial/wrong-typed cohort is validated-and-dropped whole
|
|
69
|
+
* (the worker's own success already proved the mechanics — advisory detail is never
|
|
70
|
+
* half-rendered), leaving the `stacked` passthrough as the visible mismatch signal. An absent
|
|
71
|
+
* `stacked` (an old worker) is the same no-augmentation arm.
|
|
72
|
+
*/
|
|
29
73
|
function decodeReady(payload: ColdJson): ReadyOk | null {
|
|
30
74
|
const pr = objectField(payload, "pr");
|
|
31
75
|
if (pr === undefined) return null;
|
|
32
76
|
const number = numberField(pr, "number");
|
|
33
77
|
const url = stringField(pr, "url");
|
|
34
78
|
if (number === undefined || url === undefined) return null;
|
|
35
|
-
|
|
79
|
+
const result: ReadyOk = {
|
|
80
|
+
pr: { number, url },
|
|
81
|
+
was_draft: booleanField(payload, "was_draft"),
|
|
82
|
+
stacked: booleanField(payload, "stacked"),
|
|
83
|
+
};
|
|
84
|
+
if (result.stacked === true) {
|
|
85
|
+
const objective = stringField(payload, "objective");
|
|
86
|
+
const node = stringField(payload, "node");
|
|
87
|
+
const stampedHead = stringField(payload, "stamped_head");
|
|
88
|
+
const stampAdvanced = booleanField(payload, "stamp_advanced");
|
|
89
|
+
const plan = stringField(payload, "plan");
|
|
90
|
+
const parentCheckpoint = stringField(payload, "parent_checkpoint");
|
|
91
|
+
if (
|
|
92
|
+
objective !== undefined &&
|
|
93
|
+
node !== undefined &&
|
|
94
|
+
stampedHead !== undefined &&
|
|
95
|
+
stampAdvanced !== undefined &&
|
|
96
|
+
plan !== undefined &&
|
|
97
|
+
parentCheckpoint !== undefined
|
|
98
|
+
) {
|
|
99
|
+
result.handoff = {
|
|
100
|
+
objective,
|
|
101
|
+
node,
|
|
102
|
+
stamped_head: stampedHead,
|
|
103
|
+
stamp_advanced: stampAdvanced,
|
|
104
|
+
plan,
|
|
105
|
+
parent_checkpoint: parentCheckpoint,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
36
110
|
}
|
|
37
111
|
|
|
38
112
|
/**
|
|
@@ -49,39 +123,157 @@ export async function markReady(pi: ExtensionAPI, ctx: ExtensionContext): Promis
|
|
|
49
123
|
if (!r.ok) return fail(r.message, r.errorType);
|
|
50
124
|
|
|
51
125
|
const verb = r.data.was_draft ? "Marked ready" : "Already ready";
|
|
52
|
-
|
|
126
|
+
let message = `${verb}: PR #${r.data.pr.number} is open for review.`;
|
|
127
|
+
const handoff = r.data.handoff;
|
|
128
|
+
if (handoff !== undefined) {
|
|
129
|
+
// Stamp facts only — the continuation is announced by driveReadyReconcile, and only once
|
|
130
|
+
// its refusal arms (gate, cohort, evidence) have actually accepted the drive.
|
|
131
|
+
const stamped = handoff.stamp_advanced ? "Handoff stamped" : "Handoff already stamped";
|
|
132
|
+
message +=
|
|
133
|
+
` ${stamped}: objective #${handoff.objective} node ${handoff.node} at ` +
|
|
134
|
+
`${handoff.stamped_head}.`;
|
|
135
|
+
}
|
|
136
|
+
return ok(message, r.data, { terminate: true });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One loud skipped-pass warning (the stamp itself stands; re-running `/ready` re-enters). */
|
|
140
|
+
function warnSkippedPass(ctx: ExtensionContext, reason: string, retry: string): void {
|
|
141
|
+
report(
|
|
142
|
+
ctx,
|
|
143
|
+
"ready",
|
|
144
|
+
"warning",
|
|
145
|
+
`ready-time reconcile pass not driven — ${reason}. The handoff stamp stands; re-run ${retry} to enter the pass.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* After a successful stacked stamp, drive the session into the ready-time reconcile pass
|
|
151
|
+
* (contracts.md §8.66): inject the rendered `stages/objective-reconcile-ready.md` guidance —
|
|
152
|
+
* the warm twin of the cold wrapper's seeded launch. Fires on EVERY successful stacked stamp,
|
|
153
|
+
* `existed=true` re-stamps included (re-running `/ready` re-enters reconciliation). The
|
|
154
|
+
* refusal arms are LOUD, never silent: a gate-active session (the pass's write tools are
|
|
155
|
+
* gated off — a drive would dead-end), a malformed/mixed-version stacked cohort, and evidence
|
|
156
|
+
* failing the strict vocabulary all warn and skip; the stamp itself always stands.
|
|
157
|
+
* Incremental results and failures drive nothing, quietly.
|
|
158
|
+
*/
|
|
159
|
+
export async function driveReadyReconcile(
|
|
160
|
+
pi: ExtensionAPI,
|
|
161
|
+
ctx: ExtensionContext,
|
|
162
|
+
gating: ToolGating,
|
|
163
|
+
details: ReadyDetails,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
if (details.ok !== true) return;
|
|
166
|
+
const handoff = details.handoff;
|
|
167
|
+
if (handoff === undefined) {
|
|
168
|
+
if (details.stacked === true) {
|
|
169
|
+
// A successful stacked stamp whose continuation cohort failed to decode — a
|
|
170
|
+
// malformed/mixed-version envelope must never fail silent.
|
|
171
|
+
warnSkippedPass(
|
|
172
|
+
ctx,
|
|
173
|
+
"the worker reported a stacked stamp but its continuation facts were malformed " +
|
|
174
|
+
"(a mixed-version envelope?)",
|
|
175
|
+
"/ready",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return; // incremental / old worker: nothing to drive
|
|
179
|
+
}
|
|
180
|
+
const retry = `\`perk ready ${READY_EVIDENCE_ID_RE.test(handoff.plan) ? handoff.plan : "<plan>"}\``;
|
|
181
|
+
if (gating.isActive()) {
|
|
182
|
+
warnSkippedPass(
|
|
183
|
+
ctx,
|
|
184
|
+
"this session is read-only (the pass's write tools are gated off); exit the read-only " +
|
|
185
|
+
"session or run the pass from a terminal",
|
|
186
|
+
retry,
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const idsValid =
|
|
191
|
+
READY_EVIDENCE_ID_RE.test(handoff.objective) &&
|
|
192
|
+
READY_EVIDENCE_ID_RE.test(handoff.node) &&
|
|
193
|
+
READY_EVIDENCE_ID_RE.test(handoff.plan);
|
|
194
|
+
const shasValid =
|
|
195
|
+
READY_FULL_SHA_RE.test(handoff.stamped_head) &&
|
|
196
|
+
READY_FULL_SHA_RE.test(handoff.parent_checkpoint);
|
|
197
|
+
if (!idsValid || !shasValid || !Number.isInteger(details.pr.number)) {
|
|
198
|
+
warnSkippedPass(
|
|
199
|
+
ctx,
|
|
200
|
+
"the stamp evidence failed strict validation (ids marker-safe; both diff-range " +
|
|
201
|
+
"endpoints full 40-hex lowercase)",
|
|
202
|
+
retry,
|
|
203
|
+
);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const backend = resolveIssueBackendId(ctx.cwd);
|
|
207
|
+
const url = backend === "linear" ? await fetchObjectiveUrl(pi, ctx, handoff.objective) : "";
|
|
208
|
+
const readClause = objectiveReadInstruction(backend, handoff.objective, url);
|
|
209
|
+
const message =
|
|
210
|
+
render("stages/objective-reconcile-ready.md", {
|
|
211
|
+
objective: handoff.objective,
|
|
212
|
+
node: handoff.node,
|
|
213
|
+
plan: handoff.plan,
|
|
214
|
+
pr: String(details.pr.number),
|
|
215
|
+
parent_checkpoint: handoff.parent_checkpoint,
|
|
216
|
+
stamped_head: handoff.stamped_head,
|
|
217
|
+
read_clause: readClause,
|
|
218
|
+
}) + bindingSuffix(ctx.cwd, "command:objective-reconcile");
|
|
219
|
+
// Announce the continuation only HERE — after every refusal arm has accepted the drive.
|
|
220
|
+
report(
|
|
221
|
+
ctx,
|
|
222
|
+
"ready",
|
|
223
|
+
"info",
|
|
224
|
+
`continuing into the ready-time reconcile pass — objective #${handoff.objective}, ` +
|
|
225
|
+
`pinned range ${handoff.parent_checkpoint}..${handoff.stamped_head}`,
|
|
226
|
+
);
|
|
227
|
+
if (ctx.isIdle()) {
|
|
228
|
+
// The `/ready` command path (idle): inject an immediate turn.
|
|
229
|
+
pi.sendUserMessage(message);
|
|
230
|
+
} else {
|
|
231
|
+
// The `ready` tool path (streaming): deliver after the terminating ready batch.
|
|
232
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
233
|
+
}
|
|
53
234
|
}
|
|
54
235
|
|
|
55
236
|
const TOOL_GUIDELINES = [
|
|
56
|
-
"
|
|
57
|
-
"ready
|
|
237
|
+
"For an incremental plan, call ready only when the PR is ready for human review; it marks the draft PR ready (the deliberate review gate). submit keeps the PR draft on purpose.",
|
|
238
|
+
"For a STACKED plan, /ready is the deliberate HUMAN handoff made AFTER review + address: it stamps the exact verified published head into the delivery journal (draft and non-draft PRs alike), and the recorded stamp unblocks planning of the layer's direct dependents. Never call it as routine post-submit choreography — review happens on the draft layer PR; only invoke it when the human explicitly asks.",
|
|
239
|
+
"ready operates on the active plan's worktree — it takes no arguments; the PR is discovered from the local plan-ref's branch. Idempotent: an already-ready PR is success, and a re-run converges on the same stamp.",
|
|
240
|
+
"A failed stamp (error_type ready_stamp_failed) names its own remediation: the ambiguous/transient arms converge on re-run; deterministic failures need their named repair first.",
|
|
58
241
|
];
|
|
59
242
|
|
|
60
243
|
/** Register the warm door: the `ready` terminating tool + the `/ready` command twin. */
|
|
61
|
-
export function registerReady(pi: ExtensionAPI): void {
|
|
244
|
+
export function registerReady(pi: ExtensionAPI, gating: ToolGating): void {
|
|
62
245
|
pi.registerTool({
|
|
63
246
|
name: "ready",
|
|
64
247
|
label: "Mark PR ready",
|
|
65
248
|
description:
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
|
|
249
|
+
"Ready the active plan's PR. Incremental: mark the draft PR ready for review (the " +
|
|
250
|
+
"deliberate review gate; submit keeps the PR draft). Stacked: the deliberate post-review " +
|
|
251
|
+
"HUMAN handoff — stamps the exact verified published head (draft and non-draft PRs); " +
|
|
252
|
+
"never routine post-submit choreography, never auto-run. Terminating: ends the turn.",
|
|
253
|
+
promptSnippet:
|
|
254
|
+
"Ready the PR: open the draft for review (incremental) or record the post-review " +
|
|
255
|
+
"handoff stamp (stacked; human-asked only). Terminates the turn.",
|
|
69
256
|
promptGuidelines: TOOL_GUIDELINES,
|
|
70
257
|
executionMode: "sequential",
|
|
71
258
|
parameters: { type: "object", additionalProperties: false, properties: {} },
|
|
72
259
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
73
|
-
|
|
260
|
+
const result = await markReady(pi, ctx);
|
|
261
|
+
await driveReadyReconcile(pi, ctx, gating, result.details);
|
|
262
|
+
return result;
|
|
74
263
|
},
|
|
75
264
|
});
|
|
76
265
|
|
|
77
266
|
registerPerkCommand(pi, "ready", {
|
|
78
|
-
description:
|
|
267
|
+
description:
|
|
268
|
+
"Ready the plan's PR: open the draft for review (incremental) or record the " +
|
|
269
|
+
"post-review handoff stamp (stacked).",
|
|
79
270
|
handler: async (_args, ctx) => {
|
|
80
271
|
const result = await markReady(pi, ctx);
|
|
81
272
|
// Failure already reported loudly via failFor (the single error surface) — success only.
|
|
82
273
|
if (result.details.ok) {
|
|
83
274
|
report(ctx, "ready", "info", result.content[0]?.text ?? "ready done");
|
|
84
275
|
}
|
|
276
|
+
await driveReadyReconcile(pi, ctx, gating, result.details);
|
|
85
277
|
},
|
|
86
278
|
});
|
|
87
279
|
}
|