@mgiles/perk 3.2.0 → 3.3.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 (202) hide show
  1. package/README.md +5 -0
  2. package/extension/authoring/gist/draft.ts +198 -0
  3. package/extension/authoring/gist/prose.ts +46 -0
  4. package/extension/authoring/gist/review.ts +133 -0
  5. package/extension/authoring/gist/save.ts +118 -0
  6. package/extension/authoring/objective/draft.ts +345 -0
  7. package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
  8. package/extension/authoring/objective/planning.ts +124 -0
  9. package/extension/authoring/objective/prose.ts +103 -0
  10. package/extension/authoring/objective/review.ts +128 -0
  11. package/extension/authoring/objective/save.ts +224 -0
  12. package/extension/authoring/plan/draft.ts +84 -0
  13. package/extension/authoring/plan/prose.ts +41 -0
  14. package/extension/authoring/plan/review.ts +269 -0
  15. package/extension/authoring/plan/save.ts +256 -0
  16. package/extension/authoring/plan/source.ts +82 -0
  17. package/extension/authoring/refinement/context.ts +468 -0
  18. package/extension/authoring/refinement/draft.ts +261 -0
  19. package/extension/authoring/refinement/prose.ts +79 -0
  20. package/extension/authoring/refinement/review.ts +111 -0
  21. package/extension/authoring/refinement/save.ts +119 -0
  22. package/extension/authoring/review/approvalGate.ts +34 -0
  23. package/extension/authoring/review/draftContext.ts +68 -0
  24. package/extension/codeReview/automated.ts +352 -0
  25. package/extension/codeReview/submission.ts +229 -0
  26. package/extension/delivery/address.ts +295 -0
  27. package/extension/delivery/ci.ts +355 -0
  28. package/extension/delivery/commitCompact.ts +93 -0
  29. package/extension/delivery/conflictResolution.ts +247 -0
  30. package/extension/delivery/ready.ts +193 -0
  31. package/extension/delivery/stackConflict.ts +361 -0
  32. package/extension/delivery/stackObjective.ts +16 -0
  33. package/extension/delivery/stackReconcile.ts +165 -0
  34. package/extension/delivery/submit.ts +171 -0
  35. package/extension/index.ts +365 -380
  36. package/extension/learning/analystWave.ts +324 -0
  37. package/extension/learning/audit.ts +667 -0
  38. package/extension/learning/capture.ts +92 -0
  39. package/extension/learning/containment.ts +104 -0
  40. package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
  41. package/extension/learning/dreamAnalysis.ts +435 -0
  42. package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
  43. package/extension/{waves → learning}/dreamReport.ts +35 -31
  44. package/extension/learning/harvest.ts +491 -0
  45. package/extension/learning/prose.ts +66 -0
  46. package/extension/learning/routing.ts +79 -0
  47. package/extension/pi/v1/bashScanTimeout.ts +64 -0
  48. package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -311
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +53 -33
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +146 -114
  52. package/extension/{doors/stackReviewBrowser.ts → pi/v1/codeReview/stack.ts} +62 -29
  53. package/extension/pi/v1/codeReview/submit.ts +354 -0
  54. package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
  55. package/extension/pi/v1/contextEvidence.ts +80 -0
  56. package/extension/pi/v1/contextInjection.ts +207 -0
  57. package/extension/{doors → pi/v1/delivery}/address.ts +154 -267
  58. package/extension/pi/v1/delivery/ci.ts +570 -0
  59. package/extension/pi/v1/delivery/commitCompact.ts +201 -0
  60. package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
  61. package/extension/{doors → pi/v1/delivery}/land.ts +123 -61
  62. package/extension/pi/v1/delivery/ready.ts +322 -0
  63. package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
  64. package/extension/pi/v1/delivery/stackDrive.ts +120 -0
  65. package/extension/pi/v1/delivery/stackLand.ts +223 -0
  66. package/extension/pi/v1/delivery/stackRecover.ts +265 -0
  67. package/extension/pi/v1/delivery/stackStatus.ts +237 -0
  68. package/extension/pi/v1/delivery/stackSync.ts +658 -0
  69. package/extension/pi/v1/delivery/submit.ts +389 -0
  70. package/extension/pi/v1/delivery/submitConflict.ts +186 -0
  71. package/extension/pi/v1/draftReview.ts +431 -0
  72. package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
  73. package/extension/pi/v1/gist.ts +794 -0
  74. package/extension/pi/v1/learning/audit.ts +186 -0
  75. package/extension/pi/v1/learning/dream.ts +207 -0
  76. package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
  77. package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
  78. package/extension/pi/v1/learning/learn.ts +585 -0
  79. package/extension/pi/v1/lifecycleGates.ts +127 -0
  80. package/extension/{factories → pi/v1}/objective.ts +53 -33
  81. package/extension/pi/v1/objectiveAuthoring.ts +672 -0
  82. package/extension/pi/v1/objectiveDreamGate.ts +160 -0
  83. package/extension/{factories/objectivePlan.ts → pi/v1/objectivePlanning.ts} +328 -533
  84. package/extension/pi/v1/objectiveRefinement.ts +1320 -0
  85. package/extension/pi/v1/objectiveReview.ts +451 -0
  86. package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
  87. package/extension/pi/v1/plan.ts +812 -0
  88. package/extension/pi/v1/planReview.ts +820 -0
  89. package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
  90. package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
  91. package/extension/pi/v1/providers/plannotator.ts +487 -0
  92. package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +73 -27
  93. package/extension/pi/v1/providers/selection.ts +43 -0
  94. package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
  95. package/extension/pi/v1/review.ts +538 -0
  96. package/extension/pi/v1/reviewOutcome.ts +9 -0
  97. package/extension/pi/v1/scoutWave.ts +318 -0
  98. package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
  99. package/extension/session/branchWorkflowSession.ts +60 -0
  100. package/extension/session/lifecycle.ts +644 -0
  101. package/extension/session/lifecycleGates.ts +64 -0
  102. package/extension/session/saveDestination.ts +87 -0
  103. package/extension/session/workflowSession.ts +971 -0
  104. package/extension/substrate/agentScratch.ts +27 -54
  105. package/extension/substrate/bashScanTimeout.ts +181 -0
  106. package/extension/substrate/bindingDelivery.ts +38 -30
  107. package/extension/substrate/bindings.ts +4 -5
  108. package/extension/substrate/cache.ts +64 -12
  109. package/extension/substrate/childRestrictions.ts +39 -0
  110. package/extension/substrate/coldDoor.ts +17 -1
  111. package/extension/substrate/config.ts +157 -21
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/prompts.ts +22 -0
  115. package/extension/substrate/registry.ts +2 -0
  116. package/extension/substrate/resolverLease.ts +5 -4
  117. package/extension/substrate/sessionData.ts +85 -152
  118. package/extension/substrate/toolGating.ts +263 -84
  119. package/extension/substrate/unifiedDiff.ts +1 -1
  120. package/extension/substrate/workflowState.ts +178 -163
  121. package/extension/substrate/worktreeResolverLock.ts +261 -0
  122. package/extension/surfaces/surfaces.ts +79 -27
  123. package/extension/waves/adversarialReviewWave.ts +87 -46
  124. package/extension/waves/blockedReports.ts +59 -0
  125. package/extension/waves/draftReviewWave.ts +42 -42
  126. package/extension/waves/laneIdentity.ts +77 -0
  127. package/extension/waves/objectiveExplorerWave.ts +24 -24
  128. package/extension/waves/prReviewWave.ts +89 -77
  129. package/extension/waves/reportWave.ts +438 -578
  130. package/extension/waves/reviewClassifierWave.ts +22 -22
  131. package/extension/waves/rpcAdapter.ts +100 -15
  132. package/extension/waves/scoutWave.ts +192 -0
  133. package/extension/waves/transport.ts +480 -0
  134. package/extension/worker/sdkAdapter.ts +494 -0
  135. package/extension/worker/stageExecution.ts +679 -0
  136. package/extension/workerMain.ts +18 -19
  137. package/package.json +6 -4
  138. package/prompts/_fixtures/live.yaml +43 -18
  139. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  140. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  142. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  143. package/prompts/contexts/objective-refinement.md +17 -0
  144. package/prompts/contexts/read-only.md +1 -1
  145. package/prompts/stages/conflict-resolution-continuation.md +9 -6
  146. package/prompts/stages/conflict-resolution.md +4 -4
  147. package/prompts/stages/objective-plan/guidance.md +2 -2
  148. package/prompts/stages/objective-plan/seed.md +9 -1
  149. package/prompts/stages/objective-reconcile-ready.md +1 -1
  150. package/prompts/stages/objective-reconcile.md +1 -1
  151. package/prompts/stages/objective-refine/seed.md +18 -0
  152. package/prompts/stages/objective-review-browser.md +4 -4
  153. package/prompts/stages/objective-sync.md +1 -1
  154. package/prompts/stages/plan-review-browser.md +4 -4
  155. package/prompts/stages/pr-review-browser/active.md +3 -4
  156. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  157. package/prompts/stages/pr-review-terminal/active.md +3 -3
  158. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  159. package/prompts/stages/pr-review.md +3 -3
  160. package/prompts/stages/stack-review-browser/stack.md +5 -6
  161. package/shared/README.md +8 -0
  162. package/shared/bindings.yaml +3 -3
  163. package/shared/contracts.md +2601 -506
  164. package/shared/fixtures/issues-table.json +130 -0
  165. package/shared/registry.yaml +13 -0
  166. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  167. package/shared/schemas/outputs/objective-stack-status.schema.json +6 -1
  168. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  169. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  170. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  171. package/extension/doors/auditWaveTools.ts +0 -352
  172. package/extension/doors/ciExecutor.ts +0 -756
  173. package/extension/doors/commitCompact.ts +0 -251
  174. package/extension/doors/dreamWaveTools.ts +0 -489
  175. package/extension/doors/learn.ts +0 -668
  176. package/extension/doors/lifecycleGates.ts +0 -207
  177. package/extension/doors/objectiveStack.ts +0 -1543
  178. package/extension/doors/prReviewDynamic.ts +0 -276
  179. package/extension/doors/ready.ts +0 -279
  180. package/extension/doors/submit.ts +0 -373
  181. package/extension/doors/submitPrReview.ts +0 -505
  182. package/extension/factories/gistAuthor.ts +0 -94
  183. package/extension/factories/gistDraft.ts +0 -265
  184. package/extension/factories/gistSave.ts +0 -251
  185. package/extension/factories/implementHere.ts +0 -116
  186. package/extension/factories/objectiveAuthor.ts +0 -98
  187. package/extension/factories/objectiveDraft.ts +0 -466
  188. package/extension/factories/objectiveSave.ts +0 -366
  189. package/extension/factories/planDraft.ts +0 -140
  190. package/extension/factories/planMode.ts +0 -205
  191. package/extension/factories/planReview.ts +0 -1237
  192. package/extension/factories/planSave.ts +0 -604
  193. package/extension/factories/planTitle.ts +0 -141
  194. package/extension/substrate/structuredOutput.ts +0 -202
  195. package/extension/waves/auditWave.ts +0 -312
  196. package/extension/waves/harvestWave.ts +0 -399
  197. package/extension/waves/learnWave.ts +0 -155
  198. package/extension/waves/memoryAdapter.ts +0 -139
  199. package/extension/waves/prReviewDynamicWave.ts +0 -777
  200. package/extension/worker/readOnlySession.ts +0 -294
  201. package/extension/worker/worker.ts +0 -899
  202. package/prompts/stages/pr-review-dynamic.md +0 -7
@@ -0,0 +1,352 @@
1
+ // The fixed automated review flow (contracts §8.4) — the typed feature operations behind the
2
+ // warm `/pr-review` door's `run_pr_review_wave` + `post_pr_review` tools. This module owns the
3
+ // POLICY: the per-activation review-pass state machine (`null` permits the backwards-compatible
4
+ // standalone post; a valid new pass moves to `pending` BEFORE target resolution; a normalized
5
+ // outcome records `{pr, complete, attempted, covered, minimumVerdict}` PR-bound and single-use;
6
+ // one successful post consumes it; incomplete coverage refuses a clean verdict; effective
7
+ // actionable evidence refuses a clean verdict; a mutation-time PR mismatch demotes back to
8
+ // `pending`), plus the `last_pr_review` record a real post applies through the session seam
9
+ // (classification ignored — the seam owns loudness).
10
+ //
11
+ // The recorded `minimumVerdict` is a code-owned floor projected ONCE from the reviewer's final
12
+ // effective reports (post-retry, post-blocked-reclassification): parent reconciliation can raise
13
+ // a verdict to actionable but never lower it below the evidence. It is private activation state
14
+ // — never part of the durable `last_pr_review` record, the tool aggregate, or the attempt
15
+ // receipts (§8.35), and never a second schema validator (the engine owns report validation).
16
+ //
17
+ // Pi-free by construction (importDirectionGuard Rule D): the Pi adapter
18
+ // (`pi/v1/codeReview/automated.ts`) owns registration, tool-boundary decode, the cold-door and
19
+ // RPC-wave compositions, and Result rendering; this module sees only the ports below. The wave
20
+ // vocabulary arrives type-only from `waves/prReviewWave.ts` (a legal feature→mechanism type
21
+ // edge) — `ChangeReviewOutcome` is an alias of `PrReviewWaveOutcome`, never a second
22
+ // hand-mirrored vocabulary.
23
+
24
+ import type { PrReviewRecord, WorkflowSession } from "../session/workflowSession.ts";
25
+ import type { PrReviewAngle, PrReviewWaveOutcome } from "../waves/prReviewWave.ts";
26
+
27
+ // ------------------------------------------------------------------------ ports
28
+
29
+ /** The resolved active-plan PR every lane in a pass reviews. */
30
+ export interface ReviewTarget {
31
+ number: number;
32
+ url: string;
33
+ }
34
+
35
+ /** The target-resolution role (production: the `perk pr url --json` cold-door composition). */
36
+ export interface ReviewTargetResolver {
37
+ resolve(): Promise<
38
+ { ok: true; target: ReviewTarget } | { ok: false; message: string; errorType: string }
39
+ >;
40
+ }
41
+
42
+ /**
43
+ * The external reviewer's request. Cancellation ownership is explicit: the tool's
44
+ * execute-callback signal rides the request; the production adapter forwards it into
45
+ * `runPrReviewWave`'s `opts.signal` (cancellation normalizes into the outcome, never a throw).
46
+ */
47
+ export interface ChangeReviewRequest {
48
+ pr: number;
49
+ angles: readonly PrReviewAngle[];
50
+ directive?: string;
51
+ signal?: AbortSignal;
52
+ }
53
+
54
+ /** A type-only alias of the wave outcome — never a second hand-mirrored vocabulary. */
55
+ export type ChangeReviewOutcome = PrReviewWaveOutcome;
56
+
57
+ /** The external reviewer role (production: RPC adapter + `runPrReviewWave` + configured model). */
58
+ export interface ChangeReviewer {
59
+ review(request: ChangeReviewRequest): Promise<ChangeReviewOutcome>;
60
+ }
61
+
62
+ /** One reconciled inline finding (the exact `review-post --batch` `comments[]` row). */
63
+ export interface ReviewComment {
64
+ path: string;
65
+ line: number;
66
+ body: string;
67
+ }
68
+
69
+ /** The publishing port's input (`expected_pr` threading stays feature policy). */
70
+ export interface AutomatedReviewBatch {
71
+ verdict: "clean" | "actionable";
72
+ summary: string;
73
+ comments?: ReviewComment[];
74
+ fyi?: string[];
75
+ expectedPr?: number;
76
+ }
77
+
78
+ /** The cold door's ok-arm fields (the `review-post --json` surface). */
79
+ export interface PostOk {
80
+ pr: number;
81
+ mode?: string;
82
+ verdict?: string;
83
+ comment_count?: number;
84
+ next_command?: string;
85
+ }
86
+
87
+ /**
88
+ * The publishing outcome — `errorType` required (the cold-door seam always supplies it; the op
89
+ * branches on the `"review_target_changed"` literal).
90
+ */
91
+ export type PublishOutcome =
92
+ | { ok: true; data: PostOk }
93
+ | { ok: false; message: string; errorType: string };
94
+
95
+ /** The publishing role (production: the `perk pr review-post --json --batch` composition). */
96
+ export interface ReviewPublisher {
97
+ publish(batch: AutomatedReviewBatch): Promise<PublishOutcome>;
98
+ }
99
+
100
+ // ------------------------------------------------------------------------ the pass state
101
+
102
+ /**
103
+ * The review-pass post state: `null` preserves standalone posting before any valid wave attempt;
104
+ * a decoded new pass invalidates old evidence immediately (`pending`); only one `recorded`
105
+ * outcome can post, after which `consumed` refuses duplicates until another valid pass starts.
106
+ */
107
+ export type ReviewPassState =
108
+ | null
109
+ | { state: "pending" }
110
+ | {
111
+ state: "recorded";
112
+ pr: number;
113
+ complete: boolean;
114
+ attempted: readonly string[];
115
+ covered: readonly string[];
116
+ /**
117
+ * The lowest verdict the effective evidence admits: `actionable` iff any effective report
118
+ * is actionable or carries surviving findings. A primitive snapshot — mutating the source
119
+ * outcome after recording never changes it.
120
+ */
121
+ minimumVerdict: AutomatedReviewBatch["verdict"];
122
+ }
123
+ | { state: "consumed" };
124
+
125
+ /**
126
+ * A plain per-activation holder (the installer creates one per activation; the two feature ops
127
+ * own every transition — no module-level state, ever).
128
+ */
129
+ export interface ReviewPassHolder {
130
+ current: ReviewPassState;
131
+ }
132
+
133
+ // ------------------------------------------------------------------------ run the wave
134
+
135
+ /** The decoded wave selection (strict decoding stays at the adapter boundary). */
136
+ export interface ReviewSelection {
137
+ angles: readonly PrReviewAngle[];
138
+ directive?: string;
139
+ signal?: AbortSignal;
140
+ }
141
+
142
+ export type RunAutomatedReviewOutcome =
143
+ | { kind: "no_target"; message: string; errorType: string }
144
+ | {
145
+ kind: "reviewed";
146
+ pr: number;
147
+ outcome: ChangeReviewOutcome;
148
+ attempted: readonly string[];
149
+ /** Non-null ⟺ the wave is incomplete — the adapter renders the loud degrade from it. */
150
+ incompleteWarning: { uncovered: string[]; reasons: string } | null;
151
+ };
152
+
153
+ /**
154
+ * Project the minimum postable verdict from the reviewer's EFFECTIVE reports (the final
155
+ * post-retry set — `outcomeOf`'s ordered reports, never attempt receipts or superseded attempts).
156
+ * `actionable` iff any assignment's report is a non-null, non-array object whose exact
157
+ * `verdict` is `"actionable"` OR whose `findings` is a nonempty array; otherwise `clean`.
158
+ * The findings arm is a conservative safeguard against a contradictory report object reaching
159
+ * the feature below schema validation — not permission for the engine schema to accept one.
160
+ * FYI/summary prose is never consulted: diagnostics are DATA, not a verdict.
161
+ */
162
+ function minimumVerdictOf(
163
+ reports: ChangeReviewOutcome["reports"],
164
+ ): AutomatedReviewBatch["verdict"] {
165
+ for (const assignment of reports) {
166
+ const report: unknown = assignment.report;
167
+ if (typeof report !== "object" || report === null || Array.isArray(report)) continue;
168
+ if ("verdict" in report && report.verdict === "actionable") return "actionable";
169
+ if ("findings" in report && Array.isArray(report.findings) && report.findings.length > 0) {
170
+ return "actionable";
171
+ }
172
+ }
173
+ return "clean";
174
+ }
175
+
176
+ /**
177
+ * Run one automated review pass: invalidate old evidence (`pending`) → resolve the target
178
+ * (failure leaves the state pending) → run the reviewer → record the PR-bound manifest with
179
+ * `attempted = [...angles, "ponytail"]` plus the minimum verdict projected from the effective
180
+ * reports. Recording COPIES the arrays and stores only the primitive verdict — the holder owns
181
+ * its evidence, never aliasing the outcome (or its reports) returned to the adapter.
182
+ */
183
+ export async function runAutomatedReview(
184
+ selection: ReviewSelection,
185
+ deps: { resolver: ReviewTargetResolver; reviewer: ChangeReviewer; state: ReviewPassHolder },
186
+ ): Promise<RunAutomatedReviewOutcome> {
187
+ deps.state.current = { state: "pending" };
188
+ const resolved = await deps.resolver.resolve();
189
+ if (!resolved.ok) {
190
+ return { kind: "no_target", message: resolved.message, errorType: resolved.errorType };
191
+ }
192
+ const pr = resolved.target.number;
193
+ // Cancellation normalizes into the outcome (`cancelled`, no retry) — never a throw.
194
+ const outcome = await deps.reviewer.review({
195
+ pr,
196
+ angles: selection.angles,
197
+ ...(selection.directive !== undefined ? { directive: selection.directive } : {}),
198
+ ...(selection.signal !== undefined ? { signal: selection.signal } : {}),
199
+ });
200
+ const attempted = [...selection.angles, "ponytail"];
201
+ deps.state.current = {
202
+ state: "recorded",
203
+ pr,
204
+ complete: outcome.complete,
205
+ attempted: [...attempted],
206
+ covered: [...outcome.covered],
207
+ minimumVerdict: minimumVerdictOf(outcome.reports),
208
+ };
209
+ // Loud degrade — the `unavailable` arm surfaces here too, never a silent fallback.
210
+ const incompleteWarning = outcome.complete
211
+ ? null
212
+ : {
213
+ uncovered: attempted.filter((angle) => !outcome.covered.includes(angle)),
214
+ reasons: outcome.failures
215
+ .map((f) => `${f.key ?? "wave"}: ${f.reason} — ${f.detail}`)
216
+ .join("; "),
217
+ };
218
+ return { kind: "reviewed", pr, outcome, attempted, incompleteWarning };
219
+ }
220
+
221
+ // ------------------------------------------------------------------------ publish the outcome
222
+
223
+ /** The decoded post params (strict decoding stays at the adapter boundary). */
224
+ export interface AutomatedPost {
225
+ verdict: "clean" | "actionable";
226
+ summary: string;
227
+ comments?: ReviewComment[];
228
+ fyi?: string[];
229
+ /** Standalone fallback only; recorded-wave calls use the authoritative attempted manifest. */
230
+ angles?: string[];
231
+ }
232
+
233
+ export type PublishAutomatedReviewOutcome =
234
+ | {
235
+ kind: "ineligible";
236
+ errorType:
237
+ | "review_wave_unavailable"
238
+ | "review_wave_consumed"
239
+ | "incomplete_coverage"
240
+ | "review_verdict_conflict";
241
+ message: string;
242
+ }
243
+ | { kind: "stale"; errorType: "stale_review_wave"; message: string }
244
+ | { kind: "publish_failed"; message: string; errorType: string }
245
+ | { kind: "posted"; data: PostOk; record: PrReviewRecord };
246
+
247
+ /**
248
+ * Publish the reconciled outcome to the PR: the eligibility ladder (pending ⇒
249
+ * `review_wave_unavailable`; consumed ⇒ `review_wave_consumed`; a clean verdict over an
250
+ * incomplete recorded wave ⇒ `incomplete_coverage`; a clean verdict over a complete recorded
251
+ * wave whose minimum is actionable ⇒ `review_verdict_conflict` — state untouched and nothing
252
+ * recorded on all four; the record survives for a reconciled actionable post) → the
253
+ * publisher (a `review_target_changed` failure while a recorded state exists demotes to
254
+ * `pending`; any other failure passes through verbatim, state untouched) → on success apply
255
+ * `record-pr-review` (classification ignored — the seam owns loudness) and consume iff
256
+ * recorded. The standalone-post arm (a `null` state) is preserved: caller-supplied angles fill
257
+ * both manifests.
258
+ */
259
+ export async function publishAutomatedReview(
260
+ post: AutomatedPost,
261
+ deps: { publisher: ReviewPublisher; state: ReviewPassHolder; session: WorkflowSession },
262
+ ): Promise<PublishAutomatedReviewOutcome> {
263
+ const current = deps.state.current;
264
+ if (current?.state === "pending") {
265
+ return {
266
+ kind: "ineligible",
267
+ errorType: "review_wave_unavailable",
268
+ message: "the latest review pass has no recorded outcome; rerun /pr-review before posting",
269
+ };
270
+ }
271
+ if (current?.state === "consumed") {
272
+ return {
273
+ kind: "ineligible",
274
+ errorType: "review_wave_consumed",
275
+ message:
276
+ "the recorded review outcome has already been posted; rerun /pr-review before posting again",
277
+ };
278
+ }
279
+ // Incomplete coverage is never a clean review. An actionable post may still record the
280
+ // findings plus the coverage caveat, consuming that recorded outcome on success.
281
+ if (post.verdict === "clean" && current?.state === "recorded" && !current.complete) {
282
+ return {
283
+ kind: "ineligible",
284
+ errorType: "incomplete_coverage",
285
+ message:
286
+ "incomplete coverage is never a clean review — the recorded review wave left angle(s) " +
287
+ "uncovered; post the actionable findings with a coverage note, or post nothing and " +
288
+ "suggest re-running /pr-review",
289
+ };
290
+ }
291
+
292
+ // Complete coverage is necessary but insufficient for clean: the recorded floor (any effective
293
+ // actionable assessment — even with empty findings — or surviving finding) contradicts a clean
294
+ // post. No coercion, no override, no automatic post: the parent posts a reconciled actionable
295
+ // review against the same record or posts nothing.
296
+ if (
297
+ post.verdict === "clean" &&
298
+ current?.state === "recorded" &&
299
+ current.minimumVerdict === "actionable"
300
+ ) {
301
+ return {
302
+ kind: "ineligible",
303
+ errorType: "review_verdict_conflict",
304
+ message:
305
+ "the recorded review outcome contains an actionable assessment or surviving findings; a " +
306
+ "clean verdict would contradict that evidence — post a reconciled actionable review or " +
307
+ "post nothing",
308
+ };
309
+ }
310
+
311
+ // A recorded wave binds the Python mutation to the PR that every child reviewed. Standalone
312
+ // calls intentionally omit `expected_pr` for backwards-compatible direct posting.
313
+ const recorded = current?.state === "recorded" ? current : null;
314
+ const batch: AutomatedReviewBatch = { verdict: post.verdict, summary: post.summary };
315
+ if (post.comments !== undefined) batch.comments = post.comments;
316
+ if (post.fyi !== undefined) batch.fyi = post.fyi;
317
+ if (recorded !== null) batch.expectedPr = recorded.pr;
318
+
319
+ const published = await deps.publisher.publish(batch);
320
+ if (!published.ok) {
321
+ if (recorded !== null && published.errorType === "review_target_changed") {
322
+ deps.state.current = { state: "pending" };
323
+ return {
324
+ kind: "stale",
325
+ errorType: "stale_review_wave",
326
+ message:
327
+ "the active PR changed after this review wave; the recorded reports are stale — rerun " +
328
+ "/pr-review before posting",
329
+ };
330
+ }
331
+ return { kind: "publish_failed", message: published.message, errorType: published.errorType };
332
+ }
333
+
334
+ const data = published.data;
335
+ // Record the outcome (best-effort tier, seam-reported): the classification is ignored — the
336
+ // post already succeeded and the seam owns the loud read-back warning.
337
+ const standaloneAngles = post.angles ?? [];
338
+ const attempted = recorded?.attempted ?? standaloneAngles;
339
+ const covered = recorded?.covered ?? standaloneAngles;
340
+ const record: PrReviewRecord = {
341
+ pr: data.pr,
342
+ verdict: post.verdict,
343
+ angles: [...attempted],
344
+ covered_angles: [...covered],
345
+ comment_count: data.comment_count ?? null,
346
+ mode: data.mode ?? null,
347
+ at: new Date().toISOString(),
348
+ };
349
+ deps.session.apply({ kind: "record-pr-review", record });
350
+ if (recorded !== null) deps.state.current = { state: "consumed" };
351
+ return { kind: "posted", data, record };
352
+ }
@@ -0,0 +1,229 @@
1
+ // The curated-submission flow (contracts §8.4's per-door posting contract) — the typed feature
2
+ // operation behind the warm `submit_pr_review` tool: nothing perk-driven reaches GitHub before
3
+ // the human triage; the verdict lands last, atomically with the comments. This module owns the
4
+ // POLICY: the enforced `already_posted` resume guard over the `review_posts` ledger (presence
5
+ // refuses; absence is NOT proof — the ledger is best-effort), the formal-event gate ladder
6
+ // (headless refuses, interactive confirms, `comment` posts on the conversational go-ahead
7
+ // alone), the `dry_run` anchor-repair loop's bypass of both gates, and the two session records
8
+ // a real success always attempts (`record-review` then `append-review-post`, unconditionally in
9
+ // that order, ignoring both classifications — the session seam owns loudness).
10
+ //
11
+ // Pi-free by construction (importDirectionGuard Rule D): the Pi adapter
12
+ // (`pi/v1/codeReview/submit.ts`) owns registration, tool-boundary decode, the cold-door
13
+ // composition, and Result rendering; this module sees only the ports below.
14
+
15
+ import type {
16
+ ReviewPostRow,
17
+ ReviewSubmissionRecord,
18
+ WorkflowSession,
19
+ } from "../session/workflowSession.ts";
20
+
21
+ // ------------------------------------------------------------------------ vocabulary
22
+
23
+ export type ReviewEvent = "approve" | "request-changes" | "comment";
24
+
25
+ /** One curated inline comment (the exact `review-submit --batch` `comments[]` row). */
26
+ export interface SubmitComment {
27
+ path: string;
28
+ line: number;
29
+ side?: "LEFT" | "RIGHT";
30
+ body: string;
31
+ }
32
+
33
+ /** The cold door's ok-arm fields (the `review-submit --json` surface; render-only → lenient). */
34
+ export interface SubmitOk {
35
+ dry_run?: boolean;
36
+ pr?: number;
37
+ event?: string;
38
+ mode?: string;
39
+ comment_count?: number;
40
+ }
41
+
42
+ /** One `bad_anchors` `invalid[]` row (the cold door's per-comment repair detail). */
43
+ export interface InvalidAnchor {
44
+ index: number;
45
+ path: string;
46
+ line: number;
47
+ side: string;
48
+ reason: string;
49
+ }
50
+
51
+ /** The feature input (booleans normalized at the adapter decode). */
52
+ export interface CuratedSubmission {
53
+ pr: number;
54
+ event: ReviewEvent;
55
+ body: string;
56
+ comments?: SubmitComment[];
57
+ dryRun: boolean;
58
+ allowRepost: boolean;
59
+ }
60
+
61
+ /**
62
+ * The posting port's input — deliberately WITHOUT `allowRepost`: that flag is feature ledger
63
+ * policy and never crosses the posting boundary.
64
+ */
65
+ export interface SubmitBatch {
66
+ pr: number;
67
+ event: ReviewEvent;
68
+ body: string;
69
+ comments?: SubmitComment[];
70
+ dryRun: boolean;
71
+ }
72
+
73
+ // ------------------------------------------------------------------------ ports
74
+
75
+ /**
76
+ * The posting outcome: `bad_anchors` carries the strict-decoded repair rows (decode drift ⇒ the
77
+ * `failed` arm — uncertainty renders plain, never a half table). `errorType` is required on the
78
+ * `failed` arm — the cold-door seam always supplies it.
79
+ */
80
+ export type ReviewSubmitOutcome =
81
+ | { ok: true; data: SubmitOk }
82
+ | { ok: false; kind: "bad_anchors"; invalid: InvalidAnchor[]; message: string }
83
+ | { ok: false; kind: "failed"; message: string; errorType: string };
84
+
85
+ /** The external posting role (production: the `perk pr review-submit` cold-door composition). */
86
+ export interface ReviewSubmitter {
87
+ submit(batch: SubmitBatch): Promise<ReviewSubmitOutcome>;
88
+ }
89
+
90
+ /**
91
+ * The formal-event human gate — a discriminated union, so the unsafe call is unrepresentable:
92
+ * a headless gate HAS no confirm to invoke. Production: `ctx.hasUI` selects the arm; the
93
+ * interactive arm wraps `ctx.ui.confirm`.
94
+ */
95
+ export type FormalEventGate =
96
+ | { kind: "headless" }
97
+ | { kind: "interactive"; confirm(question: string, summary: string): Promise<boolean> };
98
+
99
+ // ------------------------------------------------------------------------ the result union
100
+
101
+ /**
102
+ * The enumerated curated-submission outcome; every arm carries its policy-owned message text
103
+ * (the refusal arms carry ONLY the message — the prior-row / invalid-anchor detail is already
104
+ * rendered into it, and no production caller reads it structurally).
105
+ */
106
+ export type SubmitCuratedOutcome =
107
+ | { kind: "already_posted"; message: string }
108
+ | { kind: "headless_formal_event"; message: string }
109
+ | { kind: "user_declined"; message: string }
110
+ | { kind: "bad_anchors"; message: string }
111
+ | { kind: "submit_failed"; message: string; errorType: string }
112
+ | { kind: "dry_run_ok"; data: SubmitOk }
113
+ | { kind: "posted"; data: SubmitOk; record: ReviewSubmissionRecord };
114
+
115
+ /** Flag spelling → the REST wire spelling shown in the human confirm. */
116
+ const WIRE_EVENT: Record<ReviewEvent, string> = {
117
+ approve: "APPROVE",
118
+ "request-changes": "REQUEST_CHANGES",
119
+ comment: "COMMENT",
120
+ };
121
+
122
+ /** The body's first line, truncated for the confirm-dialog summary. */
123
+ function bodyFirstLine(body: string): string {
124
+ const line = body.split("\n", 1)[0] ?? "";
125
+ return line.length > 120 ? `${line.slice(0, 117)}…` : line;
126
+ }
127
+
128
+ // ------------------------------------------------------------------------ the operation
129
+
130
+ /**
131
+ * Submit the human-curated review batch to the foreign PR. Policy order (exactly): the resume
132
+ * guard (skipped on `dryRun`/`allowRepost`; refusal on a prior ledger row for the PR — absence
133
+ * is NOT proof of no post) → the formal-event gate (skipped on `dryRun` and for `comment`; the
134
+ * headless arm refuses; a decline executes nothing) → the posting port → on a real success the
135
+ * two session records, `record-review` then `append-review-post`, unconditionally in that order,
136
+ * ignoring both classifications (the seam owns loudness).
137
+ */
138
+ export async function submitCuratedReview(
139
+ input: CuratedSubmission,
140
+ deps: { submitter: ReviewSubmitter; gate: FormalEventGate; session: WorkflowSession },
141
+ ): Promise<SubmitCuratedOutcome> {
142
+ const commentCount = input.comments?.length ?? 0;
143
+
144
+ // The enforced resume guard (before the confirm AND the cold-door mutation): a PR that
145
+ // already has a review_posts ledger row in this session is a confirmed success — a repeat
146
+ // real post is refused unless explicitly deliberate. A ledger row can only be MISSING
147
+ // spuriously (best-effort tier), never present spuriously — so the guard refuses on
148
+ // presence and stays silent on absence (a missing row still means: verify posted-vs-pending
149
+ // against GitHub before re-posting).
150
+ if (!input.dryRun && !input.allowRepost) {
151
+ const prior = deps.session.reviewPosts().filter((row) => row.pr === input.pr);
152
+ const last = prior.at(-1);
153
+ if (last !== undefined) {
154
+ return {
155
+ kind: "already_posted",
156
+ message:
157
+ `a ${last.event} review was already posted to PR #${input.pr} in this session ` +
158
+ `(review_posts row at ${last.at}) — on a stack resume skip this member; pass ` +
159
+ "allow_repost: true only for a deliberate second review of the same PR",
160
+ };
161
+ }
162
+ }
163
+
164
+ if (!input.dryRun && input.event !== "comment") {
165
+ if (deps.gate.kind === "headless") {
166
+ return {
167
+ kind: "headless_formal_event",
168
+ message:
169
+ "headless sessions cannot post formal review verdicts — re-run interactively or use " +
170
+ "event: comment",
171
+ };
172
+ }
173
+ const wire = WIRE_EVENT[input.event];
174
+ const firstLine = bodyFirstLine(input.body);
175
+ const summary =
176
+ `event: ${wire} · ${commentCount} inline comment(s)` +
177
+ (firstLine.length > 0 ? `\nbody: ${firstLine}` : "");
178
+ const yes = await deps.gate.confirm(`Post ${wire} review to PR #${input.pr}?`, summary);
179
+ if (!yes) {
180
+ return {
181
+ kind: "user_declined",
182
+ message: `user declined the ${input.event} review — nothing was submitted`,
183
+ };
184
+ }
185
+ }
186
+
187
+ const batch: SubmitBatch = {
188
+ pr: input.pr,
189
+ event: input.event,
190
+ body: input.body,
191
+ ...(input.comments !== undefined ? { comments: input.comments } : {}),
192
+ dryRun: input.dryRun,
193
+ };
194
+ const outcome = await deps.submitter.submit(batch);
195
+
196
+ if (!outcome.ok) {
197
+ if (outcome.kind === "bad_anchors") {
198
+ // The repair-loop arm: render the per-comment invalid[] detail (rows already strict-
199
+ // decoded by the adapter — a drifting payload arrived as the `failed` arm instead).
200
+ const table = outcome.invalid
201
+ .map(
202
+ (row) => ` comment[${row.index}] ${row.path}:${row.line} (${row.side}) — ${row.reason}`,
203
+ )
204
+ .join("\n");
205
+ return {
206
+ kind: "bad_anchors",
207
+ message: `${outcome.message}\n${table}\nrepair these anchors and re-run with dry_run: true`,
208
+ };
209
+ }
210
+ return { kind: "submit_failed", message: outcome.message, errorType: outcome.errorType };
211
+ }
212
+
213
+ if (input.dryRun) return { kind: "dry_run_ok", data: outcome.data };
214
+
215
+ // Record the outcome (best-effort tier, seam-reported): record-review then append-review-post,
216
+ // BOTH always attempted, in that order, ignoring both classifications — the submission already
217
+ // succeeded and the seam owns the loud read-back warnings.
218
+ const record: ReviewSubmissionRecord = {
219
+ pr: outcome.data.pr ?? input.pr,
220
+ event: input.event,
221
+ comment_count: outcome.data.comment_count ?? null,
222
+ mode: outcome.data.mode ?? null,
223
+ at: new Date().toISOString(),
224
+ };
225
+ const row: ReviewPostRow = { pr: record.pr, event: input.event, at: record.at };
226
+ deps.session.apply({ kind: "record-review", record });
227
+ deps.session.apply({ kind: "append-review-post", row });
228
+ return { kind: "posted", data: outcome.data, record };
229
+ }