harnery 0.37.0 → 0.38.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 (184) hide show
  1. package/dist/commander.d.ts +9 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +7 -2
  4. package/dist/commands/admission.d.ts +21 -0
  5. package/dist/commands/admission.d.ts.map +1 -0
  6. package/dist/commands/admission.js +565 -0
  7. package/dist/commands/agents.d.ts +7 -0
  8. package/dist/commands/agents.d.ts.map +1 -1
  9. package/dist/commands/agents.js +61 -1
  10. package/dist/commands/artifacts.d.ts.map +1 -1
  11. package/dist/commands/artifacts.js +48 -2
  12. package/dist/commands/browse-ai.d.ts +2 -2
  13. package/dist/commands/browse-ai.d.ts.map +1 -1
  14. package/dist/commands/browse-ai.js +6 -4
  15. package/dist/commands/browse.d.ts.map +1 -1
  16. package/dist/commands/browse.js +349 -21
  17. package/dist/commands/fetch.js +1 -0
  18. package/dist/commands/qa-record.d.ts +144 -0
  19. package/dist/commands/qa-record.d.ts.map +1 -0
  20. package/dist/commands/qa-record.js +0 -0
  21. package/dist/commands/qa-run.d.ts +7 -3
  22. package/dist/commands/qa-run.d.ts.map +1 -1
  23. package/dist/commands/qa-run.js +268 -13
  24. package/dist/commands/qa-status.d.ts +71 -0
  25. package/dist/commands/qa-status.d.ts.map +1 -0
  26. package/dist/commands/qa-status.js +490 -0
  27. package/dist/commands/qa-verify.d.ts +40 -0
  28. package/dist/commands/qa-verify.d.ts.map +1 -0
  29. package/dist/commands/qa-verify.js +180 -0
  30. package/dist/commands/review-pack.d.ts +4 -0
  31. package/dist/commands/review-pack.d.ts.map +1 -0
  32. package/dist/commands/review-pack.js +1001 -0
  33. package/dist/core/agents/qa-signal.d.ts +111 -0
  34. package/dist/core/agents/qa-signal.d.ts.map +1 -0
  35. package/dist/core/agents/qa-signal.js +231 -0
  36. package/dist/core/agents/session-name-display.d.ts +20 -5
  37. package/dist/core/agents/session-name-display.d.ts.map +1 -1
  38. package/dist/core/agents/session-name-display.js +67 -7
  39. package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
  40. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  41. package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
  42. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  43. package/dist/core/agents/state/heartbeat-writer.js +17 -0
  44. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  45. package/dist/core/agents/state/live-coordination-view.js +1 -0
  46. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  47. package/dist/core/artifacts/constants.d.ts +1 -1
  48. package/dist/core/artifacts/constants.js +1 -1
  49. package/dist/core/artifacts/index.d.ts +57 -6
  50. package/dist/core/artifacts/index.d.ts.map +1 -1
  51. package/dist/core/artifacts/index.js +265 -10
  52. package/dist/core/config.d.ts +8 -0
  53. package/dist/core/config.d.ts.map +1 -1
  54. package/dist/core/config.js +16 -0
  55. package/dist/core/diagnostics/bundle.d.ts +16 -0
  56. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  57. package/dist/core/diagnostics/bundle.js +101 -7
  58. package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
  59. package/dist/core/events/v3/bootstrap.js +10 -0
  60. package/dist/core/events/v3/coordination-view.d.ts +3 -0
  61. package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
  62. package/dist/core/events/v3/coordination-view.js +69 -8
  63. package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
  64. package/dist/core/events/v3/producers/intake.js +9 -2
  65. package/dist/core/events/v3/producers/recorder.d.ts +23 -0
  66. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  67. package/dist/core/events/v3/producers/recorder.js +264 -18
  68. package/dist/core/hooks/cli.js +120 -27
  69. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  70. package/dist/core/hooks/resolve/transcript.js +10 -3
  71. package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
  72. package/dist/core/hooks/session-name-presence.js +4 -1
  73. package/dist/core/qa-artifacts.d.ts +20 -0
  74. package/dist/core/qa-artifacts.d.ts.map +1 -0
  75. package/dist/core/qa-artifacts.js +110 -0
  76. package/dist/core/resources/contract.d.ts +6 -0
  77. package/dist/core/resources/contract.d.ts.map +1 -1
  78. package/dist/core/resources/sampler.d.ts +7 -0
  79. package/dist/core/resources/sampler.d.ts.map +1 -1
  80. package/dist/core/resources/sampler.js +106 -5
  81. package/dist/lib/admission.d.ts +71 -0
  82. package/dist/lib/admission.d.ts.map +1 -0
  83. package/dist/lib/admission.js +264 -0
  84. package/dist/lib/agent-browser/client.d.ts +1 -1
  85. package/dist/lib/agent-browser/client.d.ts.map +1 -1
  86. package/dist/lib/agent-browser/client.js +1 -5
  87. package/dist/lib/browser/capture-fidelity.d.ts +39 -0
  88. package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
  89. package/dist/lib/browser/capture-fidelity.js +84 -0
  90. package/dist/lib/browser/client.d.ts +41 -1
  91. package/dist/lib/browser/client.d.ts.map +1 -1
  92. package/dist/lib/browser/client.js +167 -9
  93. package/dist/lib/browser/critique.d.ts +38 -1
  94. package/dist/lib/browser/critique.d.ts.map +1 -1
  95. package/dist/lib/browser/critique.js +34 -6
  96. package/dist/lib/browser/index.d.ts +4 -2
  97. package/dist/lib/browser/index.d.ts.map +1 -1
  98. package/dist/lib/browser/index.js +2 -0
  99. package/dist/lib/browser/page-review-judge.d.ts +64 -0
  100. package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
  101. package/dist/lib/browser/page-review-judge.js +270 -0
  102. package/dist/lib/browser/page-review-pack.d.ts +613 -0
  103. package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
  104. package/dist/lib/browser/page-review-pack.js +1751 -0
  105. package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
  106. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  107. package/dist/lib/browser/qa-run-contracts.js +136 -1
  108. package/dist/lib/browser/qa-run.d.ts +100 -10
  109. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  110. package/dist/lib/browser/qa-run.js +768 -169
  111. package/dist/lib/browser/request-diagnostics.d.ts +13 -0
  112. package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
  113. package/dist/lib/browser/request-diagnostics.js +18 -0
  114. package/dist/lib/browser/tiling.d.ts +19 -0
  115. package/dist/lib/browser/tiling.d.ts.map +1 -1
  116. package/dist/lib/browser/tiling.js +28 -0
  117. package/dist/lib/cookies/client.d.ts +9 -0
  118. package/dist/lib/cookies/client.d.ts.map +1 -1
  119. package/dist/lib/cookies/client.js +197 -44
  120. package/dist/lib/cookies/extra.d.ts +18 -0
  121. package/dist/lib/cookies/extra.d.ts.map +1 -0
  122. package/dist/lib/cookies/extra.js +14 -0
  123. package/dist/lib/cookies/index.d.ts +2 -1
  124. package/dist/lib/cookies/index.d.ts.map +1 -1
  125. package/dist/lib/cookies/index.js +2 -1
  126. package/dist/lib/durable-job.d.ts +124 -0
  127. package/dist/lib/durable-job.d.ts.map +1 -0
  128. package/dist/lib/durable-job.js +296 -0
  129. package/dist/lib/http/client.d.ts +7 -1
  130. package/dist/lib/http/client.d.ts.map +1 -1
  131. package/dist/lib/http/client.js +2 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +5 -2
  134. package/package.json +8 -2
  135. package/src/commander.ts +50 -2
  136. package/src/commands/admission.ts +699 -0
  137. package/src/commands/agents.ts +87 -1
  138. package/src/commands/artifacts.ts +97 -21
  139. package/src/commands/browse-ai.ts +10 -5
  140. package/src/commands/browse.ts +481 -20
  141. package/src/commands/fetch.ts +1 -0
  142. package/src/commands/qa-record.ts +682 -0
  143. package/src/commands/qa-run.ts +335 -16
  144. package/src/commands/qa-status.ts +608 -0
  145. package/src/commands/qa-verify.ts +238 -0
  146. package/src/commands/review-pack.ts +1281 -0
  147. package/src/core/agents/qa-signal.ts +261 -0
  148. package/src/core/agents/session-name-display.ts +78 -7
  149. package/src/core/agents/state/heartbeat-reader.ts +7 -0
  150. package/src/core/agents/state/heartbeat-writer.ts +23 -0
  151. package/src/core/agents/state/live-coordination-view.ts +1 -0
  152. package/src/core/agents/state/live-coordination-writer.ts +5 -0
  153. package/src/core/artifacts/constants.ts +1 -1
  154. package/src/core/artifacts/index.ts +370 -21
  155. package/src/core/config.ts +23 -0
  156. package/src/core/diagnostics/bundle.ts +119 -11
  157. package/src/core/events/v3/bootstrap.ts +10 -0
  158. package/src/core/events/v3/coordination-view.ts +100 -11
  159. package/src/core/events/v3/producers/intake.ts +9 -2
  160. package/src/core/events/v3/producers/recorder.ts +312 -18
  161. package/src/core/hooks/cli.ts +140 -32
  162. package/src/core/hooks/resolve/transcript.ts +10 -3
  163. package/src/core/hooks/session-name-presence.ts +6 -1
  164. package/src/core/qa-artifacts.ts +126 -0
  165. package/src/core/resources/contract.ts +7 -0
  166. package/src/core/resources/sampler.ts +138 -6
  167. package/src/lib/admission.ts +347 -0
  168. package/src/lib/agent-browser/client.ts +2 -10
  169. package/src/lib/browser/capture-fidelity.ts +98 -0
  170. package/src/lib/browser/client.ts +206 -10
  171. package/src/lib/browser/critique.ts +62 -7
  172. package/src/lib/browser/index.ts +36 -0
  173. package/src/lib/browser/page-review-judge.ts +360 -0
  174. package/src/lib/browser/page-review-pack.ts +2384 -0
  175. package/src/lib/browser/qa-run-contracts.ts +366 -3
  176. package/src/lib/browser/qa-run.ts +868 -190
  177. package/src/lib/browser/request-diagnostics.ts +27 -0
  178. package/src/lib/browser/tiling.ts +32 -0
  179. package/src/lib/cookies/client.ts +228 -42
  180. package/src/lib/cookies/extra.ts +28 -0
  181. package/src/lib/cookies/index.ts +2 -0
  182. package/src/lib/durable-job.ts +407 -0
  183. package/src/lib/http/client.ts +13 -1
  184. package/src/lib/instructions/templates.ts +5 -2
@@ -0,0 +1,1281 @@
1
+ // `review-pack create <target>` / `review-pack judge <dir>`: build a page
2
+ // review pack (tiles, DOM, signature per rendering context, plus review.md)
3
+ // and judge one from disk through one bounded pool of vision calls. qa-run
4
+ // composes the same two library halves; this command exposes them directly
5
+ // so an agent can prepare a page for review, or review a pack, on its own.
6
+
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import type { Command } from "commander";
10
+ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
11
+ import { artifactsRoot } from "../core/artifacts/index.ts";
12
+ import { resolveBinName, reviewPackAutoCleanEnabled } from "../core/config.ts";
13
+ import { createManagedQaOutParent, resolveQaRepoRoot } from "../core/qa-artifacts.ts";
14
+ import { DEFAULT_CRITIQUE_RUBRIC } from "../lib/browser/critique.ts";
15
+ import { judgePageReviewPack, toCritiqueRecords } from "../lib/browser/page-review-judge.ts";
16
+ import {
17
+ aggregateMachineOutcome,
18
+ deleteExpiredPacks,
19
+ expandedTileFilename,
20
+ finalizePageReviewPack,
21
+ findPackTile,
22
+ findPageReviewPacks,
23
+ isPackDeletable,
24
+ listPackContexts,
25
+ machineFindingTargets,
26
+ PAGE_REVIEW_DEFAULT_RETENTION_MINUTES,
27
+ PAGE_REVIEW_DISPOSITIONS,
28
+ PAGE_REVIEW_FINDING_CATEGORIES,
29
+ PAGE_REVIEW_FINDING_SEVERITIES,
30
+ PAGE_REVIEW_SUBAGENT_MODELS,
31
+ PAGE_REVIEW_VERDICT_SCHEMA,
32
+ type PageReviewContextRecord,
33
+ type PageReviewDisposition,
34
+ type PageReviewFindingCategory,
35
+ type PageReviewFindingSeverity,
36
+ type PageReviewFindingsDocument,
37
+ type PageReviewPackManifest,
38
+ type PageReviewPackRow,
39
+ type PageReviewRetention,
40
+ type PageReviewVerdictDocument,
41
+ packPaths,
42
+ readPackContext,
43
+ readPackFindings,
44
+ readPackInspectionPlan,
45
+ readPackManifest,
46
+ readPackVerdict,
47
+ resolvePackVerdict,
48
+ validateFindingsDocument,
49
+ writePackFindings,
50
+ } from "../lib/browser/page-review-pack.ts";
51
+ import { defaultQaRunExec, QA_RUN_HEADLESS_ONLY_ENV } from "../lib/browser/qa-run.ts";
52
+ import { contextIdFor, type QaRunContext } from "../lib/browser/qa-run-contracts.ts";
53
+
54
+ interface CreateOpts {
55
+ out?: string;
56
+ context?: string[];
57
+ scope?: string[];
58
+ noBands?: boolean;
59
+ maxTiles?: string;
60
+ concurrency?: string;
61
+ timeout?: string;
62
+ retain?: string;
63
+ json?: boolean;
64
+ }
65
+
66
+ interface JudgeOpts {
67
+ pool?: string;
68
+ allowMetered?: boolean;
69
+ context?: string[];
70
+ retain?: string;
71
+ json?: boolean;
72
+ }
73
+
74
+ interface CleanOpts {
75
+ root?: string;
76
+ yes?: boolean;
77
+ includeUnmanaged?: boolean;
78
+ json?: boolean;
79
+ }
80
+
81
+ interface ListOpts {
82
+ root?: string;
83
+ json?: boolean;
84
+ }
85
+
86
+ interface PackListRow {
87
+ dir: string;
88
+ target: string;
89
+ created_at: string;
90
+ age_minutes: number;
91
+ size_bytes: number | null;
92
+ contexts: number;
93
+ tiles: number;
94
+ machine_outcome: string | null;
95
+ reviewed_outcome: string | null;
96
+ expires_at: string | null;
97
+ managed: boolean | null;
98
+ expired: boolean;
99
+ }
100
+
101
+ function packListRow(
102
+ dir: string,
103
+ manifest: ReturnType<typeof readPackManifest>,
104
+ verdict: ReturnType<typeof readPackVerdict>,
105
+ nowMs: number,
106
+ ): PackListRow {
107
+ const expiresAt = manifest.retention?.expires_at ?? null;
108
+ const expiresMs = expiresAt === null ? Number.NaN : Date.parse(expiresAt);
109
+ return {
110
+ dir,
111
+ target: manifest.target,
112
+ created_at: manifest.created_at,
113
+ age_minutes: Math.max(0, Math.round((nowMs - Date.parse(manifest.created_at)) / 60_000)),
114
+ size_bytes: typeof manifest.size_bytes === "number" ? manifest.size_bytes : null,
115
+ contexts: manifest.contexts.length,
116
+ tiles: manifest.contexts.reduce((sum, ctx) => sum + ctx.tiles.length, 0),
117
+ machine_outcome: aggregateMachineOutcome(manifest.critique),
118
+ reviewed_outcome: verdict?.reviewed_outcome ?? null,
119
+ expires_at: expiresAt,
120
+ managed: typeof manifest.retention?.managed === "boolean" ? manifest.retention.managed : null,
121
+ expired: Number.isFinite(expiresMs) && expiresMs <= nowMs,
122
+ };
123
+ }
124
+
125
+ function packListLine(row: PackListRow): string {
126
+ const age =
127
+ row.age_minutes < 120 ? `${row.age_minutes}m` : `${Math.round(row.age_minutes / 60)}h`;
128
+ const expiry = row.expires_at
129
+ ? row.expired
130
+ ? `expired${row.managed === false ? " (unmanaged)" : ""}`
131
+ : row.expires_at
132
+ : "no retention";
133
+ return (
134
+ `${age.padStart(6)} ${formatBytes(row.size_bytes).padStart(9)} ${String(row.contexts).padStart(3)} ` +
135
+ `${String(row.tiles).padStart(5)} ${(row.machine_outcome ?? "-").padEnd(10)} ` +
136
+ `${(row.reviewed_outcome ?? "-").padEnd(10)} ${expiry.padEnd(26)} ${row.dir}`
137
+ );
138
+ }
139
+
140
+ interface ExpandOpts {
141
+ tile?: string;
142
+ context?: string;
143
+ dpr?: string;
144
+ timeout?: string;
145
+ json?: boolean;
146
+ }
147
+
148
+ interface FindingsAddOpts {
149
+ context?: string;
150
+ tile?: string[];
151
+ severity?: string;
152
+ category?: string;
153
+ observation?: string;
154
+ recommendation?: string;
155
+ id?: string;
156
+ reviewer?: string;
157
+ json?: boolean;
158
+ }
159
+
160
+ interface ReviewAddOpts {
161
+ reviewer?: string;
162
+ model?: string;
163
+ assigned?: string[];
164
+ completed?: string[];
165
+ status?: string;
166
+ json?: boolean;
167
+ }
168
+
169
+ interface DispositionOpts {
170
+ note?: string;
171
+ by?: string;
172
+ json?: boolean;
173
+ }
174
+
175
+ interface VerdictOpts {
176
+ json?: boolean;
177
+ }
178
+
179
+ /** Next free review-subagent finding id in the `F001` series. */
180
+ function nextFindingId(findings: ReadonlyArray<{ id: string }>): string {
181
+ let max = 0;
182
+ for (const finding of findings) {
183
+ const match = /^F(\d+)$/.exec(finding.id);
184
+ if (match) max = Math.max(max, Number.parseInt(match[1] ?? "0", 10));
185
+ }
186
+ return `F${String(max + 1).padStart(3, "0")}`;
187
+ }
188
+
189
+ /** Read the manifest and findings of a pack, or emit one error and return undefined. */
190
+ function readReviewerPack(
191
+ dir: string,
192
+ emit: EmitContext,
193
+ ):
194
+ | { packDir: string; manifest: PageReviewPackManifest; findings: PageReviewFindingsDocument }
195
+ | undefined {
196
+ const packDir = resolve(dir);
197
+ let manifest: PageReviewPackManifest;
198
+ try {
199
+ manifest = readPackManifest(packDir);
200
+ } catch (err: unknown) {
201
+ emit.error({
202
+ code: "review_pack_unreadable",
203
+ message: `not a page review pack: ${err instanceof Error ? err.message : String(err)}`,
204
+ });
205
+ process.exitCode = 1;
206
+ return undefined;
207
+ }
208
+ let findings: PageReviewFindingsDocument;
209
+ try {
210
+ findings = readPackFindings(packDir);
211
+ } catch (err: unknown) {
212
+ emit.error({
213
+ code: "review_pack_findings_unreadable",
214
+ message: `findings.json unreadable: ${err instanceof Error ? err.message : String(err)}`,
215
+ });
216
+ process.exitCode = 1;
217
+ return undefined;
218
+ }
219
+ return { packDir, manifest, findings };
220
+ }
221
+
222
+ /** Validate then write findings.json; on violations emit every one and return false. */
223
+ function commitFindings(
224
+ packDir: string,
225
+ doc: PageReviewFindingsDocument,
226
+ emit: EmitContext,
227
+ ): boolean {
228
+ const errors = validateFindingsDocument(doc);
229
+ if (errors.length > 0) {
230
+ for (const error of errors) emit.log(`findings.json: ${error}`, "error");
231
+ emit.error({
232
+ code: "review_pack_findings_invalid",
233
+ message: `${errors.length} validation error(s); findings.json was not written`,
234
+ });
235
+ process.exitCode = 1;
236
+ return false;
237
+ }
238
+ writePackFindings(packDir, doc);
239
+ return true;
240
+ }
241
+
242
+ /** Rewrite review.md and the evidence files from the manifest on disk, the way `judge` does. */
243
+ function refreshPackFromManifest(packDir: string, manifest: PageReviewPackManifest): void {
244
+ finalizePageReviewPack({
245
+ packDir,
246
+ target: manifest.target,
247
+ ...(manifest.tested_revision !== undefined
248
+ ? { tested_revision: manifest.tested_revision }
249
+ : {}),
250
+ contexts: manifest.contexts,
251
+ gates: manifest.gates,
252
+ critique: manifest.critique,
253
+ ...(manifest.pool ? { pool: manifest.pool } : {}),
254
+ warnings: manifest.warnings.filter((w) => !w.includes("tile cap reached")),
255
+ createdAt: manifest.created_at,
256
+ judgeCommand: judgeCommandFor(packDir),
257
+ });
258
+ }
259
+
260
+ const DEFAULT_CONTEXTS: QaRunContext[] = [
261
+ { id: "desktop-light-default", viewport: "desktop", theme: "light", state: "default" },
262
+ { id: "mobile-light-default", viewport: "mobile", theme: "light", state: "default" },
263
+ { id: "desktop-dark-default", viewport: "desktop", theme: "dark", state: "default" },
264
+ { id: "mobile-dark-default", viewport: "mobile", theme: "dark", state: "default" },
265
+ ];
266
+
267
+ /** `<viewport>:<theme>:<state>` → context; theme and state default. */
268
+ function parseContext(spec: string): QaRunContext {
269
+ const [viewport, themeRaw = "light", state = "default"] = spec.split(":");
270
+ if (!viewport) {
271
+ throw new Error(
272
+ `invalid --context ${JSON.stringify(spec)}: expected <viewport>[:<theme>[:<state>]]`,
273
+ );
274
+ }
275
+ if (themeRaw !== "light" && themeRaw !== "dark") {
276
+ throw new Error(`invalid --context ${JSON.stringify(spec)}: theme must be light or dark`);
277
+ }
278
+ const theme: "light" | "dark" = themeRaw;
279
+ const base = { viewport, theme, state };
280
+ return { id: contextIdFor(base), ...base };
281
+ }
282
+
283
+ function judgeCommandFor(packDir: string): string {
284
+ return `${resolveBinName()} review-pack judge ${packDir}`;
285
+ }
286
+
287
+ /** `--retain <minutes>` → minutes, or an error message. */
288
+ function parseRetainMinutes(raw: string | undefined): number | { error: string } {
289
+ if (raw === undefined) return PAGE_REVIEW_DEFAULT_RETENTION_MINUTES;
290
+ const n = Number.parseInt(raw, 10);
291
+ if (!Number.isInteger(n) || n < 1 || n > 43_200) {
292
+ return { error: "--retain must be an integer between 1 and 43200 (minutes)" };
293
+ }
294
+ return n;
295
+ }
296
+
297
+ /** Retention that ends `minutes` from now. */
298
+ function retentionFromNow(minutes: number, managed: boolean): PageReviewRetention {
299
+ return { expires_at: new Date(Date.now() + minutes * 60_000).toISOString(), managed };
300
+ }
301
+
302
+ /** Delete expired managed packs from the artifact store when
303
+ * `review_pack.auto_clean` is on; a failed sweep is a warning, never a blocker. */
304
+ function sweepExpiredPacksIfEnabled(repoRoot: string, emit: EmitContext): void {
305
+ if (!reviewPackAutoCleanEnabled(repoRoot)) return;
306
+ try {
307
+ const swept = deleteExpiredPacks({ roots: [artifactsRoot(repoRoot)], dryRun: false });
308
+ if (swept.deleted.length > 0) {
309
+ emit.log(
310
+ `removed ${swept.deleted.length} expired page review pack${swept.deleted.length === 1 ? "" : "s"}`,
311
+ "info",
312
+ );
313
+ }
314
+ } catch (err: unknown) {
315
+ emit.log(
316
+ `expired pack sweep skipped: ${err instanceof Error ? err.message : String(err)}`,
317
+ "warn",
318
+ );
319
+ }
320
+ }
321
+
322
+ function formatBytes(bytes: number | null): string {
323
+ if (bytes === null) return "-";
324
+ if (bytes < 1024) return `${bytes} B`;
325
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
326
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
327
+ }
328
+
329
+ function packRowLine(row: PageReviewPackRow, includeUnmanaged: boolean): string {
330
+ const state = isPackDeletable(row, includeUnmanaged)
331
+ ? "delete"
332
+ : row.expired
333
+ ? row.managed === false
334
+ ? "expired (unmanaged, kept)"
335
+ : "expired (no retention, kept)"
336
+ : row.expires_at
337
+ ? `expires ${row.expires_at}`
338
+ : "no retention";
339
+ return `${state.padEnd(28)} ${formatBytes(row.size_bytes).padStart(9)} ${row.dir} ${row.target}`;
340
+ }
341
+
342
+ export function registerReviewPackCommand(
343
+ program: Command,
344
+ emit: EmitContext,
345
+ context?: HarneryProgramContext,
346
+ ): void {
347
+ const root = program
348
+ .command("review-pack")
349
+ .description(
350
+ "Page review packs: capture a page's rendering contexts (tiles, DOM, signature) " +
351
+ "into a directory an agent can review without a browser, and judge a pack from " +
352
+ "disk through one bounded pool of vision calls.",
353
+ );
354
+
355
+ root
356
+ .command("create <target>")
357
+ .description(
358
+ "Render each context once through child browse processes into <out>/contexts/<id>/, " +
359
+ "closing every browser before any vision call, then write review.md, the inspection " +
360
+ "plan, coverage, and a findings.json skeleton. Judge afterwards with `review-pack judge`.",
361
+ )
362
+ .option("--out <dir>", "Pack directory (default: a managed .harnery/artifacts workspace).")
363
+ .option(
364
+ "--context <spec>",
365
+ "Rendering context <viewport>[:<theme>[:<state>]] (repeatable; default desktop and " +
366
+ "mobile in light and dark).",
367
+ (value: string, previous: string[] = []) => [...previous, value],
368
+ [] as string[],
369
+ )
370
+ .option(
371
+ "--scope <selector>",
372
+ "Also tile one screenshot per element matching <selector> (repeatable).",
373
+ (value: string, previous: string[] = []) => [...previous, value],
374
+ [] as string[],
375
+ )
376
+ .option("--no-bands", "Keep only --scope tiles; skip full-page bands.")
377
+ .option(
378
+ "--max-tiles <n>",
379
+ "Full-page band cap per context (browse --check-critique-max-tiles).",
380
+ )
381
+ .option("--concurrency <n>", "Concurrent capture browsers (1-8; default 2).", "2")
382
+ .option("--timeout <ms>", "Per-capture timeout in milliseconds (default 120000).", "120000")
383
+ .option(
384
+ "--retain <minutes>",
385
+ `Minutes the pack lives before the whole directory is deleted (1-43200; default ` +
386
+ `${PAGE_REVIEW_DEFAULT_RETENTION_MINUTES}). A later judge restarts the clock. A pack ` +
387
+ "written to --out is unmanaged and never deleted automatically.",
388
+ )
389
+ .option("--json", "Print the pack manifest as JSON.")
390
+ .action(async (target: string, opts: CreateOpts) => {
391
+ const retainMinutes = parseRetainMinutes(opts.retain);
392
+ if (typeof retainMinutes !== "number") {
393
+ emit.error({ code: "review_pack_invalid_retain", message: retainMinutes.error });
394
+ process.exitCode = 1;
395
+ return;
396
+ }
397
+ let contexts: QaRunContext[];
398
+ try {
399
+ contexts = opts.context?.length ? opts.context.map(parseContext) : DEFAULT_CONTEXTS;
400
+ } catch (err: unknown) {
401
+ emit.error({
402
+ code: "review_pack_invalid_context",
403
+ message: err instanceof Error ? err.message : String(err),
404
+ });
405
+ process.exitCode = 1;
406
+ return;
407
+ }
408
+ const concurrency = Math.max(
409
+ 1,
410
+ Math.min(8, Number.parseInt(opts.concurrency ?? "2", 10) || 2),
411
+ );
412
+ const timeoutMs = Math.max(1000, Number.parseInt(opts.timeout ?? "120000", 10) || 120_000);
413
+ const repoRoot = resolveQaRepoRoot(context);
414
+ sweepExpiredPacksIfEnabled(repoRoot, emit);
415
+ let packDir: string;
416
+ try {
417
+ packDir =
418
+ opts.out !== undefined
419
+ ? resolve(opts.out)
420
+ : createManagedQaOutParent(repoRoot, "review-pack");
421
+ mkdirSync(packDir, { recursive: true });
422
+ } catch (err: unknown) {
423
+ emit.error({
424
+ code: "review_pack_output_unavailable",
425
+ message: `cannot create the pack directory: ${err instanceof Error ? err.message : String(err)}`,
426
+ });
427
+ process.exitCode = 1;
428
+ return;
429
+ }
430
+ const cliScript = process.argv[1];
431
+ if (!cliScript) {
432
+ emit.error({
433
+ code: "review_pack_no_cli_script",
434
+ message: "cannot resolve the host CLI script path for child browse invocations",
435
+ });
436
+ process.exitCode = 1;
437
+ return;
438
+ }
439
+ // Children run the same host CLI so profiles, cookies, and headers match
440
+ // a hand-run browse byte for byte.
441
+ const browseArgv = [process.execPath, cliScript, "browse"];
442
+ const env = { ...process.env };
443
+ const records: PageReviewContextRecord[] = [];
444
+ const failures: Array<{ context_id: string; reason: string }> = [];
445
+ let next = 0;
446
+ const worker = async (): Promise<void> => {
447
+ while (true) {
448
+ const index = next++;
449
+ const ctx = contexts[index];
450
+ if (!ctx) return;
451
+ const argv = [
452
+ ...browseArgv,
453
+ target,
454
+ "--viewport",
455
+ ctx.viewport,
456
+ ...(ctx.theme === "dark" ? ["--color-scheme", "dark"] : []),
457
+ "--out",
458
+ resolve(packDir, "..", `${ctx.id}-capture`),
459
+ "--no-screenshot",
460
+ "--review-pack",
461
+ packDir,
462
+ "--review-pack-context",
463
+ ctx.id,
464
+ "--qa-theme",
465
+ ctx.theme,
466
+ "--qa-state",
467
+ ctx.state,
468
+ ...(opts.noBands ? ["--no-review-pack-bands"] : []),
469
+ ...(opts.scope ?? []).flatMap((selector) => ["--review-pack-scope", selector]),
470
+ ...(opts.maxTiles !== undefined ? ["--check-critique-max-tiles", opts.maxTiles] : []),
471
+ ];
472
+ emit.log(`capture ${ctx.id}`, "info");
473
+ const res = await defaultQaRunExec(argv, { timeoutMs, env });
474
+ if (res.error || res.exitCode !== 0) {
475
+ const tail = (res.stderr || res.stdout).trim().split("\n").slice(-3).join(" ");
476
+ failures.push({
477
+ context_id: ctx.id,
478
+ reason: res.error ?? `exit code ${res.exitCode}${tail ? `: ${tail}` : ""}`,
479
+ });
480
+ continue;
481
+ }
482
+ try {
483
+ records.push(readPackContext(packDir, ctx.id));
484
+ } catch (err: unknown) {
485
+ failures.push({
486
+ context_id: ctx.id,
487
+ reason: `pack context unreadable: ${err instanceof Error ? err.message : String(err)}`,
488
+ });
489
+ }
490
+ }
491
+ };
492
+ await Promise.all(Array.from({ length: Math.min(concurrency, contexts.length) }, worker));
493
+ // Manifest order regardless of completion order.
494
+ records.sort(
495
+ (a, b) =>
496
+ contexts.findIndex((c) => c.id === a.id) - contexts.findIndex((c) => c.id === b.id),
497
+ );
498
+ for (const failure of failures) {
499
+ emit.log(`capture ${failure.context_id} failed: ${failure.reason}`, "warn");
500
+ }
501
+ if (records.length === 0) {
502
+ emit.error({
503
+ code: "review_pack_no_contexts",
504
+ message: "no context was captured; see the capture failures above",
505
+ });
506
+ process.exitCode = 1;
507
+ return;
508
+ }
509
+ const out = finalizePageReviewPack({
510
+ packDir,
511
+ target,
512
+ contexts: records,
513
+ critique: null,
514
+ warnings: failures.map(
515
+ (f) => `${f.context_id}: capture failed (${f.reason}); not in this pack`,
516
+ ),
517
+ judgeCommand: judgeCommandFor(packDir),
518
+ // The clock starts at capture; the judge restarts it when it runs.
519
+ retention: retentionFromNow(retainMinutes, opts.out === undefined),
520
+ });
521
+ const manifest = readPackManifest(packDir);
522
+ emit.log(
523
+ `pack: ${records.length}/${contexts.length} context(s), ` +
524
+ `${records.reduce((sum, r) => sum + r.tiles.length, 0)} tile(s) → ${out.review}`,
525
+ failures.length > 0 ? "warn" : "info",
526
+ );
527
+ if (manifest.retention) {
528
+ emit.log(
529
+ manifest.retention.managed
530
+ ? `expires: ${manifest.retention.expires_at} (the whole pack is deleted; --retain to extend)`
531
+ : `expires: ${manifest.retention.expires_at} (unmanaged: never deleted automatically)`,
532
+ "info",
533
+ );
534
+ }
535
+ emit.log(`judge: ${judgeCommandFor(packDir)}`, "info");
536
+ if (opts.json) emit.data(manifest as unknown as Record<string, unknown>);
537
+ if (failures.length > 0) process.exitCode = 4;
538
+ });
539
+
540
+ root
541
+ .command("judge <dir>")
542
+ .description(
543
+ "Judge an existing pack from disk: every tile of every context through one bounded pool " +
544
+ "of vision calls (no browser). Writes evidence/critique.json and refreshes review.md and " +
545
+ "the inspection plan with the machine findings. Exit 2 on a high-severity finding, 4 when " +
546
+ "no provider judged.",
547
+ )
548
+ .option(
549
+ "--pool <n>",
550
+ "Vision calls in flight across the pack (1-16; default: the provider's own).",
551
+ )
552
+ .option(
553
+ "--allow-metered",
554
+ "Permit the provider's metered-API fallback. Default: subscription-backed headless " +
555
+ "harnesses only.",
556
+ )
557
+ .option(
558
+ "--context <id>",
559
+ "Judge only this context id (repeatable; default every context in the pack).",
560
+ (value: string, previous: string[] = []) => [...previous, value],
561
+ [] as string[],
562
+ )
563
+ .option(
564
+ "--retain <minutes>",
565
+ `Minutes the pack lives after this judge finishes (1-43200; default ` +
566
+ `${PAGE_REVIEW_DEFAULT_RETENTION_MINUTES}). Whether the pack is managed is kept from ` +
567
+ "its manifest.",
568
+ )
569
+ .option("--json", "Print the judge result as JSON.")
570
+ .action(async (dir: string, opts: JudgeOpts) => {
571
+ const packDir = resolve(dir);
572
+ const retainMinutes = parseRetainMinutes(opts.retain);
573
+ if (typeof retainMinutes !== "number") {
574
+ emit.error({ code: "review_pack_invalid_retain", message: retainMinutes.error });
575
+ process.exitCode = 1;
576
+ return;
577
+ }
578
+ let manifest: ReturnType<typeof readPackManifest>;
579
+ try {
580
+ manifest = readPackManifest(packDir);
581
+ } catch (err: unknown) {
582
+ emit.error({
583
+ code: "review_pack_unreadable",
584
+ message: `not a page review pack: ${err instanceof Error ? err.message : String(err)}`,
585
+ });
586
+ process.exitCode = 1;
587
+ return;
588
+ }
589
+ let pool: number | undefined;
590
+ if (opts.pool !== undefined) {
591
+ const n = Number.parseInt(opts.pool, 10);
592
+ if (!Number.isInteger(n) || n < 1 || n > 16) {
593
+ emit.error({
594
+ code: "review_pack_invalid_pool",
595
+ message: "--pool must be an integer between 1 and 16",
596
+ });
597
+ process.exitCode = 1;
598
+ return;
599
+ }
600
+ pool = n;
601
+ }
602
+ const available = listPackContexts(packDir);
603
+ const wanted = opts.context?.length ? opts.context : available;
604
+ const missing = wanted.filter((id) => !available.includes(id));
605
+ if (missing.length > 0) {
606
+ emit.error({
607
+ code: "review_pack_unknown_context",
608
+ message: `context(s) not in the pack: ${missing.join(", ")} (have: ${available.join(", ") || "none"})`,
609
+ });
610
+ process.exitCode = 1;
611
+ return;
612
+ }
613
+ const prior = process.env[QA_RUN_HEADLESS_ONLY_ENV];
614
+ if (!opts.allowMetered) process.env[QA_RUN_HEADLESS_ONLY_ENV] = "1";
615
+ let provider: Awaited<
616
+ ReturnType<NonNullable<HarneryProgramContext["critiqueProviderLoader"]>>
617
+ >;
618
+ try {
619
+ provider = context?.critiqueProvider ?? (await context?.critiqueProviderLoader?.());
620
+ } catch (err: unknown) {
621
+ provider = undefined;
622
+ emit.log(
623
+ `critique provider failed to load: ${err instanceof Error ? err.message : String(err)}`,
624
+ "warn",
625
+ );
626
+ }
627
+ const result = await judgePageReviewPack({
628
+ packDir,
629
+ provider,
630
+ rubric: DEFAULT_CRITIQUE_RUBRIC,
631
+ ...(pool !== undefined ? { concurrency: pool } : {}),
632
+ contextIds: wanted,
633
+ onLog: (message) => emit.log(message, "info"),
634
+ });
635
+ if (prior === undefined) delete process.env[QA_RUN_HEADLESS_ONLY_ENV];
636
+ else process.env[QA_RUN_HEADLESS_ONLY_ENV] = prior;
637
+
638
+ const merged = new Map((manifest.critique ?? []).map((row) => [row.context_id, row]));
639
+ for (const row of toCritiqueRecords(result)) merged.set(row.context_id, row);
640
+ finalizePageReviewPack({
641
+ packDir,
642
+ target: manifest.target,
643
+ ...(manifest.tested_revision !== undefined
644
+ ? { tested_revision: manifest.tested_revision }
645
+ : {}),
646
+ contexts: manifest.contexts,
647
+ gates: manifest.gates,
648
+ critique: [...merged.values()],
649
+ pool: result.pool,
650
+ warnings: manifest.warnings.filter((w) => !w.includes("tile cap reached")),
651
+ createdAt: manifest.created_at,
652
+ judgeCommand: judgeCommandFor(packDir),
653
+ // The judge's finish time starts the retention clock; a pack whose
654
+ // manifest never said it was managed stays unmanaged.
655
+ retention: retentionFromNow(retainMinutes, manifest.retention?.managed === true),
656
+ });
657
+ for (const row of result.contexts) {
658
+ const high = row.findings.filter((f) => f.severity === "high").length;
659
+ emit.log(
660
+ `${row.context_id}: ${row.outcome.toUpperCase()} · ${row.tiles_reviewed}/${row.tiles_total} tiles judged` +
661
+ (row.tiles_reused > 0 ? `, ${row.tiles_reused} reused` : "") +
662
+ ` · ${high} high / ${row.findings.length - high} other finding(s)` +
663
+ (row.error ? ` · ${row.error}` : ""),
664
+ row.outcome === "pass" ? "info" : "warn",
665
+ );
666
+ }
667
+ emit.log(
668
+ `judge: ${result.outcome} · pool ${result.pool.concurrency} · ${result.tiles_reviewed} tile(s) in ${result.pool.wall_time_ms}ms · ${packPaths(packDir).review}`,
669
+ result.outcome === "pass" ? "info" : "warn",
670
+ );
671
+ if (opts.json) emit.data(result as unknown as Record<string, unknown>);
672
+ if (result.outcome === "fail") process.exitCode = 2;
673
+ else if (result.outcome !== "pass") process.exitCode = 4;
674
+ });
675
+
676
+ root
677
+ .command("expand <dir>")
678
+ .description(
679
+ "Re-capture ONE tile region of an existing pack at a higher device scale factor: the " +
680
+ "same target and context are rendered again through a child browse, the tile's rect " +
681
+ "is cropped at that DPR into contexts/<id>/tiles/<tile>@<dpr>x.png, the context record " +
682
+ "gains an `expanded` entry, and review.md is refreshed. Existing tiles are never touched.",
683
+ )
684
+ .requiredOption("--tile <id>", "Tile id to expand (e.g. T012).")
685
+ .option(
686
+ "--context <id>",
687
+ "Context the tile belongs to. Required when more than one context carries that tile id.",
688
+ )
689
+ .option("--dpr <n>", "Device scale factor for the re-capture (1-4; default 2).", "2")
690
+ .option("--timeout <ms>", "Capture timeout in milliseconds (default 120000).", "120000")
691
+ .option("--json", "Print the expanded tile record as JSON.")
692
+ .action(async (dir: string, opts: ExpandOpts) => {
693
+ const packDir = resolve(dir);
694
+ let manifest: ReturnType<typeof readPackManifest>;
695
+ try {
696
+ manifest = readPackManifest(packDir);
697
+ } catch (err: unknown) {
698
+ emit.error({
699
+ code: "review_pack_unreadable",
700
+ message: `not a page review pack: ${err instanceof Error ? err.message : String(err)}`,
701
+ });
702
+ process.exitCode = 1;
703
+ return;
704
+ }
705
+ const dpr = Number.parseFloat(opts.dpr ?? "2");
706
+ if (!Number.isFinite(dpr) || dpr < 1 || dpr > 4) {
707
+ emit.error({
708
+ code: "review_pack_invalid_dpr",
709
+ message: "--dpr must be a number between 1 and 4",
710
+ });
711
+ process.exitCode = 1;
712
+ return;
713
+ }
714
+ const tileId = (opts.tile ?? "").trim();
715
+ let located: ReturnType<typeof findPackTile>;
716
+ try {
717
+ located = findPackTile(manifest.contexts, tileId, opts.context);
718
+ } catch (err: unknown) {
719
+ emit.error({
720
+ code: "review_pack_unknown_tile",
721
+ message: err instanceof Error ? err.message : String(err),
722
+ });
723
+ process.exitCode = 1;
724
+ return;
725
+ }
726
+ const ctx = located.context;
727
+ const cliScript = process.argv[1];
728
+ if (!cliScript) {
729
+ emit.error({
730
+ code: "review_pack_no_cli_script",
731
+ message: "cannot resolve the host CLI script path for the child browse invocation",
732
+ });
733
+ process.exitCode = 1;
734
+ return;
735
+ }
736
+ const timeoutMs = Math.max(1000, Number.parseInt(opts.timeout ?? "120000", 10) || 120_000);
737
+ // Same render inputs as the original capture: target, viewport, color
738
+ // scheme, theme and state labels. Only the DPR differs.
739
+ const argv = [
740
+ process.execPath,
741
+ cliScript,
742
+ "browse",
743
+ manifest.target,
744
+ "--viewport",
745
+ ctx.viewport,
746
+ ...(ctx.theme === "dark" ? ["--color-scheme", "dark"] : []),
747
+ "--out",
748
+ resolve(packDir, "..", `${ctx.id}-expand-${tileId}`),
749
+ "--no-screenshot",
750
+ "--review-pack",
751
+ packDir,
752
+ "--review-pack-context",
753
+ ctx.id,
754
+ "--review-pack-expand",
755
+ tileId,
756
+ "--device-scale-factor",
757
+ String(dpr),
758
+ "--qa-theme",
759
+ ctx.theme,
760
+ "--qa-state",
761
+ ctx.state,
762
+ ];
763
+ emit.log(`expand ${ctx.id}/${tileId} at ${dpr}× from ${manifest.target}`, "info");
764
+ const res = await defaultQaRunExec(argv, { timeoutMs, env: { ...process.env } });
765
+ if (res.error || res.exitCode !== 0) {
766
+ const tail = (res.stderr || res.stdout).trim().split("\n").slice(-3).join(" ");
767
+ emit.error({
768
+ code: "review_pack_expand_failed",
769
+ message:
770
+ `re-capture of ${ctx.id}/${tileId} did not complete: ` +
771
+ (res.error ?? `exit code ${res.exitCode}${tail ? `: ${tail}` : ""}`),
772
+ });
773
+ process.exitCode = 4;
774
+ return;
775
+ }
776
+ let updated: PageReviewContextRecord;
777
+ try {
778
+ updated = readPackContext(packDir, ctx.id);
779
+ } catch (err: unknown) {
780
+ emit.error({
781
+ code: "review_pack_expand_failed",
782
+ message: `pack context unreadable after expand: ${err instanceof Error ? err.message : String(err)}`,
783
+ });
784
+ process.exitCode = 4;
785
+ return;
786
+ }
787
+ const expanded = updated.expanded?.find(
788
+ (entry) => entry.tile === tileId && entry.dpr === dpr,
789
+ );
790
+ if (!expanded) {
791
+ emit.error({
792
+ code: "review_pack_expand_failed",
793
+ message: `the child browse exited 0 but ${ctx.id} carries no expanded record for ${tileId} at ${dpr}×`,
794
+ });
795
+ process.exitCode = 4;
796
+ return;
797
+ }
798
+ finalizePageReviewPack({
799
+ packDir,
800
+ target: manifest.target,
801
+ ...(manifest.tested_revision !== undefined
802
+ ? { tested_revision: manifest.tested_revision }
803
+ : {}),
804
+ contexts: manifest.contexts.map((entry) => (entry.id === ctx.id ? updated : entry)),
805
+ gates: manifest.gates,
806
+ critique: manifest.critique,
807
+ ...(manifest.pool ? { pool: manifest.pool } : {}),
808
+ warnings: manifest.warnings.filter((w) => !w.includes("tile cap reached")),
809
+ createdAt: manifest.created_at,
810
+ judgeCommand: judgeCommandFor(packDir),
811
+ });
812
+ emit.log(
813
+ `expanded ${ctx.id}/${tileId} at ${dpr}× → ${expanded.width}×${expanded.height} px · ` +
814
+ `${expandedTileFilename(tileId, dpr)} · ${packPaths(packDir).review}`,
815
+ "info",
816
+ );
817
+ if (opts.json) emit.data(expanded as unknown as Record<string, unknown>);
818
+ });
819
+
820
+ root
821
+ .command("clean")
822
+ .description(
823
+ "Delete expired page review packs. Previews by default; --yes deletes. A pack is " +
824
+ "eligible once its manifest's retention.expires_at has passed and it was written to " +
825
+ "the managed artifact store (managed: true); --include-unmanaged also takes packs " +
826
+ "written to an explicit --out. Every deleted pack leaves a pack-expired.json stub.",
827
+ )
828
+ .option(
829
+ "--root <dir>",
830
+ "Directory to search: the root itself, each child workspace, and run-*/pack beneath " +
831
+ "them (default: the project's .harnery/artifacts store).",
832
+ )
833
+ .option("--yes", "Delete. Without it, only report what would be deleted.")
834
+ .option("--include-unmanaged", "Also delete expired packs whose manifest says managed: false.")
835
+ .option("--json", "Print every pack found and the deleted set as JSON.")
836
+ .action((opts: CleanOpts) => {
837
+ const searchRoot =
838
+ opts.root !== undefined ? resolve(opts.root) : artifactsRoot(resolveQaRepoRoot(context));
839
+ const includeUnmanaged = opts.includeUnmanaged === true;
840
+ const dryRun = opts.yes !== true;
841
+ let result: ReturnType<typeof deleteExpiredPacks>;
842
+ try {
843
+ result = deleteExpiredPacks({ roots: [searchRoot], includeUnmanaged, dryRun });
844
+ } catch (err: unknown) {
845
+ emit.error({
846
+ code: "review_pack_clean_failed",
847
+ message: `expired pack sweep failed: ${err instanceof Error ? err.message : String(err)}`,
848
+ });
849
+ process.exitCode = 1;
850
+ return;
851
+ }
852
+ if (opts.json) {
853
+ emit.data({
854
+ root: searchRoot,
855
+ dry_run: dryRun,
856
+ include_unmanaged: includeUnmanaged,
857
+ candidates: result.candidates,
858
+ deleted: result.deleted,
859
+ });
860
+ return;
861
+ }
862
+ if (result.candidates.length === 0) {
863
+ emit.log(`no page review packs under ${searchRoot}`, "info");
864
+ return;
865
+ }
866
+ for (const row of result.candidates) emit.log(packRowLine(row, includeUnmanaged), "info");
867
+ const n = result.deleted.length;
868
+ const packs = `${n} pack${n === 1 ? "" : "s"}`;
869
+ if (dryRun) {
870
+ emit.log(
871
+ n > 0
872
+ ? `would delete ${packs}; rerun with --yes to delete` +
873
+ (includeUnmanaged ? "" : " (--include-unmanaged for explicit --out packs)")
874
+ : "nothing to delete",
875
+ "info",
876
+ );
877
+ } else {
878
+ emit.log(`deleted ${packs}; each left a pack-expired.json stub`, n > 0 ? "info" : "debug");
879
+ }
880
+ });
881
+
882
+ root
883
+ .command("list")
884
+ .description(
885
+ "List every page review pack under the artifact store (or --root): age, size, contexts, " +
886
+ "tiles, machine outcome, reviewed outcome when a verdict exists, and expiry.",
887
+ )
888
+ .option(
889
+ "--root <dir>",
890
+ "Directory to search (default: the project's .harnery/artifacts store).",
891
+ )
892
+ .option("--json", "Print the rows as JSON.")
893
+ .action((opts: ListOpts) => {
894
+ const searchRoot =
895
+ opts.root !== undefined ? resolve(opts.root) : artifactsRoot(resolveQaRepoRoot(context));
896
+ const nowMs = Date.now();
897
+ const rows: PackListRow[] = [];
898
+ for (const dir of findPageReviewPacks([searchRoot])) {
899
+ try {
900
+ rows.push(packListRow(dir, readPackManifest(dir), readPackVerdict(dir), nowMs));
901
+ } catch (err: unknown) {
902
+ emit.log(
903
+ `${dir}: manifest unreadable (${err instanceof Error ? err.message : String(err)})`,
904
+ "warn",
905
+ );
906
+ }
907
+ }
908
+ if (opts.json) {
909
+ emit.data({ root: searchRoot, packs: rows });
910
+ return;
911
+ }
912
+ if (rows.length === 0) {
913
+ emit.log(`no page review packs under ${searchRoot}`, "info");
914
+ return;
915
+ }
916
+ emit.log(
917
+ `${"age".padStart(6)} ${"size".padStart(9)} ${"ctx".padStart(3)} ${"tiles".padStart(5)} ` +
918
+ `${"machine".padEnd(10)} ${"reviewed".padEnd(10)} ${"expiry".padEnd(26)} pack`,
919
+ "info",
920
+ );
921
+ for (const row of rows) emit.log(packListLine(row), "info");
922
+ });
923
+
924
+ root
925
+ .command("show <dir>")
926
+ .description(
927
+ "Summarize one pack from manifest.json: target, expiry, size, and a context table with " +
928
+ "tiles, hit bands, coverage, capture source, and finding counts. Read review.md for the " +
929
+ "tiles themselves.",
930
+ )
931
+ .option("--json", "Print the summary as JSON.")
932
+ .action((dir: string, opts: { json?: boolean }) => {
933
+ const packDir = resolve(dir);
934
+ let manifest: ReturnType<typeof readPackManifest>;
935
+ try {
936
+ manifest = readPackManifest(packDir);
937
+ } catch (err: unknown) {
938
+ emit.error({
939
+ code: "review_pack_not_found",
940
+ message: `${packDir}: not a page review pack (${err instanceof Error ? err.message : String(err)})`,
941
+ });
942
+ process.exitCode = 1;
943
+ return;
944
+ }
945
+ const verdict = readPackVerdict(packDir);
946
+ const critiqueById = new Map((manifest.critique ?? []).map((row) => [row.context_id, row]));
947
+ const contexts = manifest.contexts.map((ctx) => {
948
+ const critique = critiqueById.get(ctx.id);
949
+ const findings = critique?.findings ?? [];
950
+ return {
951
+ id: ctx.id,
952
+ viewport: ctx.viewport,
953
+ theme: ctx.theme,
954
+ state: ctx.state,
955
+ page: ctx.page,
956
+ tiles: ctx.tiles.length,
957
+ hit_bands: ctx.hit_bands ?? 0,
958
+ coverage: ctx.coverage,
959
+ capture_source: ctx.capture_fidelity?.source ?? "full-page",
960
+ machine_outcome: critique?.outcome ?? null,
961
+ high: findings.filter((f) => f.severity === "high").length,
962
+ medium: findings.filter((f) => f.severity === "medium").length,
963
+ low: findings.filter((f) => f.severity === "low").length,
964
+ };
965
+ });
966
+ const summary = {
967
+ dir: packDir,
968
+ target: manifest.target,
969
+ tested_revision: manifest.tested_revision ?? null,
970
+ created_at: manifest.created_at,
971
+ expires_at: manifest.retention?.expires_at ?? null,
972
+ managed: manifest.retention?.managed ?? null,
973
+ size_bytes: manifest.size_bytes ?? null,
974
+ machine_outcome: aggregateMachineOutcome(manifest.critique),
975
+ reviewed_outcome: verdict?.reviewed_outcome ?? null,
976
+ warnings: manifest.warnings,
977
+ contexts,
978
+ review: resolve(packDir, manifest.files.review),
979
+ };
980
+ if (opts.json) {
981
+ emit.data(summary);
982
+ return;
983
+ }
984
+ emit.log(`pack: ${packDir}`, "info");
985
+ emit.log(`target: ${manifest.target}`, "info");
986
+ emit.log(
987
+ `created ${manifest.created_at}; expires ${summary.expires_at ?? "never (no retention)"}` +
988
+ `${summary.managed === false ? " (unmanaged)" : ""}; ${formatBytes(summary.size_bytes)}`,
989
+ "info",
990
+ );
991
+ emit.log(
992
+ `machine outcome: ${summary.machine_outcome ?? "not judged"}; reviewed outcome: ${summary.reviewed_outcome ?? "no verdict"}`,
993
+ "info",
994
+ );
995
+ for (const ctx of contexts) {
996
+ const cov = `${ctx.coverage.bands_reviewed}/${ctx.coverage.bands_total} bands${ctx.coverage.capped ? ", capped" : ""}`;
997
+ emit.log(
998
+ ` ${ctx.id.padEnd(24)} ${ctx.page.width}×${ctx.page.height} px ${String(ctx.tiles).padStart(3)} tiles` +
999
+ `${ctx.hit_bands > 0 ? ` (+${ctx.hit_bands} hit)` : ""} ${cov} ${ctx.capture_source} ` +
1000
+ `${ctx.machine_outcome ?? "not judged"} ${ctx.high}h/${ctx.medium}m/${ctx.low}l`,
1001
+ "info",
1002
+ );
1003
+ }
1004
+ for (const warning of manifest.warnings) emit.log(` warning: ${warning}`, "warn");
1005
+ emit.log(`review: ${summary.review}`, "info");
1006
+ });
1007
+
1008
+ const reviews = root
1009
+ .command("reviews")
1010
+ .description("Record delegated review-subagent coverage for primary tiles.");
1011
+
1012
+ reviews
1013
+ .command("add <dir>")
1014
+ .description(
1015
+ "Record one review subagent's assigned and completed primary tiles. Re-running with " +
1016
+ "the same reviewer replaces that reviewer's record.",
1017
+ )
1018
+ .requiredOption("--reviewer <id>", "Review subagent name or stable id.")
1019
+ .requiredOption("--model <name>", `Subagent model: ${PAGE_REVIEW_SUBAGENT_MODELS.join(", ")}.`)
1020
+ .requiredOption(
1021
+ "--assigned <context/tile>",
1022
+ "Assigned primary tile, e.g. desktop-light-default/T012 (repeatable).",
1023
+ (value: string, previous: string[] = []) => [...previous, value],
1024
+ [] as string[],
1025
+ )
1026
+ .option(
1027
+ "--completed <context/tile>",
1028
+ "Assigned tile opened at native pixels (repeatable).",
1029
+ (value: string, previous: string[] = []) => [...previous, value],
1030
+ [] as string[],
1031
+ )
1032
+ .option("--status <status>", "complete or incomplete.", "complete")
1033
+ .option("--json", "Print the written delegated-review record as JSON.")
1034
+ .action((dir: string, opts: ReviewAddOpts) => {
1035
+ const pack = readReviewerPack(dir, emit);
1036
+ if (!pack) return;
1037
+ const { packDir, findings: doc } = pack;
1038
+ const plan = readPackInspectionPlan(packDir);
1039
+ const primary = new Set(
1040
+ plan.contexts.flatMap((context) =>
1041
+ context.primary_tiles.map((tile) => `${context.context_id}/${tile.id}`),
1042
+ ),
1043
+ );
1044
+ const assigned = [...new Set((opts.assigned ?? []).map((tile) => tile.trim()))].filter(
1045
+ Boolean,
1046
+ );
1047
+ const completed = [...new Set((opts.completed ?? []).map((tile) => tile.trim()))].filter(
1048
+ Boolean,
1049
+ );
1050
+ const unknown = assigned.filter((tile) => !primary.has(tile));
1051
+ if (unknown.length > 0) {
1052
+ emit.error({
1053
+ code: "review_pack_unknown_primary_tile",
1054
+ message: `assigned tile(s) are not primary tiles in the inspection plan: ${unknown.join(", ")}`,
1055
+ });
1056
+ process.exitCode = 1;
1057
+ return;
1058
+ }
1059
+ const record = {
1060
+ reviewer: opts.reviewer?.trim() ?? "",
1061
+ model: opts.model as (typeof PAGE_REVIEW_SUBAGENT_MODELS)[number],
1062
+ assigned_tiles: assigned,
1063
+ completed_tiles: completed,
1064
+ status: opts.status as "complete" | "incomplete",
1065
+ };
1066
+ const nextReviews = doc.delegated_reviews.filter(
1067
+ (entry) => entry.reviewer !== record.reviewer,
1068
+ );
1069
+ const reviewerNames = [
1070
+ ...new Set([...nextReviews.map((entry) => entry.reviewer), record.reviewer]),
1071
+ ];
1072
+ const next: PageReviewFindingsDocument = {
1073
+ ...doc,
1074
+ reviewer: reviewerNames.filter(Boolean).join(", ") || null,
1075
+ reviewed_at: new Date().toISOString(),
1076
+ delegated_reviews: [...nextReviews, record],
1077
+ };
1078
+ if (!commitFindings(packDir, next, emit)) return;
1079
+ emit.log(
1080
+ `${record.reviewer} · ${record.completed_tiles.length}/${record.assigned_tiles.length} assigned primary tiles · ${record.status}`,
1081
+ "info",
1082
+ );
1083
+ if (opts.json) emit.data(record as unknown as Record<string, unknown>);
1084
+ });
1085
+
1086
+ const findings = root
1087
+ .command("findings")
1088
+ .description("Review-subagent findings serialized into the pack's findings.json.");
1089
+
1090
+ findings
1091
+ .command("add <dir>")
1092
+ .description(
1093
+ "Append one review-subagent finding to findings.json. The document is validated against " +
1094
+ "findings.schema.json before the atomic write; every violation is printed at once. " +
1095
+ "Ids run F001, F002, ... unless --id is given.",
1096
+ )
1097
+ .requiredOption("--context <id>", "Context id the finding belongs to (see review.md).")
1098
+ .requiredOption(
1099
+ "--tile <id>",
1100
+ "Tile id the finding cites, e.g. T012 (repeatable; becomes the evidence list).",
1101
+ (value: string, previous: string[] = []) => [...previous, value],
1102
+ [] as string[],
1103
+ )
1104
+ .requiredOption("--severity <s>", `One of ${PAGE_REVIEW_FINDING_SEVERITIES.join(", ")}.`)
1105
+ .requiredOption("--category <c>", `One of ${PAGE_REVIEW_FINDING_CATEGORIES.join(", ")}.`)
1106
+ .requiredOption("--observation <text>", "What the tile shows, in one or two sentences.")
1107
+ .option("--recommendation <text>", "What should change.")
1108
+ .option("--id <id>", "Finding id (uppercase letters, digits, _ or -; default next F###).")
1109
+ .option("--reviewer <name>", "Sets the document's top-level reviewer.")
1110
+ .option("--json", "Print the written finding as JSON.")
1111
+ .action((dir: string, opts: FindingsAddOpts) => {
1112
+ const pack = readReviewerPack(dir, emit);
1113
+ if (!pack) return;
1114
+ const { packDir, manifest, findings: doc } = pack;
1115
+ const contextId = opts.context ?? "";
1116
+ const ctx = manifest.contexts.find((c) => c.id === contextId);
1117
+ if (!ctx) {
1118
+ emit.error({
1119
+ code: "review_pack_unknown_context",
1120
+ message: `context ${JSON.stringify(contextId)} is not in the pack (have: ${manifest.contexts.map((c) => c.id).join(", ") || "none"})`,
1121
+ });
1122
+ process.exitCode = 1;
1123
+ return;
1124
+ }
1125
+ const tileIds = (opts.tile ?? []).map((t) => t.trim()).filter((t) => t.length > 0);
1126
+ const unknownTiles = tileIds.filter((id) => !ctx.tiles.some((t) => t.id === id));
1127
+ if (unknownTiles.length > 0) {
1128
+ emit.error({
1129
+ code: "review_pack_unknown_tile",
1130
+ message: `tile(s) not in ${ctx.id}: ${unknownTiles.join(", ")}`,
1131
+ });
1132
+ process.exitCode = 1;
1133
+ return;
1134
+ }
1135
+ const id = (opts.id ?? "").trim() || nextFindingId(doc.findings);
1136
+ if (doc.findings.some((f) => f.id === id)) {
1137
+ emit.error({
1138
+ code: "review_pack_duplicate_finding",
1139
+ message: `finding id ${id} already exists in findings.json`,
1140
+ });
1141
+ process.exitCode = 1;
1142
+ return;
1143
+ }
1144
+ const finding = {
1145
+ id,
1146
+ severity: (opts.severity ?? "") as PageReviewFindingSeverity,
1147
+ category: (opts.category ?? "") as PageReviewFindingCategory,
1148
+ context_id: ctx.id,
1149
+ evidence: tileIds,
1150
+ observation: opts.observation ?? "",
1151
+ ...(opts.recommendation !== undefined ? { recommendation: opts.recommendation } : {}),
1152
+ };
1153
+ const next: PageReviewFindingsDocument = {
1154
+ ...doc,
1155
+ reviewer: opts.reviewer ?? doc.reviewer,
1156
+ reviewed_at: new Date().toISOString(),
1157
+ findings: [...doc.findings, finding],
1158
+ };
1159
+ if (!commitFindings(packDir, next, emit)) return;
1160
+ emit.log(
1161
+ `${id} · ${finding.severity} · ${finding.category} · ${ctx.id}/${tileIds.join(",")} → ${packPaths(packDir).findings}`,
1162
+ "info",
1163
+ );
1164
+ if (opts.json) emit.data(finding as unknown as Record<string, unknown>);
1165
+ });
1166
+
1167
+ root
1168
+ .command("disposition <dir> <target> <disposition>")
1169
+ .description(
1170
+ "Record the reviewer's verdict on one machine finding. <target> is " +
1171
+ "<context-id>/<tile-id>#<n>, n the finding's 0-based position among that tile's " +
1172
+ `findings in evidence/critique.json; <disposition> is one of ${PAGE_REVIEW_DISPOSITIONS.join(", ")}. ` +
1173
+ "A target that names no machine finding is refused; a prior disposition of the same " +
1174
+ "target is replaced. evidence/critique.json itself is never rewritten.",
1175
+ )
1176
+ .option("--note <text>", "Why; what the tile actually shows.")
1177
+ .option("--by <name>", "Who dispositioned it.")
1178
+ .option("--json", "Print the written disposition as JSON.")
1179
+ .action((dir: string, target: string, disposition: string, opts: DispositionOpts) => {
1180
+ const pack = readReviewerPack(dir, emit);
1181
+ if (!pack) return;
1182
+ const { packDir, manifest, findings: doc } = pack;
1183
+ if (!(PAGE_REVIEW_DISPOSITIONS as readonly string[]).includes(disposition)) {
1184
+ emit.error({
1185
+ code: "review_pack_invalid_disposition",
1186
+ message: `disposition must be one of ${PAGE_REVIEW_DISPOSITIONS.join(", ")} (got ${JSON.stringify(disposition)})`,
1187
+ });
1188
+ process.exitCode = 1;
1189
+ return;
1190
+ }
1191
+ if (manifest.critique === null) {
1192
+ emit.error({
1193
+ code: "review_pack_not_judged",
1194
+ message: `the judge has not run for this pack, so there is no machine finding to disposition; run: ${judgeCommandFor(packDir)}`,
1195
+ });
1196
+ process.exitCode = 1;
1197
+ return;
1198
+ }
1199
+ const targets = machineFindingTargets(manifest.critique);
1200
+ const machine = targets.get(target.trim());
1201
+ if (!machine) {
1202
+ const sample = [...targets.keys()].slice(0, 8).join(", ");
1203
+ emit.error({
1204
+ code: "review_pack_unknown_finding",
1205
+ message:
1206
+ `${JSON.stringify(target)} names no machine finding in evidence/critique.json` +
1207
+ (sample ? ` (known targets include: ${sample})` : " (the critique holds no findings)"),
1208
+ });
1209
+ process.exitCode = 1;
1210
+ return;
1211
+ }
1212
+ const entry = {
1213
+ target: target.trim(),
1214
+ disposition: disposition as PageReviewDisposition,
1215
+ ...(opts.note !== undefined ? { note: opts.note } : {}),
1216
+ ...(opts.by !== undefined ? { by: opts.by } : {}),
1217
+ at: new Date().toISOString(),
1218
+ };
1219
+ const kept = (doc.dispositions ?? []).filter((d) => d.target !== entry.target);
1220
+ const next: PageReviewFindingsDocument = {
1221
+ ...doc,
1222
+ reviewed_at: new Date().toISOString(),
1223
+ dispositions: [...kept, entry],
1224
+ };
1225
+ if (!commitFindings(packDir, next, emit)) return;
1226
+ emit.log(
1227
+ `${entry.target} · ${machine.severity} · ${machine.category} → ${entry.disposition}` +
1228
+ (kept.length < (doc.dispositions ?? []).length ? " (replaced)" : ""),
1229
+ "info",
1230
+ );
1231
+ if (opts.json) emit.data(entry as unknown as Record<string, unknown>);
1232
+ });
1233
+
1234
+ root
1235
+ .command("verdict <dir>")
1236
+ .description(
1237
+ "Combine the machine critique with the reviewer's dispositions and findings into one " +
1238
+ "reviewed outcome: a machine high counts unless dispositioned artifact, not-a-defect, " +
1239
+ "or duplicate-of-gate; a review-subagent finding at high or critical counts. Every " +
1240
+ "primary tile also needs completed delegated-review coverage. Writes evidence/verdict.json " +
1241
+ "and refreshes review.md. Exit 2 on fail, 4 when critique or delegated coverage is " +
1242
+ "skipped or incomplete, 1 on a findings.json validation error.",
1243
+ )
1244
+ .option("--json", "Print the verdict as JSON.")
1245
+ .action((dir: string, opts: VerdictOpts) => {
1246
+ const pack = readReviewerPack(dir, emit);
1247
+ if (!pack) return;
1248
+ const { packDir, manifest, findings: doc } = pack;
1249
+ const errors = validateFindingsDocument(doc);
1250
+ if (errors.length > 0) {
1251
+ for (const error of errors) emit.log(`findings.json: ${error}`, "error");
1252
+ emit.error({
1253
+ code: "review_pack_findings_invalid",
1254
+ message: `${errors.length} validation error(s) in findings.json; fix them before a verdict`,
1255
+ });
1256
+ process.exitCode = 1;
1257
+ return;
1258
+ }
1259
+ const verdict: PageReviewVerdictDocument = {
1260
+ schema: PAGE_REVIEW_VERDICT_SCHEMA,
1261
+ ...resolvePackVerdict(manifest, readPackInspectionPlan(packDir), doc),
1262
+ reviewed_at: new Date().toISOString(),
1263
+ };
1264
+ const paths = packPaths(packDir);
1265
+ mkdirSync(paths.evidenceDir, { recursive: true });
1266
+ writeFileSync(paths.verdict, `${JSON.stringify(verdict, null, 2)}\n`);
1267
+ refreshPackFromManifest(packDir, manifest);
1268
+ for (const target of verdict.unmatched_dispositions) {
1269
+ emit.log(`disposition ${target} names no machine finding; ignored`, "warn");
1270
+ }
1271
+ emit.log(
1272
+ `reviewed ${verdict.reviewed_outcome.toUpperCase()} · machine ${verdict.machine_outcome} · ` +
1273
+ `highs ${verdict.high_total}: ${verdict.high_confirmed} confirmed, ${verdict.high_dismissed} dismissed, ${verdict.high_open} open · ` +
1274
+ `reviewer high/critical ${verdict.reviewer_high} · delegated tiles ${verdict.primary_tiles_reviewed}/${verdict.primary_tiles_total} · ${paths.verdict}`,
1275
+ verdict.reviewed_outcome === "pass" ? "info" : "warn",
1276
+ );
1277
+ if (opts.json) emit.data(verdict as unknown as Record<string, unknown>);
1278
+ if (verdict.reviewed_outcome === "fail") process.exitCode = 2;
1279
+ else if (verdict.reviewed_outcome !== "pass") process.exitCode = 4;
1280
+ });
1281
+ }