@mgiles/perk 1.0.1 → 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 (157) hide show
  1. package/README.md +67 -57
  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 +21 -13
  6. package/extension/doors/address.ts +6 -5
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +23 -15
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +33 -10
  11. package/extension/doors/learn.ts +240 -34
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/lifecycleGates.ts +2 -1
  14. package/extension/doors/plannotatorHandoff.ts +509 -0
  15. package/extension/doors/prReview.ts +22 -37
  16. package/extension/doors/prReviewBrowser.ts +341 -0
  17. package/extension/doors/prReviewTerminal.ts +267 -0
  18. package/extension/doors/ready.ts +2 -1
  19. package/extension/doors/selfcheck.ts +240 -6
  20. package/extension/doors/submit.ts +37 -19
  21. package/extension/doors/submitPrReview.ts +408 -0
  22. package/extension/factories/implementHere.ts +116 -0
  23. package/extension/factories/objective.ts +17 -6
  24. package/extension/factories/objectiveAuthor.ts +15 -32
  25. package/extension/factories/objectiveDraft.ts +1 -1
  26. package/extension/factories/objectivePlan.ts +16 -34
  27. package/extension/factories/objectiveSave.ts +7 -17
  28. package/extension/factories/planMode.ts +24 -38
  29. package/extension/factories/planReview.ts +291 -177
  30. package/extension/factories/planSave.ts +23 -8
  31. package/extension/index.ts +117 -20
  32. package/extension/substrate/bindingDelivery.ts +32 -10
  33. package/extension/substrate/bindings.ts +4 -2
  34. package/extension/substrate/cache.ts +36 -9
  35. package/extension/substrate/clipboard.ts +81 -0
  36. package/extension/substrate/command.ts +25 -0
  37. package/extension/substrate/config.ts +98 -76
  38. package/extension/substrate/consoleCapture.ts +90 -0
  39. package/extension/substrate/git.ts +77 -0
  40. package/extension/substrate/miniJinja.ts +480 -0
  41. package/extension/substrate/paths.ts +38 -0
  42. package/extension/substrate/prompts.ts +15 -24
  43. package/extension/substrate/providers.ts +62 -8
  44. package/extension/substrate/sessionData.ts +1 -1
  45. package/extension/substrate/sessionPointers.ts +184 -0
  46. package/extension/substrate/structuredOutput.ts +3 -1
  47. package/extension/substrate/terminalLaunch.ts +178 -0
  48. package/extension/substrate/toolGating.ts +332 -73
  49. package/extension/substrate/toolParams.ts +7 -0
  50. package/extension/substrate/workflowState.ts +54 -2
  51. package/extension/surfaces/footerProvider.ts +8 -4
  52. package/extension/surfaces/surfaces.ts +338 -13
  53. package/extension/vendor/btw/btw.ts +10 -0
  54. package/extension/worker/readOnlySession.ts +19 -6
  55. package/extension/worker/worker.ts +187 -31
  56. package/extension/workerMain.ts +14 -13
  57. package/package.json +3 -7
  58. package/prompts/README.md +56 -5
  59. package/prompts/_fixtures/cases.yaml +52 -131
  60. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  61. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  62. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  63. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  64. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  65. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  66. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  67. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  68. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  69. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  70. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  71. package/prompts/_fixtures/live.yaml +396 -0
  72. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  73. package/prompts/_fixtures/templates/cond_if.md +7 -0
  74. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  75. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  76. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  77. package/prompts/_fixtures/templates/trim_block.md +5 -0
  78. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  79. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  80. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  81. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  82. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  83. package/prompts/contexts/objective-authoring.md +20 -0
  84. package/prompts/contexts/plan-authoring.md +24 -0
  85. package/prompts/contexts/read-only.md +10 -0
  86. package/prompts/stages/conflict-resolution.md +4 -0
  87. package/prompts/stages/learn-code.md +8 -0
  88. package/prompts/stages/learn-docs.md +7 -6
  89. package/prompts/stages/learn-orchestrate.md +6 -0
  90. package/prompts/stages/learn.md +1 -1
  91. package/prompts/stages/objective-author/adopt.md +12 -0
  92. package/prompts/stages/objective-author/file.md +9 -0
  93. package/prompts/stages/objective-author/seed.md +9 -0
  94. package/prompts/stages/objective-plan/guidance.md +1 -1
  95. package/prompts/stages/objective-plan/seed.md +3 -2
  96. package/prompts/stages/objective-reconcile.md +7 -0
  97. package/prompts/stages/objective-replan.md +14 -0
  98. package/prompts/stages/objective-save.md +9 -0
  99. package/prompts/stages/plan-from/adopt.md +10 -0
  100. package/prompts/stages/plan-from/file.md +9 -0
  101. package/prompts/stages/pr-review-browser/active.md +11 -0
  102. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  103. package/prompts/stages/pr-review-terminal/active.md +12 -0
  104. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  105. package/prompts/stages/pr-review-terminal/local.md +4 -0
  106. package/prompts/stages/pr-review.md +6 -0
  107. package/prompts/stages/replan.md +13 -0
  108. package/prompts/stages/skills/create-from.md +15 -0
  109. package/prompts/stages/skills/create.md +9 -0
  110. package/prompts/stages/skills/refine.md +9 -0
  111. package/shared/README.md +23 -13
  112. package/shared/bindings.yaml +22 -2
  113. package/shared/contracts-history.md +191 -0
  114. package/shared/contracts.md +2258 -1638
  115. package/shared/providers.yaml +8 -1
  116. package/shared/registry.yaml +10 -11
  117. package/shared/schemas/contracts/bindings.schema.json +38 -0
  118. package/shared/schemas/contracts/providers.schema.json +89 -0
  119. package/shared/schemas/contracts/registry.schema.json +98 -0
  120. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  121. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  122. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  123. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  124. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  125. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  126. package/shared/schemas/outputs/init-report.schema.json +419 -0
  127. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  128. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  129. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  130. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  131. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  132. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  133. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  134. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  135. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  136. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  137. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  138. package/extension/doors/learnDocs.ts +0 -99
  139. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  140. package/prompts/_fixtures/golden/address-action.txt +0 -10
  141. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  142. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  143. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  144. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  145. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  146. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  147. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  148. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  149. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  150. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  151. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  152. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  153. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  154. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  155. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  156. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  157. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
@@ -6,8 +6,10 @@
6
6
  // (`runFirstPartyReview`): display the draft in pi's built-in `ctx.ui.editor` dialog (scrollable,
7
7
  // Ctrl+G opens the user's external $EDITOR), write optional human edits back to the draft via
8
8
  // `writePlanDraft` BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes — a failed
9
- // write-back ABORTS the review fail-open, nothing saved), then a 3-option approve/deny/skip
10
- // `ctx.ui.select` verdict, with deny feedback via a second editor dialog.
9
+ // write-back ABORTS the review fail-open, nothing saved), then an approve/deny/skip
10
+ // `ctx.ui.select` verdict on the plan arm with a 4th "Implement here — no issue saved" option
11
+ // (§8.23; suppressed in objective-node planning sessions) — with deny feedback via a second
12
+ // editor dialog.
11
13
  //
12
14
  // REVIEW SEMANTICS (file-first, approval auto-saves): the review runs while the session is still
13
15
  // read-only (the tool is in READ_ONLY_TOOLS — review happens before the gate ever comes off).
@@ -48,11 +50,14 @@ import {
48
50
  createPlannotatorBridge,
49
51
  isPlannotatorPlanSelected,
50
52
  } from "../adapters/planAdapterPlannotator.ts";
53
+ import type { Result } from "../substrate/result.ts";
51
54
  import type { ToolGating } from "../substrate/toolGating.ts";
52
55
  import { paramsOf, stringParam } from "../substrate/toolParams.ts";
53
56
  import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
57
+ import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
54
58
  import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
55
59
  import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
60
+ import { readNodeClaim } from "./objectivePlan.ts";
56
61
  import { type ObjectiveApprovalSaveOutcome, objectiveApprovalSave } from "./objectiveSave.ts";
57
62
  import { writePlanDraft } from "./planDraft.ts";
58
63
  import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./planSave.ts";
@@ -62,12 +67,15 @@ import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./pla
62
67
  /**
63
68
  * The review outcome a backend produces, mapped into a tool result below (also `details.status`).
64
69
  * The `dismissed` arm is FIRST-PARTY ONLY (Esc anywhere = fail-open skip; the plannotator bridge
65
- * never produces it).
70
+ * never produces it). The `implement-here` arm is first-party PLAN-arm only (the human chose the
71
+ * no-save exit — contracts.md §8.23); the plannotator bridge never produces it (its browser
72
+ * envelope returns only approve/deny) and the objective arm never offers it.
66
73
  */
67
74
  export type ReviewOutcome =
68
75
  | { status: "unavailable"; warning: string }
69
76
  | { status: "aborted" }
70
77
  | { status: "dismissed" }
78
+ | { status: "implement-here"; reviewId: string }
71
79
  | { status: "completed"; approved: boolean; feedback?: string; reviewId: string };
72
80
 
73
81
  interface ToolResult {
@@ -76,21 +84,70 @@ interface ToolResult {
76
84
  terminate?: boolean;
77
85
  }
78
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
+
79
134
  const SKIP_TEXT =
80
135
  "no interactive review surface available — present the complete plan to the user in your next message.";
81
136
 
82
137
  function skipResult(): ToolResult {
83
- return { content: [{ type: "text", text: SKIP_TEXT }], details: { status: "skipped" } };
138
+ return { content: [{ type: "text", text: SKIP_TEXT }], details: { ok: true, status: "skipped" } };
84
139
  }
85
140
 
86
141
  /**
87
- * Map a non-approved review outcome into the model-facing tool result (exported for the offline
88
- * tests). The `completed` case renders the DENIED text — the execute path routes approved
89
- * outcomes to `approvedSaveResult` first, so callers only reach `completed` here with
90
- * `approved: false` (kept total for safety). The `dismissed` arm renders as a skip — the human
91
- * 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.
92
149
  */
93
- export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
150
+ function subjectReviewOutcomeResult(subject: ReviewSubject, outcome: ReviewOutcome): ToolResult {
94
151
  switch (outcome.status) {
95
152
  case "unavailable":
96
153
  return {
@@ -99,15 +156,21 @@ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
99
156
  type: "text",
100
157
  text:
101
158
  `WARNING: ${outcome.warning} — no review performed. ` +
102
- "Present the complete plan to the user in your next message instead.",
159
+ `Present ${subject.presentUnavailable} instead.`,
103
160
  },
104
161
  ],
105
- details: { status: "unavailable" },
162
+ details: {
163
+ ok: false,
164
+ error: outcome.warning,
165
+ error_type: "unavailable",
166
+ status: "unavailable",
167
+ ...subject.detailsExtra,
168
+ },
106
169
  };
107
170
  case "aborted":
108
171
  return {
109
- content: [{ type: "text", text: "plan review aborted (turn interrupted)." }],
110
- details: { status: "aborted" },
172
+ content: [{ type: "text", text: `${subject.noun} review aborted (turn interrupted).` }],
173
+ details: { ok: true, status: "aborted", ...subject.detailsExtra },
111
174
  };
112
175
  case "dismissed":
113
176
  return {
@@ -115,24 +178,41 @@ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
115
178
  {
116
179
  type: "text",
117
180
  text:
118
- "plan review dismissed — present the complete plan to the user; the human runs " +
119
- "/plan-save (the manual failsafe).",
181
+ `${subject.noun} review dismissed — present ${subject.present}; the human runs ` +
182
+ `${subject.failsafeCmd} (the manual failsafe).`,
183
+ },
184
+ ],
185
+ details: { ok: true, status: "skipped", reason: "dismissed", ...subject.detailsExtra },
186
+ };
187
+ case "implement-here":
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.
191
+ return {
192
+ content: [
193
+ {
194
+ type: "text",
195
+ text:
196
+ `implement-here verdict received ${subject.implementHereWhere} — nothing saved; ` +
197
+ `present ${subject.present}.`,
120
198
  },
121
199
  ],
122
- details: { status: "skipped", reason: "dismissed" },
200
+ details: { ok: true, status: "skipped", reason: "implement-here", ...subject.detailsExtra },
123
201
  };
124
202
  case "completed": {
125
203
  const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
126
204
  const text =
127
- "plan DENIED — revise per this feedback, rewrite the working draft with plan_draft, " +
128
- `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}`;
129
207
  return {
130
208
  content: [{ type: "text", text }],
131
209
  details: {
210
+ ok: true,
132
211
  status: "completed",
133
212
  approved: outcome.approved,
134
213
  feedback: outcome.feedback ?? null,
135
214
  reviewId: outcome.reviewId,
215
+ ...subject.detailsExtra,
136
216
  },
137
217
  };
138
218
  }
@@ -140,52 +220,82 @@ export function reviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
140
220
  }
141
221
 
142
222
  /**
143
- * Map an APPROVED review outcome + the `approvalSave` outcome into the model-facing tool result
144
- * (exported for the offline tests). A successful save TERMINATES the turn (propagating the
145
- * seam's `terminate: true` intent); a failed save is non-terminating, leaves the gate read-only,
146
- * and directs the human `/plan-save` failsafe. Reviewer feedback is surfaced loudly as
147
- * implementation guidance — the approved bytes were saved verbatim, never post-edited. `edited`
148
- * (first-party only) flags that human edits were written back to the draft pre-verdict, so the
149
- * saved bytes carry them. The `no-plan` arm is defensively unreachable (the reviewed plan is
150
- * 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.
151
226
  */
152
- 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,
153
254
  outcome: Extract<ReviewOutcome, { status: "completed" }>,
154
- save: ApprovalSaveOutcome,
155
- opts: { paramMismatch: boolean; edited?: boolean },
255
+ save: SubjectSaveOutcome,
256
+ opts?: { paramMismatch?: boolean; edited?: boolean },
156
257
  ): ToolResult {
157
258
  const feedback = outcome.feedback
158
- ? "\n\nReviewer feedback (implementation guidance — the approved plan was saved verbatim):\n" +
159
- outcome.feedback
259
+ ? `\n\nReviewer feedback (implementation guidance — the approved ${subject.noun} was saved ` +
260
+ `verbatim):\n${outcome.feedback}`
160
261
  : "";
161
262
  const base = {
162
263
  status: "completed",
163
264
  approved: true,
164
265
  reviewId: outcome.reviewId,
165
266
  feedback: outcome.feedback ?? null,
166
- ...(opts.edited === true ? { edited: true } : {}),
267
+ ...subject.detailsExtra,
268
+ ...(opts?.edited === true ? { edited: true } : {}),
167
269
  };
168
270
  if (save.status === "saved") {
169
271
  const saveText = save.result.content[0]?.text ?? "";
170
- const mismatch = opts.paramMismatch
171
- ? "\n\n⚠ differing plan param ignored — the validated draft was reviewed and saved."
172
- : "";
173
272
  const edited =
174
- 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
+ : "";
175
278
  return {
176
279
  content: [
177
280
  {
178
281
  type: "text",
179
- text: `plan APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}`,
282
+ text: `${subject.noun} APPROVED by reviewer.${feedback}\n\n${saveText}${edited}${mismatch}`,
180
283
  },
181
284
  ],
182
- 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
+ },
183
293
  terminate: true,
184
294
  };
185
295
  }
186
296
  const error =
187
- save.status === "no-plan"
188
- ? "no plan source resolved"
297
+ save.status === "no-source"
298
+ ? subject.noSourceError
189
299
  : save.result.details.ok
190
300
  ? "unknown save failure"
191
301
  : save.result.details.error;
@@ -194,14 +304,68 @@ export function approvedSaveResult(
194
304
  {
195
305
  type: "text",
196
306
  text:
197
- `plan APPROVED by reviewer, but the auto-save FAILED (${error}) — the session stays ` +
198
- `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}`,
199
310
  },
200
311
  ],
201
312
  details: {
313
+ ok: false,
314
+ error,
315
+ error_type: "save_failed",
202
316
  ...base,
203
317
  saved: false,
204
- save: save.status === "no-plan" ? null : save.result.details,
318
+ save: save.status === "no-source" ? null : save.result.details,
319
+ },
320
+ };
321
+ }
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
+
342
+ /**
343
+ * Map an IMPLEMENT-HERE review outcome + the `implementHereExit` outcome into the model-facing
344
+ * tool result (exported for the offline tests). NON-terminating on purpose — the model continues
345
+ * the turn and implements immediately. The text is the implement-here guidance; when the human
346
+ * edited the plan during review (`edited`), the final reviewed bytes are inlined so the model
347
+ * implements THOSE, not its stale in-context version (the draft write-back already happened
348
+ * pre-verdict). Nothing is saved: no issue, no plan-ref, the draft artifact intact (§8.23).
349
+ */
350
+ export function implementHereResult(
351
+ outcome: Extract<ReviewOutcome, { status: "implement-here" }>,
352
+ exit: { gateExited: boolean },
353
+ opts: { cwd: string; plan: string; edited: boolean },
354
+ ): ToolResult {
355
+ return {
356
+ content: [
357
+ {
358
+ type: "text",
359
+ text: implementHereGuidance(opts.cwd, { editedPlan: opts.edited ? opts.plan : undefined }),
360
+ },
361
+ ],
362
+ details: {
363
+ ok: true,
364
+ status: "implement-here",
365
+ saved: false,
366
+ gateExited: exit.gateExited,
367
+ reviewId: outcome.reviewId,
368
+ ...(opts.edited ? { edited: true } : {}),
205
369
  },
206
370
  };
207
371
  }
@@ -218,10 +382,22 @@ export interface PlanReviewUI {
218
382
  ): Promise<string | undefined>;
219
383
  }
220
384
 
221
- /** The verdict options (plain text — charter D3: no emoji outside the footer). */
222
- const VERDICT_APPROVE = "Approveauto-save to GitHub";
223
- const VERDICT_DENY = "Deny send feedback for revision";
224
- 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
+
399
+ /** The optional 4th verdict (plan arm only): the no-save implement-here exit (§8.23). */
400
+ const VERDICT_IMPLEMENT_HERE = "Implement here — no issue saved";
225
401
 
226
402
  const REVIEW_EDITOR_TITLE =
227
403
  "Plan review — Enter: continue to verdict · Esc: skip · Ctrl+G: $EDITOR";
@@ -238,7 +414,10 @@ const DENY_FEEDBACK_TITLE = "Deny feedback (optional) — Enter to send";
238
414
  * AbortSignal — `signal?.aborted` is checked before each dialog (the aborted arm wins).
239
415
  *
240
416
  * Presentation options (defaults preserve the plan-path behavior byte-for-byte):
241
- * `editorTitle`/`verdicts` swap the displayed strings; `viewOnly: true` skips the write-back
417
+ * `editorTitle`/`verdicts` swap the displayed strings; `verdicts.implementHere`, when present,
418
+ * makes the verdict select 4 options — approve, implement-here, deny, skip (implement-here sits
419
+ * adjacent to approve: both are "accept the plan" outcomes) — and selecting it returns the
420
+ * `implement-here` outcome arm; `viewOnly: true` skips the write-back
242
421
  * branch entirely — the editor output is used only for Esc/dismissed detection, `plan` is
243
422
  * returned unchanged and `edited` stays false (deny+feedback is the change channel).
244
423
  */
@@ -249,16 +428,13 @@ export async function runFirstPartyReview(args: {
249
428
  writeDraft(plan: string): boolean;
250
429
  signal?: AbortSignal;
251
430
  editorTitle?: string;
252
- verdicts?: { approve: string; deny: string; skip: string };
431
+ verdicts?: { approve: string; deny: string; skip: string; implementHere?: string };
253
432
  viewOnly?: boolean;
254
433
  }): Promise<{ outcome: ReviewOutcome; plan: string; edited: boolean }> {
255
434
  const { ui, writeDraft, signal } = args;
256
435
  const editorTitle = args.editorTitle ?? REVIEW_EDITOR_TITLE;
257
- const verdicts = args.verdicts ?? {
258
- approve: VERDICT_APPROVE,
259
- deny: VERDICT_DENY,
260
- skip: VERDICT_SKIP,
261
- };
436
+ const verdicts: { approve: string; deny: string; skip: string; implementHere?: string } =
437
+ args.verdicts ?? verdictsFor(PLAN_SUBJECT);
262
438
  let plan = args.plan;
263
439
  let edited = false;
264
440
  const result = (
@@ -291,15 +467,18 @@ export async function runFirstPartyReview(args: {
291
467
  }
292
468
 
293
469
  if (signal?.aborted) return result({ status: "aborted" });
294
- const verdict = await ui.select(
295
- "Plan review verdict",
296
- [verdicts.approve, verdicts.deny, verdicts.skip],
297
- { signal },
298
- );
470
+ const options =
471
+ verdicts.implementHere === undefined
472
+ ? [verdicts.approve, verdicts.deny, verdicts.skip]
473
+ : [verdicts.approve, verdicts.implementHere, verdicts.deny, verdicts.skip];
474
+ const verdict = await ui.select("Plan review verdict", options, { signal });
299
475
  if (signal?.aborted) return result({ status: "aborted" });
300
476
  if (verdict === verdicts.approve) {
301
477
  return result({ status: "completed", approved: true, reviewId: randomUUID() });
302
478
  }
479
+ if (verdicts.implementHere !== undefined && verdict === verdicts.implementHere) {
480
+ return result({ status: "implement-here", reviewId: randomUUID() });
481
+ }
303
482
  if (verdict === verdicts.deny) {
304
483
  const feedback = await ui.editor(DENY_FEEDBACK_TITLE, "");
305
484
  if (signal?.aborted) return result({ status: "aborted" });
@@ -316,135 +495,38 @@ export async function runFirstPartyReview(args: {
316
495
 
317
496
  // ------------------------------------------------------------------- the objective review arm
318
497
 
319
- /** The objective-flavored verdict options (approval auto-saves). */
320
- const OBJECTIVE_VERDICTS = {
321
- approve: "Approve — auto-save to GitHub",
322
- deny: "Deny — send feedback for revision",
323
- skip: "Skip — decide later (manual /objective-save)",
324
- };
325
-
326
498
  const OBJECTIVE_REVIEW_EDITOR_TITLE =
327
499
  "Objective review (view only — edits are not saved) — Enter: continue to verdict · Esc: skip · " +
328
500
  "Ctrl+G: $EDITOR";
329
501
 
330
502
  /**
331
503
  * Map a non-approved objective review outcome into the model-facing tool result (exported for
332
- * the offline tests) — the objective-flavored sibling of `reviewOutcomeResult`. Every arm
333
- * carries `details.subject: "objective"`; the texts redirect to `objective_draft` /
334
- * `/objective-save`. The `completed` case renders the DENIED text the execute path routes
335
- * approved outcomes to `approvedObjectiveSaveResult` first, so callers only reach `completed`
336
- * 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.
337
509
  */
338
510
  export function objectiveReviewOutcomeResult(outcome: ReviewOutcome): ToolResult {
339
- switch (outcome.status) {
340
- case "unavailable":
341
- return {
342
- content: [
343
- {
344
- type: "text",
345
- text:
346
- `WARNING: ${outcome.warning} — no review performed. ` +
347
- "Present the complete objective + structured roadmap to the user instead.",
348
- },
349
- ],
350
- details: { status: "unavailable", subject: "objective" },
351
- };
352
- case "aborted":
353
- return {
354
- content: [{ type: "text", text: "objective review aborted (turn interrupted)." }],
355
- details: { status: "aborted", subject: "objective" },
356
- };
357
- case "dismissed":
358
- return {
359
- content: [
360
- {
361
- type: "text",
362
- text:
363
- "objective review dismissed — present the complete objective + structured roadmap " +
364
- "to the user; the human runs /objective-save (the manual failsafe).",
365
- },
366
- ],
367
- details: { status: "skipped", reason: "dismissed", subject: "objective" },
368
- };
369
- case "completed": {
370
- const feedback = outcome.feedback ? `\n\nReviewer feedback:\n${outcome.feedback}` : "";
371
- return {
372
- content: [
373
- {
374
- type: "text",
375
- text:
376
- "objective DENIED — revise per this feedback, rewrite the working draft with " +
377
- `objective_draft, then call plan_review again.${feedback}`,
378
- },
379
- ],
380
- details: {
381
- status: "completed",
382
- approved: outcome.approved,
383
- feedback: outcome.feedback ?? null,
384
- reviewId: outcome.reviewId,
385
- subject: "objective",
386
- },
387
- };
388
- }
389
- }
511
+ return subjectReviewOutcomeResult(OBJECTIVE_SUBJECT, outcome);
390
512
  }
391
513
 
392
514
  /**
393
515
  * Map an APPROVED objective review outcome + the `objectiveApprovalSave` outcome into the
394
516
  * model-facing tool result (exported for the offline tests) — the objective sibling of
395
- * `approvedSaveResult` (no `paramMismatch`/`edited` opts: the objective path reviews only the
396
- * rendered draft, view-only). A successful save TERMINATES the turn; a failed save is
397
- * non-terminating, leaves the gate read-only, and directs the human `/objective-save` failsafe.
398
- * The `no-draft` arm is defensively unreachable (the review just read the draft) but maps to
399
- * 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`).
400
520
  */
401
521
  export function approvedObjectiveSaveResult(
402
522
  outcome: Extract<ReviewOutcome, { status: "completed" }>,
403
523
  save: ObjectiveApprovalSaveOutcome,
404
524
  ): ToolResult {
405
- const feedback = outcome.feedback
406
- ? "\n\nReviewer feedback (implementation guidance — the approved objective was saved " +
407
- `verbatim):\n${outcome.feedback}`
408
- : "";
409
- const base = {
410
- status: "completed",
411
- approved: true,
412
- reviewId: outcome.reviewId,
413
- feedback: outcome.feedback ?? null,
414
- subject: "objective",
415
- };
416
- if (save.status === "saved") {
417
- const saveText = save.result.content[0]?.text ?? "";
418
- return {
419
- content: [
420
- { type: "text", text: `objective APPROVED by reviewer.${feedback}\n\n${saveText}` },
421
- ],
422
- details: { ...base, saved: true, gateExited: save.gateExited, save: save.result.details },
423
- terminate: true,
424
- };
425
- }
426
- const error =
427
- save.status === "no-draft"
428
- ? "no objective draft resolved"
429
- : save.result.details.ok
430
- ? "unknown save failure"
431
- : save.result.details.error;
432
- return {
433
- content: [
434
- {
435
- type: "text",
436
- text:
437
- `objective APPROVED by reviewer, but the auto-save FAILED (${error}) — the session ` +
438
- "stays read-only. Ask the user to run /objective-save (the manual failsafe) to " +
439
- `retry.${feedback}`,
440
- },
441
- ],
442
- details: {
443
- ...base,
444
- saved: false,
445
- save: save.status === "no-draft" ? null : save.result.details,
446
- },
447
- };
525
+ return approvedSubjectSaveResult(
526
+ OBJECTIVE_SUBJECT,
527
+ outcome,
528
+ save.status === "no-draft" ? { status: "no-source" } : save,
529
+ );
448
530
  }
449
531
 
450
532
  /**
@@ -477,7 +559,13 @@ export async function executeObjectiveReview(
477
559
  "(prose + the structured roadmap), then call plan_review again.",
478
560
  },
479
561
  ],
480
- 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
+ },
481
569
  };
482
570
  }
483
571
  // 3. The reviewed bytes are the RENDERED markdown (prose + roadmap table) — never raw JSON.
@@ -495,7 +583,7 @@ export async function executeObjectiveReview(
495
583
  writeDraft: () => true, // unreachable under viewOnly — the branch is skipped
496
584
  signal: sig,
497
585
  editorTitle: OBJECTIVE_REVIEW_EDITOR_TITLE,
498
- verdicts: OBJECTIVE_VERDICTS,
586
+ verdicts: verdictsFor(OBJECTIVE_SUBJECT),
499
587
  viewOnly: true,
500
588
  });
501
589
  outcome = fp.outcome;
@@ -542,7 +630,13 @@ export async function executePlanReview(
542
630
  text: "plan_review takes { plan?: string } — omit it (the plan-draft artifact is preferred) or pass a string.",
543
631
  },
544
632
  ],
545
- 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
+ },
546
640
  };
547
641
  }
548
642
  // 1. Objective-author session → the objective review arm: the rendered
@@ -565,7 +659,13 @@ export async function executePlanReview(
565
659
  "param), then call plan_review again.",
566
660
  },
567
661
  ],
568
- 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
+ },
569
669
  };
570
670
  }
571
671
  // 4. Backend dispatch: plannotator-selected → the event-bus bridge; ANY other selection
@@ -577,17 +677,31 @@ export async function executePlanReview(
577
677
  if (isPlannotatorPlanSelected(ctx.cwd)) {
578
678
  outcome = await bridge.review(src.plan, sig);
579
679
  } else {
680
+ // The 4th verdict (implement-here, the no-save exit) is offered UNLESS this is an
681
+ // objective-node planning session — a node-linked plan must save (the node advance and
682
+ // backlink depend on it), so the claim suppresses it back to the 3-option select.
580
683
  const fp = await runFirstPartyReview({
581
684
  ui: ctx.ui,
582
685
  plan: src.plan,
583
686
  writeDraft: (text) => writePlanDraft(pi, ctx, text).details.ok,
584
687
  signal: sig,
688
+ verdicts:
689
+ readNodeClaim(ctx) === null
690
+ ? { ...verdictsFor(PLAN_SUBJECT), implementHere: VERDICT_IMPLEMENT_HERE }
691
+ : undefined,
585
692
  });
586
693
  outcome = fp.outcome;
587
694
  reviewedPlan = fp.plan;
588
695
  edited = fp.edited;
589
696
  }
590
- // 5. An APPROVED decision (either backend) wires into the approvalSave seam (auto-save → D1a
697
+ // 5. IMPLEMENT-HERE (first-party only) routes before the generic mapper (mirror the
698
+ // approved-first split): gate exit WITHOUT save through the implementHereExit seam → a
699
+ // NON-terminating result carrying the implement-now guidance.
700
+ if (outcome.status === "implement-here") {
701
+ const exit = implementHereExit(ctx, gating);
702
+ return implementHereResult(outcome, exit, { cwd: ctx.cwd, plan: reviewedPlan, edited });
703
+ }
704
+ // 6. An APPROVED decision (either backend) wires into the approvalSave seam (auto-save → D1a
591
705
  // gate exit → terminating result); everything else maps via reviewOutcomeResult.
592
706
  if (outcome.status === "completed" && outcome.approved) {
593
707
  const save = await approvalSave(pi, ctx, gating, { reviewedPlan });
@@ -621,7 +735,7 @@ export function registerPlanReview(pi: ExtensionAPI, gating: ToolGating): void {
621
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.",
622
736
  "Call plan_review only when the plan is decision-complete.",
623
737
  "On a DENIED review, revise per the feedback, rewrite the draft with plan_draft, then call plan_review again.",
624
- "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.",
625
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).",
626
740
  ],
627
741
  executionMode: "sequential",