@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
|
@@ -28,8 +28,14 @@ import { failFor, type OkDetails, ok, type Result } from "../substrate/result.ts
|
|
|
28
28
|
import { captureSessionPointer } from "../substrate/sessionPointers.ts";
|
|
29
29
|
import { appendWorkflowState, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
30
30
|
import { report } from "../surfaces/report.ts";
|
|
31
|
+
import { planningStageRefusal } from "./lifecycleGates.ts";
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* The bounded conflict-resolution re-drive cap: drive the resolver at most this many times.
|
|
35
|
+
* The counter behind it (`conflict_resolution_attempts`) is SHARED with `/objective-sync`'s
|
|
36
|
+
* retained-continuation conflict drive (objectiveStack.ts) — submit- and sync-episode attempts
|
|
37
|
+
* deliberately share one bound, reset on any clean completion of either surface.
|
|
38
|
+
*/
|
|
33
39
|
export const CONFLICT_RESOLUTION_ATTEMPT_CAP = 2;
|
|
34
40
|
|
|
35
41
|
/** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
|
|
@@ -167,6 +173,11 @@ function isUnmergeable(details: SubmitDetails): details is OkDetails<SubmitOk> {
|
|
|
167
173
|
export async function submitPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<SubmitResult> {
|
|
168
174
|
const fail = failFor(ctx, "submit");
|
|
169
175
|
|
|
176
|
+
// Planning sessions never legitimately submit — the first check, before any cold-door
|
|
177
|
+
// delegation (a positioned stacked planning session's cwd binding is the PREDECESSOR).
|
|
178
|
+
const planningRefusal = planningStageRefusal(ctx, "submit");
|
|
179
|
+
if (planningRefusal !== null) return fail(planningRefusal, "planning_session");
|
|
180
|
+
|
|
170
181
|
// Stamp this implement run id into the plan-header `impl_run_ids` linkage (contracts.md
|
|
171
182
|
// §8.35) so a later/other session can resolve the implement session pointers cross-run.
|
|
172
183
|
// Mirrors planSave's `--run-id` thread; covers the interactive /submit AND the headless worker
|
|
@@ -203,7 +214,7 @@ export async function submitPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise
|
|
|
203
214
|
const conflicted = r.data.mergeable === false;
|
|
204
215
|
// Reset the counter on every clean (or undetermined) submit — idempotent; keeps a later
|
|
205
216
|
// independent conflict bounded fresh.
|
|
206
|
-
if (r.data.mergeable !== false) resetConflictAttempts(pi, ctx);
|
|
217
|
+
if (r.data.mergeable !== false) resetConflictAttempts(pi, ctx, "submit");
|
|
207
218
|
// Automatic-cascade facts supersede the generic stacked suffix. A malformed operation block was
|
|
208
219
|
// dropped by the lenient decoder, so it falls back to the pre-existing stack wording.
|
|
209
220
|
const deliverySuffix =
|
|
@@ -227,35 +238,47 @@ export async function submitPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise
|
|
|
227
238
|
return ok(message, r.data, { terminate: true });
|
|
228
239
|
}
|
|
229
240
|
|
|
230
|
-
/**
|
|
231
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Reset `conflict_resolution_attempts` to 0 (idempotent: a no-op when already 0/absent). The
|
|
243
|
+
* counter is shared across the two warm conflict drives, so `scope` names the TRUE resetting
|
|
244
|
+
* surface for failure reports — `/submit` passes "submit", the stack door "objective-sync".
|
|
245
|
+
*/
|
|
246
|
+
export function resetConflictAttempts(
|
|
247
|
+
pi: ExtensionAPI,
|
|
248
|
+
ctx: ExtensionContext,
|
|
249
|
+
scope: string,
|
|
250
|
+
): void {
|
|
232
251
|
const attempts = rebuildWorkflowState(branchOf(ctx)).conflict_resolution_attempts ?? 0;
|
|
233
252
|
if (attempts === 0) return;
|
|
234
253
|
appendWorkflowState(pi, ctx, {
|
|
235
254
|
data: { conflict_resolution_attempts: 0 },
|
|
236
255
|
field: "conflict_resolution_attempts",
|
|
237
256
|
expected: 0,
|
|
238
|
-
scope
|
|
257
|
+
scope,
|
|
239
258
|
failure: "conflict_resolution_attempts reset read-back failed (expected 0)",
|
|
240
259
|
});
|
|
241
260
|
}
|
|
242
261
|
|
|
243
262
|
/**
|
|
244
263
|
* The follow-up guidance the warm `/submit` injects to dispatch the conflict-resolver (modeled on
|
|
245
|
-
* `prReviewGuidance`). Pure + exported for offline tests.
|
|
246
|
-
*
|
|
247
|
-
* model is
|
|
264
|
+
* `prReviewGuidance`). Pure + exported for offline tests. `worktree` is the plan worktree the
|
|
265
|
+
* child's task text pins with a concrete `cd <worktree>` command — a dispatched child otherwise
|
|
266
|
+
* has no cwd guarantee and can run its commands outside the plan worktree. When `model` is set,
|
|
267
|
+
* the ONE workflowScript call carries a workflow-level `model` default; otherwise the agent's
|
|
268
|
+
* default model is used.
|
|
248
269
|
*/
|
|
249
270
|
export function conflictResolutionGuidance(
|
|
250
271
|
base: string,
|
|
251
272
|
attempt: number,
|
|
252
273
|
cap: number,
|
|
274
|
+
worktree: string,
|
|
253
275
|
model?: string,
|
|
254
276
|
): string {
|
|
255
277
|
return render("stages/conflict-resolution.md", {
|
|
256
278
|
base,
|
|
257
279
|
attempt: String(attempt),
|
|
258
280
|
cap: String(cap),
|
|
281
|
+
worktree,
|
|
259
282
|
model: model ?? "",
|
|
260
283
|
});
|
|
261
284
|
}
|
|
@@ -267,7 +290,8 @@ export function conflictResolutionGuidance(
|
|
|
267
290
|
* `followUp` user message is a separate deliberate new turn. Short-circuits (sends nothing) unless
|
|
268
291
|
* the submit succeeded with a definitively-unmergeable PR. Bounded by
|
|
269
292
|
* `CONFLICT_RESOLUTION_ATTEMPT_CAP` via the `conflict_resolution_attempts` field: past the cap it
|
|
270
|
-
* surfaces the unresolved conflict loudly instead of looping.
|
|
293
|
+
* surfaces the unresolved conflict loudly instead of looping. The counter is shared with
|
|
294
|
+
* `/objective-sync`'s retained-continuation conflict drive.
|
|
271
295
|
*/
|
|
272
296
|
export function driveConflictResolution(
|
|
273
297
|
pi: ExtensionAPI,
|
|
@@ -298,7 +322,9 @@ export function driveConflictResolution(
|
|
|
298
322
|
});
|
|
299
323
|
const model = subagentModel(ctx.cwd, "conflict-resolver");
|
|
300
324
|
const message =
|
|
301
|
-
|
|
325
|
+
// `/submit` runs only in worktree-bound sessions (planning sessions are refused first), so
|
|
326
|
+
// the session cwd IS the plan worktree.
|
|
327
|
+
conflictResolutionGuidance(base, next, CONFLICT_RESOLUTION_ATTEMPT_CAP, ctx.cwd, model) +
|
|
302
328
|
bindingSuffix(ctx.cwd, "command:submit");
|
|
303
329
|
if (ctx.isIdle()) {
|
|
304
330
|
// The `/submit` command path (idle): inject an immediate turn.
|
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
// The warm `submit_pr_review` tool — the agent-driven curated-posting surface shared by the
|
|
2
|
-
// PR-review doors (`/pr-review-terminal`, `/pr-review-browser`).
|
|
1
|
+
// The warm `submit_pr_review` tool — the agent-driven curated-posting surface shared by the
|
|
2
|
+
// PR-review doors (`/pr-review-terminal`, `/pr-review-browser`, `/stack-review-browser`).
|
|
3
3
|
//
|
|
4
4
|
// `submit_pr_review` implements the per-door posting contract (contracts §8.4): nothing perk-
|
|
5
5
|
// driven reaches GitHub before the human triage; ALL perk-side posting flows through this tool
|
|
6
6
|
// on every door (`gh` mutations and direct `perk pr review-submit` calls are forbidden); the
|
|
7
7
|
// verdict lands last, atomically with the comments. On the terminal door this tool is the sole
|
|
8
|
-
// posting path. On the browser door the human platform-posts from the plannotator
|
|
9
|
-
// native posting IS the GitHub path; perk composes nothing by default and posts only
|
|
10
|
-
// human explicitly hands it (typically a request-changes verdict, which the UI cannot
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// to
|
|
14
|
-
//
|
|
15
|
-
//
|
|
8
|
+
// posting path. On the single-PR browser door the human platform-posts from the plannotator
|
|
9
|
+
// UI — that native posting IS the GitHub path; perk composes nothing by default and posts only
|
|
10
|
+
// what the human explicitly hands it (typically a request-changes verdict, which the UI cannot
|
|
11
|
+
// post). On the stack door the local-diff session has NO attached PR, so ALL posting is
|
|
12
|
+
// perk-side after triage: one real call per member PR, bottom→top, each under its own dry-run
|
|
13
|
+
// anchor validation. It delegates to the Python cold door (`perk pr review-submit` — mutations
|
|
14
|
+
// canonical in Python) via `runColdDoor` (the batch rides the run-scratch stdin channel), then
|
|
15
|
+
// appends `last_review` AND the accumulating `review_posts` ledger row to `perk:workflow-state`
|
|
16
|
+
// (the stack flow's resume authority — posted PRs are skipped, never replayed). The human gate
|
|
17
|
+
// splits: explicit conversational go-ahead ALWAYS (pinned in the guidelines/skill); formal
|
|
18
|
+
// events (`approve`/`request-changes`) additionally get the structural gate — headless refuses,
|
|
19
|
+
// interactive raises a blocking `ctx.ui.confirm`.
|
|
16
20
|
// `dry_run` is the anchor-repair loop: no gates, no record, nothing posted.
|
|
17
21
|
|
|
18
22
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -34,7 +38,12 @@ import {
|
|
|
34
38
|
stringParam,
|
|
35
39
|
type ToolParams,
|
|
36
40
|
} from "../substrate/toolParams.ts";
|
|
37
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
appendWorkflowState,
|
|
43
|
+
branchOf,
|
|
44
|
+
type EntrySink,
|
|
45
|
+
rebuildWorkflowState,
|
|
46
|
+
} from "../substrate/workflowState.ts";
|
|
38
47
|
import type { Severity } from "../surfaces/report.ts";
|
|
39
48
|
|
|
40
49
|
// ------------------------------------------------------------------------ params
|
|
@@ -55,6 +64,7 @@ export interface SubmitParams {
|
|
|
55
64
|
body: string;
|
|
56
65
|
comments?: SubmitComment[];
|
|
57
66
|
dry_run?: boolean;
|
|
67
|
+
allow_repost?: boolean;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/** Decode the optional `comments` array; null = present-but-malformed (whole-batch refusal). */
|
|
@@ -87,7 +97,7 @@ function decodeSubmitComments(p: ToolParams): SubmitComment[] | undefined | null
|
|
|
87
97
|
* malformed field ⇒ null (whole-batch refusal). `pr` must be an int; `event` exactly one of the
|
|
88
98
|
* three flag spellings; `body` a string (EMPTY ALLOWED — the cold door owns the event-conditioned
|
|
89
99
|
* body rule and reports `bad_batch`); each `comments` row strict on
|
|
90
|
-
* path/line(int)/side(LEFT|RIGHT)/body; `dry_run`
|
|
100
|
+
* path/line(int)/side(LEFT|RIGHT)/body; `dry_run` and `allow_repost` booleans.
|
|
91
101
|
*/
|
|
92
102
|
export function decodeSubmitParams(params: unknown): SubmitParams | null {
|
|
93
103
|
const p = paramsOf(params);
|
|
@@ -102,9 +112,12 @@ export function decodeSubmitParams(params: unknown): SubmitParams | null {
|
|
|
102
112
|
if (comments === null) return null;
|
|
103
113
|
const dryRun = booleanParam(p, "dry_run");
|
|
104
114
|
if (dryRun === null) return null;
|
|
115
|
+
const allowRepost = booleanParam(p, "allow_repost");
|
|
116
|
+
if (allowRepost === null) return null;
|
|
105
117
|
const result: SubmitParams = { pr, event, body };
|
|
106
118
|
if (comments !== undefined) result.comments = comments;
|
|
107
119
|
if (dryRun !== undefined) result.dry_run = dryRun;
|
|
120
|
+
if (allowRepost !== undefined) result.allow_repost = allowRepost;
|
|
108
121
|
return result;
|
|
109
122
|
}
|
|
110
123
|
|
|
@@ -167,6 +180,42 @@ function decodeInvalidAnchors(payload: ColdJson): InvalidAnchor[] | null {
|
|
|
167
180
|
return rows;
|
|
168
181
|
}
|
|
169
182
|
|
|
183
|
+
// ------------------------------------------------------------------------ the posting ledger
|
|
184
|
+
|
|
185
|
+
/** One `review_posts` ledger row: a REAL submission that reached GitHub. */
|
|
186
|
+
export interface ReviewPostRow {
|
|
187
|
+
pr: number;
|
|
188
|
+
event: string;
|
|
189
|
+
at: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Tolerant re-narrow of the rebuilt `review_posts` list (best-effort tier: a malformed row is
|
|
194
|
+
* dropped, never a refusal — the ledger only ever grows from this tool's own writes).
|
|
195
|
+
*/
|
|
196
|
+
export function reviewPostsOf(raw: unknown): ReviewPostRow[] {
|
|
197
|
+
if (!Array.isArray(raw)) return [];
|
|
198
|
+
const rows: ReviewPostRow[] = [];
|
|
199
|
+
for (const item of raw) {
|
|
200
|
+
const row = paramsOf(item);
|
|
201
|
+
if (row === null) continue;
|
|
202
|
+
if (typeof row.pr !== "number" || !Number.isInteger(row.pr)) continue;
|
|
203
|
+
if (typeof row.event !== "string" || typeof row.at !== "string") continue;
|
|
204
|
+
rows.push({ pr: row.pr, event: row.event, at: row.at });
|
|
205
|
+
}
|
|
206
|
+
return rows;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Ledger equality for the read-back verification (order-sensitive — posting order matters). */
|
|
210
|
+
function reviewPostsEqual(rebuilt: unknown, expected: unknown): boolean {
|
|
211
|
+
const a = reviewPostsOf(rebuilt);
|
|
212
|
+
const b = reviewPostsOf(expected);
|
|
213
|
+
if (a.length !== b.length) return false;
|
|
214
|
+
return a.every(
|
|
215
|
+
(row, i) => row.pr === b[i]?.pr && row.event === b[i]?.event && row.at === b[i]?.at,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
170
219
|
/** Flag spelling → the REST wire spelling shown in the human confirm. */
|
|
171
220
|
const WIRE_EVENT: Record<ReviewEvent, string> = {
|
|
172
221
|
approve: "APPROVE",
|
|
@@ -209,6 +258,27 @@ export async function submitPrReview(
|
|
|
209
258
|
const dryRun = params.dry_run === true;
|
|
210
259
|
const commentCount = params.comments?.length ?? 0;
|
|
211
260
|
|
|
261
|
+
// The enforced resume guard (before the confirm AND the cold-door mutation): a PR that
|
|
262
|
+
// already has a review_posts ledger row in this session is a confirmed success — a repeat
|
|
263
|
+
// real post is refused unless explicitly deliberate. A ledger row can only be MISSING
|
|
264
|
+
// spuriously (best-effort tier), never present spuriously — so the guard refuses on
|
|
265
|
+
// presence and stays silent on absence (a missing row still means: verify posted-vs-pending
|
|
266
|
+
// against GitHub before re-posting).
|
|
267
|
+
if (!dryRun && params.allow_repost !== true) {
|
|
268
|
+
const prior = reviewPostsOf(rebuildWorkflowState(branchOf(ctx)).review_posts).filter(
|
|
269
|
+
(row) => row.pr === params.pr,
|
|
270
|
+
);
|
|
271
|
+
const last = prior.at(-1);
|
|
272
|
+
if (last !== undefined) {
|
|
273
|
+
return fail(
|
|
274
|
+
`a ${last.event} review was already posted to PR #${params.pr} in this session ` +
|
|
275
|
+
`(review_posts row at ${last.at}) — on a stack resume skip this member; pass ` +
|
|
276
|
+
"allow_repost: true only for a deliberate second review of the same PR",
|
|
277
|
+
"already_posted",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
212
282
|
if (!dryRun && params.event !== "comment") {
|
|
213
283
|
if (!ctx.hasUI) {
|
|
214
284
|
return fail(
|
|
@@ -305,6 +375,21 @@ export async function submitPrReview(
|
|
|
305
375
|
failure: "last_review read-back failed",
|
|
306
376
|
});
|
|
307
377
|
|
|
378
|
+
// The accumulating per-PR ledger (read-rebuild-append): ordered rows, one per real success —
|
|
379
|
+
// the stack flow's partial-outcome/resume authority. Same best-effort tier as last_review.
|
|
380
|
+
const posts: ReviewPostRow[] = [
|
|
381
|
+
...reviewPostsOf(rebuildWorkflowState(branchOf(ctx)).review_posts),
|
|
382
|
+
{ pr: record.pr, event: params.event, at: record.at },
|
|
383
|
+
];
|
|
384
|
+
appendWorkflowState(pi, ctx, {
|
|
385
|
+
data: { review_posts: posts },
|
|
386
|
+
field: "review_posts",
|
|
387
|
+
expected: posts,
|
|
388
|
+
scope: "review",
|
|
389
|
+
failure: "review_posts read-back failed",
|
|
390
|
+
equals: reviewPostsEqual,
|
|
391
|
+
});
|
|
392
|
+
|
|
308
393
|
let text =
|
|
309
394
|
`submitted ${params.event} review to PR #${record.pr} ` +
|
|
310
395
|
`(${data.comment_count ?? commentCount} inline comment(s))`;
|
|
@@ -320,10 +405,10 @@ export async function submitPrReview(
|
|
|
320
405
|
|
|
321
406
|
const TOOL_GUIDELINES = [
|
|
322
407
|
"Call submit_pr_review only after the human triage has settled the batch AND the human has explicitly approved posting — nothing reaches GitHub before triage.",
|
|
323
|
-
"Validate first with dry_run: true and repair any reported anchors until validation passes; a dry-run never posts, never gates, and records nothing.",
|
|
324
|
-
"Make ONE real call: comments + body + event land atomically in a single review — the verdict never lands before the comments.",
|
|
408
|
+
"Validate first with dry_run: true and repair any reported anchors until validation passes; a dry-run never posts, never gates, and records nothing. A stack review dry-runs ALL per-PR batches before ANY real post.",
|
|
409
|
+
"Make ONE real call per target PR: comments + body + event land atomically in a single review — the verdict never lands before the comments. A stack review posts one review per member PR, bottom→top; each real success appends a {pr, event, at} row to the review_posts workflow-state ledger. The tool ENFORCES skip-on-resume: a real post to a PR that already has a ledger row is refused (already_posted) unless allow_repost: true — a deliberate second review only. A MISSING row is not proof of no post (the ledger is best-effort): verify posted-vs-pending against GitHub before re-posting.",
|
|
325
410
|
"Formal events (approve / request-changes) additionally raise a blocking in-TUI confirm; headless sessions refuse them (use event: comment or re-run interactively).",
|
|
326
|
-
"All perk-side GitHub posting flows through this tool on
|
|
411
|
+
"All perk-side GitHub posting flows through this tool on every review door — never post via gh or bash (direct perk pr review-submit calls are forbidden). On /pr-review-terminal this tool is the sole posting path. On /pr-review-browser the plannotator UI's native platform-posting is the human's own GitHub path, and perk posts only what the human explicitly hands it (typically a request-changes verdict). On /stack-review-browser the local-diff session has NO attached PR, so ALL posting is perk-side after triage.",
|
|
327
412
|
];
|
|
328
413
|
|
|
329
414
|
// ------------------------------------------------------------------------ registration
|
|
@@ -334,9 +419,12 @@ export function registerSubmitPrReview(pi: ExtensionAPI): void {
|
|
|
334
419
|
name: "submit_pr_review",
|
|
335
420
|
label: "Submit PR review",
|
|
336
421
|
description:
|
|
337
|
-
"Submit the human-curated review-door outcome to the
|
|
338
|
-
"(comments + body + event) via the perk cold door
|
|
339
|
-
"
|
|
422
|
+
"Submit the human-curated review-door outcome to the target PR as ONE atomic review " +
|
|
423
|
+
"(comments + body + event) via the perk cold door — the posting surface of the " +
|
|
424
|
+
"/pr-review-terminal, /pr-review-browser, and /stack-review-browser doors (a stack " +
|
|
425
|
+
"review makes one real call per member PR). dry_run validates the anchors without " +
|
|
426
|
+
"posting (the repair loop); a real submission records last_review and appends the " +
|
|
427
|
+
"review_posts ledger row in workflow-state.",
|
|
340
428
|
promptSnippet: "Submit the curated review batch to the PR",
|
|
341
429
|
promptGuidelines: TOOL_GUIDELINES,
|
|
342
430
|
executionMode: "sequential",
|
|
@@ -363,7 +451,9 @@ export function registerSubmitPrReview(pi: ExtensionAPI): void {
|
|
|
363
451
|
type: "array",
|
|
364
452
|
description:
|
|
365
453
|
"The curated inline comments — human-authored or human-approved only, each anchored " +
|
|
366
|
-
"to a line in the PR diff
|
|
454
|
+
"to a line in the PR diff. Single-PR mode: never re-anchor a child's finding. Stack " +
|
|
455
|
+
"mode: the parent re-anchors combined-diff findings into per-PR coordinates under " +
|
|
456
|
+
"the dry-run loop.",
|
|
367
457
|
items: {
|
|
368
458
|
type: "object",
|
|
369
459
|
additionalProperties: false,
|
|
@@ -386,6 +476,13 @@ export function registerSubmitPrReview(pi: ExtensionAPI): void {
|
|
|
386
476
|
"Validate the batch + anchors without posting (the anchor-repair loop). No gates, " +
|
|
387
477
|
"no last_review record.",
|
|
388
478
|
},
|
|
479
|
+
allow_repost: {
|
|
480
|
+
type: "boolean",
|
|
481
|
+
description:
|
|
482
|
+
"Deliberately post ANOTHER review to a PR that already has a review_posts ledger " +
|
|
483
|
+
"row in this session — the enforced resume guard refuses with already_posted " +
|
|
484
|
+
"otherwise. Never pass it to work around a stack-resume refusal.",
|
|
485
|
+
},
|
|
389
486
|
},
|
|
390
487
|
},
|
|
391
488
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -631,9 +631,10 @@ export function objectiveReadInstruction(
|
|
|
631
631
|
* Fetch the objective's URL via `perk objective show <id> --json` (reading `objective.url`).
|
|
632
632
|
* Lenient: returns "" on any failure / missing url — never throws (the seed prompt's step-1
|
|
633
633
|
* `perk objective show <id>` step surfaces the URL anyway). Only called for the linear backend
|
|
634
|
-
* (github needs no clause → no fetch).
|
|
634
|
+
* (github needs no clause → no fetch). Exported for the warm drives that compose the same
|
|
635
|
+
* backend-aware read clause (the ready-time reconcile drive in `doors/ready.ts`).
|
|
635
636
|
*/
|
|
636
|
-
async function fetchObjectiveUrl(
|
|
637
|
+
export async function fetchObjectiveUrl(
|
|
637
638
|
pi: ExtensionAPI,
|
|
638
639
|
ctx: ExtensionContext,
|
|
639
640
|
objectiveId: string,
|
|
@@ -680,7 +681,7 @@ export function reconcileGuidance(objective: string, backend = "github", url = "
|
|
|
680
681
|
}
|
|
681
682
|
|
|
682
683
|
const RECONCILE_TOOL_GUIDELINES = [
|
|
683
|
-
"Call reconcile_objective only to rewrite the objective's Reconcilable prose region after a PR merged — the roadmap table and Immutable notes are never touched.",
|
|
684
|
+
"Call reconcile_objective only to rewrite the objective's Reconcilable prose region after a PR merged or after a stacked ready stamp (the ready-time pass) — the roadmap table and Immutable notes are never touched.",
|
|
684
685
|
"Pass reconcile_objective the FULL replacement prose; it overwrites the marker-bounded Reconcilable region wholesale.",
|
|
685
686
|
"Judgment + durable writes stay with you; skip reconcile_objective when nothing is stale (do not churn).",
|
|
686
687
|
];
|
|
@@ -688,6 +689,7 @@ const RECONCILE_TOOL_GUIDELINES = [
|
|
|
688
689
|
const ADD_NODE_TOOL_GUIDELINES = [
|
|
689
690
|
"Use add_objective_node SPARINGLY — only during reconciliation, when a genuine new unit of work emerged that wasn't planned: a deferred follow-up the PR flagged, an uncovered defect/gap, a missing prerequisite for a later node, or human-requested work from the engagement block.",
|
|
690
691
|
"add_objective_node is only for genuinely-new, unplanned work — never to restate, rename, or re-scope an existing node (use objective_node's `description` for that).",
|
|
692
|
+
"Stacked objectives accept guarded `pending` tail-appends only — a refusal means the discovery is structural: route it to `perk objective replan`.",
|
|
691
693
|
"Judgment + durable writes stay with you; add_objective_node delegates the write to the canonical Python plane.",
|
|
692
694
|
];
|
|
693
695
|
|
|
@@ -820,9 +822,13 @@ export function registerObjectivePlan(pi: ExtensionAPI, gating: ToolGating): voi
|
|
|
820
822
|
label: "Reconcile objective prose",
|
|
821
823
|
description:
|
|
822
824
|
"Rewrite the objective's Reconcilable prose region (the marker-bounded prose in the " +
|
|
823
|
-
"objective body) to reconcile it against
|
|
824
|
-
"
|
|
825
|
-
|
|
825
|
+
"objective body) to reconcile it against the pass's evidence — a merged PR (post-land) or " +
|
|
826
|
+
"a stacked layer's pinned accepted diff range (the ready-time pass). The Mechanical " +
|
|
827
|
+
"roadmap table and any Immutable notes are NEVER touched. Delegates the write to the perk " +
|
|
828
|
+
"cold door.",
|
|
829
|
+
promptSnippet:
|
|
830
|
+
"Reconcile the objective's Reconcilable prose region against the pass's evidence " +
|
|
831
|
+
"(merged diff, or the ready-time pinned accepted range)",
|
|
826
832
|
promptGuidelines: RECONCILE_TOOL_GUIDELINES,
|
|
827
833
|
executionMode: "sequential",
|
|
828
834
|
parameters: {
|
|
@@ -87,8 +87,11 @@ function decodeObjectiveCreate(payload: ColdJson): ObjectiveCreatePayload | null
|
|
|
87
87
|
* `{input}` and the save stamps `generated_at`; the approval path passes the artifact block
|
|
88
88
|
* through with its stored stamp AND stored parts — the stored parts are byte-compared against
|
|
89
89
|
* the freshly re-rendered ones, so run-scratch drift or artifact tamper between draft-write
|
|
90
|
-
* and save refuses `bad_state` (nothing saved, the gate stays on).
|
|
91
|
-
*
|
|
90
|
+
* and save refuses `bad_state` (nothing saved, the gate stays on). On the dream arm the
|
|
91
|
+
* reviewed CANONICAL parts cross to the Python plane through the run-scoped
|
|
92
|
+
* `dream-report-transfer.json` handoff (§8.64) — staged atomically before the cold door (a
|
|
93
|
+
* write failure is the soft `scratch_failed` refusal; the door is not invoked) — and
|
|
94
|
+
* `perk objective create` re-validates the transfer and converges the companion idempotently.
|
|
92
95
|
*/
|
|
93
96
|
export async function saveObjective(
|
|
94
97
|
pi: ExtensionAPI,
|
package/extension/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { registerPrReviewTerminal } from "./doors/prReviewTerminal.ts";
|
|
|
35
35
|
import { registerReady } from "./doors/ready.ts";
|
|
36
36
|
import { registerReviewWaveTools } from "./doors/reviewWaveTools.ts";
|
|
37
37
|
import { registerSelfcheck } from "./doors/selfcheck.ts";
|
|
38
|
+
import { registerOpenStackReview, registerStackReviewBrowser } from "./doors/stackReviewBrowser.ts";
|
|
38
39
|
import { registerSubmit } from "./doors/submit.ts";
|
|
39
40
|
import { registerSubmitPrReview } from "./doors/submitPrReview.ts";
|
|
40
41
|
import { registerGistAuthor } from "./factories/gistAuthor.ts";
|
|
@@ -260,6 +261,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
260
261
|
if (handoff === null || handoff.run_id !== decision.runId) {
|
|
261
262
|
reportError(`handoff missing or mismatched for run ${decision.runId}`);
|
|
262
263
|
} else {
|
|
264
|
+
// The objective-plan cold door's handoff_extra carries the node link
|
|
265
|
+
// (objective_id/node_id): persist it as the objective_node_claim so the implement-here
|
|
266
|
+
// exits are structurally suppressed in COLD objective-plan sessions too (the warm
|
|
267
|
+
// `objective_node` tool records the claim; a cold factory session never calls it — the
|
|
268
|
+
// door marked the node before launch). Blank/absent ids persist nothing; the claim
|
|
269
|
+
// clears on a successful node-linked save exactly as the warm-recorded one does.
|
|
270
|
+
const handoffObjective = handoff.objective_id;
|
|
271
|
+
const handoffNode = handoff.node_id;
|
|
272
|
+
const nodeClaim =
|
|
273
|
+
typeof handoffObjective === "string" &&
|
|
274
|
+
handoffObjective.trim() !== "" &&
|
|
275
|
+
typeof handoffNode === "string" &&
|
|
276
|
+
handoffNode.trim() !== ""
|
|
277
|
+
? { objective: handoffObjective, node: handoffNode }
|
|
278
|
+
: undefined;
|
|
263
279
|
const data: WorkflowState = {
|
|
264
280
|
run_id: decision.runId,
|
|
265
281
|
pi_session_id: currentSessionId ?? undefined,
|
|
@@ -268,6 +284,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
268
284
|
// Record the launched stage so the interior can tell e.g. objective-author from plan
|
|
269
285
|
// (both are read-only) and inject the right authoring context (planMode vs objectiveAuthor).
|
|
270
286
|
stage: handoff.stage,
|
|
287
|
+
...(nodeClaim !== undefined ? { objective_node_claim: nodeClaim } : {}),
|
|
271
288
|
};
|
|
272
289
|
const okAppend = appendWorkflowState(pi, ctx, {
|
|
273
290
|
data,
|
|
@@ -572,7 +589,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
572
589
|
registerSubmit(pi);
|
|
573
590
|
|
|
574
591
|
// The warm `ready` door: the deliberate draft→ready review gate (submit keeps draft).
|
|
575
|
-
|
|
592
|
+
// Takes `gating`: the warm ready→reconcile continuation refuses (loudly) to drive the
|
|
593
|
+
// ready-time pass into a read-only session (contracts.md §8.66).
|
|
594
|
+
registerReady(pi, gating);
|
|
576
595
|
|
|
577
596
|
// Warm doors: `land` merges + sets pending-learn; `learn` clears it (TS-only).
|
|
578
597
|
registerLand(pi);
|
|
@@ -630,6 +649,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
630
649
|
// human's own platform-post from the UI, with `submit_pr_review` for request-changes only.
|
|
631
650
|
registerPrReviewBrowser(pi);
|
|
632
651
|
|
|
652
|
+
// The warm `/stack-review-browser` door + its cold-launch twin (`open_stack_review`): the
|
|
653
|
+
// stacked-PR browser review over the combined base→top diff — one reviewer wave with
|
|
654
|
+
// `stack: true`, then judgment-routed per-PR posting through `submit_pr_review`.
|
|
655
|
+
registerStackReviewBrowser(pi);
|
|
656
|
+
registerOpenStackReview(pi);
|
|
657
|
+
|
|
633
658
|
// The warm `/plan-review-browser` door: the summonable streaming draft review — the
|
|
634
659
|
// plannotator plan-review browser on the working plan draft, draft reviewers streaming
|
|
635
660
|
// phrase-anchored findings in; APPROVE auto-saves via the approvalSave seam, DENY returns a
|
|
@@ -58,8 +58,10 @@ export interface PerkConfig {
|
|
|
58
58
|
* workflowScript call — a default flowing onto every lane, single-child runs included (as
|
|
59
59
|
* /pr-review does); when a key is absent the agent's frontmatter `model` (in
|
|
60
60
|
* `.pi/agents/perk/<name>.md`; the session-auditor's in its repo-local def) is the default.
|
|
61
|
-
* (`subagents.agentOverrides`
|
|
62
|
-
*
|
|
61
|
+
* (Since pi-subagents 0.52, `subagents.agentOverrides` also reaches custom/project agents —
|
|
62
|
+
* but only as a frontmatter-sensitive fill that never displaces a field the def's own
|
|
63
|
+
* frontmatter sets; every perk def pins `model:` in frontmatter, so this inline workflow-level
|
|
64
|
+
* injection remains the mechanism.)
|
|
63
65
|
* A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
|
|
64
66
|
* `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
|
|
65
67
|
* pi-subagents on the injected value (the last-colon segment counts as thinking only when it
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// perk-owned dot-directory path construction — the TS twin of perk/substrate/paths.py
|
|
2
2
|
// (contracts.md §8.1).
|
|
3
3
|
//
|
|
4
|
-
// The sole construction site for the perk-owned config family (`config.toml`/`local.toml`)
|
|
5
|
-
//
|
|
4
|
+
// The sole construction site for the perk-owned config family (`config.toml`/`local.toml`).
|
|
5
|
+
// The config family now lives at `.perk/` (TS reads the target only — the legacy
|
|
6
6
|
// `.pi/perk.toml` migration is Python-side). The workflow family lives in the established cache
|
|
7
7
|
// seam (substrate/cache.ts's `workflowDir`); together these two modules own every perk-owned
|
|
8
8
|
// dot-path on this plane.
|
|
@@ -17,11 +17,6 @@ import { join } from "node:path";
|
|
|
17
17
|
export const CONFIG_FILENAME = "config.toml";
|
|
18
18
|
export const LOCAL_CONFIG_FILENAME = "local.toml";
|
|
19
19
|
|
|
20
|
-
/** The perk-owned dot-dir root (shared with Pi today). */
|
|
21
|
-
export function perkDir(cwd: string): string {
|
|
22
|
-
return join(cwd, ".pi");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
20
|
/** The single config-family redirection point (the file helpers derive from it). */
|
|
26
21
|
export function configDir(cwd: string): string {
|
|
27
22
|
return join(cwd, ".perk");
|