@volter/supercode-orchestrator 0.5.228 → 0.5.229

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 (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
package/board/ci.mjs CHANGED
@@ -1,13 +1,15 @@
1
1
  // Review candidates are observed before a board mutation. No network or Git
2
2
  // process runs inside the optimistic transaction. Deployment is not a completion gate.
3
- import { execFile } from 'node:child_process';
3
+ import { runtimeDoor } from './runtime-door.mjs';
4
+ import { execFile, spawn } from 'node:child_process';
4
5
  import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from 'node:fs';
5
6
  import { dirname, join, relative } from 'node:path';
6
7
  import { createHash } from 'node:crypto';
7
8
  import { AsyncLocalStorage } from 'node:async_hooks';
9
+ import { isDeepStrictEqual } from 'node:util';
8
10
  import { branchCheckouts, checkoutOf, isLocalWorkspace, isWorkspacePath, resolveWorkspace, plainWorkspace, workspaceTask } from './workspace.mjs';
9
11
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
10
- import { githubFetch, githubDiscovery, githubDiscoveryHeld, githubDiscoveryUsed, GITHUB_DISCOVERY_ATTEMPTS } from './github-rest.mjs';
12
+ import { githubFetch, githubDiscovery, githubDiscoveryHeld, githubDiscoveryObservedAt, githubDiscoveryUsed, githubSourceFact, GITHUB_DISCOVERY_ATTEMPTS } from './github-rest.mjs';
11
13
 
12
14
  // GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
13
15
  // waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
@@ -127,12 +129,20 @@ const actionReads = new AsyncLocalStorage();
127
129
  /** Fresh observations belong to one action only; callers and later actions never share this snapshot. */
128
130
  export const withFreshReviewReads = (fn) => actionReads.getStore() ? fn() : actionReads.run(new Map(), fn);
129
131
  async function github(path, all = false, { purpose = 'validation' } = {}) {
130
- const reads = purpose === 'validation' && actionReads.getStore();
132
+ const reads = actionReads.getStore();
131
133
  if (!reads) return githubUncached(path, all, { purpose });
132
134
  const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${all}\0${path}`;
133
135
  if (!reads.has(key)) reads.set(key, githubUncached(path, all, { purpose }));
134
136
  return reads.get(key);
135
137
  }
138
+ // Retain the provider's observation clock beside the returned object. Durable history
139
+ // watermarks must share GitHub updated_at's clock, including when this action coalesces a read.
140
+ const githubObservationTimes = new WeakMap();
141
+ function historyObservedAt(list) {
142
+ const at = githubObservationTimes.get(list);
143
+ if (!Number.isFinite(at)) throw new Error('GitHub history has no provider Date observation; its completeness is unknown');
144
+ return at;
145
+ }
136
146
  async function wireRead(fn) {
137
147
  const release = await admitGithubRead();
138
148
  try { return await fn(); } finally { release(); }
@@ -146,7 +156,7 @@ async function githubUncached(path, all = false, { purpose = 'validation' } = {}
146
156
  const context = admitGithubContext(rateKey);
147
157
  if (!context) return { code: 'github_read_admission_full', error: 'eight GitHub origin/credential contexts are active; not requested' };
148
158
  try {
149
- let url = base + path, out = [];
159
+ let url = base + path, out = [], observedAt = Infinity;
150
160
  for (let page = 0; url && page < 10; page += 1) {
151
161
  // No await separates admission from fetch. Every page observes a
152
162
  // refusal retained while an earlier page or another read was in flight.
@@ -162,15 +172,17 @@ async function githubUncached(path, all = false, { purpose = 'validation' } = {}
162
172
  }
163
173
  // An older in-flight success is not recovery from a newer refusal.
164
174
  if (context.rateLimit && Date.now() >= context.rateLimit.until) context.rateLimit = null;
165
- return { body: await r.json(), next: /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null };
175
+ return { observedAt: Date.parse(r.headers.get('date')), body: await r.json(), next: /<([^>]+)>;\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null };
166
176
  });
167
177
  if (pageRead.result) return pageRead.result;
168
178
  const body = pageRead.body;
169
- if (!all) return { body };
179
+ observedAt = Math.min(observedAt, pageRead.observedAt);
180
+ if (!all) return { body, observedAt };
170
181
  out = out.concat(body);
171
182
  url = pageRead.next;
172
183
  }
173
- return { body: out };
184
+ if (url) throw new Error('GitHub list exceeds ten pages; source set remains unknown');
185
+ return { body: out, observedAt };
174
186
  } finally {
175
187
  context.readers -= 1;
176
188
  if (!context.readers && !context.rateLimit) githubReadContexts.delete(rateKey);
@@ -188,16 +200,17 @@ async function githubUncached(path, all = false, { purpose = 'validation' } = {}
188
200
  throw Object.assign(new Error(`GitHub: ${result.error.trim().slice(-500)}${nativeCode ? ` (${nativeCode})` : ''}`, { cause: result.cause }), result.rateLimit
189
201
  ? { code: 'github_rate_limited', status: result.status, retry_at: result.rateLimit.retryAt, deferred: true }
190
202
  : result.code ? { code: result.code, deferred: result.code === 'github_read_admission_full' }
191
- : result.cause?.code ? { code: result.cause.code, deferred: result.cause.code === 'GITHUB_BUDGET' }
203
+ : result.cause?.code ? { code: result.cause.code, deferred: Boolean(result.cause.deferred), retry_at: result.cause.retry_at }
192
204
  : result.status ? { status: result.status } : {});
193
205
  }
206
+ if (result.body && typeof result.body === 'object') githubObservationTimes.set(result.body, result.observedAt);
194
207
  return result.body;
195
208
  }
196
209
  const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
197
210
  const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
198
211
  /** A PR as the board reads it (gh's field names). */
199
212
  const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
200
- headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
213
+ headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft), updatedAt: p.updated_at });
201
214
  const discoveryIdentity = async key => `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
202
215
  const discoveryRead = async (key, ttl, read, options) => githubDiscovery(await discoveryIdentity(key), ttl, read, options);
203
216
  const readPullRequest = async (url) => discoverPullRequest(url, { fresh: true });
@@ -212,13 +225,44 @@ const MERGED_TTL = 7 * 86400_000, CLOSED_TTL = 6 * 3600_000;
212
225
  /** A long retention lengthened by up to a quarter, fixed for each key: entries one scan filled together (a cold strict
213
226
  * scan reads an arc's every declared PR and earlier branch at once) end over that quarter, not in the same round. */
214
227
  const spread = (ttl, key) => ttl + Number.parseInt(createHash('sha256').update(key).digest('hex').slice(0, 8), 16) % Math.floor(ttl / 4);
228
+ const sourceFact = async (key, value) => githubSourceFact(await discoveryIdentity(key), value);
229
+ const mergedFact = async (url, value) => {
230
+ const key = `merged:${pullRequestKey(url)}`;
231
+ const held = await sourceFact(key);
232
+ if (value?.state === 'MERGED') {
233
+ if (held && held.headRefOid !== value.headRefOid) throw new Error(`Inconsistent merged head for ${url}; source remains unknown`);
234
+ return sourceFact(key, value);
235
+ }
236
+ return held;
237
+ };
215
238
  export async function discoverPullRequest(url, { fresh = false } = {}) {
239
+ const immutable = await mergedFact(url);
240
+ if (immutable) return immutable;
216
241
  const key = pullRequestKey(url);
217
- return discoveryRead(key, 60_000, async () => pullRequest(repositoryOf(url),
242
+ const pr = await discoveryRead(key, 60_000, async () => pullRequest(repositoryOf(url),
218
243
  await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: fresh ? 'validation' : 'discovery' })),
219
244
  { fresh, ttlOf: pr => pr.state === 'MERGED' ? spread(MERGED_TTL, key) : pr.state === 'CLOSED' ? spread(CLOSED_TTL, key) : 60_000 });
245
+ if (pr.state === 'MERGED') await mergedFact(url, pr);
246
+ return pr;
220
247
  }
221
248
 
249
+ /** The repository list precedes retained closed observations, so a reopened PR
250
+ * is visible without polling every queued PR through fresh validation. */
251
+ export async function discoverQueuedPullRequest(url) {
252
+ const immutable = await mergedFact(url);
253
+ if (immutable) return immutable;
254
+ const list = await openPullRequests(repositoryOf(url));
255
+ return list.find(pr => pr.number === numberOf(url)) ?? discoverPullRequest(url);
256
+ }
257
+ /** When the shared open-PR list of `url`'s repository was last read (ms), or 0: what a queued request whose
258
+ * admission failed for no reason of its own waits to see move (ADR 0252). */
259
+ export async function openListObservedAt(url) {
260
+ return githubDiscoveryObservedAt(await discoveryIdentity(`open:${repositoryOf(url).toLowerCase()}`));
261
+ }
262
+ // Every discovery caller shares this complete, paged five-minute list.
263
+ const openPullRequests = (repository, { fresh = false } = {}) => discoveryRead(`open:${repository.toLowerCase()}`, 300_000, async () =>
264
+ (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map(p => pullRequest(repository, p)), { fresh });
265
+
222
266
  /**
223
267
  * Merge a reviewed PR at the head its review judged, and only there: GitHub refuses the merge when the PR's head is no
224
268
  * longer that commit (409), so a head pushed after the verdict is never merged unreviewed. Answers
@@ -296,6 +340,27 @@ export function withCompletionEvidence(board, id, state, fn) {
296
340
  }
297
341
  }
298
342
 
343
+ /** Unsettled requests in FIFO order. Explicit replacements inherit the named request's slot; a settlement can
344
+ * give an already queued replacement that slot without rewriting either immutable request. */
345
+ export function pendingReviewRequests(db, id) {
346
+ return db.prepare(`SELECT q.id,q.payload,
347
+ MIN(COALESCE(json_extract(CASE WHEN json_valid(q.payload) THEN q.payload END,'$.queue_order'),q.id),
348
+ COALESCE((SELECT MIN(json_extract(CASE WHEN json_valid(s.payload) THEN s.payload END,'$.queue_order'))
349
+ FROM task_events s WHERE s.task_id=q.task_id AND s.kind='review_queue_settled'
350
+ AND json_extract(CASE WHEN json_valid(s.payload) THEN s.payload END,'$.replacement_event')=q.id),q.id)) AS queue_order
351
+ FROM task_events q WHERE q.task_id=? AND q.kind='review_queued'
352
+ AND NOT EXISTS (SELECT 1 FROM task_events settled WHERE settled.task_id=q.task_id
353
+ AND settled.kind IN ('review_queue_started','review_queue_settled')
354
+ AND json_extract(CASE WHEN json_valid(settled.payload) THEN settled.payload END,'$.queued_event')=q.id)
355
+ ORDER BY queue_order,q.id`).all(id).map(row => ({ ...JSON.parse(row.payload ?? '{}'), id: row.id, queue_order: row.queue_order }));
356
+ }
357
+
358
+ /** The oldest retained review request not yet applied. The existing card event stream is its queue. */
359
+ export const pendingReviewRequest = (db, id) => pendingReviewRequests(db, id)[0] ?? null;
360
+
361
+ /** A PR's identity across spellings of its URL (a trailing slash, letter case). */
362
+ export const prIdentity = url => String(url).replace(/\/$/, '').toLowerCase();
363
+
299
364
  /** The latest explicitly requested PRs are the reviewer's candidate, never the deployment tip. */
300
365
  export function reviewCandidate(board, id) {
301
366
  const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
@@ -303,6 +368,48 @@ export function reviewCandidate(board, id) {
303
368
  return payload?.ci?.pr || payload?.ci?.no_code || payload?.ci?.pr_reviewed?.length ? payload.ci : null;
304
369
  }
305
370
 
371
+ /** A source declaration, as SQL over its `commented` event row `c`: `comment --pr` or `--pr none` (`sources`), and
372
+ * `--source` or `--source none` (`branches`). A PR disposition or release readback comment carries its one `--pr` too
373
+ * (engine.comment) but declares nothing: it is the comment whose evidence event (`pr_disposition_recorded`,
374
+ * `pr_release_readback`) is recorded after it in the same write, before the card's next comment. */
375
+ const declaringSql = (c) => `${c}.kind='commented' AND json_valid(${c}.payload) AND (json_type(${c}.payload,'$.branches')='array'
376
+ OR (json_type(${c}.payload,'$.sources')='array' AND NOT EXISTS (SELECT 1 FROM task_events k WHERE k.task_id=${c}.task_id
377
+ AND k.kind IN ('pr_disposition_recorded','pr_release_readback') AND k.id>${c}.id
378
+ AND k.id<COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id=${c}.task_id AND n.kind='commented' AND n.id>${c}.id), 9223372036854775807))))`;
379
+
380
+ /** The first source declaration (declaringSql) the arc made after event `sinceId` (every declaration when there is none),
381
+ * or null. A review request's frozen candidate no longer states the arc's declared set once one follows it, so the next
382
+ * review is built from the declarations (VO-3775 t_1daf732e: its PRs #2596 and #2606 moved to another card and the arc
383
+ * was re-declared, and every later reviewer run stayed bound to request 121444 naming them). */
384
+ export function redeclaredSince(db, id, sinceId) {
385
+ return db.prepare(`SELECT c.id FROM task_events c WHERE c.task_id=? AND c.id>? AND ${declaringSql('c')} ORDER BY c.id LIMIT 1`).get(id, sinceId ?? 0)?.id ?? null;
386
+ }
387
+ /** What the arc cleared after its latest review request, as a test of one of that request's PRs (`{ url, repository,
388
+ * branch }`): when it cleared its PR declarations (`comment --pr none`), the PR URLs it had itself declared (`--pr`)
389
+ * before that clear; when it cleared its source declarations (`comment --source none`), the PRs found on the
390
+ * `--source` branches it had declared before that clear and has not declared again since. The declarations since are
391
+ * the complete declared set, so those are not carried into it from that request's candidate; the other PRs the board
392
+ * discovered (a merged `wt/<card>` PR in a sibling repository, which discovery can no longer list) are. */
393
+ function clearedDeclarations(db, id) {
394
+ const asked = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id=? AND kind='review_requested'").get(id)?.id ?? 0;
395
+ const clearedAt = (field) => db.prepare(`SELECT MAX(id) AS id FROM task_events WHERE task_id=? AND id>? AND kind='commented' AND json_valid(payload)
396
+ AND json_type(payload,'$.${field}')='array' AND json_array_length(payload,'$.${field}')=0`).get(id, asked)?.id;
397
+ const before = (field, at) => db.prepare(`SELECT payload FROM task_events WHERE task_id=? AND id<? AND kind='commented' AND json_valid(payload)
398
+ AND json_type(payload,'$.${field}')='array'`).all(id, at).flatMap((row) => JSON.parse(row.payload)[field]);
399
+ const branchKey = (repository, branch) => `${repository.toLowerCase()}:${branch}`;
400
+ const urls = new Set(), branches = new Set();
401
+ const prs = clearedAt('sources');
402
+ if (prs) for (const source of before('sources', prs)) if (typeof source?.url === 'string') urls.add(source.url);
403
+ const sources = clearedAt('branches');
404
+ if (sources) {
405
+ const kept = new Set(arcSources(db, id).map((b) => branchKey(b.repository, b.branch)));
406
+ for (const b of before('branches', sources)) {
407
+ if (typeof b?.repository === 'string' && typeof b?.branch === 'string' && !kept.has(branchKey(b.repository, b.branch))) branches.add(branchKey(b.repository, b.branch));
408
+ }
409
+ }
410
+ return (source) => urls.has(source.url) || (typeof source.branch === 'string' && branches.has(branchKey(source.repository, source.branch)));
411
+ }
412
+
306
413
  /** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
307
414
  export const candidatePullRequests = (ci) => ci?.prs ?? (ci?.pr ? [{ repository: ci.repository, branch: ci.branch, commit: ci.commit, source_sha: ci.source_sha ?? ci.commit, pr: ci.pr }] : []);
308
415
  // The repository a checkout declares (remote.origin.url), not where a URL rewrite sends its transport.
@@ -376,18 +483,52 @@ async function recreatedWorktree(workspace, resolved) {
376
483
  try { const made = await resolveWorkspace(workspace, true); return made.path === resolved.path && checkoutPresent(made.path); } catch { return false; }
377
484
  }
378
485
 
379
- /** Historical work can already be merged when an outage prevented the board's
380
- * review request. Read only the arc's declared branch, never all closed PRs. */
381
- async function mergedBranchPullRequests(repository, branch, { fresh = false, ttl = 900_000 } = {}) {
382
- // `ttl` is how long an answer is kept; a failed read is kept only the usual fifteen minutes, never the long lifetime
383
- // an earlier branch's answer gets (a cached failure is re-thrown to every round until it ends).
384
- const key = `${repository.toLowerCase()}:${branch}`;
385
- const read = async () => {
386
- const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
387
- return (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' }))
388
- .filter(p => p.merged_at).map(p => pullRequest(repository, p));
389
- };
390
- return discoveryRead(`history:${key}`, 900_000, read, { fresh, ttlOf: () => ttl });
486
+ /** Seed a branch once, then discover subsequent closed/merged work with one repository
487
+ * listing, sorted by update time. The durable watermark survives cache expiry/restart.
488
+ * Open-list discovery independently follows branch reuse, including drafts. */
489
+ async function branchHistories(repository, branches, { fresh = false, admit }) {
490
+ const unique = [...new Set(branches)];
491
+ const snapshots = await readTogether(unique, async branch => {
492
+ const key = `branch-history:${repository}:${branch}`;
493
+ let held = await sourceFact(key);
494
+ if (!held) {
495
+ if (!await admit(`history:${repository}:${branch}`)) return null;
496
+ const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
497
+ const path = `/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`;
498
+ const list = await github(path, true, { purpose: fresh ? 'validation' : 'discovery' });
499
+ const at = historyObservedAt(list), prs = list.filter(p => p.merged_at).map(p => pullRequest(repository, p));
500
+ held = { at, prs };
501
+ await sourceFact(key, held);
502
+ for (const pr of prs) await mergedFact(pr.url, pr);
503
+ }
504
+ return { key, branch, ...held };
505
+ });
506
+ const held = snapshots.filter(Boolean);
507
+ if (!held.length) return [];
508
+ const cutoff = Math.min(...held.map(h => h.at)) - 2000;
509
+ if (fresh || Date.now() - cutoff > 300_000) {
510
+ let began = Infinity;
511
+ const updates = [];
512
+ let complete = false;
513
+ for (let page = 1; page <= 10; page++) {
514
+ const path = `/repos/${repository}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=${page}`;
515
+ const list = await github(path, false,
516
+ { purpose: fresh ? 'validation' : 'discovery' });
517
+ began = Math.min(began, historyObservedAt(list));
518
+ if (!Array.isArray(list) || list.some(p => !Number.isFinite(Date.parse(p.updated_at)))) throw new Error(`Unreadable closed-PR history for ${repository}`);
519
+ updates.push(...list.filter(p => p.merged_at && Date.parse(p.updated_at) >= cutoff).map(p => pullRequest(repository, p)));
520
+ if (list.length < 100 || list.some(p => Date.parse(p.updated_at) < cutoff)) { complete = true; break; }
521
+ }
522
+ if (!complete) throw new Error(`Closed-PR history for ${repository} exceeds ten pages since its watermark; source set remains unknown`);
523
+ for (const snapshot of held) {
524
+ const prs = new Map(snapshot.prs.map(pr => [pr.url, pr]));
525
+ for (const pr of updates) if (pr.headRefName === snapshot.branch) prs.set(pr.url, pr);
526
+ snapshot.prs = [...prs.values()];
527
+ await sourceFact(snapshot.key, { at: began, prs: snapshot.prs });
528
+ }
529
+ for (const pr of updates) await mergedFact(pr.url, pr);
530
+ }
531
+ return held.flatMap(h => h.prs);
391
532
  }
392
533
 
393
534
  async function branchCheckout(board, id, repository, branch) {
@@ -438,14 +579,13 @@ function declarationsOf(db, id, field, adding = null) {
438
579
  return declarations.slice(from);
439
580
  }
440
581
 
441
- /** Each repository and branch the arc declared (`branches`), at its latest declaration (`at`, that declaration's id). */
582
+ /** Stable membership identity: an equivalent declaration never changes the member's first declaration. */
442
583
  function declaredBranches(db, id, adding = null) {
443
584
  const out = [];
444
585
  for (const declaration of declarationsOf(db, id, 'branches', adding)) for (const b of declaration.items) {
445
586
  if (typeof b?.repository !== 'string' || typeof b?.branch !== 'string') continue;
446
587
  const known = out.find((r) => r.repository.toLowerCase() === b.repository.toLowerCase() && r.branch === b.branch);
447
- if (known) known.at = declaration.id;
448
- else out.push({ repository: b.repository, branch: b.branch, at: declaration.id });
588
+ if (!known) out.push({ repository: b.repository, branch: b.branch, at: declaration.id });
449
589
  }
450
590
  return out;
451
591
  }
@@ -458,20 +598,7 @@ export function arcSources(db, id, adding = null) {
458
598
  return declaredBranches(db, id, adding).map(({ repository, branch }) => ({ repository, branch }));
459
599
  }
460
600
 
461
- /** How long the merged history of an earlier-declared branch is kept, how many reads an advisory discovery starts for
462
- * one arc that it does not already hold (declared PRs and earlier branches' histories; the rest are read on later
463
- * rounds), and the share of the home's discovery budget (GITHUB_DISCOVERY_ATTEMPTS an hour, every arc's) past which it
464
- * starts none: an arc's declarations add up for as long as it runs, and several arcs filling their caches at once
465
- * (three at four reads a round would start 720 an hour) leave the rest of that budget to every arc's routine reads. */
466
- const EARLIER_BRANCH_TTL = 6 * 3600_000, NEW_READS_PER_ROUND = 4, FILL_SHARE = 0.7;
467
-
468
- /** The declared branches (`repository:branch`, the repository lower-cased) the arc's latest `--source` declaration does
469
- * not name: work it declared earlier. Their merged history is read on a long cache (EARLIER_BRANCH_TTL), never skipped. */
470
- function earlierDeclaredBranches(db, id) {
471
- const declared = declaredBranches(db, id);
472
- const last = Math.max(0, ...declared.map((b) => b.at));
473
- return new Set(declared.filter((b) => b.at !== last).map((b) => `${b.repository.toLowerCase()}:${b.branch}`));
474
- }
601
+ const NEW_READS_PER_ROUND = 4, FILL_SHARE = 0.7;
475
602
 
476
603
  /** The PRs the arc declared (`comment --pr URL --pr-head SHA`), each at its latest declaration: every declaration since
477
604
  * the latest `--pr none` adds to the ones before it, in any of the arc's repositories, on any run. Discovery reads each
@@ -484,6 +611,12 @@ export function declaredPullRequests(db, id) {
484
611
  latest.delete(source.url);
485
612
  latest.set(source.url, { url: source.url, head: source.head });
486
613
  }
614
+ for (const row of db.prepare(`SELECT payload FROM task_events WHERE task_id=? AND kind='pr_disposition_recorded' AND json_valid(payload) ORDER BY id`).all(id)) {
615
+ const source = JSON.parse(row.payload);
616
+ // Only a release-only PR is kept once closed: its release readback settles it. A closed PR left to merge can never
617
+ // merge, so it leaves the set as any closed declared PR does.
618
+ latest.set(source.url, { url: source.url, head: source.head, retained: source.disposition === 'release-only' });
619
+ }
487
620
  return [...latest.values()];
488
621
  }
489
622
 
@@ -528,25 +661,26 @@ export function declaredWorkBranches(db, id) {
528
661
  return [...new Set([...declared, ...reviewed].filter(Boolean))];
529
662
  }
530
663
 
531
- /** Every PR of the arc, open (not a draft) or merged, at its current head, each proposed to its repository's default
532
- * branch, but for one merged at a head its PR review passed (`pr_reviewed`, settled): the arc's final review request
664
+ /** Every PR of the arc, open (not a draft) or merged, at its current head, whatever its base, but for one merged at a head its PR review passed (`pr_reviewed`, settled): the arc's final review request
533
665
  * names them all, and whether a review is due depends only on the heads no verdict judged (reviewDue). A PR review
534
666
  * names one ready PR of them (requireCurrentReviewSet). A PR of the arc is one it declared (declaredPullRequests) or one whose head is the card's
535
667
  * work branch in the card's own repository, a `wt/<card id>` branch in any repository this machine keeps a checkout
536
668
  * of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
537
669
  * checked out (or no branch), is not a work branch: such a card's PRs are only its `wt/<card>` and registered ones, and with none it
538
670
  * answers `{ waits }` saying so. The final review covers them all; the caller decides the transition. */
539
- export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, refuseStacked = false, repositories = [] } = {}) {
540
- if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, refuseStacked, repositories });
671
+ export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, repositories = [] } = {}) {
672
+ if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, repositories });
541
673
  // Every authorization entry acquires the broad advisory scope, not only
542
674
  // dispatch. An older receipt and a checkout now on main still have to see
543
675
  // implicit wt/<card> PRs. Discovery failures leave that scope unknown.
544
- const scope = new Set(repositories);
545
- await discoverReviewSources(board, id, { repositories, strict: true }, scope);
546
- return discoverReviewSources(board, id, { fresh: true, checkWorkspaces, refuseStacked, repositories: [...scope] });
676
+ return withFreshReviewReads(async () => {
677
+ const scope = new Set(repositories);
678
+ await discoverReviewSources(board, id, { repositories, strict: true }, scope);
679
+ return discoverReviewSources(board, id, { fresh: true, checkWorkspaces, repositories: [...scope] });
680
+ });
547
681
  }
548
682
 
549
- async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false, refuseStacked = false, repositories = [], strict = false } = {}, scope = null) {
683
+ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false, repositories = [], strict = false } = {}, scope = null) {
550
684
  const { own, registered, ownRepository, repositories: arcScope } = await arcRepositories(board, id);
551
685
  // A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
552
686
  // discovery cannot see; it never hides what discovery found. Its PRs are bounded by the arc's repositories (the card's
@@ -558,7 +692,27 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
558
692
  // (volter-ai/volter#134 left four t_ef2aa6ce review requests with no word). A PR the review already includes is never refused.
559
693
  // Every PR the arc declared, on any run and in any repository, is one of them (declaredPullRequests), not only its latest declaration's.
560
694
  const declared = declaredPullRequests(board.db, id);
561
- const undeclared = (outside) => refusalOf(outside.map(({ url, repository }) => refused('undeclared', url, `${url} is declared as a source of ${id}, but ${repository} is not one of the arc's repositories (its workspace, its declared sources, its tasks', or one holding its wt/${id} branch), so a declaration cannot name it: declare the repository on the arc: comment ${id} "<why>" --source ${repository}:<branch>, or clear the arc's PR declarations with --pr none and declare its PRs again without it.`)));
695
+ // A sibling source can leave the open list by merging between reviews. Keep the
696
+ // exact previously observed URL, including settled sources whose pass can be voided. A `--pr none` or `--source none`
697
+ // since that request re-declared the complete set: a PR the arc had declared, or one found on a `--source` branch it
698
+ // cleared, is not carried into it from the frozen candidate (VO-3775 kept PRs moved to another card); another one the
699
+ // board discovered is (clearedDeclarations).
700
+ const previous = reviewCandidate(board, id);
701
+ const dropped = clearedDeclarations(board.db, id);
702
+ const priorSources = [
703
+ ...candidatePullRequests(previous).map(p => ({ url: p.pr.url, head: p.commit, repository: p.repository ?? repositoryOf(p.pr.url), branch: p.branch })),
704
+ ...(previous?.pr_reviewed ?? []).map(p => ({ url: p.url, head: p.head, repository: repositoryOf(p.url), branch: p.branch })),
705
+ ].filter(source => !dropped(source));
706
+ // The PRs only the frozen candidate carries, not a `--pr` declaration: `--pr none` does not drop them.
707
+ const carried = new Map();
708
+ for (const source of priorSources) if (!declared.some(p => p.url === source.url)) { declared.push({ url: source.url, head: source.head }); carried.set(source.url, source); }
709
+ const scopeWords = `not one of the arc's repositories (its workspace, its declared sources, its tasks', or one holding its wt/${id} branch)`;
710
+ const undeclared = (outside) => refusalOf(outside.map(({ url, repository }) => {
711
+ const found = carried.get(url);
712
+ return refused('undeclared', url, found
713
+ ? `${url} is in ${id}'s last review request${found.branch ? `, found on ${repository}:${found.branch}` : ''}, but ${repository} is now ${scopeWords}, so the review cannot include it: declare the repository on the arc again: comment ${id} "<why>" --source ${repository}:${found.branch ?? '<branch>'}. To leave it out instead, declare that branch with --source and then clear the arc's source declarations with --source none, which drops the PRs the last review found on each --source branch it clears, and declare again the sources the arc keeps.`
714
+ : `${url} is declared as a source of ${id}, but ${repository} is ${scopeWords}, so a declaration cannot name it: declare the repository on the arc: comment ${id} "<why>" --source ${repository}:<branch>, or clear the arc's PR declarations with --pr none and declare its PRs again without it.`);
715
+ }));
562
716
  // A card with no repository of its own and none registered (scratch) has no discovery: every declared PR is outside.
563
717
  if (!ownRepository && !registered.length) {
564
718
  const outside = outsideArcRepositories(declared.map(source => source.url), arcScope);
@@ -573,7 +727,6 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
573
727
  // Broad discovery finds wt/<card> work in sibling repositories. Authorization
574
728
  // refreshes only this arc's repositories, including siblings already found or
575
729
  // requested; an unrelated checkout is never a validation-purpose PR scan.
576
- const previous = reviewCandidate(board, id);
577
730
  const requested = [...candidatePullRequests(previous).map(p => p.repository ?? repositoryOf(p.pr.url)),
578
731
  ...(previous?.review_repositories ?? [])];
579
732
  const siblings = fresh ? [] : await knownRepositories({ strict });
@@ -581,11 +734,6 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
581
734
  ...repositories, ...siblings].filter(Boolean).map(r => r.toLowerCase()))];
582
735
  const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
583
736
  .filter(Boolean).map(r => r.toLowerCase()));
584
- // A branch an earlier `--source` declaration named, and no task or the latest declaration names, keeps its merged
585
- // history on a long cache: it is read, never skipped, but a lane card's weeks of branches stay a small share of the
586
- // home's discovery budget. A task's branch and the latest declaration's are read as before.
587
- const earlier = earlierDeclaredBranches(board.db, id);
588
- if (earlier.size) for (const r of registeredBranches(board, id, [])) earlier.delete(`${r.repository.toLowerCase()}:${r.branch}`);
589
737
  // An advisory discovery starts at most NEW_READS_PER_ROUND reads it does not already hold for the arc's earlier
590
738
  // branches and declared PRs, and only one once the home has spent FILL_SHARE of its hour's discovery budget (every
591
739
  // arc's reads), so an arc still advances through a long busy stretch (issue() still refuses past the budget itself);
@@ -603,9 +751,8 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
603
751
  };
604
752
  const openSeen = new Map(), openKey = (url) => `${repositoryOf(url).toLowerCase()}#${numberOf(url)}`;
605
753
  // A repository's open PRs, every page, on the five-minute list every arc reading that repository shares.
606
- const openList = (repository) => discoveryRead(`open:${repository.toLowerCase()}`, 300_000, async () =>
607
- (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map(p => pullRequest(repository, p)), { fresh });
608
- const stacked = [], workRepositories = new Set();
754
+ const openList = (repository) => openPullRequests(repository, { fresh });
755
+ const workRepositories = new Set();
609
756
  const groups = await readTogether(repositoriesToRead, async repository => {
610
757
  const prs = [];
611
758
  const hasWorkBranches = repository === ownRepository || registered.some(r => r.repository === repository);
@@ -624,7 +771,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
624
771
  if (fresh || strict || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
625
772
  return []; // An unread sibling stays unread: do not cache an empty successful result.
626
773
  }
627
- // Include excluded drafts/other bases in the fresh scope: becoming ready
774
+ // Include excluded drafts in the fresh scope: becoming ready
628
775
  // must not let a sibling source escape the complete-set check.
629
776
  for (const pr of list.items) openSeen.set(openKey(pr.url), pr);
630
777
  if (list.items.some(pr => mine(repository, pr.headRefName))) reviewRepositories.add(repository);
@@ -634,34 +781,21 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
634
781
  if (repository === ownRepository && branch) {
635
782
  if (branch === list.defaultBranch) branch = null;
636
783
  }
637
- // a draft is not handed over yet: its review starts when it is marked ready; a PR to another base (main→prod) is a release, not the arc's work
638
- for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
639
- // A ready PR on one of the arc's branches proposed to any branch but the default (stacked on another PR, or a wrong
640
- // base) would leave the arc's one review set without a word: it is kept as stacked, and a review request refuses
641
- // and tells it until it is retargeted to the default branch. A PR from the default branch itself is a release.
642
- for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.defaultBranch && pr.headRefName !== pr.defaultBranch && pr.baseRefName !== pr.defaultBranch && mine(repository, pr.headRefName)) stacked.push(pr);
784
+ // a draft is not handed over yet: its review starts when it is marked ready; a ready PR on one of the arc's branches
785
+ // is the arc's work whatever its base, and is reviewed against that base; a PR from the default branch itself
786
+ // (main→prod) is a release, not the arc's work
787
+ for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.headRefName !== list.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
643
788
  // A manual review and merge during a dispatcher outage may predate any
644
789
  // board request. The source-review door already accepts merged heads; make
645
790
  // them discoverable so the ordinary independent verdict can be retained.
646
- // An open PR (including a draft or wrong base) on that branch takes priority.
791
+ // An open PR (including a draft) on that branch takes priority.
647
792
  const historical = [...new Set([
648
793
  ...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
649
794
  ...registered.filter(r => r.repository === repository).map(r => r.branch),
650
795
  ])];
651
- // A merged PR stays in the set at its merged head, judged or not: the review reads the arc's whole diff in every
652
- // round, and only an unjudged head makes that review due (reviewDue).
653
- const histories = await readTogether(historical, async headBranch => {
654
- if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
655
- const older = earlier.has(`${repository}:${headBranch}`) && !(repository === ownRepository && headBranch === branch);
656
- // An earlier branch's history already held is its answer, in a fresh scan too, until its long cache ends.
657
- const held = older ? await heldRead(`history:${repository}:${headBranch}`) : null;
658
- if (older && !held && !await admit(`history:${repository}:${headBranch}`)) return [];
659
- return (held ?? await mergedBranchPullRequests(repository, headBranch, { fresh, ...(older ? { ttl: spread(EARLIER_BRANCH_TTL, `history:${repository}:${headBranch}`) } : {}) })).filter(pr =>
660
- !pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
661
- && pr.baseRefName === pr.defaultBranch);
662
- });
663
- for (const history of histories) for (const pr of history) {
664
- if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
796
+ const histories = await branchHistories(repository, historical.filter(head => head !== list.defaultBranch), { fresh, admit });
797
+ for (const pr of histories) {
798
+ if (!pr.isCrossRepository && !prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
665
799
  }
666
800
  return prs;
667
801
  });
@@ -687,6 +821,8 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
687
821
  const read = await readTogether(sources, async source => {
688
822
  const listed = openSeen.get(openKey(source.url));
689
823
  if (listed) return listed;
824
+ const immutable = await mergedFact(source.url);
825
+ if (immutable) return immutable;
690
826
  const held = await heldRead(pullRequestKey(source.url));
691
827
  if (held && (!fresh || held.state === 'MERGED')) return held;
692
828
  if (!held && !await admit(pullRequestKey(source.url))) return { deferred: true };
@@ -705,28 +841,31 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
705
841
  if (unread) throw unread.unread;
706
842
  // Reads this round did not start leave the set incomplete: the review waits for them, never runs without them.
707
843
  if (deferred) throw Object.assign(new Error(`${deferred} of ${id}'s earlier-declared PRs and branches are not read yet (${spent ? `the home has spent ${Math.round(FILL_SHARE * 100)}% of its hour's ${GITHUB_DISCOVERY_ATTEMPTS} discovery reads, so an arc starts one new read a round` : `an advisory round starts at most ${NEW_READS_PER_ROUND} new reads for an arc`}); the rest are read on the next rounds`), { code: 'github_read_admission_full', deferred: true });
708
- for (const pr of read) {
709
- if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
844
+ for (const [i, pr] of read.entries()) {
845
+ if (pr.isCrossRepository || (!['OPEN', 'MERGED'].includes(pr.state) && !sources[i].retained) || (pr.state === 'OPEN' && pr.isDraft && !sources[i].retained)) continue;
710
846
  if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
711
847
  }
712
848
  // A PR merged at a head a review passed (its PR review, or an earlier final review: passedHead) is settled: the arc's
713
849
  // final review neither names nor owes it. It is kept apart as `pr_reviewed`, so an arc whose every PR is settled still
714
850
  // gets its final review, of its Acceptance against them (VO-3766: PRs merged at the heads a final review passed, then
715
851
  // PR-reviewed PRs merged, left no PR the final review could name, and its open Acceptance waited forever).
716
- const settled = prs.filter((p) => p.observed?.state === 'MERGED' && passedHead(board, id, p.head));
852
+ const settled = prs.filter(p => prDisposition(board, id, p.url, p.head).disposition === 'release-only'
853
+ ? Boolean(releaseReadback(board, id, p.url, p.head))
854
+ : p.observed?.state === 'MERGED' && passedHead(board, id, p.head));
717
855
  for (const p of settled) prs.splice(prs.indexOf(p), 1);
718
- const reviewed = settled.length ? { pr_reviewed: settled.map((p) => ({ url: p.url, head: p.head })) } : {};
719
- // Retain scope even when all matching PRs are excluded drafts/other bases,
856
+ const dispositions = [...prs, ...settled].map(p => ({ url: p.url, head: p.head, ...prDisposition(board, id, p.url, p.head) })).filter(p => p.event != null);
857
+ // Each settled PR keeps the branch it was found on, so a `--source none` clearing that branch drops it (clearedDeclarations).
858
+ const reviewed = { ...(settled.length ? { pr_reviewed: settled.map(p => ({ url: p.url, head: p.head, ...(p.observed?.headRefName ? { branch: p.observed.headRefName } : {}), ...prDisposition(board, id, p.url, p.head) })) } : {}),
859
+ ...(dispositions.length ? { pr_dispositions: dispositions } : {}) };
860
+ // Retain scope even when all matching PRs are excluded drafts,
720
861
  // so a fresh scan cannot inherit that exclusion.
721
- if (scope) for (const repository of reviewRepositories) scope.add(repository);
722
- stacked.sort((a, b) => a.url.localeCompare(b.url));
723
- // Advisory discovery can be up to five minutes old: the dispatcher tells a stacked refusal only once a fresh read
724
- // (refuseStacked) confirms it.
725
- if (stacked.length && (checkWorkspaces || refuseStacked)) throw refusalOf(stacked.map((pr) => refused('base', pr.url, `${pr.url} proposes ${pr.headRefName} to ${pr.baseRefName}, not ${pr.defaultBranch}: retarget it to ${pr.defaultBranch} so the arc's review includes it.`)));
726
- if (!prs.length && (stacked.length || settled.length)) return { pr: [], pr_head: [], branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url), ...reviewed };
862
+ // The fresh phase must inspect every enumerated sibling: an advisory empty list
863
+ // cannot prove it still has no matching ready source.
864
+ if (scope) for (const repository of repositoriesToRead) scope.add(repository);
865
+ if (!prs.length && settled.length) return { pr: [], pr_head: [], branch, review_repositories: [...reviewRepositories].sort(), ...reviewed };
727
866
  if (!prs.length) return branch || !ownRepository ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own.path} is on ${current}, its repository's default` : `its checkout ${own.path} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
728
867
  // Advisory facts can refuse an attempt before its authoritative REST scan. Check every PR's properties before
729
- // doing checkout work: a wrong base cannot be repaired by revalidating the whole arc every round. Discovery
868
+ // doing checkout work: a refused PR cannot be repaired by revalidating the whole arc every round. Discovery
730
869
  // refreshes changed heads/bases/states; local readiness is read each round. Neither check authorizes a move:
731
870
  // inspectCompletionEvidence repeats both, in the same order, against fresh metadata and the pushed head. Each
732
871
  // collects every PR's refusal, so the same state always refuses with the same set.
@@ -734,7 +873,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
734
873
  await refuseTogether(prs, pr => requireReviewPullRequestProperties(pr.url, pr.head, pr.observed));
735
874
  await refuseTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
736
875
  }
737
- return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url), ...reviewed };
876
+ return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort(), ...reviewed };
738
877
  }
739
878
 
740
879
  /** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
@@ -805,7 +944,8 @@ async function knownRepositories({ strict = false } = {}) {
805
944
  * the current heads), and a PR that verdict's request did not name is due at whatever head it has: one that was a draft
806
945
  * when the request was made. */
807
946
  export async function reviewDue(board, id, prs) {
808
- if (prs.length && answeredChanges(board, id)) return true;
947
+ // Only a final review's verdict is answered here: a PR review requested since (a fix's own PR) must not hide it.
948
+ if (prs.length && answeredChanges(board, id, ['final'])) return true;
809
949
  // A review the manager returned with no verdict is due again only for new work: a PR head its request did not name.
810
950
  const returned = returnedReview(board, id);
811
951
  if (returned) return prs.some((p) => !returned.heads.some((h) => h.pr.url === p.url && h.commit === p.head));
@@ -813,12 +953,15 @@ export async function reviewDue(board, id, prs) {
813
953
  if (!unjudged.length) return false;
814
954
  const voided = board.db.prepare("SELECT r.payload FROM task_events v JOIN task_events r ON r.id=json_extract(v.payload,'$.review_request') AND r.task_id=v.task_id WHERE v.task_id=? AND v.kind='review_voided' ORDER BY v.id DESC LIMIT 1").get(id);
815
955
  if (candidatePullRequests(voided && JSON.parse(voided.payload)?.ci).some(j => unjudged.some(p => p.url === j.pr.url && p.head === j.commit))) return true;
956
+ // The last final review's own verdict, the one that answered it before the next request (as verdictOn reads it): a
957
+ // PR or plan review's verdict, or a later review's, never dates the arc's PRs for its final review.
958
+ const answered = `v.id > r.id AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)`;
816
959
  const last = board.db.prepare(`SELECT r.payload, (SELECT MIN(v.created_at) FROM task_events v WHERE v.task_id = r.task_id
817
- AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id) AS verdict_at,
960
+ AND v.kind IN ('changes_requested', 'review_pass') AND ${answered}) AS verdict_at,
818
961
  (SELECT v.kind FROM task_events v WHERE v.task_id = r.task_id
819
- AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id ORDER BY v.id LIMIT 1) AS verdict
820
- FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND (${requestKindSql('r')}) != 'plan'
821
- AND EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id)
962
+ AND v.kind IN ('changes_requested', 'review_pass') AND ${answered} ORDER BY v.id LIMIT 1) AS verdict
963
+ FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND (${requestKindSql('r')}) = 'final'
964
+ AND EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass') AND ${answered})
822
965
  ORDER BY r.id DESC LIMIT 1`).get(id);
823
966
  if (!last) return true;
824
967
  const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
@@ -835,61 +978,139 @@ export async function reviewDue(board, id, prs) {
835
978
  }
836
979
 
837
980
  /** Whether a PR review is due for one ready PR at `head` (`[{ url, head }]` as reviewDue takes them): no PR review
838
- * passed at that head, and no verdict judged it or the last verdict's findings are answered (answeredChanges). The
839
- * arc's other PRs neither hold it nor make it due: each is reviewed when its coder marks it ready. */
981
+ * passed at that head, and no verdict judged it or the last PR or final review verdict on that head has its findings
982
+ * answered (answeredHead; a plan review's findings neither hold it nor admit it). The
983
+ * arc's other PRs and their verdicts neither hold it nor make it due: each is reviewed when its coder marks it ready. */
840
984
  export function prReviewDue(board, id, { head }) {
841
985
  if (prReviewedHead(board, id, head)) return false;
842
986
  // a PR review's or the final review's verdict on this head stands in its way; a plan review's never does (requestKindSql)
843
- return !judgedHead(board, id, head) || answeredChanges(board, id);
987
+ return !judgedHead(board, id, head) || answeredHead(board, id, head, ['pr', 'final']);
988
+ }
989
+
990
+ /** Whether a plan review is due for the proposed ADR's PR at `head`: no plan review passed at that head, and no plan
991
+ * review's verdict judged it or the last plan verdict's findings are answered (answeredChanges). Only plan verdicts count:
992
+ * a PR review's or the final review's verdict, on this PR or any other of the arc, neither holds it nor makes it due
993
+ * (an ADR amendment pushed before an unrelated PR's findings is still a plan never reviewed at that head). */
994
+ export function planReviewDue(board, id, { head }) {
995
+ if (verdictOn(board, id, head, ['plan_review_pass'], ['plan'])) return false;
996
+ return !verdictOn(board, id, head, ['changes_requested'], ['plan']) || answeredChanges(board, id, ['plan']);
844
997
  }
845
998
 
846
999
  /** Whether an arc whose every PR is settled (merged at the head its PR review passed: `pr_reviewed`) is due its final
847
- * review, of its Acceptance against them: its last review event is a PR review's request (that review is over, and the
848
- * arc is back with its implementer), a voided verdict, or changes requested whose tasks are all closed. After a pass,
849
- * or a final review the manager returned, nothing is new until another PR review is requested. */
850
- export function settledReviewDue(board, id) {
1000
+ * review, of its Acceptance against them: its last review event is a PR or plan review's request (that review is over, and the
1001
+ * arc is back with its implementer), a voided verdict, or changes requested whose tasks are all closed. A review the
1002
+ * manager returned gave no verdict, and its arc can reach none by a push once every PR is merged: it is due again once
1003
+ * the settled set (`current`, the round's `pr_reviewed`) is not the one the returned request named, or the arc
1004
+ * re-declared its sources after the return (returnedReview). After a pass nothing is new until another PR review is requested. */
1005
+ export function settledReviewDue(board, id, current = []) {
851
1006
  const last = board.db.prepare("SELECT id, kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided', 'review_returned') ORDER BY id DESC LIMIT 1").get(id);
852
1007
  if (!last || last.kind === 'review_voided') return true;
853
1008
  if (last.kind === 'changes_requested') return answeredChanges(board, id);
854
- return last.kind === 'review_requested' && Boolean(board.db.prepare("SELECT 1 FROM task_events WHERE task_id = ? AND kind = 'pr_review_requested' AND id > ? LIMIT 1").get(id, last.id));
855
- }
856
-
857
- /** Whether the review in progress on the arc is a PR review: its `pr_review_requested` is newer than its last
858
- * `review_requested` (the PR review's request records both, in that order; the final review's records only the latter). */
859
- export function prReviewInProgress(board, id) {
860
- const at = (kind) => board.db.prepare('SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = ?').get(id, kind)?.id ?? 0;
861
- return at('pr_review_requested') > at('review_requested');
1009
+ if (last.kind === 'review_returned') {
1010
+ const returned = returnedReview(board, id);
1011
+ if (!returned) return true;
1012
+ const named = new Set([...returned.heads.map((h) => `${h.pr.url}@${h.commit}`), ...returned.settled.map((p) => `${p.url}@${p.head}`)]);
1013
+ const held = new Set(current.map((p) => `${p.url}@${p.head}`));
1014
+ return named.size !== held.size || [...held].some((k) => !named.has(k));
1015
+ }
1016
+ return last.kind === 'review_requested' && Boolean(board.db.prepare("SELECT 1 FROM task_events WHERE task_id = ? AND kind IN ('pr_review_requested', 'plan_review_requested') AND id > ? LIMIT 1").get(id, last.id));
862
1017
  }
863
1018
 
864
1019
  /** A PR review's request: a session's request-review naming its PR (engine.requestReview's `review`), as the
865
1020
  * workflow's PR review clause selects it. The board's own final review request never carries one. */
866
1021
  const asksPrReview = (payload) => payload?.review === 'pr';
1022
+ /** A plan review's request: a session's `request-review --plan` naming its proposed ADR's PR (engine.requestReview). */
1023
+ const asksPlanReview = (payload) => payload?.review === 'plan';
867
1024
 
868
1025
  /** Whether the arc's last review event is changes requested and every task that verdict owns is closed now. A verdict
869
1026
  * owns the tasks filed or reopened between the request it answered and itself (`request-changes --finding` files each
870
1027
  * finding as a task just before the verdict; a reviewer may reopen one instead). A verdict that owns no task is never
871
1028
  * answered this way: only a new head is. Task state is read as every board reader reads it (statusOf: the card's
872
- * workflow stage over its stored status). */
873
- export function answeredChanges(board, id) {
1029
+ * workflow stage over its stored status).
1030
+ * With `of` (request kinds, as requestKindSql names them) only those requests' verdicts count: the latest verdict on a
1031
+ * request of those kinds, neither voided nor followed by another such request, must be changes requested, so a finding
1032
+ * on one kind of review neither holds back nor re-admits another. */
1033
+ export function answeredChanges(board, id, of = null) {
874
1034
  const db = board.db;
875
- const verdict = db.prepare("SELECT id, kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided') ORDER BY id DESC LIMIT 1").get(id);
1035
+ let verdict, asked;
1036
+ if (of) {
1037
+ verdict = db.prepare(`SELECT v.id, v.kind, r.id AS asked FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
1038
+ AND v.kind IN ('changes_requested', 'review_pass', 'pr_review_pass', 'plan_review_pass') AND v.id > r.id
1039
+ AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)
1040
+ WHERE r.task_id = ? AND r.kind = 'review_requested' AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')})
1041
+ ORDER BY v.id DESC LIMIT 1`).get(id);
1042
+ if (verdict?.kind !== 'changes_requested') return false;
1043
+ // a newer request of these kinds is a review in progress, not findings to answer
1044
+ if (db.prepare(`SELECT 1 FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND r.id > ?
1045
+ AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')}) LIMIT 1`).get(id, verdict.id)) return false;
1046
+ if (db.prepare("SELECT 1 FROM task_events WHERE task_id = ? AND kind = 'review_voided' AND id > ? AND json_extract(payload, '$.review_request') = ? LIMIT 1").get(id, verdict.id, verdict.asked)) return false;
1047
+ asked = verdict.asked;
1048
+ } else {
1049
+ verdict = db.prepare("SELECT id, kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided') ORDER BY id DESC LIMIT 1").get(id);
1050
+ if (verdict?.kind !== 'changes_requested') return false;
1051
+ asked = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_requested' AND id < ?").get(id, verdict.id)?.id ?? 0;
1052
+ }
1053
+ return findingsClosed(db, id, asked, verdict.id);
1054
+ }
1055
+
1056
+ /** Whether the findings on one PR `head` are answered: the latest verdict on a request of the kinds `of` that named that
1057
+ * head (verdictOn, so not a voided one) is changes requested, no later such request naming the head is still without a
1058
+ * verdict (a review in progress or returned, not findings to answer), and every task the verdict owns is closed, as
1059
+ * answeredChanges reads them. Only the head's own verdicts count: a later verdict on another PR of the arc neither hides
1060
+ * nor answers its findings (t_c1e92c46: #2680's pass at 7cc79ffc1 hid the answered findings on #2679 at 364911e9c, whose
1061
+ * PR review could then never be requested again). */
1062
+ export function answeredHead(board, id, head, of) {
1063
+ const db = board.db;
1064
+ const verdict = verdictOn(board, id, head, ['changes_requested', 'review_pass', 'pr_review_pass'], of, null, { latest: true });
876
1065
  if (verdict?.kind !== 'changes_requested') return false;
877
- const asked = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_requested' AND id < ?").get(id, verdict.id)?.id ?? 0;
1066
+ if (db.prepare(`SELECT 1 FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND r.id > ? AND ${namesHeadSql('r')}
1067
+ AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')})
1068
+ AND NOT EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass', 'pr_review_pass') AND v.id > r.id
1069
+ AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807))
1070
+ LIMIT 1`).get(id, verdict.id, head, head)) return false;
1071
+ return findingsClosed(db, id, verdict.asked, verdict.id);
1072
+ }
1073
+
1074
+ /** Whether the verdict `verdictId`, answering the request `asked`, owns a task and every one is closed now: the tasks
1075
+ * filed or reopened between the two (answeredChanges). */
1076
+ /** This PR's open findings, across its review rounds: the arc's open tasks filed or reopened between a request naming
1077
+ * the PR and the changes_requested verdict that answered it (the ownership findingsClosed reads). */
1078
+ export function openPrReviewFindings(db, id, request) {
1079
+ const urls = new Set(candidatePullRequests(JSON.parse(request.payload ?? '{}').ci).map(p => prIdentity(p.pr.url)));
1080
+ const asked = db.prepare("SELECT id,payload FROM task_events WHERE task_id=? AND id<=? AND kind='review_requested'").all(id, request.id)
1081
+ .filter(r => candidatePullRequests(JSON.parse(r.payload ?? '{}').ci).some(p => urls.has(prIdentity(p.pr.url)))).map(r => r.id);
1082
+ if (!asked.length) return [];
1083
+ return db.prepare(`SELECT t.id,t.title,t.body,COALESCE(c.stage,t.status) AS status FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
1084
+ WHERE c.subtask_of=? AND COALESCE(c.stage,t.status) NOT IN ('done','cancelled','archived') AND EXISTS (
1085
+ SELECT 1 FROM task_events r JOIN task_events v ON v.task_id=r.task_id AND v.kind='changes_requested' AND v.id>r.id
1086
+ AND v.id<COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id=r.task_id AND n.kind='review_requested' AND n.id>r.id),9223372036854775807)
1087
+ JOIN task_events e ON e.task_id=t.id AND e.kind IN ('created','reopened') AND e.id>r.id AND e.id<v.id
1088
+ WHERE r.task_id=? AND r.id IN (SELECT value FROM json_each(?))) ORDER BY t.created_at`).all(id, id, JSON.stringify(asked));
1089
+ }
1090
+
1091
+ function findingsClosed(db, id, asked, verdictId) {
878
1092
  const owned = db.prepare(`SELECT COALESCE(c.stage, t.status) AS status FROM supercode_cards c JOIN tasks t ON t.id = c.task_id
879
- WHERE c.subtask_of = ? AND EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = c.task_id AND e.kind IN ('created', 'reopened') AND e.id > ? AND e.id < ?)`).all(id, asked, verdict.id);
1093
+ WHERE c.subtask_of = ? AND EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = c.task_id AND e.kind IN ('created', 'reopened') AND e.id > ? AND e.id < ?)`).all(id, asked, verdictId);
880
1094
  return owned.length > 0 && owned.every((t) => ['done', 'cancelled', 'archived'].includes(t.status));
881
1095
  }
882
1096
 
883
1097
  /** The manager's return of the arc's review (`review_returned`, no verdict) while it is the arc's latest review event, with
884
- * the PR heads its review request named (`heads`, as candidatePullRequests answers them); else null. */
1098
+ * the PR heads its review request named (`heads`, as candidatePullRequests answers them); else null. A return holds
1099
+ * nothing when it was made because the arc re-declared its sources after the review request (reopenReview's
1100
+ * `redeclared`), or once the arc re-declares them after the return (redeclaredSince): its next review is built from the
1101
+ * declarations by the ordinary rules. A declaration made while the review ran, before the manager returned it, is
1102
+ * part of what the return answered and does not undo it. */
885
1103
  export function returnedReview(board, id) {
886
1104
  const db = board.db;
887
1105
  const last = db.prepare("SELECT id, kind, payload FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided', 'review_returned') ORDER BY id DESC LIMIT 1").get(id);
888
1106
  if (last?.kind !== 'review_returned') return null;
889
- const asked = JSON.parse(last.payload ?? '{}')?.review_request;
1107
+ const returned = JSON.parse(last.payload ?? '{}') ?? {};
1108
+ if (returned.redeclared || redeclaredSince(db, id, last.id)) return null;
1109
+ const asked = returned.review_request;
890
1110
  const request = asked ? db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND id = ? AND kind = 'review_requested'").get(id, asked)
891
1111
  : db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' AND id < ? ORDER BY id DESC LIMIT 1").get(id, last.id);
892
- return { id: last.id, heads: candidatePullRequests(request ? JSON.parse(request.payload)?.ci : null) };
1112
+ const ci = request ? JSON.parse(request.payload)?.ci : null;
1113
+ return { id: last.id, heads: candidatePullRequests(ci), settled: ci?.pr_reviewed ?? [] };
893
1114
  }
894
1115
 
895
1116
  /** A commit's date and source tree, read once: both are immutable. Only a successful read is cached; an unread commit
@@ -906,28 +1127,36 @@ const commitTime = async (p) => (await commitRead(p)).at;
906
1127
  /** Whether the PR's head carries the same source tree as `commit` of the same PR. */
907
1128
  const sameTree = async (p, commit) => (await commitRead(p)).tree === (await commitRead({ url: p.url, head: commit })).tree;
908
1129
 
909
- /** A PR read for a committed review or acceptance: a merged one already held is its answer (its head, base and state
1130
+ /** A PR read for review admission or acceptance: a merged one already held is its answer (its head, base and state
910
1131
  * never change again), any other is read fresh. */
911
1132
  const readSource = async (url) => {
912
- const held = await heldRead(pullRequestKey(url));
913
- return held?.state === 'MERGED' ? held : readPullRequest(url);
1133
+ return await mergedFact(url) ?? readPullRequest(url);
914
1134
  };
915
1135
 
916
- /** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. The final
1136
+ /** A cached exclusion must never hide a newly ready/reopened source from review admission or acceptance. The final
917
1137
  * review's request names the whole current set, which leaves out each PR merged at the head its PR review passed
918
- * (openReviewPullRequests: `pr_reviewed`). A verdict or acceptance of a request also stands beside a PR the request
1138
+ * (openReviewPullRequests: `pr_reviewed`). Acceptance of a request also stands beside a PR the request
919
1139
  * did not name when that PR is merged at a head an earlier verdict of the arc judged (`judged`): it was reviewed at the
920
1140
  * head it merged at, and no review is due for it (reviewDue). A PR review (`single`) names one ready PR of the set, and
921
- * its request and verdict stand beside every other PR of the arc, open or merged. */
1141
+ * its request stands beside every other PR of the arc, open or merged. */
922
1142
  async function requireCurrentReviewSet(board, id, urls, repositories = [], { judged = false, single = false } = {}) {
1143
+ if (single) {
1144
+ // A PR review needs its named PR's repository to belong to the arc's workspace, tasks or declared sources,
1145
+ // read from the card without GitHub. Its repositories are the ones already known plus that PR's repository;
1146
+ // requireReviewPullRequestProperties separately reads the named PR at its current head.
1147
+ const { repositories: arc } = await arcRepositories(board, id);
1148
+ const known = new Set([...arc].map((r) => r.toLowerCase()));
1149
+ const outside = urls.map(repositoryOf).filter((r) => !known.has(r.toLowerCase()));
1150
+ if (outside.length) throw new Error(`A PR review names a PR of the arc's repositories; ${[...new Set(outside)].join(', ')} is not one of them (declare it: comment ${id} --source owner/repo:branch).`);
1151
+ return [...new Set([...repositories, ...urls.map(repositoryOf)].map((r) => r.toLowerCase()))].sort();
1152
+ }
923
1153
  const current = await openReviewPullRequests(board, id, { fresh: true,
924
1154
  repositories: [...repositories, ...urls.map(repositoryOf)] });
925
- if (single) return current?.review_repositories ?? repositories;
926
1155
  const unnamed = (current?.pr ?? []).map((url, i) => ({ url, head: current.pr_head[i] })).filter(p => !urls.includes(p.url));
927
1156
  const settled = judged ? await readTogether(unnamed, async p => judgedHead(board, id, p.head) !== null
928
1157
  && (await readSource(p.url)).state === 'MERGED') : unnamed.map(() => false);
929
1158
  const omitted = unnamed.filter((_, i) => !settled[i]).map(p => p.url);
930
- if (omitted.length) throw new Error(`The current review source set also includes ${omitted.join(', ')}; refresh the complete candidate before requesting review, recording its verdict or accepting it`);
1159
+ if (omitted.length) throw new Error(`The current review source set also includes ${omitted.join(', ')}; refresh the complete candidate before requesting review or accepting it`);
931
1160
  return current?.review_repositories ?? repositories;
932
1161
  }
933
1162
 
@@ -949,6 +1178,71 @@ export function passedHead(board, id, head) {
949
1178
  return verdictOn(board, id, head, ['pr_review_pass', 'review_pass'], ['pr', 'final']);
950
1179
  }
951
1180
 
1181
+ /** Current exact-head disposition; a new declaration withdraws all older readbacks. */
1182
+ export function prDisposition(board, id, url, head) {
1183
+ const row = board.db.prepare(`SELECT id,payload FROM task_events WHERE task_id=? AND kind='pr_disposition_recorded'
1184
+ AND json_valid(payload) AND json_extract(payload,'$.url')=? ORDER BY id DESC LIMIT 1`).get(id, url);
1185
+ const value = row ? JSON.parse(row.payload) : null;
1186
+ return value?.head === head ? { ...value, event: row.id } : { disposition: 'merge', event: null };
1187
+ }
1188
+ export function releaseReadback(board, id, url, head) {
1189
+ const disposition = prDisposition(board, id, url, head);
1190
+ const pass = verdictOn(board, id, head, ['pr_review_pass', 'review_pass'], ['pr', 'final'], url);
1191
+ if (disposition.disposition !== 'release-only' || !pass) return null;
1192
+ const row = board.db.prepare(`SELECT payload FROM task_events WHERE task_id=? AND kind='pr_release_readback'
1193
+ AND json_valid(payload) AND json_extract(payload,'$.url')=? AND json_extract(payload,'$.head')=? ORDER BY id DESC LIMIT 1`).get(id, url, head);
1194
+ const value = row ? JSON.parse(row.payload) : null;
1195
+ if (value?.disposition_event !== disposition.event || value?.verdict !== pass.id
1196
+ || value?.target !== disposition.target || value?.artifact !== head || value?.verification?.source !== head || value?.verification?.target !== value.target
1197
+ || !['machine', 'manager-attestation'].includes(value?.verification?.kind)) return null;
1198
+ return value;
1199
+ }
1200
+ /** A retained readback becomes settlement only beside a fresh observation of its PR head. */
1201
+ export async function verifiedReleaseReadback(board, id, url, head) {
1202
+ const readback = releaseReadback(board, id, url, head);
1203
+ if (!readback) return null;
1204
+ const pr = await readPullRequest(url);
1205
+ return pr.headRefOid === head && !pr.isCrossRepository ? releaseReadback(board, id, url, head) : null;
1206
+ }
1207
+ /** Prepare evidence outside the write. The engine checks its event bindings again at commit. */
1208
+ export async function preparePrEvidence(board, id, source, options, { manager = false, actor } = {}) {
1209
+ const { url, head } = source;
1210
+ if (typeof actor !== 'string' || !actor.trim()) throw new Error('PR evidence needs the authenticated actor identity.');
1211
+ const pr = await readPullRequest(url);
1212
+ if (pr.headRefOid !== head || pr.isCrossRepository) throw new Error('PR evidence must name the current exact repository PR head.');
1213
+ const reason = String(options.reason ?? '').trim();
1214
+ if (!reason) throw new Error('PR disposition/readback needs a reason.');
1215
+ if (options.disposition != null) {
1216
+ if (!['merge', 'release-only'].includes(options.disposition)) throw new Error('PR disposition is merge or release-only.');
1217
+ const target = options.disposition === 'release-only' ? String(options.target ?? '').trim() : null;
1218
+ if (options.disposition === 'release-only' && !target) throw new Error('Release-only disposition names its intended --release-target.');
1219
+ return { kind: 'pr_disposition_recorded', url, head, disposition: options.disposition, target, actor, reason };
1220
+ }
1221
+ const disposition = prDisposition(board, id, url, head);
1222
+ const pass = verdictOn(board, id, head, ['pr_review_pass', 'review_pass'], ['pr', 'final'], url);
1223
+ if (disposition.disposition !== 'release-only' || !pass) throw new Error('Release readback needs an active release-only disposition and a valid independent pass of this exact PR/head.');
1224
+ const { target, artifact, evidence } = options.readback ?? {};
1225
+ if (typeof target !== 'string' || target !== disposition.target || artifact !== head || typeof evidence !== 'string' || !evidence.trim()) throw new Error('Release readback needs its target, exact released head as artifact, and evidence pointer.');
1226
+ let verification;
1227
+ if (target.startsWith('machine:')) {
1228
+ if (options.readback.attest) throw new Error('A machine target uses its status verifier, not an attestation.');
1229
+ const machine = target.slice('machine:'.length);
1230
+ if (!machine) throw new Error('Name the released machine.');
1231
+ const answer = await runtimeDoor(machine, 'harness.v1.machine.describe', {}, { timeout: 9000 });
1232
+ if (answer.code !== 0) throw new Error(`Release target could not be verified: ${answer.stderr}`);
1233
+ const receipt = JSON.parse(answer.stdout), selected = receipt.runtime_release?.source;
1234
+ const source = selected?.binding === 'node' ? selected.node : selected?.native;
1235
+ if (![receipt.name, receipt.id, receipt.machineId].includes(machine) || selected?.binding_verdict !== 'matches'
1236
+ || selected?.commit !== head || source?.commit !== head || source?.dirty !== false || source?.observation_error) throw new Error('Machine status does not confirm this exact reviewed source/artifact.');
1237
+ verification = { kind: 'machine', source: head, target, observed_at: Date.now(), receipt };
1238
+ } else {
1239
+ if (!manager || options.readback.attest !== true) throw new Error('This target has no machine verifier; an explicit manager attestation of the observed target and source is required.');
1240
+ verification = { kind: 'manager-attestation', source: head, target, observed_at: Date.now(), actor };
1241
+ }
1242
+ return { kind: 'pr_release_readback', url, head, actor, reason, target, artifact, evidence,
1243
+ disposition_event: disposition.event, verdict: pass.id, verification };
1244
+ }
1245
+
952
1246
  /** A review request's kind, as SQL over its `review_requested` row `r`: a PR review's and a plan review's request each
953
1247
  * record their own marker right after it (pr_review_requested, plan_review_requested), before the next request; the
954
1248
  * board's final review records none. Each kind keeps its own verdicts: a plan review, which grades no code head, never
@@ -960,17 +1254,30 @@ const requestKindSql = (r) => `CASE
960
1254
  AND k.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = ${r}.task_id AND n.kind = 'review_requested' AND n.id > ${r}.id), 9223372036854775807)) THEN 'pr'
961
1255
  ELSE 'final' END`;
962
1256
 
963
- function verdictOn(board, id, head, kinds, of) {
1257
+ /** The latest requested candidate and its kind, using the same marker ordering as verdict selection. */
1258
+ export function latestReviewRequest(db, id) {
1259
+ return db.prepare(`SELECT r.id,r.payload,${requestKindSql('r')} AS kind FROM task_events r
1260
+ WHERE r.task_id=? AND r.kind='review_requested' ORDER BY r.id DESC LIMIT 1`).get(id) ?? null;
1261
+ }
1262
+
1263
+ /** Whether review request `r` named a head, as SQL binding that head twice: its one PR's commit or any of its PRs'. */
1264
+ const namesHeadSql = (r) => `(json_extract(${r}.payload, '$.ci.commit') = ?
1265
+ OR EXISTS (SELECT 1 FROM json_each(${r}.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?))`;
1266
+
1267
+ /** The first verdict of `kinds` (the latest with `latest`) that answered a request of the kinds `of` naming `head` (of
1268
+ * `url`, when given) and was not voided, with the request it answered (`asked`), or null. */
1269
+ function verdictOn(board, id, head, kinds, of, url = null, { latest = false } = {}) {
964
1270
  if (!head) return null;
965
1271
  // A request's JSON head list is checked once before visiting its verdicts. Without CROSS JOIN SQLite starts
966
1272
  // from every verdict and reparses the same older requests for each of them (including heads never requested).
967
- return board.db.prepare(`SELECT v.id, v.kind FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
1273
+ return board.db.prepare(`SELECT v.id, v.kind, r.id AS asked FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
968
1274
  AND v.kind IN (${kinds.map((kind) => `'${kind}'`).join(', ')}) AND v.id > r.id
969
1275
  AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)
970
1276
  AND NOT EXISTS (SELECT 1 FROM task_events x WHERE x.task_id=r.task_id AND x.kind='review_voided' AND x.id>v.id AND json_extract(x.payload,'$.review_request')=r.id)
971
- WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
972
- OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?))
973
- AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')}) ORDER BY v.id LIMIT 1`).get(id, head, head) ?? null;
1277
+ WHERE r.task_id = ? AND r.kind = 'review_requested' AND ${namesHeadSql('r')}
1278
+ AND (? IS NULL OR (json_extract(r.payload,'$.ci.pr.url')=? AND json_extract(r.payload,'$.ci.commit')=?)
1279
+ OR EXISTS (SELECT 1 FROM json_each(r.payload,'$.ci.prs') p WHERE json_extract(p.value,'$.pr.url')=? AND json_extract(p.value,'$.commit')=?))
1280
+ AND (${requestKindSql('r')}) IN (${of.map((kind) => `'${kind}'`).join(', ')}) ORDER BY v.id ${latest ? 'DESC' : 'ASC'} LIMIT 1`).get(id, head, head, url, url, head, url, head) ?? null;
974
1281
  }
975
1282
 
976
1283
  export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
@@ -998,36 +1305,54 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
998
1305
  if (wanted.length !== passed.length || wanted.some((w) => !passed.some((p) => p.pr.url === w.pr.url && p.commit === w.commit))) throw new Error('The current PR heads need their independent verdict before acceptance.');
999
1306
  await readTogether(wanted, async w => {
1000
1307
  const pr = await readSource(w.pr.url);
1001
- if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
1308
+ if ((!['OPEN', 'MERGED'].includes(pr.state) && !releaseReadback(board, id, w.pr.url, w.commit)) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
1002
1309
  });
1003
1310
  Object.assign(state, { ...candidate, ready: true, reason: null });
1004
1311
  }
1005
1312
  return state;
1006
1313
  }
1007
- const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
1008
- const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
1314
+ if (stage === 'review_verdict') {
1315
+ // The verdict judges the review run's frozen candidate: the arc's latest run is its reviewer's while it reviews.
1316
+ const asked = reviewCandidate(board, id);
1317
+ const latest = board.db.prepare('SELECT metadata FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
1318
+ const bound = latest ? JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate : null;
1319
+ if (bound && !isDeepStrictEqual(bound, asked)) throw new Error('This reviewer run was launched for a different review candidate.');
1320
+ const requested = candidatePullRequests(asked);
1321
+ const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? requested.map(p => p.pr.url));
1322
+ const named = [].concat(payload.reviewed_sha ?? payload.pr_head ?? payload.commit ?? []);
1323
+ const sameSet = (left, right) => {
1324
+ const wanted = new Set(right);
1325
+ return new Set(left).size === wanted.size && left.every(value => wanted.has(value));
1326
+ };
1327
+ if (urls.some(url => typeof url !== 'string')
1328
+ || !sameSet(urls.map(prIdentity), requested.map(p => prIdentity(p.pr.url)))) throw new Error('The verdict must cover exactly the PRs its review request named.');
1329
+ if (named.some(head => typeof head !== 'string' || !/^[a-f0-9]{40}$/.test(head))
1330
+ || !sameSet(named, requested.map(p => p.commit))) throw new Error('Name exactly the requested PR heads with --reviewed-sha.');
1331
+ return { ...state, ...asked, ready: true, reason: null };
1332
+ }
1333
+ const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? []).filter(Boolean);
1009
1334
  if (urls.some(url => typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url))) throw new Error('Name each GitHub PR URL with --pr.');
1010
1335
  // Advisory heads can refuse a redundant request, never authorize one. Do
1011
1336
  // this before a fresh source scan, including for callers outside dispatch.
1012
1337
  const proposed = [].concat(payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
1013
- // A PR review (its request names its PR; its verdict answers the PR review in progress) names one
1014
- // ready PR, whatever other PRs the arc has, open or merged.
1015
- const single = stage === 'request_review' ? asksPrReview(payload) : stage === 'review_verdict' && prReviewInProgress(board, id);
1016
- if (single && stage === 'request_review' && urls.length !== 1) throw new Error('A PR review names one PR: --pr URL --pr-head SHA.');
1017
- const due = (prs) => single ? prs.every((p) => prReviewDue(board, id, p)) : reviewDue(board, id, prs);
1018
- if (stage === 'request_review' && urls.length && proposed.length === urls.length && proposed.every(head => typeof head === 'string' && /^[a-f0-9]{40}$/.test(head))
1338
+ // A PR review and a plan review each name one PR, whatever other PRs the arc has, open or merged, and each is due
1339
+ // by its own kind's verdicts alone (prReviewDue, planReviewDue). Only the final review is due by the whole set (reviewDue).
1340
+ const plan = asksPlanReview(payload);
1341
+ const single = plan || asksPrReview(payload);
1342
+ if (single && urls.length !== 1) throw new Error(plan ? 'A plan review names its proposed ADR\'s PR: --plan --pr URL --pr-head SHA.' : 'A PR review names one PR: --pr URL --pr-head SHA.');
1343
+ const due = (prs) => plan ? prs.every((p) => planReviewDue(board, id, p)) : single ? prs.every((p) => prReviewDue(board, id, p)) : reviewDue(board, id, prs);
1344
+ const kind = plan ? 'plan review' : 'PR review';
1345
+ if (urls.length && proposed.length === urls.length && proposed.every(head => typeof head === 'string' && /^[a-f0-9]{40}$/.test(head))
1019
1346
  && !await due(urls.map((url, i) => ({ url, head: proposed[i] })))) {
1020
- throw new Error(single ? 'The proposed PR head already has a verdict (a pass, or findings not yet answered); no PR review is due.' : 'Every proposed PR head already has a verdict or carries no new source; no review is due.');
1347
+ throw refused('judged', urls[0], single ? `The proposed PR head already has a ${kind}'s verdict (a pass, or findings not yet answered); no ${kind} is due.` : 'Every proposed PR head already has a verdict or carries no new source; no review is due.');
1021
1348
  }
1022
- if (stage === 'request_review' || stage === 'review_verdict') state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? [], { judged: stage === 'review_verdict', single });
1023
- if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
1349
+ state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? [], { single });
1024
1350
  // The final review of an arc whose every PR is settled by its PR review names no PR: it reviews the arc's Acceptance
1025
1351
  // against them (settledReviewDue).
1026
- const settled = stage === 'request_review' ? payload.pr_reviewed : asked?.pr_reviewed;
1352
+ const settled = payload.pr_reviewed;
1027
1353
  if (!urls.length && settled?.length && !single) return { ...state, pr_reviewed: settled };
1028
1354
  // Merged source is immutable and needs no surviving workspace. Resolve an
1029
- // unmerged candidate's checkout as before; a broken legacy path never
1030
- // prevents a verdict on already merged commits.
1355
+ // unmerged candidate's checkout before admitting its review.
1031
1356
  const read = await mapGithubSources(urls, readSource);
1032
1357
  const mergedOnly = urls.length && read.every(pr => pr.state === 'MERGED');
1033
1358
  const own = mergedOnly ? null : await repositoryWorkspace(board, id);
@@ -1045,31 +1370,18 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
1045
1370
  const strays = read.filter((pr) => pr.state !== 'MERGED' && !declared(pr));
1046
1371
  if (strays.length) throw refusalOf(strays.map((stray) => refused('undeclared', stray.url, `An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; declare it on the arc: comment ${id} "<why>" --source ${repositoryOf(stray.url)}:${stray.headRefName}`)));
1047
1372
  }
1048
- // A verdict with no requested PR (a scratch arc) keeps its own contract. An arc's review
1049
- // always names every opened PR it changed, independent of deployment.
1050
- if (stage === 'review_verdict' && !asked && !urls.length) return state;
1373
+ // An arc's review always names every opened PR it changed, independent of deployment.
1051
1374
  Object.assign(state, { required: true, ready: false });
1052
1375
  if (!urls.length) throw new Error('Name each opened PR with --pr.');
1053
- const named = [].concat(stage === 'review_verdict'
1054
- ? payload.reviewed_sha ?? payload.pr_head ?? payload.commit ?? []
1055
- : payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
1056
- if (stage === 'request_review' && named.length !== urls.length) throw new Error('Name each PR head with --pr-head, one for each --pr, in the same order.');
1057
- if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
1058
- // Every PR's properties, then (for a review request) every PR's checkout at its pushed head, as the advisory pass
1376
+ const named = [].concat(payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
1377
+ if (named.length !== urls.length) throw new Error('Name each PR head with --pr-head, one for each --pr, in the same order.');
1378
+ // Every PR's properties, then every PR's checkout at its pushed head, as the advisory pass
1059
1379
  // checks them; each collects every refusal (refuseTogether).
1060
- const prs = await refuseTogether(urls, (url, index) => requireReviewPullRequestProperties(url, stage === 'request_review' ? named[index] : null, read[index]));
1061
- if (stage === 'request_review') await refuseTogether(prs, (source, index) => requireReviewWorkspaceHead(board, id, own, read[index], source.commit, { pushed: true }));
1062
- if (stage === 'request_review') {
1063
- // A review is asked for only while some PR has a head no verdict has judged; a PR review, while its one PR's is.
1064
- if (!await due(prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error(single ? 'That PR head already has a verdict (a pass, or findings not yet answered): push a commit that changes the source, or close the tasks that own the last verdict\'s findings, before requesting its PR review.' : 'Every PR head has a verdict, carries the source tree its verdict judged, or predates it: push a commit that changes the source, or close the tasks that own the last verdict\'s findings, before requesting review.');
1065
- } else if (asked) {
1066
- const requested = candidatePullRequests(asked);
1067
- if (requested.length !== prs.length || requested.some((r) => !prs.some((p) => p.pr.url === r.pr.url && p.commit === r.commit))) throw new Error('The verdict covers the requested PRs at their requested heads; a PR whose head moved needs a new review request.');
1068
- if (requested.some((r) => !named.includes(r.commit))) throw new Error('Name every reviewed PR head with --reviewed-sha.');
1069
- const latest = board.db.prepare('SELECT metadata FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
1070
- const bound = candidatePullRequests(latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate);
1071
- if (bound.length && (bound.length !== requested.length || bound.some((b) => !requested.some((r) => r.pr.url === b.pr.url && r.commit === b.commit)))) throw new Error('This reviewer run was launched for different PR heads.');
1072
- }
1380
+ const prs = await refuseTogether(urls, (url, index) => requireReviewPullRequestProperties(url, named[index], read[index]));
1381
+ await refuseTogether(prs, (source, index) => requireReviewWorkspaceHead(board, id, own, read[index], source.commit, { pushed: true }));
1382
+ // A review is asked for only while some PR has a head no verdict has judged; a PR or plan review, while its one PR's
1383
+ // head has no verdict of its own kind.
1384
+ if (!await due(prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw refused('judged', prs[0].pr.url, single ? `That PR head already has a ${kind}'s verdict (a pass, or findings not yet answered): push a commit that changes the source, or close the tasks that own the last verdict's findings, before requesting its ${kind}.` : 'Every PR head has a verdict, carries the source tree its verdict judged, or predates it: push a commit that changes the source, or close the tasks that own the last verdict\'s findings, before requesting review.');
1073
1385
  Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}), ...(settled?.length && !single ? { pr_reviewed: settled } : {}) });
1074
1386
  } catch (error) {
1075
1387
  Object.assign(state, { required: true, ready: false, reason: String(error?.message ?? error) });
@@ -1081,6 +1393,36 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
1081
1393
  return state;
1082
1394
  }
1083
1395
 
1396
+ // Quiet tracked predicates produce no path list. Untracked detection streams only a bounded
1397
+ // sample and terminates that owned Git child once one dirty fact is known. A Git failure
1398
+ // before a dirty fact is an error, never a clean checkout or an uncommitted-state refusal.
1399
+ async function gitState(path, args, { stream = false } = {}) {
1400
+ return new Promise((resolve, reject) => {
1401
+ const child = spawn('git', args, { cwd: path, stdio: ['ignore', 'pipe', 'pipe'] });
1402
+ let sample = '', diagnostic = '', found = false, timedOut = false;
1403
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 9000);
1404
+ child.stdout.on('data', bytes => {
1405
+ if (!stream || found) return;
1406
+ sample = (sample + bytes.toString('utf8')).slice(0, 512);
1407
+ if (sample.length) { found = true; child.kill('SIGTERM'); }
1408
+ });
1409
+ child.stderr.on('data', bytes => { diagnostic = (diagnostic + bytes.toString('utf8')).slice(-500); });
1410
+ child.once('error', error => { clearTimeout(timer); reject(error); });
1411
+ child.once('close', (code) => {
1412
+ clearTimeout(timer);
1413
+ if (timedOut) return reject(new Error('git clean-state check exceeded 9 seconds'));
1414
+ if (found) return resolve(`untracked path(s): ${JSON.stringify(sample.split('\0')[0])}${sample.includes('\0') ? ' (sample; more may exist)' : '…'}`);
1415
+ if (code === 0) return resolve(null);
1416
+ if (!stream && code === 1) return resolve('tracked index/worktree changes (paths omitted)');
1417
+ reject(new Error(`git clean-state check failed (${code}): ${diagnostic}`));
1418
+ });
1419
+ });
1420
+ }
1421
+ export async function reviewWorkspaceDirty(path) {
1422
+ return await gitState(path, ['diff', '--quiet', '--no-ext-diff', '--ignore-submodules=none', 'HEAD', '--'])
1423
+ ?? await gitState(path, ['ls-files', '--others', '--exclude-standard', '-z'], { stream: true });
1424
+ }
1425
+
1084
1426
  /** Local review readiness, also usable with advisory metadata to refuse before spending REST admission. */
1085
1427
  async function requireReviewWorkspaceHead(board, id, own, observed, head, { pushed = false } = {}) {
1086
1428
  const url = observed.url, repository = repositoryOf(url), branch = observed.headRefName;
@@ -1091,8 +1433,8 @@ async function requireReviewWorkspaceHead(board, id, own, observed, head, { push
1091
1433
  if (!path) return;
1092
1434
  await run('git', ['check-ref-format', '--branch', branch], path);
1093
1435
  if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw refused('head', url, `The head named for ${url} must be its worktree head.`);
1094
- const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
1095
- if (uncommitted.length) throw refused('uncommitted', url, `Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
1436
+ const dirty = await reviewWorkspaceDirty(path);
1437
+ if (dirty) throw refused('uncommitted', url, `Commit the work in ${path} before requesting review: ${dirty}`);
1096
1438
  if (pushed) {
1097
1439
  const remote = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
1098
1440
  if (remote !== head) throw refused('unpushed', url, `Push the exact head of ${url} before requesting review.`);
@@ -1100,21 +1442,22 @@ async function requireReviewWorkspaceHead(board, id, own, observed, head, { push
1100
1442
  }
1101
1443
 
1102
1444
  /** PR-only readiness, shared by advisory refusals and fresh source validation. */
1103
- function requireReviewPullRequestProperties(url, supplied, observed) {
1445
+ export function requireReviewPullRequestProperties(url, supplied, observed) {
1104
1446
  if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
1105
1447
  const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
1106
1448
  // A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
1107
1449
  // verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
1108
1450
  const merged = observed.state === 'MERGED';
1109
- if ((observed.state !== 'OPEN' && !merged) || observed.isCrossRepository) throw refused('state', url, `Source review needs an open or merged PR from its repository (${url}).`);
1451
+ if (observed.isCrossRepository) throw refused('foreign', url, `Source review needs a PR from its own repository (${url}).`);
1452
+ if (observed.state !== 'OPEN' && !merged) throw refused('state', url, `Source review needs an open or merged PR (${url}).`);
1110
1453
  if (!merged && observed.isDraft) throw refused('draft', url, `${url} is a draft; its review starts when it is marked ready for review.`);
1111
1454
  const head = supplied ?? observed.headRefOid;
1112
1455
  if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
1113
- if (observed.headRefOid !== head) throw new Error(`The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
1456
+ if (observed.headRefOid !== head) throw refused('moved', url, `The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
1114
1457
  const branch = observed.headRefName;
1115
- const mainBranch = observed.defaultBranch ?? 'main';
1116
- // A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
1117
- if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw refused('base', url, `${url} must propose its worktree branch to the repository main branch.`);
1458
+ // A PR whose head is the default branch (main → prod) is a release, never an arc's work.
1459
+ if (branch === (observed.defaultBranch ?? 'main')) throw refused('release', url, `${url} proposes the default branch ${branch}; it is a release, not an arc's work.`);
1460
+ // Any base: the review reads the PR's whole diff against its own base (pr.base).
1118
1461
  return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
1119
1462
  }
1120
1463