@volter/supercode-orchestrator 0.5.210 → 0.5.212

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
@@ -19,6 +19,16 @@ stage's own transition, which ends the run in the default workflow. See the
19
19
  [private manual reading](../../docs/evidence/board-manual-claim-2026-10-09.md)
20
20
  for scope and limits; existing inconsistent claims are not repaired automatically.
21
21
 
22
+ Completing a task with `--commit` inherits its arc's workspace when the task is
23
+ scratch. When that workspace belongs to another machine, the board verifies the
24
+ commit against the declared repository's pushed branch head through GitHub,
25
+ without requiring the checkout on the board host. A legacy directory path is
26
+ resolved through its own machine's workspace door to obtain that identity.
27
+ Remote commits take a full SHA or at least seven hexadecimal characters; a
28
+ different head or an unread remote refuses completion. Local checkouts keep
29
+ their existing Git commit resolution. This source check does not replace the
30
+ arc's independent review.
31
+
22
32
  The orchestrator's runtime over the ontology: one typed operational model
23
33
  (`OrchestratorState` = an orchestration value plus a runtime half) whose folder is its serialization.
24
34
  `docs/ORCHESTRATOR-IR.md` is normative. The orchestration value and every codec are the ontology's, in
@@ -125,7 +135,11 @@ redacted refusal even when a failed call is fast. See
125
135
  Board discovery caches advisory reads while waiting. Review requests, verdicts
126
136
  and acceptance refresh the complete source set, including previously draft or
127
137
  closed declared sources, and validate its current heads. An unread set defers
128
- the action. Unread commit dates also defer eligibility and are never cached as
138
+ 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
141
+ a repair wakes it; a successful preflight still needs the fresh complete-source,
142
+ local and pushed-head checks before review is recorded. Unread commit dates also defer eligibility and are never cached as
129
143
  old work. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
130
144
  reserves its own attempt. All processes and credentials sharing this supercode
131
145
  home share a rolling-hour ceiling of 1,200 attempts, including at most 600
package/agents.mjs CHANGED
@@ -232,8 +232,17 @@ export function localAgentRecord(root,org,id){
232
232
  /** Rename only identities this card role declared; a reassigned standing agent keeps its own name. */
233
233
  export function renameCardAgent(root, assignment, readableId) {
234
234
  const intent=agentIntent(root,assignment.org,assignment.agent_id);
235
- if(!intent?.declaration_json)return false;
236
- const declaration=JSON.parse(intent.declaration_json);
235
+ if(!intent||assignment.state!=='active')return false;
236
+ let declaration=intent.declaration_json?JSON.parse(intent.declaration_json):null;
237
+ if(!declaration){
238
+ // Older declared intents kept only their stable key. Recover this role's own current identity, never an agent
239
+ // reassigned from another card or a standing profile. The existing Teams identity keeps its lineage and sessions.
240
+ const suffix=`/card/${assignment.target}/${assignment.role}`,base=intent.key.replace(/\/generation\/\d+$/,'');
241
+ if(!base.endsWith(suffix)||declarationHead(root,assignment.org,base)!==assignment.agent_id)return false;
242
+ const parent=declarationHead(root,assignment.org,`${base.slice(0,-suffix.length)}/root`);
243
+ if(!parent)return false;
244
+ declaration={kind:'card',card:assignment.target,role:assignment.role,profile:intent.profile_ref,parent};
245
+ }
237
246
  if(declaration.kind!=='card'||declaration.card!==assignment.target||declaration.role!==assignment.role)return false;
238
247
  const name=`${assignment.role}-${readableId}`;
239
248
  if(declaration.name===name&&declaration.readableId===readableId)return false;
@@ -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 {
@@ -1,7 +1,7 @@
1
1
  // Consume the card-creation intent after the board transaction; retries reuse the daemon-minted id. The card's agent is
2
2
  // minted and recorded here, and its declaration handed to this machine's daemon, which declares it in Teams: whoever ran
3
3
  // the effect (the creating command, or a delivery pass retrying it) makes no Teams call (D137).
4
- import { agentScope, localCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
4
+ import { agentScope, localCardAgent, renameCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
5
5
  import { cardHandleOf, readableIdOf, event, json, tx, view } from './store.mjs';
6
6
  import { workflowFor } from './workflow.mjs';
7
7
  import { cardAgentFacts, cardAgentReceiptRecorded, syncCardAgentScope } from './published.mjs';
@@ -47,6 +47,23 @@ export async function declareCardAgentEffect(board,effect) {
47
47
  }
48
48
  }
49
49
 
50
+ /** A readable-id edit carries every live role's retained declaration through the existing machine identity door.
51
+ * No assignment is created or revived here; an ended or reassigned agent keeps its own identity and history. */
52
+ export async function renameCardAgentsEffect(board,effect) {
53
+ const task=view(board,db=>db.prepare('SELECT t.readable_id,t.status,c.subtask_of,c.archived FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(effect.id));
54
+ if(!task?.readable_id||task.subtask_of||task.archived||['done','cancelled','archived'].includes(task.status))return true;
55
+ const agents=new Set();
56
+ for(const assignment of cardAgentAssignments(board.root,effect.id)) {
57
+ if(assignment.state!=='active'||!admitsScope(board,effect.id,assignment.org))continue;
58
+ renameCardAgent(board.root,assignment,task.readable_id);
59
+ agents.add(assignment.agent_id);
60
+ }
61
+ if(!agents.size)return true;
62
+ const pending=(await handOffAgents(board.root)).filter(item=>item.agent==='*'||agents.has(item.agent));
63
+ if(pending.length)throw new Error(pending.map(item=>item.error).join('; '));
64
+ return true;
65
+ }
66
+
50
67
  /** A failed board write leaves the owner's receipt pending. A failed acknowledgement repeats no board event. */
51
68
  export function recordCardAgentChanges(board,target){
52
69
  const changes=pendingCardAgentChanges(board.root,target);
@@ -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/call.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  // a connection of its own (store.mjs openBoard), and nothing is written
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { AsyncLocalStorage } from 'node:async_hooks';
18
+ import { withCredentialGuard } from './credential-guard.mjs';
18
19
 
19
20
  const calls = new AsyncLocalStorage();
20
21
  // what a call learns as it runs, and the workflow it read (pinned: a reload while it runs does not change its basis)
@@ -34,7 +35,7 @@ export function within({ caller = null, facts = {}, cwd, stdin = null, out, err,
34
35
  /** Run `fn` as a unit of the process's own work (a round, a delivery pass): the process's identity and output, with
35
36
  * what it learns and the workflow it read kept to itself. */
36
37
  export function ownCall(fn) {
37
- return calls.run({ kind: 'own', caller: null, facts: process.env, ...fresh(), get cwd() { return process.cwd(); }, stdin: null, out: own.out, err: own.err }, fn);
38
+ return withCredentialGuard(() => calls.run({ kind: 'own', caller: null, facts: process.env, ...fresh(), get cwd() { return process.cwd(); }, stdin: null, out: own.out, err: own.err }, fn));
38
39
  }
39
40
 
40
41
  /** The caller as its address names it, or null. */
@@ -89,50 +89,63 @@ export async function holdings(board, options) {
89
89
  */
90
90
  async function readHoldings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, storedOf = null, machineOf, home = null, runLaneOf = null }, unchanged) {
91
91
  const out = [];
92
- const rows = board.db.prepare("SELECT t.id, t.status, t.assignee, t.current_run_id, t.model_override, c.machine, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived' AND c.detached IS NULL").all();
93
- for (const row of rows) {
92
+ let cursor = '';
93
+ const high = board.db.prepare('SELECT MAX(id) AS id FROM tasks').get()?.id ?? '';
94
+ for (;;) {
94
95
  await yieldToOwner();
95
96
  if (!unchanged()) return null;
96
- const lane = lanes[row.assignee] ?? {};
97
- const card = {
98
- card: row.id, board: board.slug, assignee: row.assignee ?? null, status: statusOf(board.db, row),
99
- // what the board stores for an arc is read through its workflow's stored_as rules now, never the column a write has
100
- // not yet brought up to date (a paused card counted as running until its row caught up)
101
- stored: !row.subtask_of && storedOf ? storedOf(row.id) : row.status,
102
- // a card that names no machine runs on this home's: a cap on this machine counts it
103
- machine: row.machine ? machineOf(row.machine) : home,
104
- model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null,
105
- };
106
- // a session's model: what its transcript reports, else what its launch resolved, else the card's
107
- const sessionOf = (s, slot) => {
108
- const configured = s.model ?? card.model;
109
- return { ...card, counts: 'sessions', slot, session: s.session_id ?? null, unconfigured: !configured,
110
- dispatch: dispatched(s), run: s.run ?? null,
111
- machine: s.surface === 'headless' ? home : s.machine || s.surface ? machineOf(s.machine ?? row.machine ?? home)
112
- : executionHost(runLaneOf ? runLaneOf(row) : lane, row.machine, home, machineOf), harness: s.harness ?? card.harness ?? 'claude-code',
113
- model: (s.session_id ? modelOf(s) : null) ?? configured };
114
- };
115
- const sessions = fleetOk ? await sessionsOf(board.db, row.id, unchanged) : new Map();
116
- if (sessions === null) return null;
117
- const held = [];
118
- let current = null;
119
- if (row.current_run_id) {
120
- current = { ...json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.current_run_id)?.metadata, {})?.supercode, run: row.current_run_id };
121
- out.push({ ...sessionOf(current, current.slot ?? 'worker'), session: undefined, slot: undefined, counts: 'runs' });
122
- }
123
- for (const [slot, s] of sessions) {
124
- if (!alive(s)) continue;
125
- const session = sessionOf(s, slot);
126
- held.push(session);
127
- if (IN_TURN.has(liveRecord(s)?.status)) out.push({ ...session, counts: 'turns' });
97
+ // Limit candidates before filtering or joining: even a page containing only archived/detached cards is bounded.
98
+ const candidates = board.db.prepare('SELECT id FROM tasks WHERE id>? AND id<=? ORDER BY id LIMIT 32').all(cursor, high);
99
+ if (!candidates.length) break;
100
+ cursor = candidates.at(-1).id;
101
+ const rows = board.db.prepare(`SELECT t.id,t.status,t.assignee,t.current_run_id,t.model_override,c.machine,c.subtask_of
102
+ FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
103
+ WHERE t.id IN (SELECT value FROM json_each(?)) AND t.status!='archived' AND c.detached IS NULL`)
104
+ .all(JSON.stringify(candidates.map(row => row.id)));
105
+ for (const row of rows) {
106
+ await yieldToOwner();
107
+ if (!unchanged()) return null;
108
+ const lane = lanes[row.assignee] ?? {};
109
+ const card = {
110
+ card: row.id, board: board.slug, assignee: row.assignee ?? null, status: statusOf(board.db, row),
111
+ // what the board stores for an arc is read through its workflow's stored_as rules now, never the column a write has
112
+ // not yet brought up to date (a paused card counted as running until its row caught up)
113
+ stored: !row.subtask_of && storedOf ? storedOf(row.id) : row.status,
114
+ // a card that names no machine runs on this home's: a cap on this machine counts it
115
+ machine: row.machine ? machineOf(row.machine) : home,
116
+ model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null,
117
+ };
118
+ // a session's model: what its transcript reports, else what its launch resolved, else the card's
119
+ const sessionOf = (s, slot) => {
120
+ const configured = s.model ?? card.model;
121
+ return { ...card, counts: 'sessions', slot, session: s.session_id ?? null, unconfigured: !configured,
122
+ dispatch: dispatched(s), run: s.run ?? null,
123
+ machine: s.surface === 'headless' ? home : s.machine || s.surface ? machineOf(s.machine ?? row.machine ?? home)
124
+ : executionHost(runLaneOf ? runLaneOf(row) : lane, row.machine, home, machineOf), harness: s.harness ?? card.harness ?? 'claude-code',
125
+ model: (s.session_id ? modelOf(s) : null) ?? configured };
126
+ };
127
+ const sessions = fleetOk ? await sessionsOf(board.db, row.id, unchanged) : new Map();
128
+ if (sessions === null) return null;
129
+ const held = [];
130
+ let current = null;
131
+ if (row.current_run_id) {
132
+ current = { ...json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.current_run_id)?.metadata, {})?.supercode, run: row.current_run_id };
133
+ out.push({ ...sessionOf(current, current.slot ?? 'worker'), session: undefined, slot: undefined, counts: 'runs' });
134
+ }
135
+ for (const [slot, s] of sessions) {
136
+ if (!alive(s)) continue;
137
+ const session = sessionOf(s, slot);
138
+ held.push(session);
139
+ if (IN_TURN.has(liveRecord(s)?.status)) out.push({ ...session, counts: 'turns' });
140
+ }
141
+ // a live run whose session the fleet does not list yet (launching) holds a session's place already
142
+ if (current && !held.some((x) => x.slot === (current.slot ?? 'worker'))) held.unshift(sessionOf(current, current.slot ?? 'worker'));
143
+ out.push(...held);
144
+ // the card itself, whatever its runs and sessions: a cap says which by its status (a started card holds its slot
145
+ // until it is done); its model and harness are its run's session's, else its newest live session's, else its own
146
+ // a subtask is a task of its arc: it never runs or holds a slot of its own
147
+ if (!row.subtask_of) out.push({ ...(held[0] ?? { ...card, unconfigured: !card.model }), session: undefined, slot: undefined, counts: 'cards' });
128
148
  }
129
- // a live run whose session the fleet does not list yet (launching) holds a session's place already
130
- if (current && !held.some((x) => x.slot === (current.slot ?? 'worker'))) held.unshift(sessionOf(current, current.slot ?? 'worker'));
131
- out.push(...held);
132
- // the card itself, whatever its runs and sessions: a cap says which by its status (a started card holds its slot
133
- // until it is done); its model and harness are its run's session's, else its newest live session's, else its own
134
- // a subtask is a task of its arc: it never runs or holds a slot of its own
135
- if (!row.subtask_of) out.push({ ...(held[0] ?? { ...card, unconfigured: !card.model }), session: undefined, slot: undefined, counts: 'cards' });
136
149
  }
137
150
  return out;
138
151
  }
package/board/ci.mjs CHANGED
@@ -291,13 +291,15 @@ async function repositoryWorkspace(board, id) {
291
291
  // the worktree's folder, when it is still to be made, is named for its card (workspace.mjs worktreeFolder)
292
292
  const workspace = { ...owner, handle: cardHandleOf(board, owner.id) };
293
293
  const here = isLocalWorkspace(workspace);
294
+ if (!here) {
295
+ const identity = workspace.workspace_anchor ?? workspace.workspace_path;
296
+ if (!identity) return null;
297
+ // A portable remote declaration supplies the source identity. Legacy paths
298
+ // are resolved by their own machine, never read as paths on the board host.
299
+ const resolved = plainWorkspace(workspace.workspace_kind, identity, workspace.branch_name) ?? await resolveWorkspace(workspace, false);
300
+ return { ...resolved, branch: workspace.branch_name ?? resolved.branch, here: false };
301
+ }
294
302
  if (workspace.workspace_anchor) {
295
- // A portable remote declaration supplies the source identity. Reviewing
296
- // its pushed head does not require that machine to keep the old checkout.
297
- if (!here) {
298
- const declared = plainWorkspace(workspace.workspace_kind, workspace.workspace_anchor, workspace.branch_name);
299
- if (declared) return { ...declared, here: false };
300
- }
301
303
  const resolved = await resolveWorkspace(workspace, false);
302
304
  // Resolution without create names a solo worktree even before it exists on
303
305
  // this machine. Its portable repository and branch still identify the
@@ -305,7 +307,24 @@ async function repositoryWorkspace(board, id) {
305
307
  if (here && !checkoutPresent(resolved.path)) return { ...resolved, here: await recreatedWorktree(workspace, resolved) };
306
308
  return { ...resolved, here };
307
309
  }
308
- return workspace.workspace_path ? { path: workspace.workspace_path, here: here && checkoutPresent(workspace.workspace_path) } : null;
310
+ return workspace.workspace_path ? { path: workspace.workspace_path, here: checkoutPresent(workspace.workspace_path) } : null;
311
+ }
312
+
313
+ /** A supplied completion commit belongs to the workspace's machine. Local Git
314
+ * keeps its existing semantics; a remote declaration is read at its pushed head. */
315
+ async function completionSource(workspace, supplied) {
316
+ if (workspace?.here && workspace.path) {
317
+ return { repository: await originOf(workspace.path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], workspace.path) };
318
+ }
319
+ if (!workspace?.repository || !workspace.branch) throw new Error('The named commit needs a declared repository and branch, resolved through the workspace machine when only a path is recorded.');
320
+ if (typeof supplied !== 'string' || !/^[a-f0-9]{7,40}$/i.test(supplied)) throw new Error('Name the remote completion commit with its full SHA or an abbreviation of at least seven hexadecimal characters.');
321
+ // Read the declared branch, not an arbitrary commit that merely exists in
322
+ // the repository. No checkout or daemon upgrade on the board host is needed.
323
+ const pushed = await github(`/repos/${workspace.repository}/git/ref/heads/${encodeURIComponent(workspace.branch)}`);
324
+ const head = pushed?.object?.type === 'commit' ? pushed.object.sha : null;
325
+ if (typeof head !== 'string' || !/^[a-f0-9]{40}$/.test(head)) throw new Error(`The declared remote ${workspace.repository}:${workspace.branch} answered no commit head.`);
326
+ if (!head.startsWith(supplied.toLowerCase())) throw new Error(`The named commit ${supplied} must be the pushed head of ${workspace.repository}:${workspace.branch}, currently ${head}.`);
327
+ return { repository: workspace.repository, branch: workspace.branch, commit: head, source_sha: head };
309
328
  }
310
329
 
311
330
  /** A card's declared solo worktree that is missing here (deleted, or never made on this machine) while its branch is on
@@ -383,7 +402,7 @@ function registeredBranches(board, id) {
383
402
  * declaration (engine.comment), the arc's complete set. A multi-repository arc carries them on the arc card, and its
384
403
  * review discovers and accepts PRs from them with no subtask registered in that repository (VO-2858, VO-2860). */
385
404
  export function arcSources(db, id) {
386
- const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
405
+ const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_valid(payload) AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
387
406
  let declared = [];
388
407
  try { declared = row ? JSON.parse(row.payload).branches : []; } catch { /* an unreadable declaration names none */ }
389
408
  return declared.filter((b) => typeof b?.repository === 'string' && typeof b?.branch === 'string').map((b) => ({ repository: b.repository, branch: b.branch }));
@@ -396,8 +415,9 @@ export const namedWorkBranch = (id, head) => [`wt/${id}`, `wt/${id.replace(/^t_/
396
415
  /** The branch names an arc declares as its work: its own and its subtasks' `--branch`, the branches it declares as its
397
416
  * sources (arcSources), and each branch its latest review request covered. With `namedWorkBranch`, these are the branches its PRs are opened from. */
398
417
  export function declaredWorkBranches(db, id) {
399
- const declared = db.prepare(`SELECT t.branch_name FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
400
- WHERE (t.id=? OR c.subtask_of=?) AND t.status != 'cancelled' AND t.branch_name IS NOT NULL`).all(id, id).map((r) => r.branch_name);
418
+ const declared = db.prepare(`SELECT branch_name FROM tasks WHERE id=? AND status!='cancelled' AND branch_name IS NOT NULL
419
+ UNION SELECT t.branch_name FROM supercode_cards c CROSS JOIN tasks t ON t.id=c.task_id
420
+ WHERE c.subtask_of=? AND t.status!='cancelled' AND t.branch_name IS NOT NULL`).all(id, id).map((r) => r.branch_name);
401
421
  declared.push(...arcSources(db, id).map((b) => b.branch));
402
422
  const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
403
423
  let reviewed = [];
@@ -410,7 +430,7 @@ export function declaredWorkBranches(db, id) {
410
430
  * of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
411
431
  * 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
412
432
  * answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
413
- export async function openReviewPullRequests(board, id, { fresh = false } = {}) {
433
+ export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false } = {}) {
414
434
  const own = await repositoryWorkspace(board, id);
415
435
  const registered = registeredBranches(board, id);
416
436
  if (!own && !registered.length) return null;
@@ -450,7 +470,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
450
470
  if (branch === list.defaultBranch) branch = null;
451
471
  }
452
472
  // 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
453
- 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 });
473
+ 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 });
454
474
  // A manual review and merge during a dispatcher outage may predate any
455
475
  // board request. The source-review door already accepts merged heads; make
456
476
  // them discoverable so the ordinary independent verdict can be retained.
@@ -467,7 +487,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
467
487
  && pr.baseRefName === pr.defaultBranch && (answered || !judgedHead(board, id, pr.headRefOid)));
468
488
  });
469
489
  for (const history of histories) for (const pr of history) {
470
- if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
490
+ if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
471
491
  }
472
492
  return prs;
473
493
  });
@@ -481,9 +501,13 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
481
501
  const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => repositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
482
502
  for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
483
503
  if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
484
- if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
504
+ if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
485
505
  }
486
506
  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));
487
511
  return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
488
512
  }
489
513
 
@@ -636,9 +660,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
636
660
  if (stage === 'completion' || stage === 'acceptance') {
637
661
  const supplied = payload.commit ?? payload.metadata?.commit;
638
662
  if (supplied) {
639
- const path = (await repositoryWorkspace(board, id))?.path;
640
- if (!path) throw new Error('The named commit needs a resolved repository workspace.');
641
- Object.assign(state, { repository: await originOf(path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
663
+ Object.assign(state, await completionSource(await repositoryWorkspace(board, id), supplied));
642
664
  }
643
665
  const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
644
666
  if (candidate) {
@@ -712,14 +734,28 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
712
734
  return state;
713
735
  }
714
736
 
715
- /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
716
- async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
717
- if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
718
- const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
737
+ /** Local review readiness, also usable with advisory metadata to refuse before spending REST admission. */
738
+ async function requireReviewWorkspaceHead(board, id, own, observed, head, { pushed = false } = {}) {
739
+ const url = observed.url, repository = repositoryOf(url), branch = observed.headRefName;
719
740
  // An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
720
741
  // card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
721
742
  // with nothing uncommitted. A card on another machine is reviewed at its pushed head: its checkout is not read from here.
722
743
  const path = observed.state === 'MERGED' || (own && !own.here) ? null : !own ? await branchCheckout(board, id, repository, observed.headRefName) : await originOf(own.path) === repository && await run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : await branchCheckout(board, id, repository, observed.headRefName);
744
+ if (!path) return;
745
+ await run('git', ['check-ref-format', '--branch', branch], path);
746
+ if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
747
+ const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
748
+ if (uncommitted.length) throw new Error(`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 ? ', …' : ''}`);
749
+ if (pushed) {
750
+ const remote = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
751
+ if (remote !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
752
+ }
753
+ }
754
+
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) {
757
+ 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
+ const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
723
759
  // A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
724
760
  // verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
725
761
  const merged = observed.state === 'MERGED';
@@ -732,14 +768,7 @@ async function verifyPullRequest(board, id, entry, own, url, supplied, stage, ob
732
768
  const mainBranch = observed.defaultBranch ?? 'main';
733
769
  // A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
734
770
  if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
735
- if (stage === 'request_review' && path && !merged) {
736
- await run('git', ['check-ref-format', '--branch', branch], path);
737
- if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
738
- const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
739
- if (uncommitted.length) throw new Error(`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 ? ', …' : ''}`);
740
- const pushed = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
741
- if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
742
- }
771
+ if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
743
772
  return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
744
773
  }
745
774
 
package/board/cli.mjs CHANGED
@@ -15,8 +15,9 @@ 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
- import { redactText } from '@volter/teams/redact';
20
+ import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
20
21
  import { openOwnerDoor } from './owner-door.mjs';
21
22
  import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
22
23
  import { notRuntime, ownHome } from './owner-files.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
@@ -833,6 +835,15 @@ export async function runVerb(argv) {
833
835
  }
834
836
  case 'attach': {
835
837
  need(2, 'a card id and a file');
838
+ if (args.upload !== undefined) {
839
+ if (rest[1] !== '-') throw new Error('attachment upload reads its carried stdin');
840
+ withBoard(b => { if (!engine.getTask(b.db, id)) throw new Error(`no card ${id}`); });
841
+ const directory = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
842
+ const result = receiveAttachment({ directory, upload: args.upload, caller: callerAddress() ?? actorOf(), input: current().stdin,
843
+ add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() })) });
844
+ say(args, result.id ? `Attached ${result.name} to ${id} (#${result.id}, sha256 ${result.sha256})` : `Upload ${args.upload}: ${result.received ?? 'aborted'}`, result);
845
+ return 0;
846
+ }
836
847
  const source = callerPath(rest[1]);
837
848
  if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
838
849
  const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
@@ -1749,8 +1760,11 @@ async function answerVerbs(root, log) {
1749
1760
  * absence from a slow-only log cannot prove those calls met the time bound. */
1750
1761
  export async function answerOne(root, line, { readOnly = false } = {}) {
1751
1762
  const scoped = typeof slowLog.withStepScope === 'function';
1763
+ // The owner's receipt includes asynchronous custody preparation too: it is part of the caller's wait.
1764
+ const began = performance.now(), before = slowLog.processCounters?.() ?? null;
1765
+ let prepared = false;
1752
1766
  const run = async () => {
1753
- const began = performance.now(), before = slowLog.processCounters?.() ?? null;
1767
+ prepared = true;
1754
1768
  let named = null, by = null, answer = null, outcome = 'error';
1755
1769
  try {
1756
1770
  answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }, { readOnly });
@@ -1769,7 +1783,23 @@ export async function answerOne(root, line, { readOnly = false } = {}) {
1769
1783
  }
1770
1784
  }
1771
1785
  };
1772
- return scoped ? slowLog.withStepScope(run) : run();
1786
+ const guarded = async () => {
1787
+ try { return await withCredentialGuard(run); }
1788
+ catch (error) {
1789
+ if (!prepared) {
1790
+ const ended = performance.now(), ms = ended - began;
1791
+ const code = error?.code === 'custody_unreadable' ? 'custody_unreadable' : 'custody_preparation_failed';
1792
+ // No request, caller payload or error text is inspected before custody is available. Fixed metadata needs
1793
+ // no redaction read and still records the failed preparation in this request's own step scope.
1794
+ recordSlow({ kind: 'call', name: 'owner verb custody preparation', args: [], ms, outcome: code,
1795
+ slowest: null, force: true, includeFast: true,
1796
+ extra: { failure: { code, phase: 'custody preparation' }, step_scope: scoped ? 'request' : 'process_interval',
1797
+ steps: scoped ? takeSteps() : slowLog.stepsWithin?.(began, ended) ?? null } });
1798
+ }
1799
+ throw error;
1800
+ }
1801
+ };
1802
+ return scoped ? slowLog.withStepScope(guarded) : guarded();
1773
1803
  }
1774
1804
 
1775
1805
  /** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
@@ -1949,7 +1979,20 @@ async function serve(root, args = {}) {
1949
1979
  let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
1950
1980
  const listenerErrors = new Set();
1951
1981
  const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
1952
- const scanListeners = ({ waiting = true } = {}) => listenerBootstrapped && fleetRecordsCurrent() && ownCall(() => observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting }));
1982
+ const scanListeners = async ({ waiting = true } = {}) => {
1983
+ if (stopped || !fleetListeners.enabled || !listenerBootstrapped || !fleetRecordsCurrent()) return;
1984
+ const revision = listenerRevision;
1985
+ try {
1986
+ await ownCall(() => {
1987
+ if (stopped || revision !== listenerRevision || !fleetListeners.enabled || !fleetRecordsCurrent()) return;
1988
+ observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting });
1989
+ });
1990
+ } catch {
1991
+ // Every caller may schedule this without awaiting it. Preparation refusal leaves source cursors untouched;
1992
+ // the next admitted scan retries the retained observations, while the owner's public door stays available.
1993
+ listenerLog('fleet listener scan refused; retained observations wait for a prepared owner call');
1994
+ }
1995
+ };
1953
1996
  const readListenerRecords = () => {
1954
1997
  if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
1955
1998
  const revision = listenerRevision;
@@ -1964,7 +2007,9 @@ async function serve(root, args = {}) {
1964
2007
  };
1965
2008
  fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
1966
2009
  file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
1967
- enrich: event => ownCall(() => enrichFleetEvent(root, event)),
2010
+ // The scan/flush prepares custody once; enqueue needs the event synchronously before committing its cursor.
2011
+ enrich: event => enrichFleetEvent(root, event),
2012
+ work: ownCall,
1968
2013
  retry: () => scanListeners({ waiting: false }),
1969
2014
  });
1970
2015
  const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
@@ -3,7 +3,20 @@
3
3
  // gitleaks' rule set and this machine's custody values), plus the shape of an environment listing: several NAME=value
4
4
  // lines in a row. A shell that expanded `$(env)` or a pasted .env into a comment once put live keys on a card, where
5
5
  // every reader, every launch prompt and the board's own remote received them.
6
- import { redactText } from '@volter/teams/redact';
6
+ import { redactText, redactionSnapshotAsync, withRedactionSnapshot } from '@volter/teams/redact';
7
+ import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
8
+
9
+ /** One fresh, fail-closed custody observation per call/round, read before its synchronous guards. No role decision
10
+ * is kept here, and a concurrent caller cannot replace this call's redaction basis. */
11
+ export async function withCredentialGuard(fn) {
12
+ const began = performance.now();
13
+ let snapshot;
14
+ try { snapshot = await redactionSnapshotAsync(process.env, { fresh: true }); }
15
+ finally { noteStep('custody observation', performance.now() - began); }
16
+ return withRedactionSnapshot(process.env, snapshot, fn);
17
+ }
18
+
19
+ export const redactGuardText = text => redactText(text);
7
20
 
8
21
  const ENV_LINE = /^\s*(?:export\s+|declare\s+-x\s+)?[A-Z][A-Z0-9_]{2,}=\S/;
9
22
  const ENV_LINES = 5;
@@ -13,7 +26,7 @@ export function credentialDumpIn(text) {
13
26
  if (typeof text !== 'string' || text.length < 8) return null;
14
27
  const lines = text.split('\n').filter((line) => ENV_LINE.test(line)).length;
15
28
  if (lines >= ENV_LINES) return `${lines} NAME=value lines, the shape of an environment listing`;
16
- const redacted = redactText(text);
29
+ const redacted = redactGuardText(text);
17
30
  if (redacted === text) return null;
18
31
  const before = new Set(text.match(/\[redacted:[^\]]+\]/g) ?? []);
19
32
  const found = [...new Set((redacted.match(/\[redacted:[^\]]+\]/g) ?? []).filter((marker) => !before.has(marker)))]