@volter/supercode-orchestrator 0.5.211 → 0.5.213

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/README.md CHANGED
@@ -124,7 +124,10 @@ range under the same worker scope. The maximum `length` is 1,048,576 bytes.
124
124
  `XDG_CONFIG_HOME`). It needs neither a running board nor a GitHub request. It
125
125
  groups retained wire attempts by caller and endpoint, includes their purposes
126
126
  and outcomes, and shows the latest 32 response-header receipts. Unknown transport
127
- completion remains counted. Tokens and response bodies are not retained.
127
+ completion remains counted. Tokens and raw response bodies are not retained in
128
+ the attempts ledger. Budget refusals have separate local receipts, exposed as
129
+ `refusals`; `refusals_since` and `refusals_complete` say whether collection covers
130
+ the requested window. An older ledger cannot prove the absence of refusals.
128
131
 
129
132
  Board actions check their local role, guard and validator rules before GitHub.
130
133
  Fresh source observations are reused only within that action, with at most eight
@@ -132,15 +135,38 @@ concurrent REST operations per process. Owner receipts retain the return code an
132
135
  redacted refusal even when a failed call is fast. See
133
136
  [acceptance latency readings](../../docs/evidence/board-acceptance-latency-2026-10-14.md).
134
137
 
135
- Board discovery caches advisory reads while waiting. Review requests, verdicts
136
- and acceptance refresh the complete source set, including previously draft or
137
- closed declared sources, and validate its current heads. An unread set defers
138
+ Board discovery persists advisory projections in this same private SQLite file:
139
+ one open-PR list per API origin, credential digest and repository for five minutes,
140
+ independent of which card declares that repository. Default-branch metadata has
141
+ its own entry; acquiring it never repeats the list. Branch histories last fifteen
142
+ minutes, merged PR observations an hour and other PR observations a minute.
143
+ Failures remain failures for the entry's interval. In-process readers share a
144
+ pending result; another process defers on its durable claim instead of duplicating
145
+ the request. A claim expires after six minutes if its process disappears.
146
+ Successful fresh validation refreshes advisory discovery, including after restart.
147
+
148
+ Review requests, verdicts
149
+ and acceptance refresh the complete source set in the arc's repositories,
150
+ including previously draft or closed declared sources, and validate its current
151
+ heads. That scope includes its workspace, registered branches, declared sources,
152
+ previous candidate and discovered sibling work, and is retained in the source
153
+ receipt. Broad known-repository discovery remains advisory; unrelated checkouts
154
+ spend no validation calls. Every fresh source entry first acquires that advisory
155
+ scope, even for old receipts and sibling checkouts now on main; an unread scope
156
+ refuses authorization. A round with every head judged stops at advisory
157
+ eligibility, and the review evidence door refuses a redundant proposed head set
158
+ before a fresh scan. An unread set defers
138
159
  the action. Before the dispatcher spends those validation reads, it checks the
139
- advisory candidate against its local worktrees. A mismatched HEAD or uncommitted
140
- work refuses that attempt locally. Each later round rechecks the checkout, so
160
+ advisory candidate's PR properties and local worktrees. An invalid PR state,
161
+ draft, head or base refuses before the full validation scan, including a declared
162
+ PR on the wrong base. Discovery refreshes changes to those properties. A mismatched
163
+ HEAD or uncommitted work also refuses locally. Each later round rechecks the checkout, so
141
164
  a repair wakes it; a successful preflight still needs the fresh complete-source,
142
165
  local and pushed-head checks before review is recorded. Unread commit dates also defer eligibility and are never cached as
143
- old work. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
166
+ old work. An arc that already named a PR for review cannot become a no-code
167
+ disposition merely because it merged; it skips the repeated no-code history
168
+ validation. Advisory no-code and commit eligibility use discovery's allowance.
169
+ Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
144
170
  reserves its own attempt. All processes and credentials sharing this supercode
145
171
  home share a rolling-hour ceiling of 1,200 attempts, including at most 600
146
172
  discovery attempts. Endpoint exhaustion/Retry-After also defers subsequent work.
@@ -53,6 +53,11 @@ if (process.argv[2] === 'workflow') {
53
53
  const {disconnectWorkplace}=await import('../connection.mjs');
54
54
  try{const {values}=parseArgs({args:process.argv.slice(3),options:{root:{type:'string'},connection:{type:'string'}}});process.stdout.write(JSON.stringify(await disconnectWorkplace(values),null,2)+'\n');}
55
55
  catch(error){process.stderr.write(`disconnect: ${error.message}\n`);process.exitCode=1;}
56
+ } else if (process.argv[2] === 'mcp-server') {
57
+ // a session's orchestrator tools on stdio, bound by its environment (mcp/mount.mjs mounts this through the stable
58
+ // supercode)
59
+ const {serveStdio,bindingFromEnv}=await import('../mcp/server.mjs');
60
+ serveStdio(bindingFromEnv());
56
61
  } else if (process.argv[2] === 'agent-channel') {
57
62
  await import('./agent-channel.mjs');
58
63
  } else {
@@ -0,0 +1,68 @@
1
+ // Upload staging belongs to the board, beneath the authorized card, and to one caller.
2
+ // No attachment record exists until all bytes and their digest have been verified.
3
+ import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, rmSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { basename, join } from 'node:path';
6
+
7
+ const CHUNK = 1024 * 1024;
8
+ const safeName = name => basename(String(name)).replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^\.+$/, '') || 'attachment';
9
+
10
+ export function receiveAttachment({ directory, upload, caller, input, add }) {
11
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(upload) || !caller)
12
+ throw new Error('attachment upload needs its identifier and an admitted caller');
13
+ let body;
14
+ try { body = JSON.parse(input); } catch { throw new Error('attachment upload needs JSON input'); }
15
+ const staging = join(directory, '.uploads', upload), metadata = join(staging, 'upload.json'), path = join(staging, 'bytes');
16
+ if (body?.action === 'begin') {
17
+ if (!Number.isSafeInteger(body.size) || body.size < 0 || typeof body.name !== 'string') throw new Error('invalid attachment extent or name');
18
+ mkdirSync(join(directory, '.uploads'), { recursive: true });
19
+ mkdirSync(staging, { mode: 0o700 });
20
+ try {
21
+ writeFileSync(path, '', { flag: 'wx', mode: 0o600 });
22
+ writeFileSync(metadata, JSON.stringify({ caller, size: body.size, name: safeName(body.name) }), { flag: 'wx', mode: 0o600 });
23
+ } catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
24
+ return { ok: true, upload, received: 0 };
25
+ }
26
+ const held = JSON.parse(readFileSync(metadata, 'utf8'));
27
+ if (held.caller !== caller) throw new Error('attachment upload belongs to another caller');
28
+ if (body?.action === 'abort') { rmSync(staging, { recursive: true }); return { ok: true, upload, aborted: true }; }
29
+ if (body?.action === 'append') {
30
+ const bytes = typeof body.bytes === 'string' && body.bytes.length <= Math.ceil(CHUNK / 3) * 4 ? Buffer.from(body.bytes, 'base64') : null;
31
+ if (!bytes?.length || bytes.length > CHUNK || bytes.toString('base64') !== body.bytes
32
+ || !Number.isSafeInteger(body.offset) || body.offset < 0 || body.offset + bytes.length > held.size)
33
+ throw new Error('invalid attachment chunk');
34
+ const file = openSync(path, 'r+');
35
+ try {
36
+ if (fstatSync(file).size !== body.offset) throw new Error('attachment chunk is not at its next offset');
37
+ for (let written = 0; written < bytes.length;) {
38
+ const count = writeSync(file, bytes, written, bytes.length - written, body.offset + written);
39
+ if (count <= 0) throw new Error('attachment chunk write stopped');
40
+ written += count;
41
+ }
42
+ } finally { closeSync(file); }
43
+ return { ok: true, upload, received: body.offset + bytes.length };
44
+ }
45
+ if (body?.action !== 'commit' || !/^[a-f0-9]{64}$/.test(body.sha256)) throw new Error('invalid attachment upload action or digest');
46
+ const file = openSync(path, 'r'), hash = createHash('sha256'), buffer = Buffer.alloc(CHUNK);
47
+ try {
48
+ if (fstatSync(file).size !== held.size) throw new Error('attachment upload is incomplete');
49
+ for (let offset = 0; offset < held.size;) {
50
+ const count = readSync(file, buffer, 0, Math.min(CHUNK, held.size - offset), offset);
51
+ if (count <= 0) throw new Error('attachment upload ended before its extent');
52
+ hash.update(buffer.subarray(0, count)); offset += count;
53
+ }
54
+ if (hash.digest('hex') !== body.sha256) throw new Error('attachment upload digest does not match the source');
55
+ } finally { closeSync(file); }
56
+ let target;
57
+ for (let n = 0;; n++) {
58
+ target = join(directory, n ? `${n}-${held.name}` : held.name);
59
+ try { linkSync(path, target); break; } catch (error) { if (error.code !== 'EEXIST') throw error; }
60
+ }
61
+ let id;
62
+ try { id = add({ path: target, size: held.size }, held.name); }
63
+ catch (error) { unlinkSync(target); throw error; }
64
+ // The committed record is authoritative; failed staging cleanup must not turn success into an ambiguous retry.
65
+ let cleanup = null;
66
+ try { rmSync(staging, { recursive: true }); } catch { cleanup = staging; }
67
+ return { ok: true, id, path: target, size: held.size, sha256: body.sha256, name: held.name, ...(cleanup ? { cleanup } : {}) };
68
+ }
package/board/ci.mjs CHANGED
@@ -7,7 +7,7 @@ import { createHash } from 'node:crypto';
7
7
  import { AsyncLocalStorage } from 'node:async_hooks';
8
8
  import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace, plainWorkspace } from './workspace.mjs';
9
9
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
10
- import { githubFetch } from './github-rest.mjs';
10
+ import { githubFetch, githubDiscovery } from './github-rest.mjs';
11
11
  import { cardHandleOf } from './store.mjs';
12
12
 
13
13
  // GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
@@ -177,22 +177,15 @@ const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
177
177
  /** A PR as the board reads it (gh's field names). */
178
178
  const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
179
179
  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) });
180
- const readPullRequest = async (url) => pullRequest(repositoryOf(url), await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
180
+ const discoveryIdentity = async key => `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
181
+ const discoveryRead = async (key, ttl, read, options) => githubDiscovery(await discoveryIdentity(key), ttl, read, options);
182
+ const readPullRequest = async (url) => discoverPullRequest(url, { fresh: true });
181
183
  // Discovery is advisory. Already-merged sources are retained for an hour, others for a minute. Every transition
182
184
  // still uses readPullRequest/verifyPullRequest above/below, which bypass this cache and reads its current head.
183
- const discoveredPullRequests = new Map();
184
- export async function discoverPullRequest(url) {
185
- const key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${url}`;
186
- let held = discoveredPullRequests.get(key);
187
- if (held && Date.now() < held.until) return held.pending;
188
- if (discoveredPullRequests.size >= 4096 && !held) discoveredPullRequests.delete(discoveredPullRequests.keys().next().value);
189
- held = { until: Date.now() + 60_000 };
190
- held.pending = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: 'discovery' })
191
- .then(body => { const pr = pullRequest(repositoryOf(url), body); held.until = Date.now() + (pr.state === 'MERGED' ? 3600_000 : 60_000); return pr; });
192
- // Failed reads stay failed for a minute; no cached empty source or invented successful head.
193
- void held.pending.catch(() => {});
194
- discoveredPullRequests.set(key, held);
195
- return held.pending;
185
+ export async function discoverPullRequest(url, { fresh = false } = {}) {
186
+ return discoveryRead(`pr:${repositoryOf(url).toLowerCase()}:${numberOf(url)}`, 60_000, async () => pullRequest(repositoryOf(url),
187
+ await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: fresh ? 'validation' : 'discovery' })),
188
+ { fresh, ttlOf: pr => pr.state === 'MERGED' ? 3600_000 : 60_000 });
196
189
  }
197
190
 
198
191
  /**
@@ -339,31 +332,16 @@ async function recreatedWorktree(workspace, resolved) {
339
332
  try { const made = await resolveWorkspace(workspace, true); return made.path === resolved.path && checkoutPresent(made.path); } catch { return false; }
340
333
  }
341
334
 
342
- const pullRequestLists = new Map();
343
- const mergedPullRequestLists = new Map();
344
- // Keep in-flight and failed list reads too, with room for the registered branch set. History changes rarely;
345
- // ordinary open work is discovered by the repository's open-PR listing.
346
- async function discoveryList(cache, key, ttl, read) {
347
- key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
348
- let held = cache.get(key);
349
- if (held && Date.now() < held.until) return held.pending;
350
- if (cache.size >= 4096 && !held) cache.delete(cache.keys().next().value);
351
- held = { until: Date.now() + ttl };
352
- held.pending = Promise.resolve().then(read).catch(error => { held.until = Date.now() + 60_000; throw error; });
353
- void held.pending.catch(() => {}); cache.set(key, held);
354
- return held.pending;
355
- }
356
-
357
335
  /** Historical work can already be merged when an outage prevented the board's
358
336
  * review request. Read only the arc's declared branch, never all closed PRs. */
359
337
  async function mergedBranchPullRequests(repository, branch, { fresh = false } = {}) {
360
- const key = `${repository}:${branch}`;
338
+ const key = `${repository.toLowerCase()}:${branch}`;
361
339
  const read = async () => {
362
340
  const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
363
341
  return (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' }))
364
342
  .filter(p => p.merged_at).map(p => pullRequest(repository, p));
365
343
  };
366
- return fresh ? read() : discoveryList(mergedPullRequestLists, key, 900_000, read);
344
+ return discoveryRead(`history:${key}`, 900_000, read, { fresh });
367
345
  }
368
346
 
369
347
  async function branchCheckout(board, id, repository, branch) {
@@ -430,18 +408,38 @@ export function declaredWorkBranches(db, id) {
430
408
  * of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
431
409
  * 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
432
410
  * answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
433
- export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false } = {}) {
411
+ export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, repositories = [] } = {}) {
412
+ if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, repositories });
413
+ // Every authorization entry acquires the broad advisory scope, not only
414
+ // dispatch. An older receipt and a checkout now on main still have to see
415
+ // implicit wt/<card> PRs. Discovery failures leave that scope unknown.
416
+ const scope = new Set(repositories);
417
+ await discoverReviewSources(board, id, { repositories, strict: true }, scope);
418
+ return discoverReviewSources(board, id, { fresh: true, checkWorkspaces, repositories: [...scope] });
419
+ }
420
+
421
+ async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false, repositories = [], strict = false } = {}, scope = null) {
434
422
  const own = await repositoryWorkspace(board, id);
435
- const registered = registeredBranches(board, id);
423
+ const registered = registeredBranches(board, id).map(r => ({ ...r, repository: r.repository.toLowerCase() }));
436
424
  if (!own && !registered.length) return null;
437
425
  const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
438
- const ownRepository = own?.repository ?? (own?.here ? await originOf(own.path) : null);
426
+ const ownRepository = (own?.repository ?? (own?.here ? await originOf(own.path) : null))?.toLowerCase();
439
427
  if (!ownRepository && !registered.length) return null;
440
428
  const current = task?.branch_name ? null : own?.branch ?? (own?.here ? await run('git', ['branch', '--show-current'], own.path) : null);
441
429
  let branch = task?.branch_name ?? (current || null);
442
430
  const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
443
431
  || registered.some((r) => r.repository === repository && r.branch === head);
444
- const repositoriesToRead = [...new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))];
432
+ // Broad discovery finds wt/<card> work in sibling repositories. Authorization
433
+ // refreshes only this arc's repositories, including siblings already found or
434
+ // requested; an unrelated checkout is never a validation-purpose PR scan.
435
+ const previous = reviewCandidate(board, id);
436
+ const requested = [...candidatePullRequests(previous).map(p => p.repository ?? repositoryOf(p.pr.url)),
437
+ ...(previous?.review_repositories ?? [])];
438
+ const siblings = fresh ? [] : await knownRepositories({ strict });
439
+ const repositoriesToRead = [...new Set([ownRepository, ...registered.map(r => r.repository), ...requested,
440
+ ...repositories, ...siblings].filter(Boolean).map(r => r.toLowerCase()))];
441
+ const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
442
+ .filter(Boolean).map(r => r.toLowerCase()));
445
443
  const answered = answeredChanges(board, id);
446
444
  const groups = await readTogether(repositoriesToRead, async repository => {
447
445
  const prs = [];
@@ -449,22 +447,22 @@ export async function openReviewPullRequests(board, id, { fresh = false, checkWo
449
447
  // Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
450
448
  let list;
451
449
  try {
452
- const read = async () => {
453
- const items = (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map((p) => pullRequest(repository, p));
454
- // An empty unrelated repository has neither candidates nor branch history to filter. Its default
455
- // branch cannot affect this set; only the card's own or registered repositories need that metadata.
456
- const needsDefault = items.length > 0 || hasWorkBranches;
457
- const defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch
458
- ?? (needsDefault ? (await github(`/repos/${repository}`, false, { purpose: fresh ? 'validation' : 'discovery' })).default_branch : null);
459
- return { items, defaultBranch };
460
- };
461
- // An empty named-only observation cannot supply branch metadata when a later card registers this repo.
462
- const listKey = `${repository}:${hasWorkBranches ? 'work' : 'named'}`;
463
- list = fresh ? await read() : await discoveryList(pullRequestLists, listKey, 300_000, read);
450
+ const items = await discoveryRead(`open:${repository.toLowerCase()}`, 300_000, async () =>
451
+ (await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map(p => pullRequest(repository, p)), { fresh });
452
+ // Empty unrelated repositories need no default metadata. Keep that lookup separate from the shared list,
453
+ // so a card declaring the repository can acquire it without listing the same open PRs again.
454
+ const needsDefault = items.length > 0 || hasWorkBranches;
455
+ const defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch
456
+ ?? (needsDefault ? await discoveryRead(`default:${repository.toLowerCase()}`, 300_000, async () =>
457
+ (await github(`/repos/${repository}`, false, { purpose: fresh ? 'validation' : 'discovery' })).default_branch, { fresh }) : null);
458
+ list = { items, defaultBranch };
464
459
  } catch (error) {
465
- if (fresh || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
460
+ if (fresh || strict || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
466
461
  return []; // An unread sibling stays unread: do not cache an empty successful result.
467
462
  }
463
+ // Include excluded drafts/other bases in the fresh scope: becoming ready
464
+ // must not let a sibling source escape the complete-set check.
465
+ if (list.items.some(pr => mine(repository, pr.headRefName))) reviewRepositories.add(repository);
468
466
  // The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
469
467
  if (repository === ownRepository && branch) {
470
468
  if (branch === list.defaultBranch) branch = null;
@@ -497,24 +495,34 @@ export async function openReviewPullRequests(board, id, { fresh = false, checkWo
497
495
  // sources or its tasks' registered branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
498
496
  // declared one, so a push after the declaration is reviewed instead of failing every tick.
499
497
  const declaration = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.sources')='array' ORDER BY id DESC LIMIT 1").get(id);
500
- const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
501
- const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => repositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
498
+ const declaredRepositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
499
+ const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => declaredRepositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
502
500
  for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
503
501
  if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
504
502
  if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
505
503
  }
504
+ // Retain scope even when all matching PRs are excluded drafts/other bases,
505
+ // or judged merged sources, so a fresh scan cannot inherit that exclusion.
506
+ if (scope) for (const repository of reviewRepositories) scope.add(repository);
506
507
  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` };
507
- // Advisory facts can refuse an attempt before its authoritative REST scan. Recheck the local checkout each
508
- // round, so a repaired HEAD or clean worktree wakes it without caching the refusal. This is never evidence
509
- // for a transition: verifyPullRequest repeats the checks against fresh metadata and the pushed head below.
510
- if (checkWorkspaces) await readTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
511
- return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
508
+ // Advisory facts can refuse an attempt before its authoritative REST scan. Check every PR's properties before
509
+ // doing checkout work: a wrong base cannot be repaired by revalidating the whole arc every round. Discovery
510
+ // refreshes changed heads/bases/states; local readiness is read each round. Neither check authorizes a move:
511
+ // verifyPullRequest repeats both against fresh metadata and the pushed head below.
512
+ if (checkWorkspaces) {
513
+ for (const pr of prs) requireReviewPullRequestProperties(pr.url, pr.head, pr.observed);
514
+ await readTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
515
+ }
516
+ return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort() };
512
517
  }
513
518
 
514
519
  /** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
515
520
  * beyond its repository's default branch, locally or on origin, its checkout holds no uncommitted work, no PR was
516
521
  * ever opened from it, and it registered no other branch. Answers what its review reads instead of a diff, or null when the arc carries (or may carry) code. */
517
- export async function noCodeChange(board, id) {
522
+ export async function noCodeChange(board, id, { fresh = false } = {}) {
523
+ // Once this arc has named code for a review, an empty open set after merging is not a no-code disposition.
524
+ // In particular, do not revalidate the same merged branch's PR history on every advisory round.
525
+ if (candidatePullRequests(reviewCandidate(board, id)).length) return null;
518
526
  const taskRow = board.db.prepare('SELECT workspace_kind FROM tasks WHERE id=?').get(id);
519
527
  if (taskRow?.workspace_kind === 'scratch') {
520
528
  if (board.db.prepare("SELECT 1 FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE c.subtask_of=? AND t.workspace_kind!='scratch' AND t.status!='cancelled' LIMIT 1").get(id)) return null;
@@ -542,16 +550,22 @@ export async function noCodeChange(board, id) {
542
550
  const remote = (await run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own)).split(/\s+/)[0];
543
551
  if (remote) await run('git', ['merge-base', '--is-ancestor', remote, base], own);
544
552
  const owner = repository.split('/')[0];
545
- // An empty PR history can authorize a no-code review; keep this validation fresh too.
546
- if ((await github(`/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`)).length) return null;
553
+ // Advisory eligibility never spends validation's reserve. The committed disposition repeats this read fresh.
554
+ const path = `/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`;
555
+ const history = await discoveryRead(`no-code:${repository.toLowerCase()}:${branch}`, 900_000, () =>
556
+ github(path, false, { purpose: fresh ? 'validation' : 'discovery' }), { fresh });
557
+ if (history.length) return null;
547
558
  return { pr: [], pr_head: [], branch, no_code: { repository, branch, base, branch_head: remote || null } };
548
559
  } catch (error) { if (error.code === 'GITHUB_BUDGET') throw error; return null; }
549
560
  }
550
561
 
551
562
  /** The repositories this machine keeps a checkout of. */
552
- async function knownRepositories({ fresh = false } = {}) {
553
- try { return [...new Set((await branchCheckouts(null, '*')).map((row) => row.repository))]; }
554
- catch (error) { if (fresh) throw error; return []; }
563
+ async function knownRepositories({ strict = false } = {}) {
564
+ try {
565
+ const checkouts = await branchCheckouts(null, '*');
566
+ return [...new Set(checkouts.map(row => row.repository))];
567
+ }
568
+ catch (error) { if (strict) throw error; return []; }
555
569
  }
556
570
 
557
571
  /** Whether the arc's open PRs (`[{ url, head }]`) call for its review: its last verdict's findings are answered, or
@@ -612,28 +626,27 @@ export function answeredChanges(board, id) {
612
626
  return owned.length > 0 && owned.every((t) => ['done', 'cancelled', 'archived'].includes(t.status));
613
627
  }
614
628
 
615
- const commitReads = new Map();
616
629
  /** A commit's date and source tree, read once: both are immutable. Only a successful read is cached; an unread commit
617
630
  * is unknown, never evidence of old work or of a changed tree. */
618
631
  async function commitRead({ url, head }) {
619
- const key = `${githubBase()}\0${repositoryOf(url)}\0${head}`;
620
- if (commitReads.has(key)) return commitReads.get(key);
621
- const commit = (await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit;
622
- const at = Math.floor(Date.parse(commit?.committer?.date) / 1000), tree = commit?.tree?.sha;
623
- if (!Number.isFinite(at) || typeof tree !== 'string' || !tree) throw new Error(`GitHub commit ${head} of ${url} is unreadable; review eligibility is unknown`);
624
- const read = { at, tree };
625
- commitReads.set(key, read);
626
- return read;
632
+ return discoveryRead(`commit:${repositoryOf(url).toLowerCase()}:${head}`, 3600_000, async () => {
633
+ const commit = (await github(`/repos/${repositoryOf(url)}/commits/${head}`, false, { purpose: actionReads.getStore() ? 'validation' : 'discovery' })).commit;
634
+ const at = Math.floor(Date.parse(commit?.committer?.date) / 1000), tree = commit?.tree?.sha;
635
+ if (!Number.isFinite(at) || typeof tree !== 'string' || !tree) throw new Error(`GitHub commit ${head} of ${url} is unreadable; review eligibility is unknown`);
636
+ return { at, tree };
637
+ }, { cacheErrors: false });
627
638
  }
628
639
  const commitTime = async (p) => (await commitRead(p)).at;
629
640
  /** Whether the PR's head carries the same source tree as `commit` of the same PR. */
630
641
  const sameTree = async (p, commit) => (await commitRead(p)).tree === (await commitRead({ url: p.url, head: commit })).tree;
631
642
 
632
643
  /** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
633
- async function requireCurrentReviewSet(board, id, urls) {
634
- const current = await openReviewPullRequests(board, id, { fresh: true });
644
+ async function requireCurrentReviewSet(board, id, urls, repositories = []) {
645
+ const current = await openReviewPullRequests(board, id, { fresh: true,
646
+ repositories: [...repositories, ...urls.map(repositoryOf)] });
635
647
  const omitted = (current?.pr ?? []).filter(url => !urls.includes(url));
636
648
  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`);
649
+ return current?.review_repositories ?? repositories;
637
650
  }
638
651
 
639
652
  /** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
@@ -682,9 +695,16 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
682
695
  }
683
696
  const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
684
697
  const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
685
- if (stage === 'request_review' || stage === 'review_verdict') await requireCurrentReviewSet(board, id, urls);
686
- if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
687
698
  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.');
699
+ // Advisory heads can refuse a redundant request, never authorize one. Do
700
+ // this before a fresh source scan, including for callers outside dispatch.
701
+ const proposed = [].concat(payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
702
+ if (stage === 'request_review' && urls.length && proposed.length === urls.length && proposed.every(head => typeof head === 'string' && /^[a-f0-9]{40}$/.test(head))
703
+ && !await reviewDue(board, id, urls.map((url, i) => ({ url, head: proposed[i] })))) {
704
+ throw new Error('Every proposed PR head already has a verdict or carries no new source; no review is due.');
705
+ }
706
+ if (stage === 'request_review' || stage === 'review_verdict') state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? []);
707
+ if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
688
708
  // Merged source is immutable and needs no surviving workspace. Resolve an
689
709
  // unmerged candidate's checkout as before; a broken legacy path never
690
710
  // prevents a verdict on already merged commits.
@@ -752,8 +772,8 @@ async function requireReviewWorkspaceHead(board, id, own, observed, head, { push
752
772
  }
753
773
  }
754
774
 
755
- /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
756
- async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
775
+ /** PR-only readiness, shared by advisory refusals and fresh source validation. */
776
+ function requireReviewPullRequestProperties(url, supplied, observed) {
757
777
  if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
758
778
  const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
759
779
  // A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
@@ -768,10 +788,17 @@ async function verifyPullRequest(board, id, entry, own, url, supplied, stage, ob
768
788
  const mainBranch = observed.defaultBranch ?? 'main';
769
789
  // A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
770
790
  if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
771
- if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
772
791
  return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
773
792
  }
774
793
 
794
+ /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
795
+ async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
796
+ const source = requireReviewPullRequestProperties(url, supplied, observed);
797
+ const head = source.commit;
798
+ if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
799
+ return source;
800
+ }
801
+
775
802
  const digest = bytes => createHash('sha256').update(bytes).digest('hex');
776
803
  /** Read the tracked workflow revision the next serve tick will load. */
777
804
  export async function workflowRevision(root) {
package/board/cli.mjs CHANGED
@@ -15,6 +15,7 @@ import { detachSession } from './custody.mjs';
15
15
  import { taskStatusOf } from './published.mjs';
16
16
  import { executionOf } from './session-work.mjs';
17
17
  import { attachmentRecord, readAttachment } from './attachment-read.mjs';
18
+ import { receiveAttachment } from './attachment-write.mjs';
18
19
  import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
19
20
  import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
20
21
  import { openOwnerDoor } from './owner-door.mjs';
@@ -69,7 +70,7 @@ async function boardPublishing(root) {
69
70
  const board = listed?.boards?.find((b) => b.root === here);
70
71
  return board ? { ...board, refused: listed.refused ?? null } : null;
71
72
  }
72
- import { dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
73
+ import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
73
74
  import { watchBoard } from './wake.mjs';
74
75
  import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
75
76
  import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.mjs';
@@ -112,13 +113,14 @@ The board's verbs, with their flags:
112
113
  comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)...] [--source owner/repo:branch]... attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
113
114
  attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
114
115
  complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
116
+ finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
115
117
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
116
118
  pause <id> --reason <why> resume <id> cancel <id> --reason <why>
117
119
  request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
118
120
  unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
119
121
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
120
122
  specify <id> [--readable-id ID] [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
121
- decompose <id> [--child "<title>[@<profile>]"]... [--all]
123
+ decompose <id> [--child "<title>[@<profile>]"]... [--all] file the card's subtasks atomically, if its workflow allows
122
124
  diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
123
125
  github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
124
126
  notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
@@ -142,6 +144,8 @@ And:
142
144
  With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
143
145
  waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
144
146
  adopt <id> <session> [--assignee P] [--role ROLE] adopt <arc> <session> --side · release <arc> <session> detach <id> [--to person:ID] close <id> serve (= daemon) list --machine M
147
+ answer <id> <option> choose an option (its number or label) of the question the card's session shows on its pane: the
148
+ card's manager, its creator or a signed-in person; recorded on the card as question_answered with who answered
145
149
  notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
146
150
  notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
147
151
  notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
@@ -662,6 +666,13 @@ export async function runVerb(argv) {
662
666
  if (t.machine) out.push(` machine: ${t.machine}`);
663
667
  if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
664
668
  if (t.detached) out.push(` session: ${t.detached.session.address} belongs to ${t.detached.person} (detached ${ts(t.detached.at)}; dispatcher does not own it)`);
669
+ // the question the card's session shows on its pane, while it shows (VO-3395), with the door that answers it
670
+ const asked = card.runs.find((r) => r.id === t.current_run_id)?.metadata?.supercode?.question;
671
+ if (asked?.options?.length) {
672
+ out.push(` question: its session asks on pane ${asked.pane ?? '?'}${asked.machine ? ` on ${asked.machine}` : ''} (since ${ts(asked.since)}): ${asked.question || '(no words above its options)'}`);
673
+ for (const option of asked.options) out.push(` ${String(option.number).padStart(2)}. ${option.label}${option.detail ? ` (${option.detail})` : ''}`);
674
+ out.push(` answer: supercode workflow answer ${t.id} <number>`);
675
+ }
665
676
  // where the card's time went: each role's runs, and the rest waiting
666
677
  const mins = (sec) => `${Math.round(sec / 60)}m`;
667
678
  out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
@@ -833,6 +844,15 @@ export async function runVerb(argv) {
833
844
  }
834
845
  case 'attach': {
835
846
  need(2, 'a card id and a file');
847
+ if (args.upload !== undefined) {
848
+ if (rest[1] !== '-') throw new Error('attachment upload reads its carried stdin');
849
+ withBoard(b => { if (!engine.getTask(b.db, id)) throw new Error(`no card ${id}`); });
850
+ const directory = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
851
+ const result = receiveAttachment({ directory, upload: args.upload, caller: callerAddress() ?? actorOf(), input: current().stdin,
852
+ add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() })) });
853
+ say(args, result.id ? `Attached ${result.name} to ${id} (#${result.id}, sha256 ${result.sha256})` : `Upload ${args.upload}: ${result.received ?? 'aborted'}`, result);
854
+ return 0;
855
+ }
836
856
  const source = callerPath(rest[1]);
837
857
  if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
838
858
  const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
@@ -1252,6 +1272,22 @@ export async function runVerb(argv) {
1252
1272
  say(args, `${id}: ${detached.session.address} belongs to ${detached.person}; the dispatcher no longer owns it`, { ok: true, detached });
1253
1273
  return 0;
1254
1274
  }
1275
+ case 'answer': {
1276
+ // the question the card's session shows on its pane, answered through its machine's panes.answer door as this
1277
+ // board (VO-3395); the card's manager, its creator or a signed-in person answers, and is recorded as having answered
1278
+ need(2, 'a card id and the option to choose (its number or its label)');
1279
+ withBoard(b => {
1280
+ const roles = rolesFor(b.db, id, sid, workflowFor(root).params);
1281
+ if (!roles.includes('manager') && !roles.includes('creator') && !(!sid && principalOf()?.startsWith('person:')))
1282
+ throw new Error(`Only ${id}'s manager, its creator or a signed-in person answers its session's question. Nothing was typed.`);
1283
+ });
1284
+ const by = sid ? callerAddress() ?? actorOf() : principalOf() ?? actorOf();
1285
+ const answered = await withBoardAsync((b) => answerQuestion(b, id, rest.slice(1).join(' '), { by }));
1286
+ const choice = answered.choice ? `${answered.choice.number}. ${answered.choice.label}` : rest.slice(1).join(' ');
1287
+ say(args, answered.answered ? `${id}: answered "${choice}" on pane ${answered.pane} (recorded as question_answered, by ${by})`
1288
+ : `${id}: chose "${choice}" on pane ${answered.pane}, but ${answered.reason ?? 'the pane did not show it taken'}`, { ok: answered.answered === true, answer: answered });
1289
+ return answered.answered ? 0 : 1;
1290
+ }
1255
1291
  case 'adopt': {
1256
1292
  need(2, 'a card id and a session');
1257
1293
  withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });