@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,295 @@
1
+ // The review-feedback finalization feature (the address half of the delivery pair), Pi-free.
2
+ //
3
+ // One entry point — `finalizeAddress` — owns the ordering and failure semantics of the
4
+ // submit-then-resolve loop: pre-effect refusal (empty batch) → publish (composing the sibling
5
+ // submit feature's `publishVerified` — one-way import, no cycle) → resolve → request↔row
6
+ // correlation → verified-success session record → the bounded conflict decision. The decision
7
+ // timing is deliberately DIFFERENT from `/submit`'s: it runs only after full corroborated
8
+ // resolve success — a resolve failure never burns an attempt.
9
+ //
10
+ // Uncertainty is a first-class value: one module-private correlation pass derives a
11
+ // per-requested-thread fate — `resolved` | `failed` (with the positively-reported reply fact) |
12
+ // `unknown` (no row: the effect may have happened, verification failed) — consumed by (a) the
13
+ // safe-retry derivation (a reply is retained ONLY on a positive not-posted report) and (b) the
14
+ // ok-arm corroboration guard (a nominal-success envelope that fails to corroborate every
15
+ // requested thread is `published_partial`, never recorded/terminated — contracts §8.52: "full
16
+ // success" = corroborated per-thread success). The two unresolved states are DISTINCT
17
+ // discriminants — `published_partial` (rows known, retry derivable) and `published_unverified`
18
+ // (no per-thread claim) — so retry-without-rows is unrepresentable. Refusals (`bad_input`,
19
+ // `planning_session`) are adapter shapes — they never enter the outcome union.
20
+
21
+ import type {
22
+ ReviewBatchCounts,
23
+ ReviewBatchRecord,
24
+ WorkflowSession,
25
+ } from "../session/workflowSession.ts";
26
+ import {
27
+ type ConflictFollowUp,
28
+ decideConflictFollowUp,
29
+ type PublishDeps,
30
+ type PublishedChange,
31
+ publishVerified,
32
+ } from "./submit.ts";
33
+
34
+ export interface ThreadInput {
35
+ thread_id: string;
36
+ comment?: string;
37
+ }
38
+
39
+ /** One per-thread outcome row from the resolve port's batch result. */
40
+ export interface ThreadResultRow {
41
+ thread_id: string;
42
+ success: boolean;
43
+ comment_added: boolean;
44
+ error?: string | null;
45
+ }
46
+
47
+ /** One external resolve attempt: full rows, a partial report with rows, or an uncertain fail. */
48
+ export type ResolveThreadsAttempt =
49
+ | { ok: true; rows: ThreadResultRow[] }
50
+ | { ok: false; kind: "partial"; rows: ThreadResultRow[]; message: string; errorType: string }
51
+ | { ok: false; kind: "failed"; message: string; errorType: string };
52
+
53
+ /** The resolve port — production = `perk pr resolve-threads --json --batch`. */
54
+ export type ResolveThreads = (threads: ThreadInput[]) => Promise<ResolveThreadsAttempt>;
55
+
56
+ /** The finalization input (the adapter's decoded tool params). */
57
+ export interface AddressFinalization {
58
+ threads: ThreadInput[];
59
+ pr?: number;
60
+ counts?: ReviewBatchCounts;
61
+ }
62
+
63
+ export interface FinalizeAddressDeps extends PublishDeps {
64
+ resolve: ResolveThreads;
65
+ session: WorkflowSession;
66
+ }
67
+
68
+ export type FinalizeAddressOutcome =
69
+ | { kind: "empty_batch"; message: string }
70
+ | {
71
+ kind: "not_published";
72
+ /** The raw publisher failure — the adapter reproduces today's scope-"submit" report. */
73
+ publishMessage: string;
74
+ message: string;
75
+ errorType: string;
76
+ }
77
+ | {
78
+ kind: "published_partial";
79
+ change: PublishedChange;
80
+ resolveMessage: string;
81
+ message: string;
82
+ errorType: string;
83
+ results: ThreadResultRow[];
84
+ resolvedThreadIds: string[];
85
+ /** Possibly empty — the adapter omits retry_threads when empty (today's wire). */
86
+ retryThreads: ThreadInput[];
87
+ }
88
+ | {
89
+ kind: "published_unverified";
90
+ change: PublishedChange;
91
+ resolveMessage: string;
92
+ message: string;
93
+ errorType: string;
94
+ }
95
+ | {
96
+ kind: "completed";
97
+ change: PublishedChange;
98
+ results: ThreadResultRow[];
99
+ resolvedThreadIds: string[];
100
+ conflict: ConflictFollowUp;
101
+ };
102
+
103
+ /** One requested thread's correlated fate (module-private — never exported). */
104
+ type ThreadFate =
105
+ | { kind: "resolved" }
106
+ | { kind: "failed"; replyPosted: boolean }
107
+ | { kind: "unknown" };
108
+
109
+ /**
110
+ * The one correlation pass: requested ids deduped by FIRST occurrence; the row lookup is built
111
+ * `new Map(rows.map(...))` so a duplicate row's LAST observation wins (today's exact
112
+ * precedence — safe on the partial/retry path, where the envelope already failed; the ok-arm
113
+ * corroboration guard additionally refuses contradictory duplicates via `contradictedIds`).
114
+ * Rows for never-requested ids are ignored.
115
+ */
116
+ function correlateFates(
117
+ requested: ThreadInput[],
118
+ rows: ThreadResultRow[],
119
+ ): Map<string, { input: ThreadInput; fate: ThreadFate }> {
120
+ const byId = new Map(rows.map((row) => [row.thread_id, row]));
121
+ const fates = new Map<string, { input: ThreadInput; fate: ThreadFate }>();
122
+ for (const input of requested) {
123
+ if (fates.has(input.thread_id)) continue;
124
+ const row = byId.get(input.thread_id);
125
+ const fate: ThreadFate =
126
+ row === undefined
127
+ ? { kind: "unknown" }
128
+ : row.success
129
+ ? { kind: "resolved" }
130
+ : { kind: "failed", replyPosted: row.comment_added };
131
+ fates.set(input.thread_id, { input, fate });
132
+ }
133
+ return fates;
134
+ }
135
+
136
+ /**
137
+ * Requested ids whose rows DISAGREE on `success` (version-skew evidence). A contradictory
138
+ * duplicate must never corroborate a nominal success — last-row precedence would erase the
139
+ * failure observation before the guard runs — so the ok-arm treats these as uncorroborated
140
+ * and retries them with the reply stripped (both the resolve and the reply outcome are
141
+ * unknowable from contradictory evidence).
142
+ */
143
+ function contradictedIds(requested: ThreadInput[], rows: ThreadResultRow[]): Set<string> {
144
+ const requestedIds = new Set(requested.map((input) => input.thread_id));
145
+ const lastSuccess = new Map<string, boolean>();
146
+ const contradicted = new Set<string>();
147
+ for (const row of rows) {
148
+ if (!requestedIds.has(row.thread_id)) continue;
149
+ const prior = lastSuccess.get(row.thread_id);
150
+ if (prior !== undefined && prior !== row.success) contradicted.add(row.thread_id);
151
+ lastSuccess.set(row.thread_id, row.success);
152
+ }
153
+ return contradicted;
154
+ }
155
+
156
+ /**
157
+ * The only safe automatic retry batch, derived from the fates: resolved threads are omitted; a
158
+ * reply is retained ONLY on a positive not-posted report (`failed` with `replyPosted: false`);
159
+ * an `unknown` fate is outcome-unknown, so its reply is stripped rather than risked twice. A
160
+ * `contradicted` id (ok-arm only) is likewise outcome-unknown: retried with the reply stripped.
161
+ */
162
+ function retryFromFates(
163
+ fates: Map<string, { input: ThreadInput; fate: ThreadFate }>,
164
+ contradicted: Set<string> = new Set(),
165
+ ): ThreadInput[] {
166
+ const retry: ThreadInput[] = [];
167
+ for (const [id, { input, fate }] of fates) {
168
+ const effective: ThreadFate = contradicted.has(id) ? { kind: "unknown" } : fate;
169
+ if (effective.kind === "resolved") continue;
170
+ if (effective.kind === "failed" && !effective.replyPosted && input.comment !== undefined) {
171
+ retry.push({ thread_id: input.thread_id, comment: input.comment });
172
+ } else {
173
+ retry.push({ thread_id: input.thread_id });
174
+ }
175
+ }
176
+ return retry;
177
+ }
178
+
179
+ const EMPTY_BATCH_MESSAGE = "no threads to finalize (pass { threads: [{thread_id, comment?}] })";
180
+
181
+ const INSPECT_GUIDANCE =
182
+ "Inspect the resolution failure before retrying; omit any reply that may already have posted.";
183
+ const RETRY_GUIDANCE =
184
+ "Re-run finalize_address with only details.retry_threads; successful rows were omitted " +
185
+ "and replies already reported as posted were stripped.";
186
+
187
+ function resolveFailedMessage(resolveMessage: string, retryable: boolean): string {
188
+ return (
189
+ `propagation succeeded, but thread resolution failed: ${resolveMessage}. ` +
190
+ `The submit already succeeded. ${retryable ? RETRY_GUIDANCE : INSPECT_GUIDANCE}`
191
+ );
192
+ }
193
+
194
+ /** All success rows, in row order (the wire's `resolved_thread_ids` — row-derived, not fates). */
195
+ function resolvedIdsOf(rows: ThreadResultRow[]): string[] {
196
+ return rows.filter((row) => row.success).map((row) => row.thread_id);
197
+ }
198
+
199
+ /**
200
+ * Publish committed address fixes, then resolve their review threads. Ordering policy: the
201
+ * empty refusal fires before any port; a failed publish returns with no resolve call, no
202
+ * session writes, and no counter activity; the batch record and the conflict decision happen
203
+ * ONLY after corroborated full resolve success (never-burn-an-attempt). The record's
204
+ * classification is ignored — the session seam owns loudness.
205
+ */
206
+ export async function finalizeAddress(
207
+ deps: FinalizeAddressDeps,
208
+ input: AddressFinalization,
209
+ ): Promise<FinalizeAddressOutcome> {
210
+ if (input.threads.length === 0) {
211
+ return { kind: "empty_batch", message: EMPTY_BATCH_MESSAGE };
212
+ }
213
+
214
+ const published = await publishVerified(deps);
215
+ if (!published.ok) {
216
+ return {
217
+ kind: "not_published",
218
+ publishMessage: published.message,
219
+ message:
220
+ `propagation failed; threads were NOT resolved — ${published.message}. ` +
221
+ "Fix the publication failure, then re-run finalize_address.",
222
+ errorType: published.errorType,
223
+ };
224
+ }
225
+ const change = published.change;
226
+
227
+ const resolved = await deps.resolve(input.threads);
228
+ if (!resolved.ok && resolved.kind === "failed") {
229
+ return {
230
+ kind: "published_unverified",
231
+ change,
232
+ resolveMessage: resolved.message,
233
+ message: resolveFailedMessage(resolved.message, false),
234
+ errorType: resolved.errorType,
235
+ };
236
+ }
237
+
238
+ const rows = resolved.rows;
239
+ const fates = correlateFates(input.threads, rows);
240
+ const retryThreads = retryFromFates(fates);
241
+ const resolvedThreadIds = resolvedIdsOf(rows);
242
+
243
+ if (!resolved.ok) {
244
+ return {
245
+ kind: "published_partial",
246
+ change,
247
+ resolveMessage: resolved.message,
248
+ message: resolveFailedMessage(resolved.message, retryThreads.length > 0),
249
+ errorType: resolved.errorType,
250
+ results: rows,
251
+ resolvedThreadIds,
252
+ retryThreads,
253
+ };
254
+ }
255
+
256
+ // The ok-arm corroboration guard: a nominal-success envelope must corroborate EVERY requested
257
+ // thread (missing/failed/duplicate-CONTRADICTING rows under version skew route to partial —
258
+ // nothing recorded, nothing termination-eligible).
259
+ const contradicted = contradictedIds(input.threads, rows);
260
+ const uncorroborated = [...fates.entries()].filter(
261
+ ([id, { fate }]) => fate.kind !== "resolved" || contradicted.has(id),
262
+ ).length;
263
+ if (uncorroborated > 0) {
264
+ const guardRetry = retryFromFates(fates, contradicted);
265
+ const resolveMessage = `the resolve report did not corroborate ${uncorroborated} requested thread(s)`;
266
+ return {
267
+ kind: "published_partial",
268
+ change,
269
+ resolveMessage,
270
+ message: resolveFailedMessage(resolveMessage, guardRetry.length > 0),
271
+ errorType: "partial_failure",
272
+ results: rows,
273
+ resolvedThreadIds,
274
+ retryThreads: guardRetry,
275
+ };
276
+ }
277
+
278
+ const record: ReviewBatchRecord = {
279
+ pr: input.pr ?? null,
280
+ counts: input.counts ?? null,
281
+ resolved_thread_ids: resolvedThreadIds,
282
+ at: new Date().toISOString(),
283
+ };
284
+ // Classification deliberately ignored: the seam reports its own read-back warnings, and a
285
+ // recording miss must never sink an already-corroborated resolve success.
286
+ deps.session.apply({ kind: "record-review-batch", record });
287
+
288
+ return {
289
+ kind: "completed",
290
+ change,
291
+ results: rows,
292
+ resolvedThreadIds,
293
+ conflict: decideConflictFollowUp(change, deps.attempts),
294
+ };
295
+ }
@@ -0,0 +1,355 @@
1
+ // The CI-execution feature (the Run→Report half of Run→Report→Fix→Verify), Pi-free.
2
+ //
3
+ // A deterministic check runner over the project's configured `[[ci.checks]]` rows: it selects,
4
+ // orders, glob-gates, runs, and REPORTS — it never edits, fixes, or loops. Checks execute
5
+ // CONCURRENTLY (each row must be independently runnable; declared order governs the report
6
+ // order, not execution order — sequencing that matters belongs inside one row's command, e.g.
7
+ // `cmd1 && cmd2`). The caller owns the entire fix loop; this feature is a stateless oracle
8
+ // invoked once per run (the `devrun` discipline: "run and report", never "run and fix").
9
+ //
10
+ // "Read-only" here is a property of THIS MODULE and its OUTPUT, not a sandbox: the check
11
+ // commands run with full shell access through the injected `RunConfiguredCheck` port. The
12
+ // untrusted-config scope gate (`decideCiScope`) and the output-isolation wrapping live with the
13
+ // adapter that composes the ports; the feature's own defenses are output routing (full output
14
+ // persisted through the `PersistCheckOutput` port, capped model-visible slice) and never-throw
15
+ // per-check folding.
16
+
17
+ import type { CiCheck } from "../substrate/config.ts";
18
+ import { capForModel, DEFAULT_MODEL_VISIBLE_CAP } from "../substrate/modelVisible.ts";
19
+
20
+ /** One configured check's outcome. `executed` carries no `passed` field — passed ≡
21
+ * `exitCode === 0` (derived where needed, so a contradiction is unrepresentable); `skipped`
22
+ * only ever arises from a declared glob, so `glob` is required. */
23
+ export type CiCheckOutcome =
24
+ | {
25
+ kind: "executed";
26
+ name: string;
27
+ command: string;
28
+ exitCode: number;
29
+ /** The capped, model-visible output (route-don't-relay — the full output was persisted
30
+ * through the port). */
31
+ shown: string;
32
+ /** The port-minted opaque location of the persisted full output (`null` = persistence
33
+ * failed; the failure rides `error`). */
34
+ outputPath: string | null;
35
+ bytesTotal: number;
36
+ bytesShown: number;
37
+ truncated: boolean;
38
+ error?: string;
39
+ }
40
+ | { kind: "skipped"; name: string; command: string; glob: string };
41
+
42
+ /** The typed run outcome. `invalid_selection` carries the selection diagnostic (back-pressure
43
+ * is feature meaning); `completed.passed` counts skipped checks as passed. Refusals and
44
+ * tool-boundary decode failures are adapter shapes — they never enter this union. */
45
+ export type CiRunOutcome =
46
+ | { kind: "not_configured" }
47
+ | { kind: "invalid_selection"; message: string }
48
+ | {
49
+ kind: "completed";
50
+ scope: "all" | "subset";
51
+ passed: boolean;
52
+ checks: readonly CiCheckOutcome[];
53
+ };
54
+
55
+ /** The per-check display state of the typed live-progress stream. */
56
+ export type CiProgressState = "running" | "passed" | "failed" | "skipped";
57
+
58
+ export interface CiProgressEntry {
59
+ readonly name: string;
60
+ readonly state: CiProgressState;
61
+ }
62
+
63
+ /**
64
+ * The delivery-specific typed progress event union. No per-event check identity — configured
65
+ * names are not unique; the ORDERED entries snapshot is the correlation surface. `run_started`
66
+ * fires synchronously before any check launches (skips already resolved); one `check_settled`
67
+ * per executed-check completion. Every emission deep-copies the entry objects.
68
+ */
69
+ export type CiProgressEvent =
70
+ | { readonly kind: "run_started"; readonly entries: readonly CiProgressEntry[] }
71
+ | { readonly kind: "check_settled"; readonly entries: readonly CiProgressEntry[] };
72
+
73
+ /** A single deterministic command execution outcome (output = trimmed stdout + "\n" + stderr). */
74
+ export interface CiExecOutcome {
75
+ code: number;
76
+ output: string;
77
+ }
78
+
79
+ /** The semantic port "run this configured check" (one production adapter: the Pi shell runner). */
80
+ export type RunConfiguredCheck = (
81
+ check: CiCheck,
82
+ opts: { signal?: AbortSignal },
83
+ ) => Promise<CiExecOutcome>;
84
+
85
+ /** "Persist this check's full output and return its opaque location" — throws on failure. */
86
+ export type PersistCheckOutput = (checkName: string, output: string) => string;
87
+
88
+ /** The semantic port "changed files vs trunk"; `null` = unknown (the fail-open sentinel — the
89
+ * run then skips nothing). One production adapter: the git composition in the Pi adapter. */
90
+ export type ObserveChangedFiles = (opts: {
91
+ signal?: AbortSignal;
92
+ }) => Promise<ReadonlySet<string> | null>;
93
+
94
+ export type CiScope = "run" | "confirm" | "refuse";
95
+
96
+ /**
97
+ * Decide how to treat project-supplied CI. Pure (the load-bearing safety boundary):
98
+ * - `[ci] trusted` (committed config), `--allow-project-ci`, or a per-session latch ⇒ "run"
99
+ * (trust runs on EVERY surface, overriding the headless refuse below)
100
+ * - else with UI ⇒ "confirm" (ask the human)
101
+ * - else (headless, no trust/flag) ⇒ "refuse" (fail closed)
102
+ */
103
+ export function decideCiScope(args: {
104
+ hasUI: boolean;
105
+ allowFlag: boolean;
106
+ approved: boolean;
107
+ trusted: boolean;
108
+ }): CiScope {
109
+ if (args.trusted || args.allowFlag || args.approved) return "run";
110
+ return args.hasUI ? "confirm" : "refuse";
111
+ }
112
+
113
+ /**
114
+ * Run one configured check deterministically: run the port, persist the FULL combined output
115
+ * through the `PersistCheckOutput` port, cap the model-visible output. Never throws — a
116
+ * run-port throw becomes `exitCode: -1` with the error captured; a persistence throw folds to
117
+ * the same failure shape (`error` = the thrown message, `outputPath: null`) with the exit code
118
+ * intact.
119
+ */
120
+ async function runOneCheck(
121
+ check: CiCheck,
122
+ runCheck: RunConfiguredCheck,
123
+ persistOutput: PersistCheckOutput,
124
+ signal?: AbortSignal,
125
+ ): Promise<CiCheckOutcome> {
126
+ let outcome: CiExecOutcome;
127
+ try {
128
+ outcome = await runCheck(check, { signal });
129
+ } catch (err) {
130
+ const message = err instanceof Error ? err.message : String(err);
131
+ return {
132
+ kind: "executed",
133
+ name: check.name,
134
+ command: check.command,
135
+ exitCode: -1,
136
+ shown: message,
137
+ outputPath: null,
138
+ bytesTotal: 0,
139
+ bytesShown: 0,
140
+ truncated: false,
141
+ error: message,
142
+ };
143
+ }
144
+
145
+ // Persist the full output through the port: a returned string IS the location; ANY throw
146
+ // folds to the failure shape (the port owns write+verify semantics — no post-write probe).
147
+ let outputPath: string | null = null;
148
+ let writeError: string | undefined;
149
+ try {
150
+ outputPath = persistOutput(check.name, outcome.output);
151
+ } catch (err) {
152
+ writeError = err instanceof Error ? err.message : String(err);
153
+ }
154
+
155
+ // Tail-keep: pytest/tsc failure summaries live at the END of the output, so the model-visible
156
+ // slice keeps the last `cap` bytes; the persisted location still holds the full output.
157
+ const capped = capForModel(outcome.output, DEFAULT_MODEL_VISIBLE_CAP, outputPath, "tail");
158
+ return {
159
+ kind: "executed",
160
+ name: check.name,
161
+ command: check.command,
162
+ exitCode: outcome.code,
163
+ shown: capped.shown,
164
+ outputPath,
165
+ bytesTotal: capped.bytesTotal,
166
+ bytesShown: capped.bytesShown,
167
+ truncated: capped.truncated,
168
+ ...(writeError ? { error: writeError } : {}),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Dependency-free glob match. `glob` is a single comma-separated pattern string; the path matches
174
+ * iff it matches ANY pattern. Each pattern is translated to an anchored RegExp: regex metachars
175
+ * escaped, then `**` → `.*` (crosses directories) and `*` → `[^/]*` (one segment). A slash-free
176
+ * pattern is matched against the path's BASENAME (so `*.py` gates any `.py` at any depth, the
177
+ * gitignore/fnmatch rule); a pattern containing `/` is matched against the full repo-relative
178
+ * POSIX path.
179
+ */
180
+ function matchesGlob(path: string, glob: string): boolean {
181
+ for (const raw of glob.split(",")) {
182
+ const pattern = raw.trim();
183
+ if (!pattern) continue;
184
+ // Escape regex metachars, including `*` (restored below to glob semantics).
185
+ const escaped = pattern.replace(/[.+^${}()|[\]\\*]/g, "\\$&");
186
+ // Restore glob stars in one pass (so the single-`*` rule never clobbers a `**`): the escaped
187
+ // forms are `\*\*` (→ `.*`, crosses dirs) and `\*` (→ `[^/]*`, one segment).
188
+ const body = escaped.replace(/\\\*\\\*|\\\*/g, (m) => (m === "\\*\\*" ? ".*" : "[^/]*"));
189
+ const subject = pattern.includes("/") ? path : (path.split("/").pop() ?? path);
190
+ if (new RegExp(`^${body}$`).test(subject)) return true;
191
+ }
192
+ return false;
193
+ }
194
+
195
+ /** A skipped-check outcome: not executed because its glob matched no changed file (vs trunk). */
196
+ function skippedResult(check: CiCheck, glob: string): CiCheckOutcome {
197
+ return { kind: "skipped", name: check.name, command: check.command, glob };
198
+ }
199
+
200
+ export interface RunCiChecksOpts {
201
+ checks: CiCheck[];
202
+ only?: string;
203
+ signal?: AbortSignal;
204
+ }
205
+
206
+ export interface RunCiChecksDeps {
207
+ runCheck: RunConfiguredCheck;
208
+ /** Persist one check's full output; the returned string is the opaque location this feature
209
+ * treats as data (a throw folds to the check's failure shape). */
210
+ persistOutput: PersistCheckOutput;
211
+ observeChangedFiles: ObserveChangedFiles;
212
+ /** Optional typed live-progress sink. Failure-owned here: each call is wrapped in try/catch,
213
+ * so a throwing sink cannot affect the run (the callback contract is synchronous `void`). */
214
+ onProgress?: (event: CiProgressEvent) => void;
215
+ }
216
+
217
+ /**
218
+ * Run the selected checks (or all when `only` is omitted) CONCURRENTLY and report every result
219
+ * in the config's DECLARED order — declared order governs the report, not execution, so each
220
+ * `[[ci.checks]]` row must be independently runnable (sequencing that matters belongs inside one
221
+ * row's command, e.g. `cmd1 && cmd2`). `only` accepts one name or a comma-separated list — an
222
+ * EXACT name match wins before any comma-splitting (so a configured name that itself contains a
223
+ * comma or surrounding whitespace stays selectable), and each requested name selects the FIRST
224
+ * declared row with that name (duplicates never broaden a selection); the selected rows still
225
+ * run concurrently and report in declared order. Empty checks ⇒ inert `not_configured`; an
226
+ * unknown (or missing) `only` name ⇒ an actionable `invalid_selection` listing the available
227
+ * names (back-pressure, not a silent failure). Does NOT stop at the first failure.
228
+ * `passed = every executed check exited 0` (skips count as passed).
229
+ *
230
+ * **Change-scoped gating (run-all path only).** When any selected check declares a `glob`, the
231
+ * changed-file set (vs trunk) is observed ONCE — before any check launches — and each globbed
232
+ * check is skipped when no changed file matches (never a failure). A check with no `glob`
233
+ * always runs; an explicit `only` always runs (no glob gate, no observation); a fail-open
234
+ * `null` observation runs everything (never skip on uncertainty). No observation happens when
235
+ * no selected row is globbed.
236
+ */
237
+ export async function runCiChecks(
238
+ opts: RunCiChecksOpts,
239
+ deps: RunCiChecksDeps,
240
+ ): Promise<CiRunOutcome> {
241
+ const checks = opts.checks;
242
+ if (checks.length === 0) {
243
+ return { kind: "not_configured" };
244
+ }
245
+ const names = checks.map((c) => c.name);
246
+
247
+ // Explicit selection: `only` is one configured name or a comma-separated list. An exact name
248
+ // match is tried FIRST (compatibility: any accepted name — even one containing a comma or
249
+ // surrounding whitespace — stays selectable); only a non-matching string is comma-split.
250
+ // Selected rows run in DECLARED order (not argument order); no glob gate, no observation.
251
+ let selected = checks;
252
+ let explicit = false;
253
+ if (opts.only !== undefined) {
254
+ const exact = checks.find((c) => c.name === opts.only);
255
+ const requested = exact
256
+ ? [exact.name]
257
+ : opts.only
258
+ .split(",")
259
+ .map((s) => s.trim())
260
+ .filter((s) => s.length > 0);
261
+ if (requested.length === 0) {
262
+ return {
263
+ kind: "invalid_selection",
264
+ message: `no check names given; available: ${names.join(", ")}`,
265
+ };
266
+ }
267
+ const unknown = requested.filter((n) => !names.includes(n));
268
+ if (unknown.length > 0) {
269
+ return {
270
+ kind: "invalid_selection",
271
+ message: `unknown check${unknown.length > 1 ? "s" : ""} '${unknown.join("', '")}'; available: ${names.join(", ")}`,
272
+ };
273
+ }
274
+ // Each requested name selects the FIRST declared row with that name (the pre-concurrency
275
+ // `find` semantics): duplicate names never broaden an explicit selection into extra rows
276
+ // racing on the same name-keyed persisted-output target.
277
+ const wanted = new Set(requested);
278
+ const seen = new Set<string>();
279
+ selected = checks.filter((c) => {
280
+ if (!wanted.has(c.name) || seen.has(c.name)) return false;
281
+ seen.add(c.name);
282
+ return true;
283
+ });
284
+ explicit = true;
285
+ }
286
+
287
+ // The changed-set observation (compute-once, before run_started and before any launch);
288
+ // a throwing observer folds to the same fail-open `null` its contract already means.
289
+ const gate = !explicit && selected.some((c) => c.glob);
290
+ let changed: ReadonlySet<string> | null = null;
291
+ if (gate) {
292
+ try {
293
+ changed = await deps.observeChangedFiles({ signal: opts.signal });
294
+ } catch {
295
+ changed = null;
296
+ }
297
+ }
298
+
299
+ // Skip a globbed check only when we KNOW the changed set (changed !== null) and nothing matches.
300
+ const skipsByGlob = (check: CiCheck): boolean => {
301
+ if (explicit || !check.glob || changed === null) return false;
302
+ const glob = check.glob;
303
+ return ![...changed].some((f) => matchesGlob(f, glob));
304
+ };
305
+
306
+ // Typed live progress: one ordered state entry per selected check (skips resolve
307
+ // synchronously), a `run_started` emission before any launch, one `check_settled` per
308
+ // executed-check completion. Every emission deep-copies the entries — a retained earlier
309
+ // event never mutates, and a sink mutating its received entries cannot affect later events
310
+ // or the outcome. Progress is cosmetic: a throwing sink is contained (the callback
311
+ // contract is synchronous `void`).
312
+ const onProgress = deps.onProgress;
313
+ const states = selected.map((check): { name: string; state: CiProgressState } => ({
314
+ name: check.name,
315
+ state: skipsByGlob(check) ? "skipped" : "running",
316
+ }));
317
+ const emit = (kind: CiProgressEvent["kind"]): void => {
318
+ if (!onProgress) return;
319
+ try {
320
+ onProgress({
321
+ kind,
322
+ entries: states.map((entry) => ({ name: entry.name, state: entry.state })),
323
+ });
324
+ } catch {
325
+ // Progress must never break the run.
326
+ }
327
+ };
328
+ emit("run_started");
329
+
330
+ // Launch every non-skipped check at once; `map` + `Promise.all` keeps `results` in declared
331
+ // order regardless of completion order, and `runOneCheck` never throws, so `Promise.all`
332
+ // cannot reject. Wall time is the MAX of the check durations, not the sum.
333
+ const results: CiCheckOutcome[] = await Promise.all(
334
+ selected.map((check, i) => {
335
+ const glob = check.glob;
336
+ if (glob !== undefined && skipsByGlob(check)) {
337
+ return Promise.resolve(skippedResult(check, glob));
338
+ }
339
+ return runOneCheck(check, deps.runCheck, deps.persistOutput, opts.signal).then((result) => {
340
+ const entry = states[i];
341
+ if (entry) {
342
+ entry.state = result.kind === "executed" && result.exitCode === 0 ? "passed" : "failed";
343
+ }
344
+ emit("check_settled");
345
+ return result;
346
+ });
347
+ }),
348
+ );
349
+ return {
350
+ kind: "completed",
351
+ scope: explicit ? "subset" : "all",
352
+ passed: results.every((c) => c.kind === "skipped" || c.exitCode === 0),
353
+ checks: results,
354
+ };
355
+ }