@mgiles/perk 2.1.0 → 2.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.
@@ -0,0 +1,251 @@
1
+ // The warm `gist_save` door, the gist mirror of objectiveSave.ts. The in-session twin of the
2
+ // Python cold door (`perk gist create`): a deterministic, terminating tool + command that WRAP
3
+ // the existing storage — they do NOT reimplement the backend write. `saveGist()` delegates to
4
+ // `perk gist create --json` via the shared cold-door client (`runColdDoor`, the prose rides the
5
+ // run-scratch stdin channel). Unlike the plan/objective twins there is NO session linkage after
6
+ // the save — nothing consumes a gist in-session (adoption happens later via the cold doors), so
7
+ // a successful save just relays the envelope's id/url/consumption story.
8
+ //
9
+ // APPROVAL→SAVE ORCHESTRATION (mirroring objectiveSave.ts's `objectiveApprovalSave`). The
10
+ // exported `gistApprovalSave` seam is the shared APPROVED-review → save flow: re-read the
11
+ // artifact (`readGistDraft` — never the rendered markdown, never the transcript) → `saveGist` →
12
+ // D1a gate exit on a successful save (snapshot `gating.isActive()` BEFORE the save; a failed
13
+ // save leaves the gate ON). `plan_review`'s gist arm (planReview.ts) wires its APPROVED outcome
14
+ // into it; the `/gist-save` command is the artifact-first MANUAL FAILSAFE invocation of the same
15
+ // seam, keeping the legacy drive-the-session behavior as the no-draft fallback (gists have no
16
+ // transcript scrape by design).
17
+
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import { bindingSuffix } from "../substrate/bindingDelivery.ts";
20
+ import {
21
+ booleanField,
22
+ type ColdJson,
23
+ objectField,
24
+ runColdDoor,
25
+ stringField,
26
+ } from "../substrate/coldDoor.ts";
27
+ import { registerPerkCommand } from "../substrate/command.ts";
28
+ import { render } from "../substrate/prompts.ts";
29
+ import { failFor, ok, type Result } from "../substrate/result.ts";
30
+ import type { ToolGating } from "../substrate/toolGating.ts";
31
+ import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
32
+ import { report, type Severity } from "../surfaces/report.ts";
33
+ import { decodeGistSaveParams, GIST_SCOPES, type GistScope, readGistDraft } from "./gistDraft.ts";
34
+
35
+ /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
36
+ export interface GistSaveOk {
37
+ /** `id` is the opaque string gist id (GitHub "7", Linear "ENG-7"/project id) — §8.21. */
38
+ gist: { id: string; url: string };
39
+ scope: string | null;
40
+ existed: boolean | null;
41
+ }
42
+
43
+ export type GistSaveResult = Result<GistSaveOk>;
44
+
45
+ /** The decoded `perk gist create --json` payload slice the warm door consumes. */
46
+ interface GistCreatePayload {
47
+ gist: { id: string; url: string; existed: boolean | undefined };
48
+ scope: string | undefined;
49
+ }
50
+
51
+ /** Narrow the `perk gist create --json` success payload; strict on `gist`. */
52
+ function decodeGistCreate(payload: ColdJson): GistCreatePayload | null {
53
+ const gist = objectField(payload, "gist");
54
+ if (gist === undefined) return null;
55
+ const id = stringField(gist, "id");
56
+ const url = stringField(gist, "url");
57
+ if (id === undefined || url === undefined) return null;
58
+ return {
59
+ gist: { id, url, existed: booleanField(gist, "existed") },
60
+ scope: stringField(payload, "scope"),
61
+ };
62
+ }
63
+
64
+ /** The consumption pointer relayed after a save — the adoption door matching the saved scope. */
65
+ function consumptionHint(id: string, scope: string | undefined): string {
66
+ return scope === "objective"
67
+ ? `Consume with: perk objective author --from ${id}`
68
+ : `Consume with: perk plan from ${id}`;
69
+ }
70
+
71
+ /**
72
+ * The single save implementation both surfaces call. Delegates the backend write to the Python
73
+ * cold door (which owns scope resolution: an explicit `scope` wins, else the launch handoff's
74
+ * pre-seeded `gist_scope`, else `plan`). Returns a soft result (never throws); failures set
75
+ * `details.ok = false`. No session linkage on success — nothing consumes a gist in-session.
76
+ */
77
+ export async function saveGist(
78
+ pi: ExtensionAPI,
79
+ ctx: ExtensionContext,
80
+ opts: { prose: string; title?: string; scope?: GistScope },
81
+ ): Promise<GistSaveResult> {
82
+ const fail = failFor(ctx, "gist-save");
83
+
84
+ const prose = opts.prose.trim();
85
+ if (!prose) return fail("no gist prose to save (draft the gist first)", "invalid_input");
86
+ if (opts.scope !== undefined && !(GIST_SCOPES as readonly string[]).includes(opts.scope)) {
87
+ return fail("scope must be plan or objective", "invalid_input");
88
+ }
89
+
90
+ const runId = rebuildWorkflowState(branchOf(ctx)).run_id ?? "";
91
+
92
+ const args = ["gist", "create", "--json"];
93
+ if (opts.title) args.push("--title", opts.title);
94
+ if (opts.scope) args.push("--scope", opts.scope);
95
+ if (runId) args.push("--run-id", runId);
96
+ const r = await runColdDoor<GistCreatePayload>(pi, ctx, args, {
97
+ label: "perk gist create",
98
+ decode: decodeGistCreate,
99
+ stdin: { flag: "--body", content: prose, filename: "gist.md" },
100
+ });
101
+ if (!r.ok) return fail(r.message, r.errorType);
102
+
103
+ const gist = r.data.gist;
104
+ const verb = gist.existed ? "Found existing" : "Saved";
105
+ return ok(
106
+ `${verb} gist ${gist.id} → ${gist.url}\n${consumptionHint(gist.id, r.data.scope)}`,
107
+ {
108
+ gist: { id: gist.id, url: gist.url },
109
+ scope: r.data.scope ?? null,
110
+ existed: gist.existed ?? null,
111
+ },
112
+ { terminate: true },
113
+ );
114
+ }
115
+
116
+ /** The approval→save orchestration outcome (the gist `ApprovalSaveOutcome`). */
117
+ export type GistApprovalSaveOutcome =
118
+ | { status: "no-draft" }
119
+ | { status: "saved" | "save-failed"; result: GistSaveResult; gateExited: boolean };
120
+
121
+ /**
122
+ * The shared approval→save orchestration seam (the gist sibling of objectiveSave.ts's
123
+ * `objectiveApprovalSave`): an APPROVED gist review (`plan_review`'s gist arm) and the manual
124
+ * `/gist-save` failsafe both run THIS. Flow: re-read the draft artifact at save time
125
+ * (`readGistDraft` — never the rendered markdown, never in-hand bytes) → `saveGist` → gate exit
126
+ * on a successful save while read-only (the D1a pattern: snapshot `gating.isActive()` before the
127
+ * save; a failed save leaves the gate ON). No draft → `no-draft` (nothing saved, the gate
128
+ * untouched); callers render their own fallback. Title/scope precedence: explicit opts win; else
129
+ * the draft's; else the cold door derives/defaults. The returned result keeps `saveGist`'s
130
+ * `terminate: true` for tool-path callers.
131
+ */
132
+ export async function gistApprovalSave(
133
+ pi: ExtensionAPI,
134
+ ctx: ExtensionContext,
135
+ gating: ToolGating,
136
+ opts: { title?: string } = {},
137
+ ): Promise<GistApprovalSaveOutcome> {
138
+ const draft = readGistDraft(ctx);
139
+ if (draft === null) return { status: "no-draft" };
140
+ // D1a: snapshot the gate BEFORE the save; on success, exit it so save marks the read-only →
141
+ // read-write boundary in one gesture. A failed save leaves the gate on.
142
+ const wasReadOnly = gating.isActive();
143
+ const result = await saveGist(pi, ctx, {
144
+ prose: draft.prose,
145
+ title: opts.title ?? draft.title,
146
+ scope: draft.scope,
147
+ });
148
+ let gateExited = false;
149
+ if (result.details.ok && wasReadOnly) {
150
+ gating.exit(ctx);
151
+ gateExited = true;
152
+ }
153
+ return { status: result.details.ok ? "saved" : "save-failed", result, gateExited };
154
+ }
155
+
156
+ const TOOL_GUIDELINES = [
157
+ "Use gist_save only after the gist says what it means; it creates the tracked gist in the issue backend and ends the turn.",
158
+ "Pass gist_save the statement-of-intent PROSE in `prose` — problem-space only, no implementation steps or roadmap.",
159
+ "Pass gist_save's `scope` only once the consumption tier is settled (plan or objective); omit it to keep the pre-seeded/default scope.",
160
+ ];
161
+
162
+ /**
163
+ * The seed guidance the warm `/gist-save` injects to drive the save (the perk-gist-author skill
164
+ * pointer rides the skill-binding suffix — not hardcoded here). Pure + exported for offline
165
+ * tests.
166
+ */
167
+ export function gistSaveGuidance(title?: string): string {
168
+ const named = title?.trim() || "";
169
+ return render("stages/gist-save.md", { title: named });
170
+ }
171
+
172
+ /** Register the warm door: the `gist_save` tool (canonical) + the `/gist-save` twin. */
173
+ export function registerGistSave(pi: ExtensionAPI, gating: ToolGating): void {
174
+ pi.registerTool({
175
+ name: "gist_save",
176
+ label: "Save gist",
177
+ description:
178
+ "Persist a drafted gist (a statement of intent) to the issue backend as a tracked " +
179
+ "perk:gist. Terminating: ends the turn on save. Call only when the gist says what it " +
180
+ "means.",
181
+ promptSnippet: "Save the converged gist to the issue backend (terminates the turn)",
182
+ promptGuidelines: TOOL_GUIDELINES,
183
+ executionMode: "sequential",
184
+ parameters: {
185
+ type: "object",
186
+ additionalProperties: false,
187
+ required: ["prose"],
188
+ properties: {
189
+ prose: {
190
+ type: "string",
191
+ description:
192
+ "The gist prose (the problem-space intent: what we want, why it matters, what " +
193
+ "bounds it — no implementation steps).",
194
+ },
195
+ title: {
196
+ type: "string",
197
+ description: "Optional gist title (defaults to the prose's first heading).",
198
+ },
199
+ scope: {
200
+ type: "string",
201
+ enum: [...GIST_SCOPES],
202
+ description:
203
+ "Optional consumption tier: plan (plan-sized intent) or objective (objective-sized).",
204
+ },
205
+ },
206
+ },
207
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
208
+ const decoded = decodeGistSaveParams(params);
209
+ if (decoded === null) {
210
+ return failFor(
211
+ ctx,
212
+ "gist-save",
213
+ "gist_save",
214
+ )(
215
+ "gist_save needs { prose: string, scope?: plan|objective } per the tool schema",
216
+ "bad_input",
217
+ );
218
+ }
219
+ return saveGist(pi, ctx, decoded);
220
+ },
221
+ });
222
+
223
+ registerPerkCommand(pi, "gist-save", {
224
+ description:
225
+ "Save the working gist draft to the issue backend — the manual failsafe for the " +
226
+ "approval→save flow (artifact-first; drives the save only when no draft exists).",
227
+ handler: async (args, ctx) => {
228
+ const title = args.trim() || undefined;
229
+ // The artifact-first manual-failsafe invocation of the shared approval→save seam (the D1a
230
+ // gate exit lives in the seam). The legacy drive-the-session behavior is kept as the
231
+ // NO-DRAFT fallback — gists have no transcript scrape by design, so a draftless session
232
+ // still needs a working save path.
233
+ const outcome = await gistApprovalSave(pi, ctx, gating, { title });
234
+ if (outcome.status === "no-draft") {
235
+ // Exit the read-only gate so the gist_save tool (excluded from READ_ONLY_TOOLS) becomes
236
+ // reachable on the driven turn, then drive the turn (mirrors /objective-save).
237
+ if (gating.isActive()) gating.exit(ctx);
238
+ report(ctx, "gist-save", "info", "handing the save to the session");
239
+ // The perk-gist-author pointer rides the skill-binding suffix (D5) since a warm
240
+ // /gist-save outside a stage:gist-author session gets none from Mechanism A.
241
+ pi.sendUserMessage(gistSaveGuidance(title) + bindingSuffix(ctx.cwd, "stage:gist-author"));
242
+ return;
243
+ }
244
+ // Saved or save-failed: relay the save message (which carries the consumption hint).
245
+ const result = outcome.result;
246
+ const message = result.content[0]?.text ?? "gist-save done";
247
+ const severity: Severity = result.details.ok ? "info" : "error";
248
+ report(ctx, "gist-save", severity, message);
249
+ },
250
+ });
251
+ }
@@ -46,6 +46,7 @@ import { report } from "../surfaces/report.ts";
46
46
  // `Key` via the surfaces re-export (keybinding vocabulary, not rich UI) — keeps pi-tui imports
47
47
  // structurally confined to the surfaces module (the surfacesGuard pi-tui import rule).
48
48
  import { Key } from "../surfaces/surfaces.ts";
49
+ import { GIST_AUTHOR_STAGE } from "./gistAuthor.ts";
49
50
  import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
50
51
 
51
52
  /** The plan-authoring context customType (distinct from the gate's `perk:mode-context`). */
@@ -156,14 +157,16 @@ export function registerPlanMode(pi: ExtensionAPI, gating: ToolGating): void {
156
157
  });
157
158
  }
158
159
 
159
- // Inject the plan-authoring context while the read-only gate is active (display:false). The one
160
- // exception: an objective-author session is ALSO read-only, but objectiveAuthor.ts injects its
161
- // own authoring context there — so plan mode defers when the launched stage is objective-author
162
- // (the coupling break: plan-authoring context is no longer keyed off the bare read-only gate).
160
+ // Inject the plan-authoring context while the read-only gate is active (display:false). The
161
+ // exceptions: objective-author and gist-author sessions are ALSO read-only, but
162
+ // objectiveAuthor.ts / gistAuthor.ts inject their own authoring contexts there — so plan mode
163
+ // defers when the launched stage is either (the coupling break: plan-authoring context is no
164
+ // longer keyed off the bare read-only gate).
163
165
  pi.on("before_agent_start", async (_event, ctx) => {
164
166
  if (!gating.isActive()) return;
165
167
  const branch = branchOf(ctx);
166
- if (rebuildWorkflowState(branch).stage === OBJECTIVE_AUTHOR_STAGE) return;
168
+ const launchedStage = rebuildWorkflowState(branch).stage;
169
+ if (launchedStage === OBJECTIVE_AUTHOR_STAGE || launchedStage === GIST_AUTHOR_STAGE) return;
167
170
  // Once-only: injected customs persist to the branch, so a live copy suppresses re-injection;
168
171
  // compaction dropping it makes the scan come up clean and the next turn re-injects.
169
172
  if (branchCarries(branch, PLAN_MARKER)) return;
@@ -19,7 +19,12 @@
19
19
  // `plan_draft` redirect). An APPROVED outcome (either backend) wires into the shared
20
20
  // `approvalSave` seam (planSave.ts): auto-save → D1a gate exit → terminating result,
21
21
  // node link recovered from the `objective_node_claim` carrier inside `savePlan`. A DENY returns
22
- // feedback and directs a `plan_draft` rewrite + re-review. Strict on deny, FAIL-OPEN everywhere
22
+ // feedback and directs a `plan_draft` rewrite + re-review. Plannotator's browser "Direct Edits"
23
+ // (a `# Direct Edits` unified diff opening the feedback) are handled asymmetrically per arm: the
24
+ // PLAN arm mechanically applies an approved diff (strict apply → draft write-back → save the
25
+ // edited bytes; any failure falls open to the verbatim save + a loud warning); the OBJECTIVE arm
26
+ // cannot fold rendered-markdown edits into the structured draft, so an approve-with-edits SKIPS
27
+ // the save and returns one model-mediated revise round; DENY stays model-mediated on both arms. Strict on deny, FAIL-OPEN everywhere
23
28
  // else: headless / dismissed (Esc anywhere = skip, mirroring ask_user_question's dismissal — deny
24
29
  // is always explicit) / backend-unavailable all soft-skip so plan authoring never wedges — those
25
30
  // arms keep the present-the-plan + human-`/plan-save` discipline (the manual failsafe).
@@ -40,6 +45,12 @@
40
45
  // exit → a TERMINATING result; a failed save is non-terminating, leaves the gate read-only, and
41
46
  // directs the human `/objective-save` failsafe.
42
47
  //
48
+ // THE GIST ARM: a gist-author session (read-only, stage `gist-author`) routes through
49
+ // `executeGistReview` the same way — the reviewed bytes are the RENDERED gist draft
50
+ // (`readGistDraft` + `renderGistDraft`, gistDraft.ts), first-party VIEW-ONLY, implement-here
51
+ // never offered, APPROVED → the `gistApprovalSave` seam (gistSave.ts), no draft soft-skips with
52
+ // `reason: "no_gist_draft"`.
53
+ //
43
54
  // INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
44
55
  // restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
45
56
  // through the `approvalSave` seam (Invariant 1: composes, never owns).
@@ -48,12 +59,18 @@ import { randomUUID } from "node:crypto";
48
59
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
49
60
  import {
50
61
  createPlannotatorBridge,
62
+ extractDirectEdits,
63
+ hasDirectEditsHeading,
51
64
  isPlannotatorPlanSelected,
52
65
  } from "../adapters/planAdapterPlannotator.ts";
53
66
  import type { Result } from "../substrate/result.ts";
54
67
  import type { ToolGating } from "../substrate/toolGating.ts";
55
68
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
69
+ import { applyUnifiedDiff } from "../substrate/unifiedDiff.ts";
56
70
  import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
71
+ import { GIST_AUTHOR_STAGE } from "./gistAuthor.ts";
72
+ import { readGistDraft, renderGistDraft } from "./gistDraft.ts";
73
+ import { type GistApprovalSaveOutcome, gistApprovalSave } from "./gistSave.ts";
57
74
  import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
58
75
  import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
59
76
  import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
@@ -131,6 +148,17 @@ const OBJECTIVE_SUBJECT: ReviewSubject = {
131
148
  noSourceError: "no objective draft resolved",
132
149
  };
133
150
 
151
+ const GIST_SUBJECT: ReviewSubject = {
152
+ noun: "gist",
153
+ present: "the complete gist to the user",
154
+ presentUnavailable: "the complete gist to the user",
155
+ implementHereWhere: "on the gist path",
156
+ draftTool: "gist_draft",
157
+ failsafeCmd: "/gist-save",
158
+ detailsExtra: { subject: "gist" },
159
+ noSourceError: "no gist draft resolved",
160
+ };
161
+
134
162
  const SKIP_TEXT =
135
163
  "no interactive review surface available — present the complete plan to the user in your next message.";
136
164
 
@@ -244,16 +272,19 @@ type SubjectSaveOutcome =
244
272
  * (propagating the seam's `terminate: true` intent); a failed save is non-terminating, leaves
245
273
  * the gate read-only, and directs the human manual failsafe. Reviewer feedback is surfaced
246
274
  * loudly as implementation guidance — the approved bytes were saved verbatim, never post-edited.
247
- * The `paramMismatch`/`edited` opts are plan-arm-only (their literals name "plan"/"draft"): the
248
- * objective delegator never passes opts, so the suffixes render empty and `edited` never reaches
249
- * its details. The `no-source` arm is defensively unreachable (the reviewed source is always
275
+ * The `paramMismatch`/`edited`/`directEditsFailed` opts are plan-arm-only (their literals name
276
+ * "plan"/"draft"): the objective delegator never passes opts, so the suffixes render empty and
277
+ * `edited` never reaches its details. `directEditsFailed` (plannotator-only) flags that a Direct
278
+ * Edits section was seen but could not be honored — the saved arm gains a loud warning that the
279
+ * plan was saved WITHOUT the reviewer's edits, and details carry `direct_edits_applied: false`.
280
+ * The `no-source` arm is defensively unreachable (the reviewed source is always
250
281
  * non-blank) but maps to the save-failed shape rather than throwing.
251
282
  */
252
283
  function approvedSubjectSaveResult(
253
284
  subject: ReviewSubject,
254
285
  outcome: Extract<ReviewOutcome, { status: "completed" }>,
255
286
  save: SubjectSaveOutcome,
256
- opts?: { paramMismatch?: boolean; edited?: boolean },
287
+ opts?: { paramMismatch?: boolean; edited?: boolean; directEditsFailed?: boolean },
257
288
  ): ToolResult {
258
289
  const feedback = outcome.feedback
259
290
  ? `\n\nReviewer feedback (implementation guidance — the approved ${subject.noun} was saved ` +
@@ -266,6 +297,7 @@ function approvedSubjectSaveResult(
266
297
  feedback: outcome.feedback ?? null,
267
298
  ...subject.detailsExtra,
268
299
  ...(opts?.edited === true ? { edited: true } : {}),
300
+ ...(opts?.directEditsFailed === true ? { direct_edits_applied: false } : {}),
269
301
  };
270
302
  if (save.status === "saved") {
271
303
  const saveText = save.result.content[0]?.text ?? "";
@@ -275,11 +307,17 @@ function approvedSubjectSaveResult(
275
307
  opts?.paramMismatch === true
276
308
  ? "\n\n⚠ differing plan param ignored — the validated draft was reviewed and saved."
277
309
  : "";
310
+ const editsWarning =
311
+ opts?.directEditsFailed === true
312
+ ? "\n\n⚠ WARNING: the reviewer's Direct Edits could NOT be auto-applied — the plan was " +
313
+ "saved WITHOUT them. The diff remains in the reviewer feedback above; apply it to the " +
314
+ "plan issue manually or via a follow-up."
315
+ : "";
278
316
  return {
279
317
  content: [
280
318
  {
281
319
  type: "text",
282
- text: `${subject.noun} APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}`,
320
+ text: `${subject.noun} APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}${editsWarning}`,
283
321
  },
284
322
  ],
285
323
  // `ok` sits per-branch, NOT in `base` — `base` is spread into the fail branch too.
@@ -323,13 +361,15 @@ function approvedSubjectSaveResult(
323
361
  /**
324
362
  * Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
325
363
  * (exported for the offline tests) — the plan flavor of `approvedSubjectSaveResult`. `edited`
326
- * (first-party only) flags that human edits were written back to the draft pre-verdict, so the
327
- * saved bytes carry them.
364
+ * flags that human edits were written back to the draft pre-verdict (the first-party editor, or
365
+ * the plannotator Direct Edits auto-apply), so the saved bytes carry them. `directEditsFailed`
366
+ * (plannotator-only, optional — absent keeps every existing call site byte-stable) flags a
367
+ * Direct Edits section that could not be honored: the plan saved verbatim, a loud warning added.
328
368
  */
329
369
  export function approvedSaveResult(
330
370
  outcome: Extract<ReviewOutcome, { status: "completed" }>,
331
371
  save: ApprovalSaveOutcome,
332
- opts: { paramMismatch: boolean; edited?: boolean },
372
+ opts: { paramMismatch: boolean; edited?: boolean; directEditsFailed?: boolean },
333
373
  ): ToolResult {
334
374
  return approvedSubjectSaveResult(
335
375
  PLAN_SUBJECT,
@@ -535,7 +575,12 @@ export function approvedObjectiveSaveResult(
535
575
  * the transcript). First-party reviews run VIEW-ONLY (edits are never written back;
536
576
  * deny+feedback is the change channel). An APPROVED outcome wires into the
537
577
  * `objectiveApprovalSave` seam (re-read the STRUCTURED artifact → `saveObjective` → D1a gate
538
- * exit → terminating); every other outcome maps via `objectiveReviewOutcomeResult`.
578
+ * exit → terminating); every other outcome maps via `objectiveReviewOutcomeResult`. ONE
579
+ * carve-out (plannotator only): an approval whose feedback opens a Direct Edits section SKIPS
580
+ * the save — rendered-markdown edits cannot be folded back into the structured draft
581
+ * mechanically — and returns a NON-terminating revise round with the gate untouched (fold the
582
+ * diff in via `objective_draft`, re-review to confirm); perk never saves an objective the
583
+ * reviewer explicitly edited away from.
539
584
  */
540
585
  export async function executeObjectiveReview(
541
586
  pi: ExtensionAPI,
@@ -576,6 +621,42 @@ export async function executeObjectiveReview(
576
621
  let outcome: ReviewOutcome;
577
622
  if (isPlannotatorPlanSelected(ctx.cwd)) {
578
623
  outcome = await bridge.review(rendered, sig);
624
+ // APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
625
+ // approved-save routing (the approved-first discipline): the save seam re-reads the
626
+ // STRUCTURED artifact, so rendered-markdown edits — roadmap-table rows included — cannot be
627
+ // folded back without model judgment. Skip the save, keep the gate read-only, and route ONE
628
+ // revise round: the model folds the diff into `objective_draft`, then re-reviews to confirm.
629
+ // The heading check suffices (extraction success is irrelevant here — the diff goes to the
630
+ // model verbatim either way).
631
+ if (
632
+ outcome.status === "completed" &&
633
+ outcome.approved &&
634
+ outcome.feedback !== undefined &&
635
+ hasDirectEditsHeading(outcome.feedback)
636
+ ) {
637
+ return {
638
+ content: [
639
+ {
640
+ type: "text",
641
+ text:
642
+ "objective APPROVED with direct browser edits — these cannot be auto-applied to " +
643
+ "the structured draft, so nothing was saved. Fold the Direct Edits diff below into " +
644
+ "the working draft with objective_draft (prose hunks → the prose; roadmap-table " +
645
+ "hunks → the matching node fields), then call plan_review again to confirm.\n\n" +
646
+ `Reviewer feedback:\n${outcome.feedback}`,
647
+ },
648
+ ],
649
+ details: {
650
+ ok: true,
651
+ status: "revise",
652
+ reason: "direct_edits",
653
+ approved: true,
654
+ feedback: outcome.feedback,
655
+ reviewId: outcome.reviewId,
656
+ subject: "objective",
657
+ },
658
+ };
659
+ }
579
660
  } else {
580
661
  const fp = await runFirstPartyReview({
581
662
  ui: ctx.ui,
@@ -599,6 +680,111 @@ export async function executeObjectiveReview(
599
680
  return objectiveReviewOutcomeResult(outcome);
600
681
  }
601
682
 
683
+ // ------------------------------------------------------------------------ the gist review arm
684
+
685
+ const GIST_REVIEW_EDITOR_TITLE =
686
+ "Gist review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
687
+ "Ctrl+G: $EDITOR";
688
+
689
+ /**
690
+ * Map a non-approved gist review outcome into the model-facing tool result (exported for the
691
+ * offline tests) — the gist-flavored sibling of `objectiveReviewOutcomeResult`, delegating to
692
+ * `subjectReviewOutcomeResult` with `GIST_SUBJECT`. Every arm carries `details.subject: "gist"`;
693
+ * the texts redirect to `gist_draft` / `/gist-save`. The execute path routes approved outcomes
694
+ * to `approvedGistSaveResult` first, so `completed` renders DENIED here.
695
+ */
696
+ export function gistReviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
697
+ return subjectReviewOutcomeResult(GIST_SUBJECT, outcome);
698
+ }
699
+
700
+ /**
701
+ * Map an APPROVED gist review outcome + the `gistApprovalSave` outcome into the model-facing
702
+ * tool result (exported for the offline tests) — the gist sibling of
703
+ * `approvedObjectiveSaveResult`, delegating to `approvedSubjectSaveResult` with `GIST_SUBJECT`
704
+ * and no opts (the gist path reviews only the rendered draft, view-only — no
705
+ * `paramMismatch`/`edited`).
706
+ */
707
+ export function approvedGistSaveResult(
708
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
709
+ save: GistApprovalSaveOutcome,
710
+ ): ToolResult {
711
+ return approvedSubjectSaveResult(
712
+ GIST_SUBJECT,
713
+ outcome,
714
+ save.status === "no-draft" ? { status: "no-source" } : save,
715
+ );
716
+ }
717
+
718
+ /**
719
+ * The gist review arm, mirroring `executeObjectiveReview`'s shape with the rendered gist draft
720
+ * as the SOLE review source (never the `plan` param, never the transcript). First-party reviews
721
+ * run VIEW-ONLY (edits are never written back; deny+feedback is the change channel); the
722
+ * implement-here verdict is never offered (a gist is not implementable — it has no strategy).
723
+ * An APPROVED outcome wires into the `gistApprovalSave` seam (re-read the artifact → `saveGist`
724
+ * → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`.
725
+ */
726
+ export async function executeGistReview(
727
+ pi: ExtensionAPI,
728
+ ctx: ExtensionContext,
729
+ gating: ToolGating,
730
+ bridge: { review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> },
731
+ signal?: AbortSignal,
732
+ ): Promise<ToolResult> {
733
+ // 1. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
734
+ if (!ctx.hasUI) return skipResult();
735
+ // 2. The draft artifact is the sole review source — no draft → soft skip with the gist_draft
736
+ // redirect.
737
+ const draft = readGistDraft(ctx);
738
+ if (draft === null) {
739
+ return {
740
+ content: [
741
+ {
742
+ type: "text",
743
+ text:
744
+ "no gist draft to review — write the working gist with gist_draft (the " +
745
+ "statement-of-intent prose), then call plan_review again.",
746
+ },
747
+ ],
748
+ details: {
749
+ ok: false,
750
+ error: "no gist draft to review — write it with gist_draft first",
751
+ error_type: "no_gist_draft",
752
+ status: "skipped",
753
+ reason: "no_gist_draft",
754
+ },
755
+ };
756
+ }
757
+ // 3. The reviewed bytes are the RENDERED markdown (title + scope + prose) — never raw JSON.
758
+ const rendered = renderGistDraft(draft);
759
+ // 4. Backend dispatch (mirrors the objective path): plannotator-selected → the bridge; ANY
760
+ // other selection → the first-party editor, view-only.
761
+ const sig = signal ?? ctx.signal;
762
+ let outcome: ReviewOutcome;
763
+ if (isPlannotatorPlanSelected(ctx.cwd)) {
764
+ outcome = await bridge.review(rendered, sig);
765
+ } else {
766
+ const fp = await runFirstPartyReview({
767
+ ui: ctx.ui,
768
+ plan: rendered,
769
+ writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
770
+ signal: sig,
771
+ editorTitle: GIST_REVIEW_EDITOR_TITLE,
772
+ verdicts: verdictsFor(GIST_SUBJECT),
773
+ viewOnly: true,
774
+ });
775
+ outcome = fp.outcome;
776
+ }
777
+ // 5. An APPROVED decision (either backend) wires into the gistApprovalSave seam (the artifact
778
+ // is re-read at save time — never the rendered bytes; auto-save → D1a gate exit →
779
+ // terminating result); everything else maps via gistReviewOutcomeResult. Approved-first
780
+ // routing: gistReviewOutcomeResult's completed case renders DENIED.
781
+ if (outcome.status === "completed" && outcome.approved) {
782
+ const save = await gistApprovalSave(pi, ctx, gating);
783
+ return approvedGistSaveResult(outcome, save);
784
+ }
785
+ return gistReviewOutcomeResult(outcome);
786
+ }
787
+
602
788
  // ------------------------------------------------------------------------- the execute core
603
789
 
604
790
  /**
@@ -641,9 +827,14 @@ export async function executePlanReview(
641
827
  }
642
828
  // 1. Objective-author session → the objective review arm: the rendered
643
829
  // objective draft is the sole review source; a well-typed `plan` param is ignored here.
644
- if (rebuildWorkflowState(branchOf(ctx)).stage === OBJECTIVE_AUTHOR_STAGE) {
830
+ // A gist-author session likewise routes to the gist arm (the rendered gist draft).
831
+ const launchedStage = rebuildWorkflowState(branchOf(ctx)).stage;
832
+ if (launchedStage === OBJECTIVE_AUTHOR_STAGE) {
645
833
  return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
646
834
  }
835
+ if (launchedStage === GIST_AUTHOR_STAGE) {
836
+ return executeGistReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
837
+ }
647
838
  // 2. Headless → soft skip (fail-open; never wedges CI/supervisor runs on an interactive UI).
648
839
  if (!ctx.hasUI) return skipResult();
649
840
  // 3. File-first resolution: artifact → param, NEVER transcript — an approval
@@ -674,8 +865,34 @@ export async function executePlanReview(
674
865
  let outcome: ReviewOutcome;
675
866
  let reviewedPlan = src.plan;
676
867
  let edited = false;
868
+ let directEditsFailed = false;
677
869
  if (isPlannotatorPlanSelected(ctx.cwd)) {
678
870
  outcome = await bridge.review(src.plan, sig);
871
+ // APPROVE + Direct Edits (browser plan edits, contracts.md §8.23): mechanically apply the
872
+ // reviewer's diff to the exact bytes reviewed, write it back to the draft (reviewed bytes ==
873
+ // artifact bytes == saved bytes — the first-party pre-verdict write-back, replayed here
874
+ // post-verdict because the bridge only reports the diff), and save the EDITED bytes. Every
875
+ // rung fails open to the verbatim path: no section → untouched; a heading that cannot be
876
+ // parsed / applied / written back → verbatim save + a loud warning (never save bytes the
877
+ // artifact doesn't carry). DENY stays model-mediated — the feedback (diff included) passes
878
+ // through for the plan_draft rewrite.
879
+ if (outcome.status === "completed" && outcome.approved && outcome.feedback !== undefined) {
880
+ const section = extractDirectEdits(outcome.feedback);
881
+ if (section !== null) {
882
+ const patched = applyUnifiedDiff(src.plan, section.diff);
883
+ if (patched !== null && writePlanDraft(pi, ctx, patched).details.ok) {
884
+ reviewedPlan = patched;
885
+ edited = true;
886
+ // The applied diff must NOT survive into the result as "apply these exact changes"
887
+ // guidance — only the annotation remainder (when any) stays reviewer feedback.
888
+ outcome = { ...outcome, feedback: section.remainder };
889
+ } else {
890
+ directEditsFailed = true;
891
+ }
892
+ } else if (hasDirectEditsHeading(outcome.feedback)) {
893
+ directEditsFailed = true;
894
+ }
895
+ }
679
896
  } else {
680
897
  // The 4th verdict (implement-here, the no-save exit) is offered UNLESS this is an
681
898
  // objective-node planning session — a node-linked plan must save (the node advance and
@@ -705,7 +922,11 @@ export async function executePlanReview(
705
922
  // gate exit → terminating result); everything else maps via reviewOutcomeResult.
706
923
  if (outcome.status === "completed" && outcome.approved) {
707
924
  const save = await approvalSave(pi, ctx, gating, { reviewedPlan });
708
- return approvedSaveResult(outcome, save, { paramMismatch: src.paramMismatch, edited });
925
+ return approvedSaveResult(outcome, save, {
926
+ paramMismatch: src.paramMismatch,
927
+ edited,
928
+ directEditsFailed,
929
+ });
709
930
  }
710
931
  return reviewOutcomeResult(outcome);
711
932
  }