@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,261 @@
1
+ // Participating submit/address writers exclude each other by canonical worktree Git identity.
2
+ // There is NO reclamation policy. Death, age, reload and cancellation cannot prove quiescence.
3
+ import { randomUUID } from "node:crypto";
4
+ import {
5
+ closeSync,
6
+ constants,
7
+ fstatSync,
8
+ fsyncSync,
9
+ lstatSync,
10
+ openSync,
11
+ readSync,
12
+ type Stats,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { worktreeGitDir } from "./git.ts";
18
+
19
+ const OWNER_LIMIT = 16 * 1024;
20
+ export const WORKTREE_RESOLVER_LOCK = "perk-submit-conflict.lock";
21
+
22
+ interface LockRecord {
23
+ schema: 1;
24
+ token: string;
25
+ pid: number;
26
+ parentSessionId: string;
27
+ ownerRunId: string;
28
+ requestId: string;
29
+ worktreeIdentity: string;
30
+ createdAt: string;
31
+ }
32
+ export type LockOwner = Omit<LockRecord, "token" | "schema">;
33
+ export type LockFinish = {
34
+ kind: "released" | "retained" | "ownership-error" | "io-error";
35
+ path: string;
36
+ };
37
+ export interface WorktreeResolverClaim {
38
+ readonly path: string;
39
+ check(): "owned" | "ownership-error" | "io-error";
40
+ /** First choice wins, locally idempotent. Retain closes resources but never removes metadata. */
41
+ finish(disposition: "release" | "retain"): LockFinish;
42
+ }
43
+ export type WorktreeResolverAcquisition =
44
+ | { kind: "acquired"; claim: WorktreeResolverClaim }
45
+ | { kind: "unavailable" }
46
+ | { kind: "busy"; path: string; owner?: LockOwner }
47
+ | { kind: "io-error"; path: string; residue: boolean };
48
+
49
+ /** Only deterministic filesystem fault tests substitute these operations. */
50
+ export interface WorktreeLockFs {
51
+ open: typeof openSync;
52
+ close: typeof closeSync;
53
+ fstat: (fd: number) => Stats;
54
+ lstat: (path: string) => Stats;
55
+ read: (fd: number, buffer: Buffer, offset: number, length: number, position: number) => number;
56
+ write: (fd: number, data: string) => void;
57
+ sync: (fd: number) => void;
58
+ unlink: (path: string) => void;
59
+ }
60
+ const realFs: WorktreeLockFs = {
61
+ open: openSync,
62
+ close: closeSync,
63
+ fstat: fstatSync,
64
+ lstat: lstatSync,
65
+ read: readSync,
66
+ write: (fd, data) => writeFileSync(fd, data, "utf8"),
67
+ sync: fsyncSync,
68
+ unlink: unlinkSync,
69
+ };
70
+ function code(error: unknown): unknown {
71
+ return typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
72
+ }
73
+ function sameFile(a: Stats, b: Stats): boolean {
74
+ return a.isFile() && b.isFile() && a.dev === b.dev && a.ino === b.ino;
75
+ }
76
+ function readRecord(fs: WorktreeLockFs, fd: number): LockRecord | null {
77
+ const size = fs.fstat(fd).size;
78
+ if (size <= 0 || size > OWNER_LIMIT) return null;
79
+ const bytes = Buffer.alloc(size);
80
+ let read = 0;
81
+ while (read < size) {
82
+ const n = fs.read(fd, bytes, read, size - read, read);
83
+ if (n === 0) return null;
84
+ read += n;
85
+ }
86
+ let raw: unknown;
87
+ try {
88
+ raw = JSON.parse(bytes.toString("utf8"));
89
+ } catch {
90
+ return null;
91
+ }
92
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
93
+ const r = raw as Record<string, unknown>;
94
+ if (
95
+ r.schema !== 1 ||
96
+ typeof r.token !== "string" ||
97
+ !r.token ||
98
+ typeof r.pid !== "number" ||
99
+ !Number.isInteger(r.pid) ||
100
+ r.pid <= 0
101
+ )
102
+ return null;
103
+ for (const field of [
104
+ "parentSessionId",
105
+ "ownerRunId",
106
+ "requestId",
107
+ "worktreeIdentity",
108
+ "createdAt",
109
+ ] as const) {
110
+ if (typeof r[field] !== "string" || !r[field]) return null;
111
+ }
112
+ return r as unknown as LockRecord;
113
+ }
114
+ function ownerOf(r: LockRecord): LockOwner {
115
+ return {
116
+ pid: r.pid,
117
+ parentSessionId: r.parentSessionId,
118
+ ownerRunId: r.ownerRunId,
119
+ requestId: r.requestId,
120
+ worktreeIdentity: r.worktreeIdentity,
121
+ createdAt: r.createdAt,
122
+ };
123
+ }
124
+
125
+ function incumbent(fs: WorktreeLockFs, path: string): WorktreeResolverAcquisition {
126
+ let fd: number | undefined;
127
+ try {
128
+ const stat = fs.lstat(path);
129
+ if (!stat.isFile() || stat.size > OWNER_LIMIT) return { kind: "busy", path };
130
+ fd = fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
131
+ if (!sameFile(stat, fs.fstat(fd))) return { kind: "busy", path };
132
+ const record = readRecord(fs, fd);
133
+ return { kind: "busy", path, ...(record ? { owner: ownerOf(record) } : {}) };
134
+ } catch (error) {
135
+ // Even a disappearing incumbent doesn't invite a retry. A racing external replacement is
136
+ // outside the protocol; ELOOP never follows the new symlink for diagnostic reads.
137
+ if (code(error) === "ENOENT" || code(error) === "ELOOP") return { kind: "busy", path };
138
+ return { kind: "io-error", path, residue: true };
139
+ } finally {
140
+ if (fd !== undefined) fs.close(fd);
141
+ }
142
+ }
143
+
144
+ export function acquireWorktreeResolverLock(
145
+ cwd: string,
146
+ parent: { sessionId: string; runId: string; requestId: string },
147
+ opts: {
148
+ fs?: Partial<WorktreeLockFs>;
149
+ gitDir?: (cwd: string) => string | null;
150
+ /** The token minter (default `randomUUID`) — only deterministic fault tests substitute it. */
151
+ token?: () => string;
152
+ } = {},
153
+ ): WorktreeResolverAcquisition {
154
+ const identity = (opts.gitDir ?? worktreeGitDir)(cwd);
155
+ if (identity === null) return { kind: "unavailable" };
156
+ const path = join(identity, WORKTREE_RESOLVER_LOCK);
157
+ const fs = { ...realFs, ...opts.fs };
158
+ let fd: number;
159
+ try {
160
+ fd = fs.open(path, "wx", 0o600);
161
+ } catch (error) {
162
+ if (code(error) === "EEXIST") {
163
+ try {
164
+ return incumbent(fs, path);
165
+ } catch {
166
+ return { kind: "io-error", path, residue: true };
167
+ }
168
+ }
169
+ return { kind: "io-error", path, residue: false };
170
+ }
171
+ // Everything after the exclusive create runs inside the cleanup-protected block — the record
172
+ // construction included: a token-minting failure must take the same identity-fenced unlink
173
+ // path as a write failure, never escape with the descriptor open and an empty lock file that
174
+ // would wedge every later acquisition as busy.
175
+ let stat: Stats | undefined;
176
+ let token: string;
177
+ try {
178
+ stat = fs.fstat(fd);
179
+ if (!stat.isFile()) throw new Error("not a regular file");
180
+ token = (opts.token ?? randomUUID)();
181
+ const record: LockRecord = {
182
+ schema: 1,
183
+ token,
184
+ pid: process.pid,
185
+ parentSessionId: parent.sessionId,
186
+ ownerRunId: parent.runId,
187
+ requestId: parent.requestId,
188
+ worktreeIdentity: identity,
189
+ createdAt: new Date().toISOString(),
190
+ };
191
+ fs.write(fd, `${JSON.stringify(record)}\n`);
192
+ fs.sync(fd);
193
+ } catch {
194
+ let residue = true;
195
+ try {
196
+ if (stat && sameFile(stat, fs.lstat(path))) {
197
+ fs.unlink(path);
198
+ residue = false;
199
+ }
200
+ } catch {
201
+ /* Report residue; never remove an unidentified or successor file. */
202
+ }
203
+ try {
204
+ fs.close(fd);
205
+ } catch {
206
+ residue = true;
207
+ }
208
+ return { kind: "io-error", path, residue };
209
+ }
210
+ const acquiredStat = stat;
211
+ let finished: LockFinish | undefined;
212
+ function check(): "owned" | "ownership-error" | "io-error" {
213
+ if (finished) return "ownership-error";
214
+ try {
215
+ if (!sameFile(acquiredStat, fs.lstat(path)) || !sameFile(acquiredStat, fs.fstat(fd)))
216
+ return "ownership-error";
217
+ const reader = fs.open(
218
+ path,
219
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
220
+ );
221
+ try {
222
+ if (!sameFile(acquiredStat, fs.fstat(reader))) return "ownership-error";
223
+ return readRecord(fs, reader)?.token === token ? "owned" : "ownership-error";
224
+ } finally {
225
+ fs.close(reader);
226
+ }
227
+ } catch (error) {
228
+ return code(error) === "ENOENT" ? "ownership-error" : "io-error";
229
+ }
230
+ }
231
+ return {
232
+ kind: "acquired",
233
+ claim: {
234
+ path,
235
+ check,
236
+ finish(disposition) {
237
+ if (finished) return finished;
238
+ let kind: LockFinish["kind"] = "retained";
239
+ try {
240
+ if (disposition === "release") {
241
+ const ownership = check();
242
+ if (ownership === "owned") {
243
+ fs.unlink(path);
244
+ kind = "released";
245
+ } else kind = ownership;
246
+ }
247
+ } catch {
248
+ kind = "io-error";
249
+ } finally {
250
+ try {
251
+ fs.close(fd);
252
+ } catch {
253
+ kind = "io-error";
254
+ }
255
+ }
256
+ finished = { kind, path };
257
+ return finished;
258
+ },
259
+ },
260
+ };
261
+ }
@@ -6,11 +6,11 @@
6
6
  // surfaces module" is surfaces.ts + report.ts for the surfaces guard).
7
7
  //
8
8
  // Perk status (charter §6 D2): perk presents ONE footer status under the single
9
- // `perk` slot — the single-value objective segment (the checkpoint substrate is retired, so
10
- // there is no composition step). The per-feature status slots and widgets are retired (D8
11
- // sanctioned). The perk-owned footer (`perkFooter`/`installPerkFooter` below) renders the value
12
- // directly; the `perk` status slot keeps publishing — it is the RPC-visible surface (setFooter
13
- // is an RPC no-op).
9
+ // `perk` slot — the objective segment composed with the ref-counted activity facet
10
+ // (`<objective> · <activity>`, either half optional). The per-feature status slots and widgets
11
+ // are retired (D8 sanctioned). The perk-owned footer (`perkFooter`/`installPerkFooter` below)
12
+ // renders the value directly; the `perk` status slot keeps publishing — it is the RPC-visible
13
+ // surface (setFooter is an RPC no-op).
14
14
 
15
15
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
16
  import type { ReportDetailSink } from "./report.ts";
@@ -35,6 +35,19 @@ export const STATUS_SLOT_PERK = "perk";
35
35
  // --- footer identity marks (charter §5 / D3: emoji are footer-only identity, 2 cells wide) ---
36
36
  export const MARK_OBJECTIVE = "🎯";
37
37
 
38
+ // --- activity vocabulary (the optional second half of the composed `perk` status) ---
39
+ // Plain text, no emoji (D3 keeps emoji as identity marks): a Perk-owned wait the operator
40
+ // cannot otherwise see.
41
+ export const ACTIVITY_BROWSER_REVIEW = "waiting on browser review";
42
+ /** The slice an activity owner takes: begin a wait with its text, receive the matching `end`. */
43
+ export type ActivitySink = (text: string) => () => void;
44
+ /**
45
+ * The least-capable slice a wiring layer threads toward an activity owner: begin a wait against
46
+ * the live target, nothing else. The full `PerkStatusHandle` stays at the composition root, the
47
+ * objective publisher and the footer.
48
+ */
49
+ export type ActivityHandle = Pick<PerkStatusHandle, "beginActivity">;
50
+
38
51
  // --- glyph vocabulary (charter §5 / D3) — charter-law data, pinned by tests ---
39
52
  export type GlyphKind = "done" | "current" | "pending" | "warning" | "failure";
40
53
  export const GLYPHS: Record<GlyphKind, { glyph: string; themeColor: string }> = {
@@ -101,41 +114,80 @@ export function setWorkingMessage(target: WorkingMessageTarget, message?: string
101
114
  // --- the perk status (charter D2) --------------------------------------------
102
115
 
103
116
  /**
104
- * The single-value status handle: the objective publisher sets the one value through `set`, and
105
- * the handle republishes the single `perk` status slot. The footer reads the value back via `get`
106
- * and repaints via `subscribe` the slot's `setStatus` dual-publish is deliberate (RPC clients
107
- * see the slot; setFooter is an RPC no-op).
117
+ * The composed status handle: the objective publisher sets its half through `set`, activity
118
+ * owners hold ref-counted waits through `beginActivity`, and the handle republishes the single
119
+ * `perk` status slot with the composed value. The footer reads it back via `get` and repaints via
120
+ * `subscribe` the slot's `setStatus` dual-publish is deliberate (RPC clients see the slot;
121
+ * setFooter is an RPC no-op).
108
122
  */
109
123
  export interface PerkStatusHandle {
110
- /** Set (or clear, with undefined) the one value; publishes the slot. No-op headless. */
124
+ /** Set (or clear, with undefined) the objective half; publishes the slot. No-op headless. */
111
125
  set(target: StandingTarget, text: string | undefined): void;
112
- /** The current text (undefined when unset). */
126
+ /**
127
+ * Begin one activity wait: the newest live wait's text shows while any begun wait is unended;
128
+ * returns this wait's `end` (idempotent; inert after a reset — no publish). No-op headless
129
+ * (returns a no-op `end`, records nothing).
130
+ */
131
+ beginActivity(target: StandingTarget, text: string): () => void;
132
+ /** Reset every wait (session shutdown); a late `end` of a reset wait is inert. No-op headless. */
133
+ clearActivity(target: StandingTarget): void;
134
+ /** The composed value (`<objective> · <activity>`, either half optional; undefined when neither). */
113
135
  get(): string | undefined;
114
136
  /**
115
- * Subscribe to publishes: the listener fires after every headful `set` (headless `set`
116
- * calls are full no-ops, so nothing fires). Returns an unsubscribe.
137
+ * Subscribe to publishes: the listener fires after every headful `set`, begin, end and clear
138
+ * (headless calls are full no-ops, so nothing fires). Returns an unsubscribe.
117
139
  */
118
140
  subscribe(listener: () => void): () => void;
119
141
  }
120
142
 
121
143
  /**
122
- * Create the single-value `perk` status handle (one per extension instance — created in index.ts
123
- * and passed to the objective publisher; no hidden module state). Headless calls are full no-ops
124
- * (never record the text, so headless-era text can't resurrect in a later headful render).
125
- * `undefined` clears the slot. No width handling: pi's footer truncates the status line itself.
144
+ * Create the composed `perk` status handle (one per extension instance — created in index.ts
145
+ * and passed to the objective publisher and the activity owners; no hidden module state).
146
+ * Headless calls are full no-ops (never record text, so headless-era text can't resurrect in a
147
+ * later headful render). The activity is a SET of live wait tokens, not a set/clear: the browser
148
+ * doors accept a concurrent double-open, so an older wait's settle must not blank a newer one —
149
+ * the text clears only when the LAST wait ends (the newest live wait's text shows meanwhile, so
150
+ * ending one restores the remainder). An ended or reset token is simply absent, so its late `end`
151
+ * finds nothing to remove and publishes nothing — a wait begun after a reset can never be
152
+ * consumed by a stale `end`. No width handling: pi's footer truncates.
126
153
  */
127
154
  export function createPerkStatus(): PerkStatusHandle {
128
- let value: string | undefined;
155
+ let objective: string | undefined;
156
+ const live = new Set<{ text: string }>(); // insertion-ordered: the last entry is the newest
129
157
  const listeners = new Set<() => void>();
158
+ const compose = (): string | undefined => {
159
+ const activity = [...live].at(-1)?.text;
160
+ const halves = [objective, activity].filter((half) => half !== undefined);
161
+ return halves.length === 0 ? undefined : halves.join(" · ");
162
+ };
163
+ const publish = (target: StandingTarget): void => {
164
+ target.ui.setStatus(STATUS_SLOT_PERK, compose());
165
+ for (const listener of listeners) listener();
166
+ };
130
167
  return {
131
168
  set(target, text) {
132
169
  if (!target.hasUI) return;
133
- value = text;
134
- target.ui.setStatus(STATUS_SLOT_PERK, text);
135
- for (const listener of listeners) listener();
170
+ objective = text;
171
+ publish(target);
172
+ },
173
+ beginActivity(target, text) {
174
+ if (!target.hasUI) return () => {};
175
+ const token = { text };
176
+ live.add(token);
177
+ publish(target);
178
+ return () => {
179
+ // Already ended, or reset by `clearActivity`: nothing to remove, nothing to publish.
180
+ if (!live.delete(token)) return;
181
+ publish(target);
182
+ };
183
+ },
184
+ clearActivity(target) {
185
+ if (!target.hasUI) return;
186
+ live.clear();
187
+ publish(target);
136
188
  },
137
189
  get() {
138
- return value;
190
+ return compose();
139
191
  },
140
192
  subscribe(listener) {
141
193
  listeners.add(listener);
@@ -155,7 +207,7 @@ export function createPerkStatus(): PerkStatusHandle {
155
207
  export interface FooterParts {
156
208
  /** e.g. `perk v0.0.1` — standing identity (D7), dim. */
157
209
  identity: string;
158
- /** The 🎯 objective segment, verbatim (`handle.get()`). */
210
+ /** The composed perk status value (`handle.get()`), verbatim — it carries its own 🎯 mark. */
159
211
  objective?: string;
160
212
  /** Git branch (dim); omitted when not in a repo. */
161
213
  branch?: string;
@@ -329,9 +381,9 @@ export type PerkFooterFactory = (
329
381
  /**
330
382
  * The perk-owned footer factory (charter D2): replaces pi's default footer wholesale with one
331
383
  * line in the intended split layout. `render` gathers everything live per call (D10 stateless
332
- * render): the objective value via the handle, branch/guests via `footerData` (excluding perk's
333
- * own `STATUS_SLOT_PERK` — the slot keeps publishing for RPC, but the footer renders the value
334
- * directly), model/cache/context via the deps closures. Reactivity (the D2 contract): repaints on
384
+ * render): the composed status value via the handle, branch/guests via `footerData` (excluding
385
+ * perk's own `STATUS_SLOT_PERK` — the slot keeps publishing for RPC, but the footer renders the
386
+ * value directly), model/cache/context via the deps closures. Reactivity (the D2 contract): repaints on
335
387
  * every handle recompose and on branch change; `dispose` detaches both. Lifecycle (pi ≥ 0.84,
336
388
  * verified at 0.84.1): `setExtensionFooter` disposes a replaced factory's component, and pi's
337
389
  * `resetExtensionUI` restores the built-in footer (disposing this one) on /reload and before
@@ -412,7 +464,7 @@ export function formatBudgetLine(args: { tokens: number; elapsedMs: number }): s
412
464
  }
413
465
 
414
466
  // --- display-only transcript entry renderers -----------------------------------------------------
415
- // The audit §2.3 verdict (docs/design/pi-adoption-audit.md): perk's custom-entry families are
467
+ // The audit §2.3 verdict (docs/design/archive/pi-adoption-audit.md): perk's custom-entry families are
416
468
  // display-only. Most render as durable one-line transition markers; report detail is the generic
417
469
  // full-diagnostic family and always renders every logical row. Renderer BODIES live here (a
418
470
  // transcript renderer IS a rich-UI surface the surfaces module owns); registration is wiring at the
@@ -1,29 +1,31 @@
1
- // The adversarial-review `WaveSpec`-building entrypoint over the shared report-wave runner — the
2
- // human-in-the-loop review doors' (/pr-review-browser, /pr-review-terminal) vocabulary as tested
3
- // code (sibling of `prReviewWave.ts`): the four door angles, the per-lane completion-report
4
- // schema, and the lane/task composition are module-owned here, launched NON-BLOCKING via
5
- // `startReportWave` so the parent can return from the launch and hold the model-held
6
- // `subagent_wait` relay loop open while the children stream finding batches.
1
+ // The adversarial-review `ReportWaveRequest`-building entrypoint over the shared report-wave
2
+ // module — the human-in-the-loop review doors' (/pr-review-browser, /pr-review-terminal)
3
+ // vocabulary as tested code (sibling of `prReviewWave.ts`): the four door angles, the per-lane
4
+ // completion-report schema, and the lane/task composition are module-owned here, launched
5
+ // NON-BLOCKING via `wave.start` so the parent ends its turn after launch and relays batches
6
+ // on native supervisor wakes before collecting on the matching workflow-completion notice.
7
7
  //
8
8
  // ZERO retries — deliberate: the doors' contract is honest incompleteness surfaced to the human
9
9
  // during triage (an `ok: false` lane is reported, never papered over), so the pr-review
10
10
  // bounded-retry policy does not carry over.
11
11
  //
12
- // The surface handle (URL/port) is STRUCTURALLY UNREPRESENTABLE: `buildAdversarialReviewLanes`
13
- // has no URL parameter at all, so the children can never learn the review surface — enforced by
14
- // construction, pinned by the suite.
12
+ // The surface handle (URL/port) is STRUCTURALLY UNREPRESENTABLE:
13
+ // `buildAdversarialReviewAssignments` has no URL parameter at all, so the children can never
14
+ // learn the review surface — enforced by construction, pinned by the suite.
15
15
  //
16
16
  // Driven live by the registered `start_review_wave` / `collect_review_wave` tool pair
17
- // (`extension/doors/reviewWaveTools.ts`); the `agents/adversarial-reviewer.md` def completes via
17
+ // (`extension/pi/v1/codeReview/reviewWave.ts`); the `agents/adversarial-reviewer.md` def completes via
18
18
  // the `structured_output` tool this wave's `outputSchema` injects per lane.
19
19
 
20
+ import { reclassifyBlockedReports } from "./blockedReports.ts";
20
21
  import { PONYTAIL_REVIEW_SKILL } from "./ponytail.ts";
21
- import {
22
- type ReportWaveStart,
23
- startReportWave,
24
- type WaveAdapter,
25
- type WaveLane,
26
- type WaveSpec,
22
+ import type {
23
+ CollectWaveResult,
24
+ ReportAssignment,
25
+ ReportWave,
26
+ ReportWaveRef,
27
+ ReportWaveRequest,
28
+ StartWaveResult,
27
29
  } from "./reportWave.ts";
28
30
 
29
31
  /** The four-slug adversarial-review angle allowlist (claimed-intent is mandatory at the tool boundary). */
@@ -50,22 +52,29 @@ export function isAdversarialReviewAngle(value: string): value is AdversarialRev
50
52
  * The per-lane completion-report schema the wave enforces as its `outputSchema` — the engine
51
53
  * injects a `structured_output` tool into each lane and fails any lane whose report is missing
52
54
  * or schema-invalid. Transcribes the adversarial-reviewer's completion-report contract
53
- * (contracts.md §8.4): closed shapes, `{angle, summary, findings, fyi}` all required, and
54
- * DELIBERATELY NO VERDICT FIELD — the human triages every finding, so there is no clean/
55
- * actionable derivation to make consistent (hence also no if/then conditional). Finding rows
56
- * anchor candidate GitHub review comments: `line` is required-nullable (a real finding that
57
- * cannot anchor to a diff line keeps `line: null`), `side` optional (omitted RIGHT), and the
58
- * severity/confidence enums match the agent def's triage tags.
55
+ * (contracts.md §8.4): closed shapes, `{angle, summary, findings, fyi, streamed, blocked}` all
56
+ * required, and DELIBERATELY NO VERDICT FIELD — the human triages every finding, so there is no
57
+ * clean/actionable derivation to make consistent. `blocked: true` is NOT a verdict: it marks a
58
+ * required review that could not complete (context fetch failed, a context file unreadable, the
59
+ * hunt stopped early) `collectAdversarialReviewWave` normalizes it into an uncovered
60
+ * `lane-failed` BEFORE coverage is computed, so a schema-valid-but-empty report never counts as
61
+ * "no findings". The conditional mirrors `PR_REVIEW_REPORT_SCHEMA`'s blocked arm: empty
62
+ * `findings` and a nonblank `fyi` (the blocker first). Finding rows anchor candidate GitHub review
63
+ * comments: `line` is required-nullable (a real finding that cannot anchor to a diff line keeps
64
+ * `line: null`), `side` optional (omitted ⇒ RIGHT), and the severity/confidence enums match the
65
+ * agent def's triage tags.
59
66
  */
60
67
  export const ADVERSARIAL_REVIEW_REPORT_SCHEMA = {
61
68
  type: "object",
62
69
  additionalProperties: false,
63
- required: ["angle", "summary", "findings", "fyi"],
70
+ required: ["angle", "summary", "findings", "fyi", "streamed", "blocked"],
64
71
  properties: {
65
72
  angle: {
66
73
  type: "string",
67
74
  enum: ["claimed-intent", "correctness", "tests", "quality", "ponytail"],
68
75
  },
76
+ streamed: { type: "boolean" },
77
+ blocked: { type: "boolean" },
69
78
  summary: { type: "string" },
70
79
  findings: {
71
80
  type: "array",
@@ -88,12 +97,25 @@ export const ADVERSARIAL_REVIEW_REPORT_SCHEMA = {
88
97
  items: { type: "string" },
89
98
  },
90
99
  },
100
+ allOf: [
101
+ {
102
+ // `blocked` is top-level required, so this `if` cannot vacuously match an absent key.
103
+ if: { properties: { blocked: { const: true } } },
104
+ // biome-ignore lint/suspicious/noThenProperty: JSON-Schema conditional, not a thenable.
105
+ then: {
106
+ properties: {
107
+ findings: { maxItems: 0 },
108
+ fyi: { minItems: 1, items: { type: "string", pattern: "\\S" } },
109
+ },
110
+ },
111
+ },
112
+ ],
91
113
  };
92
114
 
93
115
  /**
94
- * Build the reviewer lanes for a selection: key = label = slug, the fixed agent/phase, and a
95
- * task naming the angle, the PR number, and the head-worktree path — AND NOTHING ELSE: no URL
96
- * parameter exists, so the surface handle is unrepresentable by construction (the children
116
+ * Build the reviewer assignments for a selection: key = label = slug, the fixed agent/phase,
117
+ * and a task naming the angle, the PR number, and the head-worktree path — AND NOTHING ELSE: no
118
+ * URL parameter exists, so the surface handle is unrepresentable by construction (the children
97
119
  * re-derive everything else themselves via `perk pr review-context`).
98
120
  *
99
121
  * `stack` is a DISCRIMINATOR, not a member array: with `stack: true` the task names the stack
@@ -101,16 +123,16 @@ export const ADVERSARIAL_REVIEW_REPORT_SCHEMA = {
101
123
  * children learn the authoritative ordered membership from the context worker, never from
102
124
  * relayed prose. Without it, tasks are byte-identical to the single-PR form.
103
125
  */
104
- export function buildAdversarialReviewLanes(opts: {
126
+ export function buildAdversarialReviewAssignments(opts: {
105
127
  angles: AdversarialReviewAngle[];
106
128
  pr: number;
107
129
  worktree: string;
108
130
  directive?: string;
109
131
  stack?: boolean;
110
- }): WaveLane[] {
111
- // ONE uniform suffix on every lane (the `buildPrReviewLanes` byte-posture): the parent's
112
- // judgment lever stays angle selection — the directive never re-scopes a lane, it only sets
113
- // emphasis inside the assigned angle.
132
+ }): ReportAssignment[] {
133
+ // ONE uniform suffix on every assignment (the `buildPrReviewAssignments` byte-posture): the
134
+ // parent's judgment lever stays angle selection — the directive never re-scopes an angle, it
135
+ // only sets emphasis inside the assigned angle.
114
136
  const suffix =
115
137
  opts.directive === undefined
116
138
  ? ""
@@ -121,14 +143,14 @@ export function buildAdversarialReviewLanes(opts: {
121
143
  ? `Review the PR stack topped by PR #${opts.pr} (combined diff) at ${opts.worktree}. ` +
122
144
  `Fetch context with \`perk pr review-context --pr ${opts.pr} --stack\`.`
123
145
  : `Review PR #${opts.pr} at ${opts.worktree}.`;
124
- const lanes: WaveLane[] = opts.angles.map((angle) => ({
146
+ const assignments: ReportAssignment[] = opts.angles.map((angle) => ({
125
147
  key: angle,
126
148
  label: angle,
127
149
  agent: "perk.adversarial-reviewer",
128
150
  phase: "review",
129
151
  task: `${ADVERSARIAL_REVIEW_ANGLES[angle]} ${subject}${suffix}`,
130
152
  }));
131
- lanes.push({
153
+ assignments.push({
132
154
  key: "ponytail",
133
155
  label: "ponytail",
134
156
  agent: "perk.adversarial-reviewer",
@@ -137,7 +159,7 @@ export function buildAdversarialReviewLanes(opts: {
137
159
  skill: "ponytail-review",
138
160
  requiredSkill: PONYTAIL_REVIEW_SKILL,
139
161
  });
140
- return lanes;
162
+ return assignments;
141
163
  }
142
164
 
143
165
  export interface AdversarialReviewWaveOptions {
@@ -157,25 +179,44 @@ export interface AdversarialReviewWaveOptions {
157
179
  /** Accepted for parity/tests only — the flow tool deliberately never threads its own signal. */
158
180
  signal?: AbortSignal;
159
181
  /** Test seam; production validates the exact source-bound Ponytail review skill. */
160
- requiredSkillPreflight?: WaveSpec["requiredSkillPreflight"];
182
+ requiredSkillPreflight?: ReportWaveRequest["requiredSkillPreflight"];
183
+ }
184
+
185
+ // Only the typed flag classifies an adversarial report as blocked; diagnostic prose never does.
186
+ function isBlockedFlag(report: Record<string, unknown>): boolean {
187
+ return report.blocked === true;
188
+ }
189
+
190
+ /**
191
+ * Collect the adversarial-review wave and apply the flow's lane semantics: a settled result has
192
+ * every `blocked: true` report reclassified into an uncovered assignment-keyed `lane-failed`
193
+ * (`reclassifyBlockedReports` — the same detail string as `prReviewWave.ts`) BEFORE the caller
194
+ * computes `covered`/`complete`; `none`/`running` pass through unchanged. The report stays
195
+ * verdict-free — only coverage changes.
196
+ */
197
+ export async function collectAdversarialReviewWave(
198
+ wave: ReportWave,
199
+ ref: ReportWaveRef,
200
+ ): Promise<CollectWaveResult> {
201
+ const collected = await wave.collect(ref);
202
+ if (collected.kind !== "settled") return collected;
203
+ return { ...collected, result: reclassifyBlockedReports(collected.result, isBlockedFlag) };
161
204
  }
162
205
 
163
206
  /**
164
- * Start the adversarial-review wave NON-BLOCKING (the streaming sibling): build the lanes from
165
- * the angle vocabulary and launch under the strict completeness policy — zero retries, so an
166
- * uncovered angle stays an honest, human-visible incompleteness. Returns the `startReportWave`
167
- * outcome: the run handle + never-rejecting `result` on success, or the normalized launch
168
- * failure.
207
+ * Start the adversarial-review wave NON-BLOCKING (the streaming split): build the assignments
208
+ * from the angle vocabulary and launch under the strict completeness policy — zero retries, so an
209
+ * uncovered angle stays an honest, human-visible incompleteness. Returns the `wave.start`
210
+ * outcome: the opaque ref + identity telemetry on success, or the normalized launch failure.
169
211
  */
170
212
  export async function startAdversarialReviewWave(
171
- adapter: WaveAdapter,
213
+ wave: ReportWave,
172
214
  opts: AdversarialReviewWaveOptions,
173
- ): Promise<ReportWaveStart> {
174
- return await startReportWave(
175
- adapter,
215
+ ): Promise<StartWaveResult> {
216
+ return await wave.start(
176
217
  {
177
218
  flow: "adversarial-review",
178
- lanes: buildAdversarialReviewLanes({
219
+ assignments: buildAdversarialReviewAssignments({
179
220
  angles: opts.angles,
180
221
  pr: opts.pr,
181
222
  worktree: opts.worktree,
@@ -190,6 +231,6 @@ export async function startAdversarialReviewWave(
190
231
  ? { requiredSkillPreflight: opts.requiredSkillPreflight }
191
232
  : {}),
192
233
  },
193
- opts.signal,
234
+ { signal: opts.signal },
194
235
  );
195
236
  }