@mgiles/perk 3.1.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 (211) 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 +369 -359
  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 -310
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
  52. package/extension/pi/v1/codeReview/stack.ts +606 -0
  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 +163 -265
  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 +128 -60
  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/{doors → pi/v1}/lifecycleGates.ts +14 -59
  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/pi/v1/objectivePlanning.ts +776 -0
  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 +152 -34
  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 -19
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/paths.ts +2 -7
  115. package/extension/substrate/prompts.ts +22 -0
  116. package/extension/substrate/registry.ts +2 -0
  117. package/extension/substrate/resolverLease.ts +364 -0
  118. package/extension/substrate/sessionData.ts +85 -152
  119. package/extension/substrate/toolGating.ts +279 -84
  120. package/extension/substrate/unifiedDiff.ts +1 -1
  121. package/extension/substrate/workflowState.ts +191 -166
  122. package/extension/substrate/worktreeResolverLock.ts +261 -0
  123. package/extension/surfaces/surfaces.ts +79 -27
  124. package/extension/waves/adversarialReviewWave.ts +103 -48
  125. package/extension/waves/blockedReports.ts +59 -0
  126. package/extension/waves/draftReviewWave.ts +42 -42
  127. package/extension/waves/laneIdentity.ts +77 -0
  128. package/extension/waves/objectiveExplorerWave.ts +24 -24
  129. package/extension/waves/prReviewWave.ts +89 -77
  130. package/extension/waves/reportWave.ts +438 -578
  131. package/extension/waves/reviewClassifierWave.ts +22 -22
  132. package/extension/waves/rpcAdapter.ts +100 -15
  133. package/extension/waves/scoutWave.ts +192 -0
  134. package/extension/waves/transport.ts +480 -0
  135. package/extension/worker/sdkAdapter.ts +494 -0
  136. package/extension/worker/stageExecution.ts +679 -0
  137. package/extension/workerMain.ts +18 -19
  138. package/package.json +6 -4
  139. package/prompts/_fixtures/live.yaml +98 -10
  140. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  142. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  143. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  144. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  145. package/prompts/contexts/objective-refinement.md +17 -0
  146. package/prompts/contexts/plan-authoring.md +6 -5
  147. package/prompts/contexts/read-only.md +1 -1
  148. package/prompts/stages/conflict-resolution-continuation.md +9 -0
  149. package/prompts/stages/conflict-resolution.md +4 -4
  150. package/prompts/stages/objective-author/adopt.md +1 -1
  151. package/prompts/stages/objective-author/file.md +1 -1
  152. package/prompts/stages/objective-author/seed.md +1 -1
  153. package/prompts/stages/objective-plan/guidance.md +2 -2
  154. package/prompts/stages/objective-plan/seed.md +9 -1
  155. package/prompts/stages/objective-reconcile-ready.md +7 -0
  156. package/prompts/stages/objective-reconcile.md +1 -1
  157. package/prompts/stages/objective-refine/seed.md +18 -0
  158. package/prompts/stages/objective-review-browser.md +4 -4
  159. package/prompts/stages/objective-sync.md +1 -1
  160. package/prompts/stages/plan-review-browser.md +4 -4
  161. package/prompts/stages/pr-review-browser/active.md +3 -4
  162. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  163. package/prompts/stages/pr-review-terminal/active.md +3 -3
  164. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  165. package/prompts/stages/pr-review.md +3 -3
  166. package/prompts/stages/stack-review/cold.md +1 -0
  167. package/prompts/stages/stack-review-browser/stack.md +22 -0
  168. package/shared/README.md +8 -3
  169. package/shared/bindings.yaml +6 -3
  170. package/shared/contracts.md +4499 -2147
  171. package/shared/fixtures/issues-table.json +130 -0
  172. package/shared/registry.yaml +29 -1
  173. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  174. package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
  175. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  176. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  177. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  178. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  179. package/extension/doors/auditWaveTools.ts +0 -352
  180. package/extension/doors/ciExecutor.ts +0 -756
  181. package/extension/doors/commitCompact.ts +0 -251
  182. package/extension/doors/dreamWaveTools.ts +0 -475
  183. package/extension/doors/learn.ts +0 -655
  184. package/extension/doors/objectiveStack.ts +0 -1143
  185. package/extension/doors/prReviewDynamic.ts +0 -276
  186. package/extension/doors/ready.ts +0 -87
  187. package/extension/doors/submit.ts +0 -347
  188. package/extension/doors/submitPrReview.ts +0 -408
  189. package/extension/factories/gistAuthor.ts +0 -94
  190. package/extension/factories/gistDraft.ts +0 -265
  191. package/extension/factories/gistSave.ts +0 -251
  192. package/extension/factories/implementHere.ts +0 -116
  193. package/extension/factories/objectiveAuthor.ts +0 -98
  194. package/extension/factories/objectiveDraft.ts +0 -466
  195. package/extension/factories/objectivePlan.ts +0 -975
  196. package/extension/factories/objectiveSave.ts +0 -363
  197. package/extension/factories/planDraft.ts +0 -140
  198. package/extension/factories/planMode.ts +0 -205
  199. package/extension/factories/planReview.ts +0 -1237
  200. package/extension/factories/planSave.ts +0 -604
  201. package/extension/factories/planTitle.ts +0 -141
  202. package/extension/substrate/structuredOutput.ts +0 -202
  203. package/extension/waves/auditWave.ts +0 -312
  204. package/extension/waves/harvestWave.ts +0 -399
  205. package/extension/waves/learnWave.ts +0 -155
  206. package/extension/waves/memoryAdapter.ts +0 -139
  207. package/extension/waves/prReviewDynamicWave.ts +0 -777
  208. package/extension/worker/readOnlySession.ts +0 -294
  209. package/extension/worker/worker.ts +0 -899
  210. package/prompts/stages/pr-review-dynamic.md +0 -7
  211. package/shared/contracts-history.md +0 -605
@@ -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