@mgiles/perk 1.1.0 → 2.0.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.
Files changed (93) hide show
  1. package/README.md +68 -44
  2. package/extension/adapters/planAdapterPlannotator.ts +27 -41
  3. package/extension/adapters/planAdapterTombell.ts +15 -28
  4. package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
  5. package/extension/checkpoints/checkpoints.ts +19 -12
  6. package/extension/doors/address.ts +4 -4
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +21 -14
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +31 -9
  11. package/extension/doors/learn.ts +2 -2
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/plannotatorHandoff.ts +509 -0
  14. package/extension/doors/prReview.ts +4 -4
  15. package/extension/doors/prReviewBrowser.ts +341 -0
  16. package/extension/doors/prReviewTerminal.ts +267 -0
  17. package/extension/doors/selfcheck.ts +238 -5
  18. package/extension/doors/submit.ts +20 -0
  19. package/extension/doors/submitPrReview.ts +408 -0
  20. package/extension/factories/objective.ts +15 -5
  21. package/extension/factories/objectiveAuthor.ts +15 -32
  22. package/extension/factories/objectiveDraft.ts +1 -1
  23. package/extension/factories/objectivePlan.ts +12 -10
  24. package/extension/factories/objectiveSave.ts +2 -2
  25. package/extension/factories/planMode.ts +22 -40
  26. package/extension/factories/planReview.ts +213 -191
  27. package/extension/factories/planSave.ts +7 -7
  28. package/extension/index.ts +83 -25
  29. package/extension/substrate/bindingDelivery.ts +32 -10
  30. package/extension/substrate/bindings.ts +4 -2
  31. package/extension/substrate/cache.ts +34 -7
  32. package/extension/substrate/clipboard.ts +81 -0
  33. package/extension/substrate/config.ts +88 -65
  34. package/extension/substrate/git.ts +43 -0
  35. package/extension/substrate/paths.ts +1 -1
  36. package/extension/substrate/prompts.ts +2 -2
  37. package/extension/substrate/providers.ts +62 -8
  38. package/extension/substrate/sessionPointers.ts +35 -6
  39. package/extension/substrate/structuredOutput.ts +3 -1
  40. package/extension/substrate/terminalLaunch.ts +178 -0
  41. package/extension/substrate/toolGating.ts +330 -79
  42. package/extension/substrate/toolParams.ts +7 -0
  43. package/extension/substrate/workflowState.ts +54 -2
  44. package/extension/surfaces/footerProvider.ts +8 -4
  45. package/extension/surfaces/surfaces.ts +330 -12
  46. package/extension/vendor/btw/btw.ts +10 -0
  47. package/extension/worker/readOnlySession.ts +19 -6
  48. package/extension/worker/worker.ts +77 -7
  49. package/extension/workerMain.ts +12 -13
  50. package/package.json +3 -3
  51. package/prompts/_fixtures/live.yaml +117 -2
  52. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  53. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  54. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  55. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  56. package/prompts/contexts/objective-authoring.md +20 -0
  57. package/prompts/contexts/plan-authoring.md +24 -0
  58. package/prompts/contexts/read-only.md +10 -0
  59. package/prompts/stages/conflict-resolution.md +1 -1
  60. package/prompts/stages/learn-code.md +1 -1
  61. package/prompts/stages/learn-docs.md +2 -2
  62. package/prompts/stages/learn-orchestrate.md +1 -1
  63. package/prompts/stages/objective-author/adopt.md +1 -1
  64. package/prompts/stages/objective-author/file.md +1 -1
  65. package/prompts/stages/objective-plan/guidance.md +1 -1
  66. package/prompts/stages/objective-plan/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile.md +1 -1
  68. package/prompts/stages/objective-replan.md +1 -1
  69. package/prompts/stages/plan-from/adopt.md +2 -2
  70. package/prompts/stages/plan-from/file.md +2 -2
  71. package/prompts/stages/pr-review-browser/active.md +11 -0
  72. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  73. package/prompts/stages/pr-review-terminal/active.md +12 -0
  74. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  75. package/prompts/stages/pr-review-terminal/local.md +4 -0
  76. package/prompts/stages/pr-review.md +1 -1
  77. package/prompts/stages/replan.md +2 -2
  78. package/prompts/stages/skills/create-from.md +1 -1
  79. package/prompts/stages/skills/create.md +1 -1
  80. package/prompts/stages/skills/refine.md +1 -1
  81. package/shared/README.md +22 -18
  82. package/shared/bindings.yaml +10 -2
  83. package/shared/contracts-history.md +24 -0
  84. package/shared/contracts.md +1442 -1787
  85. package/shared/providers.yaml +8 -1
  86. package/shared/registry.yaml +7 -8
  87. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  88. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  89. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  90. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  91. package/extension/doors/learnCode.ts +0 -100
  92. package/extension/doors/learnDocs.ts +0 -100
  93. package/extension/doors/prReviewLocal.ts +0 -229
@@ -50,6 +50,7 @@ import {
50
50
  createPlannotatorBridge,
51
51
  isPlannotatorPlanSelected,
52
52
  } from "../adapters/planAdapterPlannotator.ts";
53
+ import type { Result } from "../substrate/result.ts";
53
54
  import type { ToolGating } from "../substrate/toolGating.ts";
54
55
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
55
56
  import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
@@ -83,21 +84,70 @@ interface ToolResult {
83
84
  terminate?: boolean;
84
85
  }
85
86
 
87
+ /**
88
+ * The subject descriptor parameterizing the shared renderer cores below — the plan and objective
89
+ * review arms render the same outcome shapes, differing only in these fields. Module-private on
90
+ * purpose: nothing outside this module needs it (both execute arms live here), and a third review
91
+ * subject would land here too, constructing its own descriptor and reusing the cores.
92
+ */
93
+ interface ReviewSubject {
94
+ /** The display noun in every rendered text ("plan" / "objective"). */
95
+ noun: string;
96
+ /** The lowercase present-the-work phrase (dismissed / implement-here arms). */
97
+ present: string;
98
+ /** The unavailable-arm phrase (the plan flavor appends "in your next message"). */
99
+ presentUnavailable: string;
100
+ /** Where an implement-here verdict "cannot" have come from (the defensive arm's text). */
101
+ implementHereWhere: string;
102
+ /** The draft-rewrite tool the DENIED text redirects to. */
103
+ draftTool: string;
104
+ /** The manual-failsafe slash command. */
105
+ failsafeCmd: string;
106
+ /** Extra keys merged into every details object ({} on the plan arm). */
107
+ detailsExtra: Record<string, unknown>;
108
+ /** The defensively-unreachable no-source save arm's error string. */
109
+ noSourceError: string;
110
+ }
111
+
112
+ const PLAN_SUBJECT: ReviewSubject = {
113
+ noun: "plan",
114
+ present: "the complete plan to the user",
115
+ presentUnavailable: "the complete plan to the user in your next message",
116
+ implementHereWhere: "outside the execute path",
117
+ draftTool: "plan_draft",
118
+ failsafeCmd: "/plan-save",
119
+ detailsExtra: {},
120
+ noSourceError: "no plan source resolved",
121
+ };
122
+
123
+ const OBJECTIVE_SUBJECT: ReviewSubject = {
124
+ noun: "objective",
125
+ present: "the complete objective + structured roadmap to the user",
126
+ presentUnavailable: "the complete objective + structured roadmap to the user",
127
+ implementHereWhere: "on the objective path",
128
+ draftTool: "objective_draft",
129
+ failsafeCmd: "/objective-save",
130
+ detailsExtra: { subject: "objective" },
131
+ noSourceError: "no objective draft resolved",
132
+ };
133
+
86
134
  const SKIP_TEXT =
87
135
  "no interactive review surface available — present the complete plan to the user in your next message.";
88
136
 
89
137
  function skipResult(): ToolResult {
90
- return { content: [{ type: "text", text: SKIP_TEXT }], details: { status: "skipped" } };
138
+ return { content: [{ type: "text", text: SKIP_TEXT }], details: { ok: true, status: "skipped" } };
91
139
  }
92
140
 
93
141
  /**
94
- * Map a non-approved review outcome into the model-facing tool result (exported for the offline
95
- * tests). The `completed` case renders the DENIED text — the execute path routes approved
96
- * outcomes to `approvedSaveResult` first, so callers only reach `completed` here with
97
- * `approved: false` (kept total for safety). The `dismissed` arm renders as a skip — the human
98
- * declined to decide, so the present-plan + `/plan-save` manual-failsafe discipline applies.
142
+ * The shared outcome-mapper core: map a non-approved review outcome into the model-facing tool
143
+ * result for `subject`. The `completed` case renders the DENIED text — both execute paths route
144
+ * approved outcomes to their approved-save mapper FIRST, so callers only reach `completed` here
145
+ * with `approved: false` (kept total for safety; the `approved: outcome.approved` passthrough is
146
+ * deliberately behavior-preserving never hardcode `false`). The `dismissed` arm renders as a
147
+ * skip — the human declined to decide, so the present-the-work + manual-failsafe discipline
148
+ * applies.
99
149
  */
100
- export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
150
+ function subjectReviewOutcomeResult(subject: ReviewSubject, outcome: ReviewOutcome): ToolResult {
101
151
  switch (outcome.status) {
102
152
  case "unavailable":
103
153
  return {
@@ -106,15 +156,21 @@ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
106
156
  type: "text",
107
157
  text:
108
158
  `WARNING: ${outcome.warning} — no review performed. ` +
109
- "Present the complete plan to the user in your next message instead.",
159
+ `Present ${subject.presentUnavailable} instead.`,
110
160
  },
111
161
  ],
112
- details: { status: "unavailable" },
162
+ details: {
163
+ ok: false,
164
+ error: outcome.warning,
165
+ error_type: "unavailable",
166
+ status: "unavailable",
167
+ ...subject.detailsExtra,
168
+ },
113
169
  };
114
170
  case "aborted":
115
171
  return {
116
- content: [{ type: "text", text: "plan review aborted (turn interrupted)." }],
117
- details: { status: "aborted" },
172
+ content: [{ type: "text", text: `${subject.noun} review aborted (turn interrupted).` }],
173
+ details: { ok: true, status: "aborted", ...subject.detailsExtra },
118
174
  };
119
175
  case "dismissed":
120
176
  return {
@@ -122,38 +178,41 @@ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
122
178
  {
123
179
  type: "text",
124
180
  text:
125
- "plan review dismissed — present the complete plan to the user; the human runs " +
126
- "/plan-save (the manual failsafe).",
181
+ `${subject.noun} review dismissed — present ${subject.present}; the human runs ` +
182
+ `${subject.failsafeCmd} (the manual failsafe).`,
127
183
  },
128
184
  ],
129
- details: { status: "skipped", reason: "dismissed" },
185
+ details: { ok: true, status: "skipped", reason: "dismissed", ...subject.detailsExtra },
130
186
  };
131
187
  case "implement-here":
132
- // Defensively unreachable: the execute path routes implement-here to implementHereResult
133
- // FIRST (mirror the approved-first routing). Map to a skip shape rather than throwing.
188
+ // Defensively unreachable: the plan execute path routes implement-here to
189
+ // implementHereResult FIRST (mirror the approved-first routing), and the objective arm
190
+ // never offers the verdict. Map to a skip shape rather than throwing.
134
191
  return {
135
192
  content: [
136
193
  {
137
194
  type: "text",
138
195
  text:
139
- "implement-here verdict received outside the execute path — nothing saved; " +
140
- "present the complete plan to the user.",
196
+ `implement-here verdict received ${subject.implementHereWhere} — nothing saved; ` +
197
+ `present ${subject.present}.`,
141
198
  },
142
199
  ],
143
- details: { status: "skipped", reason: "implement-here" },
200
+ details: { ok: true, status: "skipped", reason: "implement-here", ...subject.detailsExtra },
144
201
  };
145
202
  case "completed": {
146
203
  const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
147
204
  const text =
148
- "plan DENIED — revise per this feedback, rewrite the working draft with plan_draft, " +
149
- `then call plan_review again.${feedback}`;
205
+ `${subject.noun} DENIED — revise per this feedback, rewrite the working draft with ` +
206
+ `${subject.draftTool}, then call plan_review again.${feedback}`;
150
207
  return {
151
208
  content: [{ type: "text", text }],
152
209
  details: {
210
+ ok: true,
153
211
  status: "completed",
154
212
  approved: outcome.approved,
155
213
  feedback: outcome.feedback ?? null,
156
214
  reviewId: outcome.reviewId,
215
+ ...subject.detailsExtra,
157
216
  },
158
217
  };
159
218
  }
@@ -161,52 +220,82 @@ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
161
220
  }
162
221
 
163
222
  /**
164
- * Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
165
- * (exported for the offline tests). A successful save TERMINATES the turn (propagating the
166
- * seam's `terminate: true` intent); a failed save is non-terminating, leaves the gate read-only,
167
- * and directs the human `/plan-save` failsafe. Reviewer feedback is surfaced loudly as
168
- * implementation guidance — the approved bytes were saved verbatim, never post-edited. `edited`
169
- * (first-party only) flags that human edits were written back to the draft pre-verdict, so the
170
- * saved bytes carry them. The `no-plan` arm is defensively unreachable (the reviewed plan is
171
- * always non-blank) but maps to the save-failed shape rather than throwing.
223
+ * Map a non-approved review outcome into the model-facing tool result (exported for the offline
224
+ * tests) the plan flavor of `subjectReviewOutcomeResult`. The execute path routes approved
225
+ * outcomes to `approvedSaveResult` first, so `completed` renders DENIED here.
172
226
  */
173
- export function approvedSaveResult(
227
+ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
228
+ return subjectReviewOutcomeResult(PLAN_SUBJECT, outcome);
229
+ }
230
+
231
+ /**
232
+ * The normalized approval-save outcome the shared core consumes — each delegator maps its
233
+ * subject-specific no-source discriminant (`no-plan` / `no-draft`) onto `no-source`. `result`
234
+ * widens to `Result<object>`: the core reads only `content[0]?.text` and the `details.ok`
235
+ * discriminant (+ `details.error` on the fail arm), passing `details` through opaquely.
236
+ */
237
+ type SubjectSaveOutcome =
238
+ | { status: "no-source" }
239
+ | { status: "saved" | "save-failed"; result: Result<object>; gateExited: boolean };
240
+
241
+ /**
242
+ * The shared approved-save mapper core: map an APPROVED review outcome + the approval-save
243
+ * outcome into the model-facing tool result for `subject`. A successful save TERMINATES the turn
244
+ * (propagating the seam's `terminate: true` intent); a failed save is non-terminating, leaves
245
+ * the gate read-only, and directs the human manual failsafe. Reviewer feedback is surfaced
246
+ * 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
250
+ * non-blank) but maps to the save-failed shape rather than throwing.
251
+ */
252
+ function approvedSubjectSaveResult(
253
+ subject: ReviewSubject,
174
254
  outcome: Extract<ReviewOutcome, { status: "completed" }>,
175
- save: ApprovalSaveOutcome,
176
- opts: { paramMismatch: boolean; edited?: boolean },
255
+ save: SubjectSaveOutcome,
256
+ opts?: { paramMismatch?: boolean; edited?: boolean },
177
257
  ): ToolResult {
178
258
  const feedback = outcome.feedback
179
- ? "\n\nReviewer feedback (implementation guidance — the approved plan was saved verbatim):\n" +
180
- outcome.feedback
259
+ ? `\n\nReviewer feedback (implementation guidance — the approved ${subject.noun} was saved ` +
260
+ `verbatim):\n${outcome.feedback}`
181
261
  : "";
182
262
  const base = {
183
263
  status: "completed",
184
264
  approved: true,
185
265
  reviewId: outcome.reviewId,
186
266
  feedback: outcome.feedback ?? null,
187
- ...(opts.edited === true ? { edited: true } : {}),
267
+ ...subject.detailsExtra,
268
+ ...(opts?.edited === true ? { edited: true } : {}),
188
269
  };
189
270
  if (save.status === "saved") {
190
271
  const saveText = save.result.content[0]?.text ?? "";
191
- const mismatch = opts.paramMismatch
192
- ? "\n\n⚠ differing plan param ignored — the validated draft was reviewed and saved."
193
- : "";
194
272
  const edited =
195
- opts.edited === true ? " · human edits were written back to the draft and saved" : "";
273
+ opts?.edited === true ? " · human edits were written back to the draft and saved" : "";
274
+ const mismatch =
275
+ opts?.paramMismatch === true
276
+ ? "\n\n⚠ differing plan param ignored — the validated draft was reviewed and saved."
277
+ : "";
196
278
  return {
197
279
  content: [
198
280
  {
199
281
  type: "text",
200
- text: `plan APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}`,
282
+ text: `${subject.noun} APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}`,
201
283
  },
202
284
  ],
203
- details: { ...base, saved: true, gateExited: save.gateExited, save: save.result.details },
285
+ // `ok` sits per-branch, NOT in `base` `base` is spread into the fail branch too.
286
+ details: {
287
+ ok: true,
288
+ ...base,
289
+ saved: true,
290
+ gateExited: save.gateExited,
291
+ save: save.result.details,
292
+ },
204
293
  terminate: true,
205
294
  };
206
295
  }
207
296
  const error =
208
- save.status === "no-plan"
209
- ? "no plan source resolved"
297
+ save.status === "no-source"
298
+ ? subject.noSourceError
210
299
  : save.result.details.ok
211
300
  ? "unknown save failure"
212
301
  : save.result.details.error;
@@ -215,18 +304,41 @@ export function approvedSaveResult(
215
304
  {
216
305
  type: "text",
217
306
  text:
218
- `plan APPROVED by reviewer, but the auto-save FAILED (${error}) — the session stays ` +
219
- `read-only. Ask the user to run /plan-save (the manual failsafe) to retry.${feedback}`,
307
+ `${subject.noun} APPROVED by reviewer, but the auto-save FAILED (${error}) — the ` +
308
+ `session stays read-only. Ask the user to run ${subject.failsafeCmd} (the manual ` +
309
+ `failsafe) to retry.${feedback}`,
220
310
  },
221
311
  ],
222
312
  details: {
313
+ ok: false,
314
+ error,
315
+ error_type: "save_failed",
223
316
  ...base,
224
317
  saved: false,
225
- save: save.status === "no-plan" ? null : save.result.details,
318
+ save: save.status === "no-source" ? null : save.result.details,
226
319
  },
227
320
  };
228
321
  }
229
322
 
323
+ /**
324
+ * Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
325
+ * (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.
328
+ */
329
+ export function approvedSaveResult(
330
+ outcome: Extract<ReviewOutcome, { status: "completed" }>,
331
+ save: ApprovalSaveOutcome,
332
+ opts: { paramMismatch: boolean; edited?: boolean },
333
+ ): ToolResult {
334
+ return approvedSubjectSaveResult(
335
+ PLAN_SUBJECT,
336
+ outcome,
337
+ save.status === "no-plan" ? { status: "no-source" } : save,
338
+ opts,
339
+ );
340
+ }
341
+
230
342
  /**
231
343
  * Map an IMPLEMENT-HERE review outcome + the `implementHereExit` outcome into the model-facing
232
344
  * tool result (exported for the offline tests). NON-terminating on purpose — the model continues
@@ -248,6 +360,7 @@ export function implementHereResult(
248
360
  },
249
361
  ],
250
362
  details: {
363
+ ok: true,
251
364
  status: "implement-here",
252
365
  saved: false,
253
366
  gateExited: exit.gateExited,
@@ -269,10 +382,20 @@ export interface PlanReviewUI {
269
382
  ): Promise<string | undefined>;
270
383
  }
271
384
 
272
- /** The verdict options (plain text — charter D3: no emoji outside the footer). */
273
- const VERDICT_APPROVE = "Approveauto-save to GitHub";
274
- const VERDICT_DENY = "Deny send feedback for revision";
275
- const VERDICT_SKIP = "Skipdecide later (manual /plan-save)";
385
+ /**
386
+ * Derive a subject's verdict options (plain text charter D3: no emoji outside the footer);
387
+ * only the skip label's manual-failsafe command varies by subject. `VERDICT_IMPLEMENT_HERE`
388
+ * stays a standalone constant on purpose the no-save exit is plan-arm-only by contract
389
+ * (§8.23), never part of the descriptor.
390
+ */
391
+ function verdictsFor(subject: ReviewSubject): { approve: string; deny: string; skip: string } {
392
+ return {
393
+ approve: "Approve — auto-save to GitHub",
394
+ deny: "Deny — send feedback for revision",
395
+ skip: `Skip — decide later (manual ${subject.failsafeCmd})`,
396
+ };
397
+ }
398
+
276
399
  /** The optional 4th verdict (plan arm only): the no-save implement-here exit (§8.23). */
277
400
  const VERDICT_IMPLEMENT_HERE = "Implement here — no issue saved";
278
401
 
@@ -310,11 +433,8 @@ export async function runFirstPartyReview(args: {
310
433
  }): Promise<{ outcome: ReviewOutcome; plan: string; edited: boolean }> {
311
434
  const { ui, writeDraft, signal } = args;
312
435
  const editorTitle = args.editorTitle ?? REVIEW_EDITOR_TITLE;
313
- const verdicts = args.verdicts ?? {
314
- approve: VERDICT_APPROVE,
315
- deny: VERDICT_DENY,
316
- skip: VERDICT_SKIP,
317
- };
436
+ const verdicts: { approve: string; deny: string; skip: string; implementHere?: string } =
437
+ args.verdicts ?? verdictsFor(PLAN_SUBJECT);
318
438
  let plan = args.plan;
319
439
  let edited = false;
320
440
  const result = (
@@ -375,149 +495,38 @@ export async function runFirstPartyReview(args: {
375
495
 
376
496
  // ------------------------------------------------------------------- the objective review arm
377
497
 
378
- /** The objective-flavored verdict options (approval auto-saves). */
379
- const OBJECTIVE_VERDICTS = {
380
- approve: "Approve — auto-save to GitHub",
381
- deny: "Deny — send feedback for revision",
382
- skip: "Skip — decide later (manual /objective-save)",
383
- };
384
-
385
498
  const OBJECTIVE_REVIEW_EDITOR_TITLE =
386
499
  "Objective review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
387
500
  "Ctrl+G: $EDITOR";
388
501
 
389
502
  /**
390
503
  * Map a non-approved objective review outcome into the model-facing tool result (exported for
391
- * the offline tests) — the objective-flavored sibling of `reviewOutcomeResult`. Every arm
392
- * carries `details.subject: "objective"`; the texts redirect to `objective_draft` /
393
- * `/objective-save`. The `completed` case renders the DENIED text the execute path routes
394
- * approved outcomes to `approvedObjectiveSaveResult` first, so callers only reach `completed`
395
- * here with `approved: false` (kept total for safety).
504
+ * the offline tests) — the objective-flavored sibling of `reviewOutcomeResult`, delegating to
505
+ * `subjectReviewOutcomeResult` with `OBJECTIVE_SUBJECT`. Every arm carries
506
+ * `details.subject: "objective"`; the texts redirect to `objective_draft` / `/objective-save`.
507
+ * The execute path routes approved outcomes to `approvedObjectiveSaveResult` first, so
508
+ * `completed` renders DENIED here.
396
509
  */
397
510
  export function objectiveReviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
398
- switch (outcome.status) {
399
- case "unavailable":
400
- return {
401
- content: [
402
- {
403
- type: "text",
404
- text:
405
- `WARNING: ${outcome.warning} — no review performed. ` +
406
- "Present the complete objective + structured roadmap to the user instead.",
407
- },
408
- ],
409
- details: { status: "unavailable", subject: "objective" },
410
- };
411
- case "aborted":
412
- return {
413
- content: [{ type: "text", text: "objective review aborted (turn interrupted)." }],
414
- details: { status: "aborted", subject: "objective" },
415
- };
416
- case "dismissed":
417
- return {
418
- content: [
419
- {
420
- type: "text",
421
- text:
422
- "objective review dismissed — present the complete objective + structured roadmap " +
423
- "to the user; the human runs /objective-save (the manual failsafe).",
424
- },
425
- ],
426
- details: { status: "skipped", reason: "dismissed", subject: "objective" },
427
- };
428
- case "implement-here":
429
- // Defensively unreachable twice over: the objective arm never offers the verdict, and the
430
- // execute path routes implement-here first. Map to a skip shape rather than throwing.
431
- return {
432
- content: [
433
- {
434
- type: "text",
435
- text:
436
- "implement-here verdict received on the objective path — nothing saved; present " +
437
- "the complete objective + structured roadmap to the user.",
438
- },
439
- ],
440
- details: { status: "skipped", reason: "implement-here", subject: "objective" },
441
- };
442
- case "completed": {
443
- const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
444
- return {
445
- content: [
446
- {
447
- type: "text",
448
- text:
449
- "objective DENIED — revise per this feedback, rewrite the working draft with " +
450
- `objective_draft, then call plan_review again.${feedback}`,
451
- },
452
- ],
453
- details: {
454
- status: "completed",
455
- approved: outcome.approved,
456
- feedback: outcome.feedback ?? null,
457
- reviewId: outcome.reviewId,
458
- subject: "objective",
459
- },
460
- };
461
- }
462
- }
511
+ return subjectReviewOutcomeResult(OBJECTIVE_SUBJECT, outcome);
463
512
  }
464
513
 
465
514
  /**
466
515
  * Map an APPROVED objective review outcome + the `objectiveApprovalSave` outcome into the
467
516
  * model-facing tool result (exported for the offline tests) — the objective sibling of
468
- * `approvedSaveResult` (no `paramMismatch`/`edited` opts: the objective path reviews only the
469
- * rendered draft, view-only). A successful save TERMINATES the turn; a failed save is
470
- * non-terminating, leaves the gate read-only, and directs the human `/objective-save` failsafe.
471
- * The `no-draft` arm is defensively unreachable (the review just read the draft) but maps to
472
- * the save-failed shape rather than throwing.
517
+ * `approvedSaveResult`, delegating to `approvedSubjectSaveResult` with `OBJECTIVE_SUBJECT` and
518
+ * no opts (the objective path reviews only the rendered draft, view-only no
519
+ * `paramMismatch`/`edited`).
473
520
  */
474
521
  export function approvedObjectiveSaveResult(
475
522
  outcome: Extract<ReviewOutcome, { status: "completed" }>,
476
523
  save: ObjectiveApprovalSaveOutcome,
477
524
  ): ToolResult {
478
- const feedback = outcome.feedback
479
- ? "\n\nReviewer feedback (implementation guidance — the approved objective was saved " +
480
- `verbatim):\n${outcome.feedback}`
481
- : "";
482
- const base = {
483
- status: "completed",
484
- approved: true,
485
- reviewId: outcome.reviewId,
486
- feedback: outcome.feedback ?? null,
487
- subject: "objective",
488
- };
489
- if (save.status === "saved") {
490
- const saveText = save.result.content[0]?.text ?? "";
491
- return {
492
- content: [
493
- { type: "text", text: `objective APPROVED by reviewer.${feedback}\n\n${saveText}` },
494
- ],
495
- details: { ...base, saved: true, gateExited: save.gateExited, save: save.result.details },
496
- terminate: true,
497
- };
498
- }
499
- const error =
500
- save.status === "no-draft"
501
- ? "no objective draft resolved"
502
- : save.result.details.ok
503
- ? "unknown save failure"
504
- : save.result.details.error;
505
- return {
506
- content: [
507
- {
508
- type: "text",
509
- text:
510
- `objective APPROVED by reviewer, but the auto-save FAILED (${error}) — the session ` +
511
- "stays read-only. Ask the user to run /objective-save (the manual failsafe) to " +
512
- `retry.${feedback}`,
513
- },
514
- ],
515
- details: {
516
- ...base,
517
- saved: false,
518
- save: save.status === "no-draft" ? null : save.result.details,
519
- },
520
- };
525
+ return approvedSubjectSaveResult(
526
+ OBJECTIVE_SUBJECT,
527
+ outcome,
528
+ save.status === "no-draft" ? { status: "no-source" } : save,
529
+ );
521
530
  }
522
531
 
523
532
  /**
@@ -550,7 +559,13 @@ export async function executeObjectiveReview(
550
559
  "(prose + the structured roadmap), then call plan_review again.",
551
560
  },
552
561
  ],
553
- details: { status: "skipped", reason: "no_objective_draft" },
562
+ details: {
563
+ ok: false,
564
+ error: "no objective draft to review — write it with objective_draft first",
565
+ error_type: "no_objective_draft",
566
+ status: "skipped",
567
+ reason: "no_objective_draft",
568
+ },
554
569
  };
555
570
  }
556
571
  // 3. The reviewed bytes are the RENDERED markdown (prose + roadmap table) — never raw JSON.
@@ -568,7 +583,7 @@ export async function executeObjectiveReview(
568
583
  writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
569
584
  signal: sig,
570
585
  editorTitle: OBJECTIVE_REVIEW_EDITOR_TITLE,
571
- verdicts: OBJECTIVE_VERDICTS,
586
+ verdicts: verdictsFor(OBJECTIVE_SUBJECT),
572
587
  viewOnly: true,
573
588
  });
574
589
  outcome = fp.outcome;
@@ -615,7 +630,13 @@ export async function executePlanReview(
615
630
  text: "plan_review takes { plan?: string } — omit it (the plan-draft artifact is preferred) or pass a string.",
616
631
  },
617
632
  ],
618
- details: { status: "skipped", reason: "bad_input" },
633
+ details: {
634
+ ok: false,
635
+ error: "plan must be a string",
636
+ error_type: "bad_input",
637
+ status: "skipped",
638
+ reason: "bad_input",
639
+ },
619
640
  };
620
641
  }
621
642
  // 1. Objective-author session → the objective review arm: the rendered
@@ -638,7 +659,13 @@ export async function executePlanReview(
638
659
  "param), then call plan_review again.",
639
660
  },
640
661
  ],
641
- details: { status: "skipped", reason: "no_plan" },
662
+ details: {
663
+ ok: false,
664
+ error: "no plan to review — write the draft with plan_draft first",
665
+ error_type: "no_plan",
666
+ status: "skipped",
667
+ reason: "no_plan",
668
+ },
642
669
  };
643
670
  }
644
671
  // 4. Backend dispatch: plannotator-selected → the event-bus bridge; ANY other selection
@@ -660,12 +687,7 @@ export async function executePlanReview(
660
687
  signal: sig,
661
688
  verdicts:
662
689
  readNodeClaim(ctx) === null
663
- ? {
664
- approve: VERDICT_APPROVE,
665
- deny: VERDICT_DENY,
666
- skip: VERDICT_SKIP,
667
- implementHere: VERDICT_IMPLEMENT_HERE,
668
- }
690
+ ? { ...verdictsFor(PLAN_SUBJECT), implementHere: VERDICT_IMPLEMENT_HERE }
669
691
  : undefined,
670
692
  });
671
693
  outcome = fp.outcome;
@@ -713,7 +735,7 @@ export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
713
735
  "Keep the working draft current with plan_draft — the validated plan-draft artifact is what plan_review reviews AND auto-saves; the plan param is only a fallback when no draft exists.",
714
736
  "Call plan_review only when the plan is decision-complete.",
715
737
  "On a DENIED review, revise per the feedback, rewrite the draft with plan_draft, then call plan_review again.",
716
- "On an APPROVED review, the plan is auto-saved and the turn ends — never re-dump the plan as a final message and never tell the user to run /plan-save; relay the save outcome instead.",
738
+ "On an APPROVED plan_review, the plan is auto-saved and the turn ends — never re-dump the plan as a final message and never tell the user to run /plan-save; relay the save outcome instead.",
717
739
  "If plan_review reports it was skipped or unavailable (headless, dismissed), fall back to presenting the complete plan; the human runs /plan-save (the manual failsafe).",
718
740
  ],
719
741
  executionMode: "sequential",
@@ -1,4 +1,4 @@
1
- // The warm `/plan-save` door (turn-3 §5/§6). The in-session twin of the Python cold
1
+ // The warm `/plan-save` door. The in-session twin of the Python cold
2
2
  // door (`perk plan save`): a deterministic, terminating tool + command that WRAP the existing
3
3
  // storage — they do NOT reimplement the GitHub write. `savePlan()` delegates to `perk plan save
4
4
  // --json` via the shared cold-door client (`runColdDoor` — the plan markdown rides the run-scratch
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // APPROVAL→SAVE ORCHESTRATION. The exported `approvalSave` seam is the shared
15
15
  // APPROVED-review → save flow: artifact-first resolution → `savePlan` (warm node-link recovery
16
- // inside, from the `objective_node_claim` carrier) → gate exit on a successful save (D1a). The
16
+ // inside, from the `objective_node_claim` carrier) → gate exit on a successful save. The
17
17
  // `/plan-save` command is the MANUAL FAILSAFE invocation of the same seam; the review backends
18
18
  // (plannotator / first-party / tombell) wire their APPROVED outcome into it.
19
19
  //
@@ -52,7 +52,7 @@ import { nodeClaimsEqual, readNodeClaim } from "./objectivePlan.ts";
52
52
  import { PLAN_DRAFT_ARTIFACT } from "./planDraft.ts";
53
53
  import { generatePlanTitle } from "./planTitle.ts";
54
54
 
55
- /** The ok-arm fields (turn-3 D6) — the `details` surface doubles as branch-safe persisted state. */
55
+ /** The ok-arm fields — the `details` surface doubles as branch-safe persisted state. */
56
56
  export interface PlanSaveOk {
57
57
  /** `issue.id` is the opaque string issue id (GitHub "42", Linear "ENG-123") — §8.21. */
58
58
  issue: { id: string; url: string };
@@ -327,7 +327,7 @@ export async function savePlan(
327
327
  sessionFile: ctx.sessionManager.getSessionFile?.(),
328
328
  });
329
329
 
330
- // Link the live session (turn-3 D4): append iff the rebuilt ref differs, with a strict read-back.
330
+ // Link the live session: append iff the rebuilt ref differs, with a strict read-back.
331
331
  const ref = r.data.plan_ref;
332
332
  if (!planRefsEqual(rebuildWorkflowState(branch()).active_plan_ref ?? null, ref)) {
333
333
  appendWorkflowState(pi, ctx, {
@@ -361,7 +361,7 @@ export async function savePlan(
361
361
  }
362
362
  // Render all THREE node-link outcomes (the silent-partial-failure fix). A failed advance
363
363
  // (`linked: false`) is a non-fatal sub-step — the plan genuinely saved — but it must be VISIBLE
364
- // (the §8.4 "warn + retriable" intent), not swallowed. Both surfaces render content[0].text, so
364
+ // (the §8.3 "surfaced, never swallowed" intent), not swallowed. Both surfaces render content[0].text, so
365
365
  // this one site fixes the tool path (the model relays it) and the command path (the user sees the
366
366
  // notify) at once.
367
367
  let linkSuffix = "";
@@ -481,8 +481,8 @@ export function decodePlanSaveParams(params: unknown): PlanSaveParams | null {
481
481
  const TOOL_GUIDELINES = [
482
482
  "Use plan_save only after the plan is decision-complete and the user has agreed; it creates the canonical GitHub plan and ends the turn.",
483
483
  "Keep the working draft current with plan_draft — the validated plan-draft artifact is what plan_save saves; the `plan` parameter is only a fallback when no draft exists. Never reference line numbers — use durable anchors (function names, behavioral descriptions, structural locations).",
484
- "Pass consumed_learn (the gathered perk:learn issue ids) only from the learned-docs factory — it links the issues the docs plan consolidates so /land closes + labels them.",
485
- "When saving an objective-factory plan, pass BOTH objective_id and node_id — this links the node to the plan and advances it planning → in_progress (no separate backlink call).",
484
+ "Pass plan_save's consumed_learn (the gathered perk:learn issue ids) only from the learned-docs factory — it links the issues the docs plan consolidates so /land closes + labels them.",
485
+ "When saving an objective-factory plan, pass plan_save BOTH objective_id and node_id — this links the node to the plan and advances it planning → in_progress (no separate backlink call).",
486
486
  ];
487
487
 
488
488
  /** Register the warm door: the `plan_save` tool (canonical) + the `/plan-save` command twin. */