@volter/supercode-orchestrator 0.5.225 → 0.5.227

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.
package/board/ci.mjs CHANGED
@@ -5,10 +5,9 @@ import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from
5
5
  import { dirname, join, relative } from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { AsyncLocalStorage } from 'node:async_hooks';
8
- import { branchCheckouts, checkoutOf, isLocalWorkspace, isWorkspacePath, resolveWorkspace, plainWorkspace } from './workspace.mjs';
8
+ import { branchCheckouts, checkoutOf, isLocalWorkspace, isWorkspacePath, resolveWorkspace, plainWorkspace, workspaceTask } from './workspace.mjs';
9
9
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
10
10
  import { githubFetch, githubDiscovery, githubDiscoveryHeld, githubDiscoveryUsed, GITHUB_DISCOVERY_ATTEMPTS } from './github-rest.mjs';
11
- import { cardHandleOf } from './store.mjs';
12
11
 
13
12
  // GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
14
13
  // waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
@@ -273,6 +272,9 @@ export function completionEvidence(board, id) {
273
272
 
274
273
  export function completionPhase(board, id, entry, verb, payload, roles = []) {
275
274
  if (verb === 'request_review') return 'request_review';
275
+ // A person's completion of their own card (person-done) is judged by no review and carries no source: its evidence is
276
+ // the person's act the Workplace reported, which the workflow checks names the card's person.
277
+ if (verb === 'complete' && payload.person && roles.includes('platform')) return 'person';
276
278
  if ((verb === 'request_state' && payload.state === 'done' || verb === 'complete') && roles.includes('reviewer')) return 'review_verdict';
277
279
  return verb === 'complete' ? 'acceptance' : 'completion';
278
280
  }
@@ -298,7 +300,7 @@ export function withCompletionEvidence(board, id, state, fn) {
298
300
  export function reviewCandidate(board, id) {
299
301
  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);
300
302
  const payload = row && JSON.parse(row.payload);
301
- return payload?.ci?.pr || payload?.ci?.no_code ? payload.ci : null;
303
+ return payload?.ci?.pr || payload?.ci?.no_code || payload?.ci?.pr_reviewed?.length ? payload.ci : null;
302
304
  }
303
305
 
304
306
  /** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
@@ -320,7 +322,7 @@ async function repositoryWorkspace(board, id) {
320
322
  const owner = workspaceOwner(board, id);
321
323
  if (!owner) return null;
322
324
  // the worktree's folder, when it is still to be made, is named for its card (workspace.mjs worktreeFolder)
323
- const workspace = { ...owner, handle: cardHandleOf(board, owner.id) };
325
+ const workspace = workspaceTask(board, owner);
324
326
  const here = isLocalWorkspace(workspace);
325
327
  if (!here) {
326
328
  const identity = workspace.workspace_anchor ?? workspace.workspace_path;
@@ -328,10 +330,14 @@ async function repositoryWorkspace(board, id) {
328
330
  // A portable remote declaration supplies the source identity. Legacy paths
329
331
  // are resolved by their own machine, never read as paths on the board host.
330
332
  const resolved = plainWorkspace(workspace.workspace_kind, identity, workspace.branch_name) ?? await resolveWorkspace(workspace, false);
333
+ // a plain folder holding the arc's repositories (workspace.mjs plainFolder) has none of its own: the review reads
334
+ // the arc's recorded ones, as a scratch arc's does
335
+ if (resolved.folder) return null;
331
336
  return { ...resolved, branch: workspace.branch_name ?? resolved.branch, here: false };
332
337
  }
333
338
  if (workspace.workspace_anchor) {
334
339
  const resolved = await resolveWorkspace(workspace, false);
340
+ if (resolved.folder) return null;
335
341
  // Resolution without create names a solo worktree even before it exists on
336
342
  // this machine. Its portable repository and branch still identify the
337
343
  // pushed PR. Only an actual directory can supply local source evidence.
@@ -469,8 +475,8 @@ function earlierDeclaredBranches(db, id) {
469
475
 
470
476
  /** The PRs the arc declared (`comment --pr URL --pr-head SHA`), each at its latest declaration: every declaration since
471
477
  * the latest `--pr none` adds to the ones before it, in any of the arc's repositories, on any run. Discovery reads each
472
- * at its current head and leaves out only a closed one, an open draft, and a merged one whose merged head a passed
473
- * review judged (discoverReviewSources). */
478
+ * at its current head and leaves out only a closed one and an open draft: a merged one is in at its merged head
479
+ * (discoverReviewSources). */
474
480
  export function declaredPullRequests(db, id) {
475
481
  const latest = new Map();
476
482
  for (const declaration of declarationsOf(db, id, 'sources')) for (const source of declaration.items) {
@@ -522,11 +528,14 @@ export function declaredWorkBranches(db, id) {
522
528
  return [...new Set([...declared, ...reviewed].filter(Boolean))];
523
529
  }
524
530
 
525
- /** Every opened PR of the arc, plus an unjudged merged PR on its declared branch, each proposed to its repository's default branch: an open PR whose head is the card's
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
533
+ * names them all, and whether a review is due depends only on the heads no verdict judged (reviewDue). A PR review
534
+ * 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
526
535
  * work branch in the card's own repository, a `wt/<card id>` branch in any repository this machine keeps a checkout
527
536
  * of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
528
537
  * 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
529
- * answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
538
+ * answers `{ waits }` saying so. The final review covers them all; the caller decides the transition. */
530
539
  export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, refuseStacked = false, repositories = [] } = {}) {
531
540
  if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, refuseStacked, repositories });
532
541
  // Every authorization entry acquires the broad advisory scope, not only
@@ -572,7 +581,6 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
572
581
  ...repositories, ...siblings].filter(Boolean).map(r => r.toLowerCase()))];
573
582
  const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
574
583
  .filter(Boolean).map(r => r.toLowerCase()));
575
- const answered = answeredChanges(board, id);
576
584
  // A branch an earlier `--source` declaration named, and no task or the latest declaration names, keeps its merged
577
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
578
586
  // home's discovery budget. A task's branch and the latest declaration's are read as before.
@@ -640,7 +648,8 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
640
648
  ...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
641
649
  ...registered.filter(r => r.repository === repository).map(r => r.branch),
642
650
  ])];
643
- // A judged merged head is passed over, except while an answered changes-requested verdict makes it due again.
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).
644
653
  const histories = await readTogether(historical, async headBranch => {
645
654
  if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
646
655
  const older = earlier.has(`${repository}:${headBranch}`) && !(repository === ownRepository && headBranch === branch);
@@ -649,7 +658,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
649
658
  if (older && !held && !await admit(`history:${repository}:${headBranch}`)) return [];
650
659
  return (held ?? await mergedBranchPullRequests(repository, headBranch, { fresh, ...(older ? { ttl: spread(EARLIER_BRANCH_TTL, `history:${repository}:${headBranch}`) } : {}) })).filter(pr =>
651
660
  !pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
652
- && pr.baseRefName === pr.defaultBranch && (answered || !judgedHead(board, id, pr.headRefOid)));
661
+ && pr.baseRefName === pr.defaultBranch);
653
662
  });
654
663
  for (const history of histories) for (const pr of history) {
655
664
  if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
@@ -660,9 +669,8 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
660
669
  // Each declared PR discovery did not find is followed at its current head (GitHub's), never pinned to the declared
661
670
  // one, so a push after the declaration is reviewed instead of failing every tick. One open in a repository listed above
662
671
  // is taken from that list, with no read of its own; any other is read (a merged answer already held is reused, in a
663
- // fresh scan too: a merged PR never changes again). One from a fork, closed, or an open draft is left out, and so is
664
- // a merged one whose merged head a passed review judged (it was reviewed at the head it merged at; one that moved
665
- // after its pass and merged unreviewed is still in). One outside the arc's repositories is in scope only when it is
672
+ // fresh scan too: a merged PR never changes again). One from a fork, closed, or an open draft is left out; a merged one
673
+ // is in at its merged head, whatever verdicts it has. One outside the arc's repositories is in scope only when it is
666
674
  // itself on the arc's work branch in its own repository. One GitHub says is absent or denied (404, 403, 410) stays
667
675
  // outside and is refused with the rest, unless another declared PR brings its repository into scope, where its read
668
676
  // error is thrown as it is. Any other read failure (5xx, reset, timeout) is thrown as a transient failure, never
@@ -699,17 +707,23 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
699
707
  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 });
700
708
  for (const pr of read) {
701
709
  if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
702
- if (pr.state === 'MERGED' && !answered && passedHead(board, id, pr.headRefOid)) continue;
703
710
  if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
704
711
  }
712
+ // A PR merged at a head a review passed (its PR review, or an earlier final review: passedHead) is settled: the arc's
713
+ // final review neither names nor owes it. It is kept apart as `pr_reviewed`, so an arc whose every PR is settled still
714
+ // gets its final review, of its Acceptance against them (VO-3766: PRs merged at the heads a final review passed, then
715
+ // 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));
717
+ 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 })) } : {};
705
719
  // Retain scope even when all matching PRs are excluded drafts/other bases,
706
- // or judged merged sources, so a fresh scan cannot inherit that exclusion.
720
+ // so a fresh scan cannot inherit that exclusion.
707
721
  if (scope) for (const repository of reviewRepositories) scope.add(repository);
708
722
  stacked.sort((a, b) => a.url.localeCompare(b.url));
709
723
  // Advisory discovery can be up to five minutes old: the dispatcher tells a stacked refusal only once a fresh read
710
724
  // (refuseStacked) confirms it.
711
- 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 one review includes it.`)));
712
- if (!prs.length && stacked.length) return { pr: [], pr_head: [], branch, review_repositories: [...reviewRepositories].sort(), stacked: stacked.map((pr) => pr.url) };
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 };
713
727
  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` };
714
728
  // Advisory facts can refuse an attempt before its authoritative REST scan. Check every PR's properties before
715
729
  // doing checkout work: a wrong base cannot be repaired by revalidating the whole arc every round. Discovery
@@ -720,7 +734,7 @@ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces
720
734
  await refuseTogether(prs, pr => requireReviewPullRequestProperties(pr.url, pr.head, pr.observed));
721
735
  await refuseTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
722
736
  }
723
- 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) };
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 };
724
738
  }
725
739
 
726
740
  /** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
@@ -792,6 +806,9 @@ async function knownRepositories({ strict = false } = {}) {
792
806
  * when the request was made. */
793
807
  export async function reviewDue(board, id, prs) {
794
808
  if (prs.length && answeredChanges(board, id)) return true;
809
+ // A review the manager returned with no verdict is due again only for new work: a PR head its request did not name.
810
+ const returned = returnedReview(board, id);
811
+ if (returned) return prs.some((p) => !returned.heads.some((h) => h.pr.url === p.url && h.commit === p.head));
795
812
  const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
796
813
  if (!unjudged.length) return false;
797
814
  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);
@@ -800,7 +817,7 @@ export async function reviewDue(board, id, prs) {
800
817
  AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id) AS verdict_at,
801
818
  (SELECT v.kind FROM task_events v WHERE v.task_id = r.task_id
802
819
  AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id ORDER BY v.id LIMIT 1) AS verdict
803
- FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
820
+ FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested' AND (${requestKindSql('r')}) != 'plan'
804
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)
805
822
  ORDER BY r.id DESC LIMIT 1`).get(id);
806
823
  if (!last) return true;
@@ -817,6 +834,37 @@ export async function reviewDue(board, id, prs) {
817
834
  return false;
818
835
  }
819
836
 
837
+ /** 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. */
840
+ export function prReviewDue(board, id, { head }) {
841
+ if (prReviewedHead(board, id, head)) return false;
842
+ // 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);
844
+ }
845
+
846
+ /** 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) {
851
+ 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
+ if (!last || last.kind === 'review_voided') return true;
853
+ 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');
862
+ }
863
+
864
+ /** A PR review's request: a session's request-review naming its PR (engine.requestReview's `review`), as the
865
+ * workflow's PR review clause selects it. The board's own final review request never carries one. */
866
+ const asksPrReview = (payload) => payload?.review === 'pr';
867
+
820
868
  /** Whether the arc's last review event is changes requested and every task that verdict owns is closed now. A verdict
821
869
  * owns the tasks filed or reopened between the request it answered and itself (`request-changes --finding` files each
822
870
  * finding as a task just before the verdict; a reviewer may reopen one instead). A verdict that owns no task is never
@@ -832,6 +880,18 @@ export function answeredChanges(board, id) {
832
880
  return owned.length > 0 && owned.every((t) => ['done', 'cancelled', 'archived'].includes(t.status));
833
881
  }
834
882
 
883
+ /** 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. */
885
+ export function returnedReview(board, id) {
886
+ const db = board.db;
887
+ 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
+ if (last?.kind !== 'review_returned') return null;
889
+ const asked = JSON.parse(last.payload ?? '{}')?.review_request;
890
+ const request = asked ? db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND id = ? AND kind = 'review_requested'").get(id, asked)
891
+ : 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) };
893
+ }
894
+
835
895
  /** A commit's date and source tree, read once: both are immutable. Only a successful read is cached; an unread commit
836
896
  * is unknown, never evidence of old work or of a changed tree. */
837
897
  async function commitRead({ url, head }) {
@@ -846,40 +906,71 @@ const commitTime = async (p) => (await commitRead(p)).at;
846
906
  /** Whether the PR's head carries the same source tree as `commit` of the same PR. */
847
907
  const sameTree = async (p, commit) => (await commitRead(p)).tree === (await commitRead({ url: p.url, head: commit })).tree;
848
908
 
849
- /** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
850
- async function requireCurrentReviewSet(board, id, urls, repositories = []) {
909
+ /** A PR read for a committed review or acceptance: a merged one already held is its answer (its head, base and state
910
+ * never change again), any other is read fresh. */
911
+ const readSource = async (url) => {
912
+ const held = await heldRead(pullRequestKey(url));
913
+ return held?.state === 'MERGED' ? held : readPullRequest(url);
914
+ };
915
+
916
+ /** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. The final
917
+ * 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
919
+ * 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
+ * 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. */
922
+ async function requireCurrentReviewSet(board, id, urls, repositories = [], { judged = false, single = false } = {}) {
851
923
  const current = await openReviewPullRequests(board, id, { fresh: true,
852
924
  repositories: [...repositories, ...urls.map(repositoryOf)] });
853
- const omitted = (current?.pr ?? []).filter(url => !urls.includes(url));
925
+ if (single) return current?.review_repositories ?? repositories;
926
+ const unnamed = (current?.pr ?? []).map((url, i) => ({ url, head: current.pr_head[i] })).filter(p => !urls.includes(p.url));
927
+ const settled = judged ? await readTogether(unnamed, async p => judgedHead(board, id, p.head) !== null
928
+ && (await readSource(p.url)).state === 'MERGED') : unnamed.map(() => false);
929
+ const omitted = unnamed.filter((_, i) => !settled[i]).map(p => p.url);
854
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`);
855
931
  return current?.review_repositories ?? repositories;
856
932
  }
857
933
 
858
- /** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
859
- * a review request naming that head, before the next request. A head is reviewed once. */
860
- export function judgedHead(board, id, head) {
934
+ /** The verdict already recorded on `head` of the card's PR: a changes-requested, a pass or a PR review's pass
935
+ * (`pr_review_pass`) that answered a review request naming that head, before the next request. A head is reviewed once. */
936
+ export function judgedHead(board, id, head, of = ['pr', 'final']) {
937
+ return verdictOn(board, id, head, ['changes_requested', 'review_pass', 'pr_review_pass'], of);
938
+ }
939
+
940
+ /** The PR review's pass recorded on `head` (`pr_review_pass`), or null. A PR merged at such a head is settled: the
941
+ * arc's final review neither names nor owes it (openReviewPullRequests). */
942
+ export function prReviewedHead(board, id, head) {
943
+ return verdictOn(board, id, head, ['pr_review_pass'], ['pr']);
944
+ }
945
+
946
+ /** A pass recorded on `head` by its PR review or by the arc's final review, or null: a PR merged at such a head is
947
+ * settled (openReviewPullRequests). */
948
+ export function passedHead(board, id, head) {
949
+ return verdictOn(board, id, head, ['pr_review_pass', 'review_pass'], ['pr', 'final']);
950
+ }
951
+
952
+ /** 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
+ * record their own marker right after it (pr_review_requested, plan_review_requested), before the next request; the
954
+ * board's final review records none. Each kind keeps its own verdicts: a plan review, which grades no code head, never
955
+ * judges a head for a PR review or the final review. */
956
+ const requestKindSql = (r) => `CASE
957
+ WHEN EXISTS (SELECT 1 FROM task_events k WHERE k.task_id = ${r}.task_id AND k.kind = 'plan_review_requested' AND k.id > ${r}.id
958
+ 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 'plan'
959
+ WHEN EXISTS (SELECT 1 FROM task_events k WHERE k.task_id = ${r}.task_id AND k.kind = 'pr_review_requested' AND k.id > ${r}.id
960
+ 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
+ ELSE 'final' END`;
962
+
963
+ function verdictOn(board, id, head, kinds, of) {
861
964
  if (!head) return null;
862
965
  // A request's JSON head list is checked once before visiting its verdicts. Without CROSS JOIN SQLite starts
863
966
  // from every verdict and reparses the same older requests for each of them (including heads never requested).
864
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
865
- AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id
866
- 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)
867
- 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)
868
- WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
869
- OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?)) ORDER BY v.id LIMIT 1`).get(id, head, head) ?? null;
870
- }
871
-
872
- /** Whether a passed review judged `head` of the card's PR: a pass that answered a review request naming that head,
873
- * before the next request, and no void of that request since. Unlike judgedHead (the first verdict on a head), a head
874
- * first sent back and passed in a later round counts. */
875
- export function passedHead(board, id, head) {
876
- if (!head) return false;
877
- return Boolean(board.db.prepare(`SELECT 1 FROM task_events r CROSS JOIN task_events v ON v.task_id = r.task_id
878
- AND v.kind = 'review_pass' AND v.id > r.id
968
+ AND v.kind IN (${kinds.map((kind) => `'${kind}'`).join(', ')}) AND v.id > r.id
879
969
  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)
880
970
  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)
881
971
  WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
882
- OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?)) LIMIT 1`).get(id, head, head));
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;
883
974
  }
884
975
 
885
976
  export async function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
@@ -889,6 +980,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
889
980
  const state = { required: false, ready: true, reason: null, phase: 'source' };
890
981
  try {
891
982
  assertSourceReceipt(payload);
983
+ if (stage === 'person') return { ...state, phase: 'person' };
892
984
  if (stage === 'completion' || stage === 'acceptance') {
893
985
  const supplied = payload.commit ?? payload.metadata?.commit;
894
986
  if (supplied) {
@@ -896,7 +988,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
896
988
  }
897
989
  const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
898
990
  if (candidate) {
899
- await requireCurrentReviewSet(board, id, candidatePullRequests(candidate).map(p => p.pr.url));
991
+ await requireCurrentReviewSet(board, id, candidatePullRequests(candidate).map(p => p.pr.url), [], { judged: true });
900
992
  const row = board.db.prepare("SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
901
993
  if (!row) throw new Error('The current candidate needs its independent verdict before acceptance.');
902
994
  const voided = board.db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='review_voided' ORDER BY id DESC LIMIT 1").get(id);
@@ -905,7 +997,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
905
997
  const wanted = candidatePullRequests(candidate);
906
998
  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.');
907
999
  await readTogether(wanted, async w => {
908
- const pr = await readPullRequest(w.pr.url);
1000
+ const pr = await readSource(w.pr.url);
909
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.`);
910
1002
  });
911
1003
  Object.assign(state, { ...candidate, ready: true, reason: null });
@@ -918,26 +1010,38 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
918
1010
  // Advisory heads can refuse a redundant request, never authorize one. Do
919
1011
  // this before a fresh source scan, including for callers outside dispatch.
920
1012
  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);
921
1018
  if (stage === 'request_review' && urls.length && proposed.length === urls.length && proposed.every(head => typeof head === 'string' && /^[a-f0-9]{40}$/.test(head))
922
- && !await reviewDue(board, id, urls.map((url, i) => ({ url, head: proposed[i] })))) {
923
- throw new Error('Every proposed PR head already has a verdict or carries no new source; no review is due.');
1019
+ && !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.');
924
1021
  }
925
- if (stage === 'request_review' || stage === 'review_verdict') state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? []);
1022
+ if (stage === 'request_review' || stage === 'review_verdict') state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? [], { judged: stage === 'review_verdict', single });
926
1023
  if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
1024
+ // 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
+ // against them (settledReviewDue).
1026
+ const settled = stage === 'request_review' ? payload.pr_reviewed : asked?.pr_reviewed;
1027
+ if (!urls.length && settled?.length && !single) return { ...state, pr_reviewed: settled };
927
1028
  // Merged source is immutable and needs no surviving workspace. Resolve an
928
1029
  // unmerged candidate's checkout as before; a broken legacy path never
929
1030
  // prevents a verdict on already merged commits.
930
- const read = await mapGithubSources(urls, readPullRequest);
1031
+ const read = await mapGithubSources(urls, readSource);
931
1032
  const mergedOnly = urls.length && read.every(pr => pr.state === 'MERGED');
932
1033
  const own = mergedOnly ? null : await repositoryWorkspace(board, id);
933
1034
  if (!own && !urls.length) {
934
1035
  return state;
935
1036
  }
936
- // A card with no repository of its own (scratch) is reviewed through the branches it registered: each unmerged PR
937
- // on one of them (or on its wt/<card> branch) is read at its pushed head, and needs no checkout to exist here.
1037
+ // A card with no repository of its own (scratch, or a plain folder holding the arc's repositories: workspace.mjs plainFolder)
1038
+ // is reviewed through the branches it registered and the PRs it declared (declaredPullRequests, each already bounded
1039
+ // to the arc's repositories): each unmerged PR on one of them (or on its wt/<card> branch) is read at its pushed
1040
+ // head, and needs no checkout to exist here.
938
1041
  if (!own && !mergedOnly) {
939
- const registered = registeredBranches(board, id);
940
- const declared = (pr) => namedWorkBranch(id, pr.headRefName) || registered.some((r) => r.branch === pr.headRefName && r.repository.toLowerCase() === repositoryOf(pr.url).toLowerCase());
1042
+ const registered = registeredBranches(board, id), key = (url) => `${repositoryOf(url).toLowerCase()}#${numberOf(url)}`;
1043
+ const recorded = new Set(declaredPullRequests(board.db, id).map((source) => key(source.url)));
1044
+ const declared = (pr) => recorded.has(key(pr.url)) || namedWorkBranch(id, pr.headRefName) || registered.some((r) => r.branch === pr.headRefName && r.repository.toLowerCase() === repositoryOf(pr.url).toLowerCase());
941
1045
  const strays = read.filter((pr) => pr.state !== 'MERGED' && !declared(pr));
942
1046
  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}`)));
943
1047
  }
@@ -956,8 +1060,8 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
956
1060
  const prs = await refuseTogether(urls, (url, index) => requireReviewPullRequestProperties(url, stage === 'request_review' ? named[index] : null, read[index]));
957
1061
  if (stage === 'request_review') await refuseTogether(prs, (source, index) => requireReviewWorkspaceHead(board, id, own, read[index], source.commit, { pushed: true }));
958
1062
  if (stage === 'request_review') {
959
- // A review is asked for only while some PR has a head no verdict has judged.
960
- if (!await reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('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.');
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.');
961
1065
  } else if (asked) {
962
1066
  const requested = candidatePullRequests(asked);
963
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.');
@@ -966,7 +1070,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
966
1070
  const bound = candidatePullRequests(latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate);
967
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.');
968
1072
  }
969
- Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}) });
1073
+ Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}), ...(settled?.length && !single ? { pr_reviewed: settled } : {}) });
970
1074
  } catch (error) {
971
1075
  Object.assign(state, { required: true, ready: false, reason: String(error?.message ?? error) });
972
1076
  if (error?.code) Object.assign(state, { code: error.code, retryable: error.code === 'GITHUB_BUDGET' });