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,296 @@
1
+ // Durable job records: a small on-disk representation of "a job that outlives
2
+ // the client that launched it". A supervisor process owns the record; a client
3
+ // only reads it. A bridge drop, a closed terminal, or a killed client
4
+ // therefore interrupts the *view* of the job, never the job itself.
5
+ //
6
+ // Layout, under a caller-supplied base directory:
7
+ //
8
+ // <base>/jobs/<job_id>/job.json immutable description (argv, cwd, resource)
9
+ // <base>/jobs/<job_id>/status.json mutable state, rewritten atomically
10
+ // <base>/jobs/<job_id>/job.log merged child stdout + stderr
11
+ //
12
+ // Classification is fail-closed, mirroring qa-status: liveness is proven by the
13
+ // PID, never assumed from the status document. A non-terminal state whose PID
14
+ // is dead classifies as "dead", never as "running".
15
+ //
16
+ // Toolkit tier: this module must not import src/core (layering check).
17
+ import { randomUUID } from "node:crypto";
18
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
19
+ import { join, resolve } from "node:path";
20
+ export const DURABLE_JOB_SCHEMA_VERSION = 1;
21
+ /** How often the supervisor rewrites status.json while a job runs. */
22
+ export const DURABLE_JOB_HEARTBEAT_MS = 15_000;
23
+ /** A running job whose heartbeat is older than this earns a staleness warning,
24
+ * but stays "running" while its PID is alive. */
25
+ export const DURABLE_JOB_HEARTBEAT_STALE_MS = 120_000;
26
+ export const DURABLE_JOB_DOCUMENT_FILENAME = "job.json";
27
+ export const DURABLE_JOB_STATUS_FILENAME = "status.json";
28
+ export const DURABLE_JOB_LOG_FILENAME = "job.log";
29
+ const JOBS_DIRNAME = "jobs";
30
+ const WRITTEN_STATES = new Set(["launching", "queued", "running", "completed"]);
31
+ function isRecord(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ /** Default PID liveness probe. EPERM counts as alive; only ESRCH is dead. */
35
+ function defaultPidAlive(pid) {
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ }
40
+ catch (err) {
41
+ return err.code !== "ESRCH";
42
+ }
43
+ }
44
+ /** Directory holding every job record under a base directory. */
45
+ export function jobsRoot(base) {
46
+ return join(resolve(base), JOBS_DIRNAME);
47
+ }
48
+ /** Directory of one job record. Does not create it. */
49
+ export function jobDirFor(base, jobId) {
50
+ return join(jobsRoot(base), jobId);
51
+ }
52
+ /** Create (idempotently) the directory for one job and return its path. */
53
+ export function createJobDir(base, jobId) {
54
+ const dir = jobDirFor(base, jobId);
55
+ mkdirSync(dir, { recursive: true });
56
+ return dir;
57
+ }
58
+ export function jobLogPath(jobDir) {
59
+ return join(resolve(jobDir), DURABLE_JOB_LOG_FILENAME);
60
+ }
61
+ function writeAtomic(path, body) {
62
+ const tmp = `${path}.${randomUUID().slice(0, 8)}.tmp`;
63
+ writeFileSync(tmp, body);
64
+ renameSync(tmp, path);
65
+ }
66
+ /** Write the immutable job description. */
67
+ export function writeJobDocument(jobDir, document) {
68
+ writeAtomic(join(resolve(jobDir), DURABLE_JOB_DOCUMENT_FILENAME), `${JSON.stringify(document, null, 2)}\n`);
69
+ }
70
+ /** Replace the status document atomically, so a reader never sees a torn file. */
71
+ export function writeJobStatus(jobDir, status) {
72
+ writeAtomic(join(resolve(jobDir), DURABLE_JOB_STATUS_FILENAME), `${JSON.stringify(status, null, 2)}\n`);
73
+ }
74
+ function readJson(path) {
75
+ return JSON.parse(readFileSync(path, "utf8"));
76
+ }
77
+ /** Read the job description, or null when it is missing or unusable. */
78
+ export function readJobDocument(jobDir) {
79
+ const path = join(resolve(jobDir), DURABLE_JOB_DOCUMENT_FILENAME);
80
+ let parsed;
81
+ try {
82
+ parsed = readJson(path);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ if (!isRecord(parsed))
88
+ return null;
89
+ if (typeof parsed.job_id !== "string")
90
+ return null;
91
+ return {
92
+ schema_version: typeof parsed.schema_version === "number"
93
+ ? parsed.schema_version
94
+ : DURABLE_JOB_SCHEMA_VERSION,
95
+ job_id: parsed.job_id,
96
+ resource: typeof parsed.resource === "string" ? parsed.resource : "",
97
+ capacity: typeof parsed.capacity === "number" ? parsed.capacity : 1,
98
+ label: typeof parsed.label === "string" ? parsed.label : "",
99
+ argv: Array.isArray(parsed.argv)
100
+ ? parsed.argv.filter((a) => typeof a === "string")
101
+ : [],
102
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : "",
103
+ created_at: typeof parsed.created_at === "string" ? parsed.created_at : "",
104
+ };
105
+ }
106
+ /** Read the status document, or null when it is missing or unusable. */
107
+ export function readJobStatus(jobDir) {
108
+ const path = join(resolve(jobDir), DURABLE_JOB_STATUS_FILENAME);
109
+ let parsed;
110
+ try {
111
+ parsed = readJson(path);
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ if (!isRecord(parsed))
117
+ return null;
118
+ if (typeof parsed.job_id !== "string")
119
+ return null;
120
+ if (typeof parsed.state !== "string" || !WRITTEN_STATES.has(parsed.state))
121
+ return null;
122
+ const queue = parsed.queue;
123
+ return {
124
+ schema_version: typeof parsed.schema_version === "number"
125
+ ? parsed.schema_version
126
+ : DURABLE_JOB_SCHEMA_VERSION,
127
+ job_id: parsed.job_id,
128
+ pid: typeof parsed.pid === "number" ? parsed.pid : 0,
129
+ state: parsed.state,
130
+ started_at: typeof parsed.started_at === "string" ? parsed.started_at : "",
131
+ updated_at: typeof parsed.updated_at === "string" ? parsed.updated_at : "",
132
+ ...(isRecord(queue) &&
133
+ typeof queue.resource === "string" &&
134
+ typeof queue.waiting_since === "string"
135
+ ? { queue: { resource: queue.resource, waiting_since: queue.waiting_since } }
136
+ : {}),
137
+ ...(typeof parsed.exit_code === "number" || parsed.exit_code === null
138
+ ? { exit_code: parsed.exit_code }
139
+ : {}),
140
+ ...(typeof parsed.signal === "string" || parsed.signal === null
141
+ ? { signal: parsed.signal }
142
+ : {}),
143
+ };
144
+ }
145
+ /**
146
+ * Classify one job directory, fail-closed. "completed" is terminal and carries
147
+ * the wrapped command's exit code. Any other written state is trusted only
148
+ * while the supervisor PID is alive; otherwise the job is "dead" — the record
149
+ * outlived the process that owned it.
150
+ */
151
+ export function classifyJob(jobDir, deps = {}) {
152
+ const isAlive = deps.pidAlive ?? defaultPidAlive;
153
+ const nowMs = deps.now ? deps.now() : Date.now();
154
+ const abs = resolve(jobDir);
155
+ if (!existsSync(abs))
156
+ return { ok: false, error: `no such job directory: ${abs}` };
157
+ const statusPath = join(abs, DURABLE_JOB_STATUS_FILENAME);
158
+ if (!existsSync(statusPath)) {
159
+ return {
160
+ ok: false,
161
+ error: `${abs} carries no ${DURABLE_JOB_STATUS_FILENAME}: not a job directory`,
162
+ };
163
+ }
164
+ const status = readJobStatus(abs);
165
+ if (!status) {
166
+ return { ok: false, error: `${statusPath} is not a readable job status document` };
167
+ }
168
+ const document = readJobDocument(abs);
169
+ let heartbeatAgeMs = null;
170
+ const updatedMs = Date.parse(status.updated_at);
171
+ if (!Number.isNaN(updatedMs))
172
+ heartbeatAgeMs = Math.max(0, nowMs - updatedMs);
173
+ const base = {
174
+ job_id: status.job_id,
175
+ pid: status.pid > 0 ? status.pid : null,
176
+ resource: document?.resource ?? null,
177
+ label: document?.label ?? null,
178
+ argv: document?.argv ?? null,
179
+ created_at: document?.created_at ?? null,
180
+ started_at: status.started_at || null,
181
+ updated_at: status.updated_at || null,
182
+ heartbeat_age_ms: heartbeatAgeMs,
183
+ job_dir: abs,
184
+ log_path: jobLogPath(abs),
185
+ };
186
+ if (status.state === "completed") {
187
+ return {
188
+ ok: true,
189
+ report: {
190
+ ...base,
191
+ state: "completed",
192
+ terminal: true,
193
+ exit_code: status.exit_code ?? null,
194
+ signal: status.signal ?? null,
195
+ warnings: [],
196
+ },
197
+ };
198
+ }
199
+ if (!(status.pid > 0 && isAlive(status.pid))) {
200
+ return {
201
+ ok: true,
202
+ report: {
203
+ ...base,
204
+ state: "dead",
205
+ terminal: false,
206
+ exit_code: null,
207
+ signal: null,
208
+ ...(status.queue ? { queue: status.queue } : {}),
209
+ warnings: [],
210
+ },
211
+ };
212
+ }
213
+ const warnings = [];
214
+ if (status.state === "running" &&
215
+ heartbeatAgeMs !== null &&
216
+ heartbeatAgeMs > DURABLE_JOB_HEARTBEAT_STALE_MS) {
217
+ warnings.push(`heartbeat stale: last update ${Math.round(heartbeatAgeMs / 1000)}s ago ` +
218
+ `(threshold ${DURABLE_JOB_HEARTBEAT_STALE_MS / 1000}s), but pid ${status.pid} is alive`);
219
+ }
220
+ return {
221
+ ok: true,
222
+ report: {
223
+ ...base,
224
+ state: status.state,
225
+ terminal: false,
226
+ exit_code: null,
227
+ signal: null,
228
+ ...(status.queue ? { queue: status.queue } : {}),
229
+ warnings,
230
+ },
231
+ };
232
+ }
233
+ /**
234
+ * Every job record under a base directory, newest first. Ordering keys on the
235
+ * description's created_at, falling back to the status document's started_at,
236
+ * then to the directory name, so a record missing its description still lists.
237
+ */
238
+ export function listJobs(base, deps = {}) {
239
+ const root = jobsRoot(base);
240
+ let names;
241
+ try {
242
+ names = readdirSync(root, { withFileTypes: true })
243
+ .filter((entry) => entry.isDirectory())
244
+ .map((entry) => entry.name);
245
+ }
246
+ catch {
247
+ return [];
248
+ }
249
+ const entries = [];
250
+ for (const name of names) {
251
+ const dir = join(root, name);
252
+ const document = readJobDocument(dir);
253
+ const classified = classifyJob(dir, deps);
254
+ const createdAt = document?.created_at || (classified.ok ? classified.report.started_at : null) || null;
255
+ const parsed = createdAt !== null ? Date.parse(createdAt) : Number.NaN;
256
+ entries.push({
257
+ entry: {
258
+ job_id: document?.job_id ?? name,
259
+ job_dir: dir,
260
+ created_at: createdAt,
261
+ document,
262
+ report: classified.ok ? classified.report : null,
263
+ ...(classified.ok ? {} : { error: classified.error }),
264
+ },
265
+ sortKey: Number.isNaN(parsed) ? 0 : parsed,
266
+ });
267
+ }
268
+ entries.sort((a, b) => {
269
+ if (b.sortKey !== a.sortKey)
270
+ return b.sortKey - a.sortKey;
271
+ return b.entry.job_id.localeCompare(a.entry.job_id);
272
+ });
273
+ return entries.map((item) => item.entry);
274
+ }
275
+ /** Describe a job's outcome as a process exit code: the wrapped command's own
276
+ * code, 1 when it died on a signal, 4 when the record is dead. */
277
+ export function jobExitCode(report) {
278
+ if (report.state === "dead")
279
+ return 4;
280
+ if (!report.terminal)
281
+ return 5;
282
+ if (report.signal !== null)
283
+ return 1;
284
+ return report.exit_code ?? 1;
285
+ }
286
+ /** Format a duration the way the status commands do. */
287
+ export function formatJobAge(ms) {
288
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
289
+ if (totalSeconds < 60)
290
+ return `${totalSeconds}s`;
291
+ const minutes = Math.floor(totalSeconds / 60);
292
+ if (minutes < 60)
293
+ return `${minutes}m ${totalSeconds % 60}s`;
294
+ const hours = Math.floor(minutes / 60);
295
+ return `${hours}h ${minutes % 60}m`;
296
+ }
@@ -1,4 +1,4 @@
1
- import type { CookieJar } from "../cookies/index.js";
1
+ import { type CookieJar, type ExtraCookies } from "../cookies/index.js";
2
2
  /**
3
3
  * Simple cookie-aware HTTP client.
4
4
  *
@@ -36,6 +36,12 @@ export interface FetchOptions<TResponseType extends FetchResponseType = "text">
36
36
  * only land when the key isn't already set.
37
37
  */
38
38
  extraHeaders?: (url: string) => Record<string, string>;
39
+ /**
40
+ * Optional callback that returns extra cookies to persist into `jar`
41
+ * before the Cookie header is built. Ignored when no jar is passed
42
+ * (`--no-cookies`). Must stay synchronous.
43
+ */
44
+ extraCookies?: ExtraCookies;
39
45
  /** Response body representation. Default `text`; use `bytes` for lossless file output. */
40
46
  responseType?: TResponseType;
41
47
  }
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/lib/http/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAE7D;;;;;;;;;GASG;AAEH,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAC;AAEjD,MAAM,WAAW,YAAY,CAAC,aAAa,SAAS,iBAAiB,GAAG,MAAM;IAC5E,gCAAgC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;IACvB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC;;;;;OAKG;IACH,GAAG,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IACvB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,8CAA8C;IAC9C,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvD,0FAA0F;IAC1F,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B;AAED,KAAK,SAAS,CAAC,aAAa,SAAS,iBAAiB,IAAI,aAAa,SAAS,OAAO,GACnF,UAAU,GACV,MAAM,CAAC;AAEX,MAAM,WAAW,WAAW,CAAC,KAAK,GAAG,MAAM;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,KAAK,CAAC;IACZ,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,aAAa,SAAS,iBAAiB,GAAG,MAAM,EACjF,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,YAAY,CAAC,aAAa,CAAM,GACrC,OAAO,CAAC,WAAW,CAAC,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC,CA8EhD"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/lib/http/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,SAAS,EACd,KAAK,YAAY,EAClB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;;;GASG;AAEH,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAC;AAEjD,MAAM,WAAW,YAAY,CAAC,aAAa,SAAS,iBAAiB,GAAG,MAAM;IAC5E,gCAAgC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;IACvB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC;;;;;OAKG;IACH,GAAG,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IACvB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,8CAA8C;IAC9C,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvD;;;;OAIG;IACH,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,0FAA0F;IAC1F,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B;AAED,KAAK,SAAS,CAAC,aAAa,SAAS,iBAAiB,IAAI,aAAa,SAAS,OAAO,GACnF,UAAU,GACV,MAAM,CAAC;AAEX,MAAM,WAAW,WAAW,CAAC,KAAK,GAAG,MAAM;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,KAAK,CAAC;IACZ,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,aAAa,SAAS,iBAAiB,GAAG,MAAM,EACjF,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,YAAY,CAAC,aAAa,CAAM,GACrC,OAAO,CAAC,WAAW,CAAC,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC,CA+EhD"}
@@ -1,3 +1,4 @@
1
+ import { applyExtraCookies, } from "../cookies/index.js";
1
2
  /**
2
3
  * Fetch a URL with optional cookie-jar attach + persist.
3
4
  *
@@ -9,6 +10,7 @@
9
10
  export async function fetchWithJar(url, opts = {}) {
10
11
  const headers = { ...(opts.headers ?? {}) };
11
12
  if (opts.jar) {
13
+ applyExtraCookies(url, opts.jar, opts.extraCookies);
12
14
  const cookieHeader = opts.jar.header(url);
13
15
  if (cookieHeader && !headers.Cookie && !headers.cookie) {
14
16
  headers.Cookie = cookieHeader;
@@ -1 +1 @@
1
- {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../../../src/lib/instructions/templates.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,+DAA+D;AAC/D,eAAO,MAAM,mBAAmB,iBAAiB,CAAC;AAClD,sEAAsE;AACtE,eAAO,MAAM,aAAa,WAAW,CAAC;AACtC;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,kBAAkB,CAAC;AAEpD,2EAA2E;AAC3E,MAAM,WAAW,WAAW;IAC1B,8CAA8C;IAC9C,MAAM,EAAE,OAAO,CAAC;IAChB,+CAA+C;IAC/C,OAAO,EAAE,OAAO,CAAC;IACjB,2CAA2C;IAC3C,GAAG,EAAE,OAAO,CAAC;IACb,4CAA4C;IAC5C,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,MAAM,EACf,MAAM,GAAE,WAAoE,GAC3E,MAAM,CAgHR;AAID,mFAAmF;AACnF,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;CACrC;AAmVD,eAAO,MAAM,MAAM,EAAE,aAAa,EAiDjC,CAAC"}
1
+ {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../../../src/lib/instructions/templates.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,+DAA+D;AAC/D,eAAO,MAAM,mBAAmB,iBAAiB,CAAC;AAClD,sEAAsE;AACtE,eAAO,MAAM,aAAa,WAAW,CAAC;AACtC;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,kBAAkB,CAAC;AAEpD,2EAA2E;AAC3E,MAAM,WAAW,WAAW;IAC1B,8CAA8C;IAC9C,MAAM,EAAE,OAAO,CAAC;IAChB,+CAA+C;IAC/C,OAAO,EAAE,OAAO,CAAC;IACjB,2CAA2C;IAC3C,GAAG,EAAE,OAAO,CAAC;IACb,4CAA4C;IAC5C,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,MAAM,EACf,MAAM,GAAE,WAAoE,GAC3E,MAAM,CAgHR;AAID,mFAAmF;AACnF,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;CACrC;AAsVD,eAAO,MAAM,MAAM,EAAE,aAAa,EAiDjC,CAAC"}
@@ -248,10 +248,13 @@ auto-route into contribute.
248
248
  ## Create (\`create <objective>\`)
249
249
 
250
250
  The web UI is the member + steward picker; don't create from the CLI (that skips
251
- the steward choice). Emit the link with the objective URL-encoded:
251
+ the steward choice). Read the dashboard base URL from the active Harnery web
252
+ status or start output, which resolves the project's configured \`web.port\`.
253
+ Append the URL-encoded objective to this path; never assume a port or reuse a
254
+ dashboard URL from another project:
252
255
 
253
256
  \`\`\`
254
- http://localhost:4276/councils/new?objective=<encoded>
257
+ <configured-dashboard-base-url>/councils/new?objective=<encoded>
255
258
  \`\`\`
256
259
 
257
260
  If the dev server isn't up, start it with \`${b} web up\`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -152,6 +152,11 @@
152
152
  "types": "./dist/core/policy/index.d.ts",
153
153
  "import": "./dist/core/policy/index.js"
154
154
  },
155
+ "./lib/admission": {
156
+ "bun": "./src/lib/admission.ts",
157
+ "types": "./dist/lib/admission.d.ts",
158
+ "import": "./dist/lib/admission.js"
159
+ },
155
160
  "./lib/agent-browser": {
156
161
  "bun": "./src/lib/agent-browser/index.ts",
157
162
  "types": "./dist/lib/agent-browser/index.d.ts",
@@ -230,7 +235,7 @@
230
235
  "access": "public",
231
236
  "provenance": true
232
237
  },
233
- "packageManager": "bun@1.3.3",
238
+ "packageManager": "bun@1.4.0",
234
239
  "scripts": {
235
240
  "build": "npm run clean && tsc -p tsconfig.build.json && node scripts/rewrite-dts-extensions.mjs",
236
241
  "clean": "rm -rf dist",
@@ -244,6 +249,7 @@
244
249
  "lint:fix": "bun x @biomejs/biome check --write src",
245
250
  "format": "bun x @biomejs/biome format --write src",
246
251
  "test": "bun run scripts/test.mjs",
252
+ "test:list": "bun run scripts/test.mjs --list",
247
253
  "test:web": "cd web && bun install && bun test",
248
254
  "test:integration": "bash tests/integration/run.sh",
249
255
  "smoke": "node scripts/smoke-test.mjs",
package/src/commander.ts CHANGED
@@ -121,6 +121,15 @@ export interface HarneryProgramContext {
121
121
  * zones behind a WAF). harn standalone skips the callback entirely.
122
122
  */
123
123
  extraHeaders?: (url: string) => Record<string, string>;
124
+ /**
125
+ * Optional callback that returns extra cookies to persist into the shared
126
+ * jar before `fetch` / `browse` attach it. Runs only when a jar is in
127
+ * play (`--no-cookies` skips it). Use this for host-minted session
128
+ * cookies; `extraHeaders` cannot merge a `Cookie` header once the jar
129
+ * has already set one. Must stay synchronous, same as `extraHeaders`.
130
+ * harn standalone skips the callback entirely.
131
+ */
132
+ extraCookies?: import("./lib/cookies/index.ts").ExtraCookies;
124
133
  /**
125
134
  * Host-injected vision-model call for `browse --check-critique`. harnery
126
135
  * ships no model client or API key; a consumer wires this to its own
@@ -443,9 +452,48 @@ function harneryCommandBundles({
443
452
  lazy(
444
453
  "qa-run <target>",
445
454
  `Run the whole page-QA matrix in one command (planner, deterministic gates per context, interactions, critique, snapshot) via child \`${binName} browse\` processes; writes page-qa-result.json with a fail-closed verdict.`,
446
- async (program) => (await import("./commands/qa-run.ts")).registerQaRunCommand(program, emit),
455
+ async (program) =>
456
+ (await import("./commands/qa-run.ts")).registerQaRunCommand(program, emit, context),
457
+ { hasOptions: true },
458
+ ),
459
+ lazy(
460
+ "review-pack",
461
+ "Page review packs: capture a page's rendering contexts (tiles, DOM, signature) into a directory an agent can review without a browser, and judge a pack from disk through one bounded pool of vision calls.",
462
+ async (program) =>
463
+ (await import("./commands/review-pack.ts")).registerReviewPackCommand(
464
+ program,
465
+ emit,
466
+ context,
467
+ ),
468
+ { hasOptions: true },
469
+ ),
470
+ lazy(
471
+ "qa-verify <path>",
472
+ "Verify a qa-run result is fresh evidence for a specific invocation: match its run identity (run_id, job digest, tested revision, age, recorded output directory) against expectations. Fail-closed on missing identity.",
473
+ async (program) =>
474
+ (await import("./commands/qa-verify.ts")).registerQaVerifyCommand(program, emit),
447
475
  { hasOptions: true },
448
476
  ),
477
+ lazy(
478
+ "qa-record <target>",
479
+ "Record hand-performed page QA as a standard result document: same schema, run identity, stage timings, and freshness guarantees as qa-run, marked evidence_source manual so it can report a defect but never a pass.",
480
+ async (program) =>
481
+ (await import("./commands/qa-record.ts")).registerQaRecordCommand(program, emit, context),
482
+ { hasOptions: true },
483
+ ),
484
+ lazy(
485
+ "qa-status [path]",
486
+ "Report a qa-run's live state (queued/launching/running/completed/dead) from its run-status.json, PID liveness, and result document; --wait reconnects to a detached run and exits with its verdict; --queue shows the machine-wide browser-qa admission queue.",
487
+ async (program) =>
488
+ (await import("./commands/qa-status.ts")).registerQaStatusCommand(program, emit, context),
489
+ { hasOptions: true },
490
+ ),
491
+ lazy(
492
+ "admission",
493
+ "Machine-wide admission control for heavy jobs: `admission run --resource <name> -- <command…>` waits for a FIFO slot then runs the command; `admission status` lists holders and waiters per resource.",
494
+ async (program) =>
495
+ (await import("./commands/admission.ts")).registerAdmissionCommand(program, emit),
496
+ ),
449
497
  lazy(
450
498
  "browse-session",
451
499
  "Control an opted-in headed browse session through an owner-only descriptor",
@@ -456,7 +504,7 @@ function harneryCommandBundles({
456
504
  "browse-ai <url>",
457
505
  `agent-browser (Vercel Labs) wrapper: accessibility-tree snapshots with element refs (@e1, @e2) for LLM consumption. Daemon-mode: successive calls reuse the same browser instance. Sister to ${binName} browse (Playwright).`,
458
506
  async (program) =>
459
- (await import("./commands/browse-ai.ts")).registerBrowseAiCommand(program, emit),
507
+ (await import("./commands/browse-ai.ts")).registerBrowseAiCommand(program, emit, context),
460
508
  { hasOptions: true },
461
509
  ),
462
510
  {