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
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { spawnSync } from "node:child_process";
12
- import { randomUUID } from "node:crypto";
12
+ import { createHash, randomUUID } from "node:crypto";
13
13
  import type { Stats } from "node:fs";
14
14
  import {
15
15
  existsSync,
@@ -18,6 +18,7 @@ import {
18
18
  readdirSync,
19
19
  readFileSync,
20
20
  renameSync,
21
+ rmdirSync,
21
22
  rmSync,
22
23
  writeFileSync,
23
24
  } from "node:fs";
@@ -40,8 +41,9 @@ export interface ArtifactActor {
40
41
  name?: string;
41
42
  }
42
43
 
43
- export interface ArtifactManifestV1 {
44
+ export interface ArtifactManifestV2 {
44
45
  schema_version: typeof ARTIFACT_SCHEMA_VERSION;
46
+ holds: ArtifactHold[];
45
47
  artifact_id: string;
46
48
  slug: string;
47
49
  purpose: string;
@@ -57,7 +59,30 @@ export interface ArtifactManifestV1 {
57
59
  oversize_acknowledged?: boolean;
58
60
  }
59
61
 
62
+ export interface ArtifactHold {
63
+ id: string;
64
+ reason: string;
65
+ set_by: ArtifactActor;
66
+ set_at: string;
67
+ }
68
+
69
+ export interface ArtifactHoldInput {
70
+ id: string;
71
+ reason: string;
72
+ }
73
+
74
+ export function artifactCapabilities() {
75
+ return {
76
+ schema_version: ARTIFACT_SCHEMA_VERSION,
77
+ holds: true,
78
+ atomic_create_holds: true,
79
+ owner_scoped_unhold: true,
80
+ explicit_v1_migration: true,
81
+ } as const;
82
+ }
83
+
60
84
  export type ArtifactClassification =
85
+ | "managed-held"
61
86
  | "managed-active"
62
87
  | "managed-current"
63
88
  | "managed-expired"
@@ -90,7 +115,12 @@ export interface ArtifactCreateInput {
90
115
  slug: string;
91
116
  purpose: string;
92
117
  retentionDays: number;
118
+ /** Sub-day retention. When set, it replaces `retentionDays` for this unit
119
+ * (a page review pack expires in minutes, not days). 1 to 5,256,000. */
120
+ retentionMinutes?: number;
93
121
  actor?: ArtifactActor;
122
+ /** Holds are persisted with the first manifest; a valid actor is required. */
123
+ holds?: ArtifactHoldInput[];
94
124
  now?: Date;
95
125
  id?: string;
96
126
  big?: boolean;
@@ -112,7 +142,7 @@ export interface ArtifactAdoptionResult {
112
142
 
113
143
  interface ParsedManifest {
114
144
  ok: true;
115
- manifest: ArtifactManifestV1;
145
+ manifest: ArtifactManifestV2;
116
146
  }
117
147
 
118
148
  interface ManifestError {
@@ -132,18 +162,32 @@ export function configuredArtifactRetentionDays(repoRoot: string): number {
132
162
  export function createArtifact(
133
163
  repoRoot: string,
134
164
  input: ArtifactCreateInput,
135
- ): { path: string; manifest: ArtifactManifestV1 } {
165
+ ): { path: string; manifest: ArtifactManifestV2 } {
166
+ return withArtifactLock(repoRoot, () => createArtifactUnlocked(repoRoot, input));
167
+ }
168
+
169
+ function createArtifactUnlocked(
170
+ repoRoot: string,
171
+ input: ArtifactCreateInput,
172
+ ): { path: string; manifest: ArtifactManifestV2 } {
136
173
  const now = input.now ?? new Date();
137
174
  assertValidDate(now, "now");
138
175
  const slug = normalizeSlug(input.slug);
139
176
  if (!slug) throw new Error("slug must contain at least one ASCII letter or digit");
140
177
  const purpose = input.purpose.trim();
141
178
  if (!purpose) throw new Error("purpose must not be empty");
142
- const retentionDays = positiveDays(input.retentionDays);
179
+ const expiresAt =
180
+ input.retentionMinutes !== undefined
181
+ ? addMinutes(now, positiveMinutes(input.retentionMinutes))
182
+ : addDays(now, positiveDays(input.retentionDays));
143
183
  const artifactId = input.id ?? randomUUID();
144
184
  if (!isSafeId(artifactId)) {
145
185
  throw new Error("artifact id must use ASCII letters, digits, hyphens, or underscores");
146
186
  }
187
+ const holds = (input.holds ?? []).map((hold) => makeHold(hold, input.actor, now));
188
+ if (new Set(holds.map((hold) => hold.id)).size !== holds.length) {
189
+ throw new Error("duplicate hold id");
190
+ }
147
191
 
148
192
  const root = artifactsRoot(repoRoot);
149
193
  mkdirSync(root, { recursive: true });
@@ -151,15 +195,16 @@ export function createArtifact(
151
195
  const path = join(root, `${date}_${slug}_${artifactId.slice(0, 8)}`);
152
196
  mkdirSync(path);
153
197
 
154
- const manifest: ArtifactManifestV1 = {
198
+ const manifest: ArtifactManifestV2 = {
155
199
  schema_version: ARTIFACT_SCHEMA_VERSION,
200
+ holds,
156
201
  artifact_id: artifactId,
157
202
  slug,
158
203
  purpose,
159
204
  created_at: now.toISOString(),
160
205
  created_by: input.actor,
161
206
  retention: {
162
- expires_at: addDays(now, retentionDays).toISOString(),
207
+ expires_at: expiresAt.toISOString(),
163
208
  },
164
209
  ...(input.big ? { oversize_acknowledged: true } : {}),
165
210
  };
@@ -201,7 +246,7 @@ export function showArtifact(
201
246
  repoRoot: string,
202
247
  ref: string,
203
248
  opts: { now?: Date; freshnessSeconds?: number } = {},
204
- ): { entry: ArtifactInventoryEntry; manifest: ArtifactManifestV1 } {
249
+ ): { entry: ArtifactInventoryEntry; manifest: ArtifactManifestV2 } {
205
250
  const path = resolveArtifactRef(repoRoot, ref);
206
251
  const entry = inventoryArtifacts(repoRoot, opts).find((row) => row.path === path);
207
252
  if (!entry) throw new Error(`artifact "${ref}" was not found`);
@@ -216,7 +261,19 @@ export function renewArtifact(
216
261
  days: number,
217
262
  reason: string,
218
263
  input: ArtifactMutationInput = {},
219
- ): ArtifactManifestV1 {
264
+ ): ArtifactManifestV2 {
265
+ return withArtifactLock(repoRoot, () =>
266
+ renewArtifactUnlocked(repoRoot, ref, days, reason, input),
267
+ );
268
+ }
269
+
270
+ function renewArtifactUnlocked(
271
+ repoRoot: string,
272
+ ref: string,
273
+ days: number,
274
+ reason: string,
275
+ input: ArtifactMutationInput,
276
+ ): ArtifactManifestV2 {
220
277
  const now = input.now ?? new Date();
221
278
  assertValidDate(now, "now");
222
279
  const retentionDays = positiveDays(days);
@@ -225,7 +282,7 @@ export function renewArtifact(
225
282
  const path = resolveArtifactRef(repoRoot, ref);
226
283
  const parsed = readManifest(path);
227
284
  if (!parsed.ok) throw new Error(parsed.reason);
228
- const manifest: ArtifactManifestV1 = {
285
+ const manifest: ArtifactManifestV2 = {
229
286
  ...parsed.manifest,
230
287
  retention: {
231
288
  expires_at: addDays(now, retentionDays).toISOString(),
@@ -241,13 +298,21 @@ export function releaseArtifact(
241
298
  repoRoot: string,
242
299
  ref: string,
243
300
  input: ArtifactMutationInput = {},
244
- ): ArtifactManifestV1 {
301
+ ): ArtifactManifestV2 {
302
+ return withArtifactLock(repoRoot, () => releaseArtifactUnlocked(repoRoot, ref, input));
303
+ }
304
+
305
+ function releaseArtifactUnlocked(
306
+ repoRoot: string,
307
+ ref: string,
308
+ input: ArtifactMutationInput,
309
+ ): ArtifactManifestV2 {
245
310
  const now = input.now ?? new Date();
246
311
  assertValidDate(now, "now");
247
312
  const path = resolveArtifactRef(repoRoot, ref);
248
313
  const parsed = readManifest(path);
249
314
  if (!parsed.ok) throw new Error(parsed.reason);
250
- const manifest: ArtifactManifestV1 = {
315
+ const manifest: ArtifactManifestV2 = {
251
316
  ...parsed.manifest,
252
317
  released_at: now.toISOString(),
253
318
  released_by: input.actor,
@@ -256,6 +321,58 @@ export function releaseArtifact(
256
321
  return manifest;
257
322
  }
258
323
 
324
+ export function holdArtifact(
325
+ repoRoot: string,
326
+ ref: string,
327
+ input: ArtifactHoldInput & { actor: ArtifactActor; now?: Date },
328
+ ): ArtifactManifestV2 {
329
+ const hold = makeHold(input, input.actor, input.now ?? new Date());
330
+ return withArtifactLock(repoRoot, () => {
331
+ const path = resolveArtifactRef(repoRoot, ref);
332
+ const parsed = readManifest(path);
333
+ if (!parsed.ok) throw new Error(parsed.reason);
334
+ const previous = parsed.manifest.holds.find((item) => item.id === hold.id);
335
+ if (previous) {
336
+ if (
337
+ previous.set_by.instance_id !== input.actor.instance_id ||
338
+ previous.reason !== hold.reason
339
+ ) {
340
+ throw new Error("hold id already exists with a different owner or reason");
341
+ }
342
+ return parsed.manifest;
343
+ }
344
+ const manifest = { ...parsed.manifest, holds: [...parsed.manifest.holds, hold] };
345
+ atomicWriteManifest(path, manifest);
346
+ return manifest;
347
+ });
348
+ }
349
+
350
+ export function unholdArtifact(
351
+ repoRoot: string,
352
+ ref: string,
353
+ id: string,
354
+ input: { actor: ArtifactActor; now?: Date },
355
+ ): ArtifactManifestV2 {
356
+ if (!validHoldId(id)) throw new Error("invalid hold id");
357
+ if (!validActor(input.actor)) throw new Error("a valid hold actor is required");
358
+ return withArtifactLock(repoRoot, () => {
359
+ const path = resolveArtifactRef(repoRoot, ref);
360
+ const parsed = readManifest(path);
361
+ if (!parsed.ok) throw new Error(parsed.reason);
362
+ const hold = parsed.manifest.holds.find((item) => item.id === id);
363
+ if (!hold) return parsed.manifest;
364
+ if (hold.set_by.instance_id !== input.actor.instance_id) {
365
+ throw new Error("only the hold owner may remove this hold");
366
+ }
367
+ const manifest = {
368
+ ...parsed.manifest,
369
+ holds: parsed.manifest.holds.filter((item) => item.id !== id),
370
+ };
371
+ atomicWriteManifest(path, manifest);
372
+ return manifest;
373
+ });
374
+ }
375
+
259
376
  /** Adopt untracked loose files and legacy directories without changing directory paths. */
260
377
  export function adoptUnmanagedArtifactFiles(
261
378
  repoRoot: string,
@@ -267,6 +384,22 @@ export function adoptUnmanagedArtifactFiles(
267
384
  actor?: ArtifactActor;
268
385
  now?: Date;
269
386
  },
387
+ ): ArtifactAdoptionResult {
388
+ if (input.yes)
389
+ return withArtifactLock(repoRoot, () => adoptUnmanagedArtifactFilesUnlocked(repoRoot, input));
390
+ return adoptUnmanagedArtifactFilesUnlocked(repoRoot, input);
391
+ }
392
+
393
+ function adoptUnmanagedArtifactFilesUnlocked(
394
+ repoRoot: string,
395
+ input: {
396
+ yes?: boolean;
397
+ big?: boolean;
398
+ purpose: string;
399
+ retentionDays: number;
400
+ actor?: ArtifactActor;
401
+ now?: Date;
402
+ },
270
403
  ): ArtifactAdoptionResult {
271
404
  const now = input.now ?? new Date();
272
405
  const candidates: ArtifactAdoptionResult["candidates"] = [];
@@ -326,7 +459,7 @@ export function adoptUnmanagedArtifactFiles(
326
459
  }
327
460
  const files = candidates.filter((candidate) => candidate.kind === "file");
328
461
  const created = files.length
329
- ? createArtifact(repoRoot, {
462
+ ? createArtifactUnlocked(repoRoot, {
330
463
  slug: "adopted-unmanaged",
331
464
  purpose: input.purpose,
332
465
  retentionDays: input.retentionDays,
@@ -341,6 +474,7 @@ export function adoptUnmanagedArtifactFiles(
341
474
  for (const candidate of directories) {
342
475
  atomicWriteManifest(candidate.path, {
343
476
  schema_version: ARTIFACT_SCHEMA_VERSION,
477
+ holds: [],
344
478
  artifact_id: randomUUID(),
345
479
  slug: normalizeSlug(candidate.name),
346
480
  purpose: `${input.purpose}: ${candidate.name}`,
@@ -361,6 +495,27 @@ export function adoptUnmanagedArtifactFiles(
361
495
  export function cleanArtifacts(
362
496
  repoRoot: string,
363
497
  opts: { yes?: boolean; now?: Date; freshnessSeconds?: number } = {},
498
+ ): ArtifactInventoryEntry[] {
499
+ if (!opts.yes) return inventoryArtifacts(repoRoot, opts);
500
+ try {
501
+ return withArtifactLock(repoRoot, () => cleanArtifactsUnlocked(repoRoot, opts));
502
+ } catch (error) {
503
+ return inventoryArtifacts(repoRoot, opts).map((row) =>
504
+ row.action === "would-delete"
505
+ ? {
506
+ ...row,
507
+ classification: "unknown",
508
+ action: "keep",
509
+ reason: errorMessage("cleanup refused", error),
510
+ }
511
+ : row,
512
+ );
513
+ }
514
+ }
515
+
516
+ function cleanArtifactsUnlocked(
517
+ repoRoot: string,
518
+ opts: { yes?: boolean; now?: Date; freshnessSeconds?: number },
364
519
  ): ArtifactInventoryEntry[] {
365
520
  const now = opts.now ?? new Date();
366
521
  const freshnessSeconds = opts.freshnessSeconds ?? 600;
@@ -535,6 +690,14 @@ function classifyArtifactPath(
535
690
  oversize_acknowledged: manifest.oversize_acknowledged === true,
536
691
  });
537
692
 
693
+ if (manifest.holds.length > 0) {
694
+ return {
695
+ ...base,
696
+ classification: "managed-held",
697
+ reason: `held: ${manifest.holds.map((hold) => hold.id).join(", ")}`,
698
+ action: "keep",
699
+ };
700
+ }
538
701
  if (base.bytes === null || lastModifiedMs === null) {
539
702
  return {
540
703
  ...base,
@@ -639,17 +802,40 @@ function readManifest(path: string): ParsedManifest | ManifestError {
639
802
  }
640
803
  let value: unknown;
641
804
  try {
805
+ const unit = lstatSync(path);
806
+ const stat = lstatSync(manifestPath);
807
+ if (
808
+ !unit.isDirectory() ||
809
+ unit.isSymbolicLink() ||
810
+ !stat.isFile() ||
811
+ stat.isSymbolicLink() ||
812
+ stat.size > 1024 * 1024
813
+ ) {
814
+ return { ok: false, reason: "manifest must be a bounded regular file in a direct directory" };
815
+ }
642
816
  value = JSON.parse(readFileSync(manifestPath, "utf8"));
643
817
  } catch (error) {
644
818
  return { ok: false, reason: errorMessage("manifest is unreadable", error) };
645
819
  }
820
+ return parseArtifactManifest(value);
821
+ }
822
+
823
+ /** Validate only the current schema. Legacy conversion belongs to migrateArtifacts. */
824
+ export function parseArtifactManifest(value: unknown): ParsedManifest | ManifestError {
646
825
  if (!value || typeof value !== "object" || Array.isArray(value)) {
647
826
  return { ok: false, reason: "manifest must be a JSON object" };
648
827
  }
649
- const m = value as Partial<ArtifactManifestV1>;
828
+ const m = value as Partial<ArtifactManifestV2>;
650
829
  if (m.schema_version !== ARTIFACT_SCHEMA_VERSION) {
651
830
  return { ok: false, reason: `unsupported schema_version ${String(m.schema_version)}` };
652
831
  }
832
+ if (
833
+ !Array.isArray(m.holds) ||
834
+ m.holds.some((hold) => !validHold(hold)) ||
835
+ new Set(m.holds.map((hold) => hold.id)).size !== m.holds.length
836
+ ) {
837
+ return { ok: false, reason: "invalid holds" };
838
+ }
653
839
  if (!isSafeId(m.artifact_id)) return { ok: false, reason: "invalid artifact_id" };
654
840
  if (typeof m.slug !== "string" || !m.slug || normalizeSlug(m.slug) !== m.slug) {
655
841
  return { ok: false, reason: "invalid slug" };
@@ -680,17 +866,169 @@ function readManifest(path: string): ParsedManifest | ManifestError {
680
866
  if (m.oversize_acknowledged !== undefined && typeof m.oversize_acknowledged !== "boolean") {
681
867
  return { ok: false, reason: "invalid oversize_acknowledged" };
682
868
  }
683
- return { ok: true, manifest: m as ArtifactManifestV1 };
869
+ return { ok: true, manifest: m as ArtifactManifestV2 };
684
870
  }
685
871
 
686
- function atomicWriteManifest(path: string, manifest: ArtifactManifestV1): void {
872
+ function atomicWriteManifest(path: string, manifest: ArtifactManifestV2): void {
687
873
  const target = join(path, ARTIFACT_MANIFEST);
688
874
  const tmp = `${target}.tmp.${process.pid}.${randomUUID().slice(0, 8)}`;
689
- writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, {
690
- encoding: "utf8",
691
- mode: 0o600,
692
- });
693
- renameSync(tmp, target);
875
+ try {
876
+ writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, {
877
+ encoding: "utf8",
878
+ mode: 0o600,
879
+ flag: "wx",
880
+ });
881
+ renameSync(tmp, target);
882
+ } finally {
883
+ rmSync(tmp, { force: true });
884
+ }
885
+ }
886
+
887
+ export interface ArtifactMigrationEntry {
888
+ path: string;
889
+ action: "keep" | "would-migrate" | "migrated";
890
+ reason: string;
891
+ preimage_path?: string;
892
+ }
893
+
894
+ /** Explicit, bounded v1 cutover. Preview never writes; every applied unit keeps its exact preimage. */
895
+ export function migrateArtifacts(
896
+ repoRoot: string,
897
+ opts: { yes?: boolean } = {},
898
+ ): ArtifactMigrationEntry[] {
899
+ const migrate = (): ArtifactMigrationEntry[] => {
900
+ const root = artifactsRoot(repoRoot);
901
+ if (!existsSync(root)) return [];
902
+ return readdirSync(root)
903
+ .sort()
904
+ .map((name): ArtifactMigrationEntry => {
905
+ const path = join(root, name);
906
+ try {
907
+ const stat = lstatSync(path);
908
+ if (stat.isSymbolicLink() || !stat.isDirectory())
909
+ throw new Error("not a direct directory");
910
+ if (containsTrackedPath(repoRoot, path))
911
+ throw new Error("artifact contains tracked files");
912
+ const target = join(path, ARTIFACT_MANIFEST);
913
+ const manifestStat = lstatSync(target);
914
+ if (
915
+ !manifestStat.isFile() ||
916
+ manifestStat.isSymbolicLink() ||
917
+ manifestStat.size > 1024 * 1024
918
+ ) {
919
+ throw new Error("manifest is not a bounded regular file");
920
+ }
921
+ const preimage = readFileSync(target, "utf8");
922
+ const legacy = JSON.parse(preimage);
923
+ if (legacy?.schema_version !== 1) {
924
+ return {
925
+ path,
926
+ action: "keep",
927
+ reason: `schema_version ${String(legacy?.schema_version)} is not a migration source`,
928
+ };
929
+ }
930
+ if (Object.hasOwn(legacy, "holds"))
931
+ throw new Error("v1 manifest unexpectedly contains holds");
932
+ const parsed = parseArtifactManifest({
933
+ ...legacy,
934
+ schema_version: ARTIFACT_SCHEMA_VERSION,
935
+ holds: [],
936
+ });
937
+ if (!parsed.ok) throw new Error(parsed.reason);
938
+ const digest = createHash("sha256").update(preimage).digest("hex");
939
+ const preimagePath = join(
940
+ resolve(repoRoot),
941
+ ".harnery/artifact-migrations",
942
+ `${digest}.v1.json`,
943
+ );
944
+ if (!opts.yes)
945
+ return {
946
+ path,
947
+ action: "would-migrate",
948
+ reason: "valid v1 manifest",
949
+ preimage_path: preimagePath,
950
+ };
951
+ mkdirSync(dirname(preimagePath), { recursive: true });
952
+ try {
953
+ writeFileSync(preimagePath, preimage, { flag: "wx", mode: 0o600 });
954
+ } catch (error) {
955
+ if (
956
+ (error as NodeJS.ErrnoException).code !== "EEXIST" ||
957
+ lstatSync(preimagePath).isSymbolicLink() ||
958
+ readFileSync(preimagePath, "utf8") !== preimage
959
+ )
960
+ throw error;
961
+ }
962
+ if (readFileSync(target, "utf8") !== preimage)
963
+ throw new Error("manifest changed before migration");
964
+ atomicWriteManifest(path, parsed.manifest);
965
+ return {
966
+ path,
967
+ action: "migrated",
968
+ reason: "v1 preimage preserved; identity and retention unchanged",
969
+ preimage_path: preimagePath,
970
+ };
971
+ } catch (error) {
972
+ return { path, action: "keep", reason: errorMessage("migration refused", error) };
973
+ }
974
+ });
975
+ };
976
+ return opts.yes ? withArtifactLock(repoRoot, migrate) : migrate();
977
+ }
978
+
979
+ /** A persistent directory lock fails closed on contention or a crashed owner. */
980
+ function withArtifactLock<T>(repoRoot: string, action: () => T): T {
981
+ const lock = join(resolve(repoRoot), ".harnery/artifacts-mutation.lock");
982
+ mkdirSync(dirname(lock), { recursive: true });
983
+ try {
984
+ mkdirSync(lock);
985
+ } catch (error) {
986
+ throw new Error(
987
+ errorMessage(
988
+ "artifact store mutation lock unavailable; retry after its owner finishes",
989
+ error,
990
+ ),
991
+ );
992
+ }
993
+ try {
994
+ return action();
995
+ } finally {
996
+ rmdirSync(lock);
997
+ }
998
+ }
999
+
1000
+ function validHoldId(value: unknown): value is string {
1001
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(value);
1002
+ }
1003
+
1004
+ function validHold(value: unknown): value is ArtifactHold {
1005
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1006
+ const hold = value as Partial<ArtifactHold>;
1007
+ return (
1008
+ validHoldId(hold.id) &&
1009
+ typeof hold.reason === "string" &&
1010
+ !!hold.reason.trim() &&
1011
+ validActor(hold.set_by) &&
1012
+ validIso(hold.set_at)
1013
+ );
1014
+ }
1015
+
1016
+ function makeHold(
1017
+ input: ArtifactHoldInput,
1018
+ actor: ArtifactActor | undefined,
1019
+ now: Date,
1020
+ ): ArtifactHold {
1021
+ assertValidDate(now, "now");
1022
+ if (!validActor(actor)) throw new Error("a valid hold actor is required");
1023
+ if (!validHoldId(input.id)) throw new Error("invalid hold id");
1024
+ if (typeof input.reason !== "string" || !input.reason.trim())
1025
+ throw new Error("hold reason must not be empty");
1026
+ return {
1027
+ id: input.id,
1028
+ reason: input.reason.trim(),
1029
+ set_by: { ...actor },
1030
+ set_at: now.toISOString(),
1031
+ };
694
1032
  }
695
1033
 
696
1034
  function ownerLiveness(
@@ -815,6 +1153,17 @@ function addDays(date: Date, days: number): Date {
815
1153
  return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
816
1154
  }
817
1155
 
1156
+ function positiveMinutes(value: number): number {
1157
+ if (!Number.isInteger(value) || value <= 0 || value > 3650 * 24 * 60) {
1158
+ throw new Error("retention minutes must be between 1 and 5256000");
1159
+ }
1160
+ return value;
1161
+ }
1162
+
1163
+ function addMinutes(date: Date, minutes: number): Date {
1164
+ return new Date(date.getTime() + minutes * 60 * 1000);
1165
+ }
1166
+
818
1167
  function isSafeId(value: unknown): value is string {
819
1168
  return (
820
1169
  typeof value === "string" &&
@@ -154,6 +154,14 @@ interface HarneryConfig {
154
154
  max_bytes?: number;
155
155
  max_unit_bytes?: number;
156
156
  };
157
+ /**
158
+ * Page review packs. `auto_clean` lets `qa-run` and `review-pack create`
159
+ * delete expired packs in the artifact store before they start. Read via
160
+ * `reviewPackAutoCleanEnabled()`; ships off.
161
+ */
162
+ review_pack?: {
163
+ auto_clean?: boolean;
164
+ };
157
165
  /**
158
166
  * `harn backup` (restic) defaults: `repo` path/URL, `password_file`, and the
159
167
  * `keep_daily`/`keep_weekly`/`keep_monthly` prune policy. Read via `backupConfig()`.
@@ -801,6 +809,21 @@ export function artifactAutoCleanEnabled(coordRoot?: string | null): boolean {
801
809
  return readConfig(root).artifacts?.auto_clean !== false;
802
810
  }
803
811
 
812
+ /**
813
+ * Whether `qa-run` and `review-pack create` sweep expired page review packs
814
+ * out of the artifact store before starting. Precedence:
815
+ * `HARNERY_REVIEW_PACK_AUTO_CLEAN` (1/true enables, 0/false disables) ->
816
+ * `review_pack.auto_clean` -> disabled. The sweep deletes only packs whose
817
+ * manifest says `managed: true` with `retention.expires_at` in the past.
818
+ */
819
+ export function reviewPackAutoCleanEnabled(coordRoot?: string | null): boolean {
820
+ const env = coordEnv("REVIEW_PACK_AUTO_CLEAN");
821
+ if (env !== undefined) return env === "1" || env.toLowerCase() === "true";
822
+ const root = coordRoot ?? findCoordRoot();
823
+ if (!root) return false;
824
+ return readConfig(root).review_pack?.auto_clean === true;
825
+ }
826
+
804
827
  /**
805
828
  * Sweep cadence in hours. Env-only (`HARNERY_ARTIFACT_AUTO_CLEAN_INTERVAL_HOURS`,
806
829
  * mainly for tests): the cadence is an implementation detail, not policy, and