@volter/supercode-orchestrator 0.5.207 → 0.5.209

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
@@ -32,7 +32,7 @@ import { join } from 'node:path';
32
32
 
33
33
  const root = process.env.SUPERCODE_ORCHESTRATOR_HOME || join(homedir(), '.supercode/orchestrator');
34
34
  const { state, vault } = await load(root); // strict; secrets in `vault`, never `state`
35
- await save(state, root); // canonical, byte-stable when unchanged
35
+ await save(state, root); // runtime state; preserves workflow.yaml
36
36
 
37
37
  const { state: hermesState } = await compile('hermes', process.env.HERMES_HOME); // a home as a state value
38
38
  await closeWorld(); // close the shared door when done
@@ -57,6 +57,14 @@ Rules the codec keeps (enforced in Rust, observed here):
57
57
  since import (UNI-22); it copies the store byte for byte when they did not.
58
58
  - Files the IR does not model are never touched by `save` and are carried by path on a migration.
59
59
 
60
+ Runtime `save` and load-time credential normalization leave `workflow.yaml` untouched,
61
+ including a symlink or an absent file. The board reads policy from the live home;
62
+ an older runtime snapshot cannot replace an edited cap. For intentional full-home
63
+ creation or replacement, use `orchestrationSave` (which writes the workflow by
64
+ default), `supercode orchestration save --from-json`, or `importFrom`.
65
+ Other SDK callers that persist runtime state set `preserve_workflow: true` on
66
+ `orchestrationSave`. See [workflow ownership](../../docs/adr/0067-live-workflow-ownership.md).
67
+
60
68
  ## Board machine-call deadlines
61
69
 
62
70
  The board owner's machine calls share one timeout budget across channel admission,
@@ -197,7 +205,7 @@ Tiers on the way out:
197
205
  too, rather than written as its own reference.
198
206
 
199
207
  Secrets never enter the model: any string under a credential-shaped key — openclaw's own rule
200
- (`orchestration/codec/openclaw.rs::is_credential_key`: the lower-cased key ENDS WITH `token|key|secret|password|
208
+ (`sdk/interchange/orchestration-openclaw.mjs`: the lower-cased key ENDS WITH `token|key|secret|password|
201
209
  credential`), minus the names it spells that way for non-secrets (`sessionKey`, `store_key`, …) —
202
210
  moves to the vault as a `{dotenv}` ref and is re-inlined on export where openclaw reads it.
203
211
 
@@ -713,3 +721,23 @@ authority, in-progress-operation refusals and already-issued-request boundaries.
713
721
  [Source observations](../../docs/evidence/board-session-detach-2026-10-09.md)
714
722
  cover actual help, compilation and read-only retained records. Live handback,
715
723
  return through adoption and released availability remain unqualified.
724
+
725
+ ## Readable card ids
726
+
727
+ Every card has a `readable_id` beside its number and native id. Create one with
728
+ `supercode workflow create "Voice calls" --readable-id voice-calls --root HOME`,
729
+ or let the board derive it from the title. Names use lowercase kebab-case, start
730
+ with a letter, have at most 32 characters and are unique among open cards.
731
+ Automatic collisions gain a numeric suffix; explicit duplicates are refused.
732
+
733
+ `workflow list` and `show` display the name. `workflow specify CARD --readable-id
734
+ voice-calls --root HOME` changes it; the same `readable_id` property is editable
735
+ in a ztrack card's metadata in `arcs.md`. A title edit keeps its existing id.
736
+ Older boards fill missing names from leading title slugs before other title words.
737
+
738
+ Generated branches use `vo-42-voice-calls`. Card agents become
739
+ `implementer-voice-calls` or `reviewer-voice-calls`, with the same immutable agent
740
+ id and sessions; the ordinary delivery pass sends existing agents' renames to
741
+ the machine. The Workplace updates its existing principal and spoken name.
742
+ Existing worktrees, explicit branches and personal naming overrides keep their
743
+ normal protections. Merge and release remain separate from this source behavior.
package/agents.mjs CHANGED
@@ -162,7 +162,7 @@ export function agentRunForKey(root,org,agent,key) {
162
162
  const declarationHead=(root,org,key)=>{const db=store(root);try{return db.prepare('SELECT agent_id FROM agent_declaration_heads WHERE org=? AND key=?').get(org,key)?.agent_id??null;}finally{db.close();}};
163
163
  const endedHere=(root,org,id)=>{const db=store(root);try{return !!db.prepare('SELECT 1 FROM agent_runs WHERE org=? AND agent_id=? AND end_reason IS NOT NULL LIMIT 1').get(org,id);}finally{db.close();}};
164
164
  function recordDeclaration(root,org,id,declaration){
165
- const db=store(root);try{db.prepare('UPDATE agent_intents SET declaration_json=? WHERE org=? AND id=? AND declared=0').run(JSON.stringify(declaration),org,id);}finally{db.close();}
165
+ const db=store(root);try{db.prepare('UPDATE agent_intents SET declaration_json=?,declared=0 WHERE org=? AND id=? AND declaration_json IS NOT ?').run(JSON.stringify(declaration),org,id,JSON.stringify(declaration));}finally{db.close();}
166
166
  }
167
167
  /** The identity a run starts as, never asking Teams: the daemon-minted id and retained intent. Its declaration is handed
168
168
  * to this machine's daemon (handOffAgents), which declares it to Teams under the same id. Nothing is granted from this
@@ -177,9 +177,11 @@ async function localAgent(options,{key,name,declaration,assignment,parent=null,s
177
177
  // is minted in its place, with fresh memory.
178
178
  if(key!=='root'&&endedHere(root,org,intent.id))intent=await nextAgentGeneration(root,intent,machine);
179
179
  const above=parent??(key==='root'?null:{id:rootIntent.id,path:basename(root)});
180
+ // Standing agents retain their names. Card declarations follow the card's editable readable id.
181
+ if(key!=='root'&&declaration.kind!=='card'){const held=agentIntent(root,org,intent.id);if(held?.declared){try{name=JSON.parse(held.declaration_json??'{}').name??name;}catch{/* its declaration unread: the name given */}}}
180
182
  if(key!=='root')recordDeclaration(root,org,intent.id,{...declaration,name,parent:above?.id??null});
181
183
  const identity={id:intent.id,kind:'agent',org,name,path:above?`${above.path}/${name}`:name,parent:above?.id??null,
182
- lineage:{starter},sponsor:options.sponsor??null,explicit_sponsor:options.sponsor??null,profile,state:'active',
184
+ lineage:{starter},profile,state:'active',
183
185
  synced:!!intent.declared,main_session:null,revision:0};
184
186
  const assigned=assignAgent(root,org,intent.id,assignment);
185
187
  return {identity,intended:intent.intended,assignment:assigned,assignments:[assignment],org,synced:false};
@@ -196,11 +198,13 @@ export async function localCardAgent(options){
196
198
  if(held&&held.agent_id!==declarationHead(root,org,`${server}/${org}/card/${card}/${role}`)&&!endedHere(root,org,held.agent_id)){
197
199
  const record=localAgentRecord(root,org,held.agent_id);
198
200
  const identity=record?{...record.identity,synced:true}:{id:held.agent_id,kind:'agent',org,name:held.agent_id,path:held.agent_id,parent:null,
199
- lineage:{starter:null},sponsor:options.sponsor??null,explicit_sponsor:options.sponsor??null,profile,state:'active',synced:true,main_session:null,revision:0};
201
+ lineage:{starter:null},profile,state:'active',synced:true,main_session:null,revision:0};
200
202
  return {identity,intended:record?.intended??resolveAgentProfile(profiles,profile),assignment:held.id,assignments:[{kind:'card',target:card,role}],org,synced:false};
201
203
  }
202
- return localAgent(options,{key:`card/${card}/${role}`,name:`${card}-${role}`,assignment:{kind:'card',target:card,role},
203
- declaration:{kind:'card',card,role,profile,sponsor:options.sponsor??null,starterSession:options.starterSession??null},
204
+ // The immutable card/role key keeps the same identity when its readable name changes.
205
+ const handle=options.handle??null,readableId=options.readableId??null;
206
+ return localAgent(options,{key:`card/${card}/${role}`,name:`${role}-${readableId??handle??card}`,assignment:{kind:'card',target:card,role},
207
+ declaration:{kind:'card',card,...(handle?{handle}:{}),...(readableId?{readableId}:{}),role,profile,starterSession:options.starterSession??null},
204
208
  starter:options.starterSession?{kind:'session',id:options.starterSession}:{kind:'automation',id:`board:${basename(options.root)}`}});
205
209
  }
206
210
  export async function localProfileAgent(options){
@@ -208,7 +212,7 @@ export async function localProfileAgent(options){
208
212
  const starter={kind:'automation',id:`orchestrator:${basename(root)}`};
209
213
  if(declared&&declared!==profile){
210
214
  const decl=declarations[declared]??{};
211
- return localAgent({...options,sponsor:decl.sponsor??null},{key:`agent/${declared}`,name:declared,assignment:{kind:'agent',target:declared,role:'declared'},
215
+ return localAgent(options,{key:`agent/${declared}`,name:declared,assignment:{kind:'agent',target:declared,role:'declared'},
212
216
  declaration:{kind:'declared',agent:declared,profile},starter});
213
217
  }
214
218
  if(profile===rootProfile)return localAgent(options,{key:'root',name:basename(root),assignment:{kind:'profile',target:profile,role:'profile'},declaration:{kind:'root',profile},starter});
@@ -220,11 +224,22 @@ export async function localProfileAgent(options){
220
224
  export function localAgentRecord(root,org,id){
221
225
  const intent=agentIntent(root,org,id);if(!intent)return null;
222
226
  const declaration=intent.declaration_json?JSON.parse(intent.declaration_json):{};
223
- const name=declaration.kind==='card'?`${declaration.card}-${declaration.role}`:declaration.kind==='profile'?declaration.profile:declaration.kind==='declared'?declaration.agent:basename(root);
227
+ const name=declaration.name??(declaration.kind==='card'?`${declaration.role}-${declaration.readableId??declaration.handle??declaration.card}`:declaration.kind==='profile'?declaration.profile:declaration.kind==='declared'?declaration.agent:basename(root));
224
228
  return {identity:{id,kind:'agent',org,name,path:declaration.kind==='root'?name:`${basename(root)}/${name}`,parent:null,lineage:{starter:null},
225
- sponsor:declaration.sponsor??null,profile:intent.profile_ref,state:'active',synced:!!intent.declared,main_session:null,revision:0},
229
+ profile:intent.profile_ref,state:'active',synced:!!intent.declared,main_session:null,revision:0},
226
230
  intended:intent.profile,assignments:intent.assignments,org,synced:!!intent.declared};
227
231
  }
232
+ /** Rename only identities this card role declared; a reassigned standing agent keeps its own name. */
233
+ export function renameCardAgent(root, assignment, readableId) {
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);
237
+ if(declaration.kind!=='card'||declaration.card!==assignment.target||declaration.role!==assignment.role)return false;
238
+ const name=`${assignment.role}-${readableId}`;
239
+ if(declaration.name===name&&declaration.readableId===readableId)return false;
240
+ recordDeclaration(root,assignment.org,assignment.agent_id,{...declaration,name,readableId});
241
+ return true;
242
+ }
228
243
  /** The id a home's mail agent runs as, read and never minted: its own declared identity (`agent/<name>`) where the home
229
244
  * holds one, else its profile's (`profile/<profile>`), as localProfileAgent keys them, each at its current generation.
230
245
  * Null when the home holds neither, or has no agent store. A channel names its agent by this id (agent-channel). */
@@ -315,7 +330,7 @@ export async function localForkAgent(options){
315
330
  const db=store(root);try{db.prepare('UPDATE agent_intents SET profile_json=? WHERE org=? AND id=?').run(JSON.stringify(intended),org,intent.id);}finally{db.close();}
316
331
  recordDeclaration(root,org,intent.id,{kind:'fork',profile:parentIntent.profile_ref,name,parent,starterSession:session});
317
332
  const assignment=assignAgent(root,org,intent.id,{kind:'fork',target:session,role:key});
318
- const identity={id:intent.id,kind:'agent',org,name,path:name,parent,lineage:{starter:{kind:'session',id:session}},sponsor:null,explicit_sponsor:null,
333
+ const identity={id:intent.id,kind:'agent',org,name,path:name,parent,lineage:{starter:{kind:'session',id:session}},
319
334
  profile:parentIntent.profile_ref,state:'active',synced:!!intent.declared,main_session:null,revision:0};
320
335
  return {identity,intended,assignment,assignments:[{kind:'fork',target:session,role:key}],org,synced:false};
321
336
  }
@@ -369,11 +384,11 @@ async function handOffOrg(root,scope,waiting){
369
384
  for(const row of rows){
370
385
  const declaration=JSON.parse(row.declaration_json);
371
386
  const body={org:scope.org,id:row.id,name:declaration.name??declaration.profile,parent:declaration.parent??null,profile:declaration.profile,
372
- ...(declaration.kind==='root'?{root:true}:{}),...(declaration.sponsor?{sponsor:declaration.sponsor}:{}),
387
+ ...(declaration.kind==='root'?{root:true}:{}),
373
388
  ...(declaration.starterSession?{starter_session:declaration.starterSession}:{}),key:digest(row.key)};
374
389
  const reply=await runtimeDoor(null,'harness.v1.agents.declare',body,{timeout:15_000});
375
390
  if(reply.code!==0){waiting.push({agent:row.id,error:`not handed to this machine's daemon: ${reply.stderr.trim().split('\n').at(-1)}`});break;}
376
- db=store(root);try{db.prepare('UPDATE agent_intents SET declared=1 WHERE org=? AND id=?').run(scope.org,row.id);}finally{db.close();}
391
+ db=store(root);try{db.prepare('UPDATE agent_intents SET declared=1 WHERE org=? AND id=? AND declaration_json=?').run(scope.org,row.id,row.declaration_json);}finally{db.close();}
377
392
  }
378
393
  for(const row of ends){
379
394
  db=store(root);let active;
@@ -469,7 +484,7 @@ export function runBudgetRefusal(root,agent,budgets,now){
469
484
  }
470
485
  export function agentPreamble(agent,{main=true,previous=[]}={}){
471
486
  const {identity:a,intended,assignments=[]}=agent;
472
- return `You are agent ${a.path} (${a.id}), ${main?'its main session':'a concurrent session'}.${a.synced===false?' This identity was minted on this machine and is not yet declared on a Teams server; it keeps this id when it is.':''}\nSponsor: ${a.sponsor??'none yet'}; parent: ${a.parent??'none'}; lineage: ${JSON.stringify(a.lineage)}.\nAssignments: ${assignments.map(value=>`${value.kind} ${value.target}, role ${value.role}`).join("; ")}; profile: ${a.profile}; permission layers: ${JSON.stringify(intended.resolved.permissions.layers)}.\nAsk again with supercode agent whoami. List your sessions with supercode agent sessions; read a transcript only when needed.${previous.length?'\nEarlier sessions:\n'+previous.map(s=>`${s.session} · ${s.machine??'unknown machine'} · ended ${s.ended_at??'live'} · ${s.end_reason??''}`).join('\n'):''}`;
487
+ return `You are agent ${a.path} (${a.id}), ${main?'its main session':'a concurrent session'}.${a.synced===false?' This identity was minted on this machine and is not yet declared on a Teams server; it keeps this id when it is.':''}\nParent: ${a.parent??'none'}; lineage: ${JSON.stringify(a.lineage)}.\nAssignments: ${assignments.map(value=>`${value.kind} ${value.target}, role ${value.role}`).join("; ")}; profile: ${a.profile}; permission layers: ${JSON.stringify(intended.resolved.permissions.layers)}.\nAsk again with supercode agent whoami. List your sessions with supercode agent sessions; read a transcript only when needed.${previous.length?'\nEarlier sessions:\n'+previous.map(s=>`${s.session} · ${s.machine??'unknown machine'} · ended ${s.ended_at??'live'} · ${s.end_reason??''}`).join('\n'):''}`;
473
488
  }
474
489
  /** The agent's reader context this home already holds (a session of it was bound here before), read from the contexts
475
490
  * file without a request, or null. A launch on this machine hands it to the session, so its Teams verbs run delegated
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { activeAgentAssignments, localAgentConversation } from '../agents.mjs';
8
- import { connectionCall, connectionCanRun, connectionPath, generateModuleConfig, privateJson } from '../connection.mjs';
8
+ import { SERVICE_APPS, SERVICE_PROGRAM, connectionCall, connectionCanRun, connectionPath, generateModuleConfig, privateJson } from '../connection.mjs';
9
9
 
10
10
  const {values}=parseArgs({options:{root:{type:'string'},connection:{type:'string'}}});
11
11
  const path=connectionPath(values.root,values.connection),folder=dirname(path),lock=join(folder,'runner.lock');
@@ -25,10 +25,15 @@ const save=()=>{record.health=health;record.raised=raised;privateJson(path,recor
25
25
  const teamPath=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`;
26
26
  const issue=(name,reason)=>{health[name]={state:'unavailable',reason,at:new Date().toISOString()};save();};
27
27
  const recover=name=>{health[name]={state:'current',at:new Date().toISOString()};save();};
28
+ // The child a grant runs in: a module its own program; a Workplace service registration the one service, which the agents
29
+ // registration hosts (cards and machines start nothing of their own).
30
+ const childOf=grant=>SERVICE_APPS.includes(grant.name)?'agents':grant.name;
28
31
  function start(grant){
32
+ if(SERVICE_APPS.includes(grant.name)&&grant.name!=='agents')return;
29
33
  if(stopped||children.has(grant.name)||!existsSync(join(values.root,'apps',`${grant.name}.${record.id}.json`)))return;
30
34
  const config=generateModuleConfig(values.root,record,grant);
31
- const child=spawn(process.execPath,[join(record.moduleRoot,grant.name,'main.mjs'),config],{stdio:['ignore','pipe','pipe'],env:{...process.env,SUPERCODE_ORCHESTRATOR_HOME:values.root,HERMES_HOME:values.root}});
35
+ const program=SERVICE_APPS.includes(grant.name)?SERVICE_PROGRAM:join(record.moduleRoot,grant.name,'main.mjs');
36
+ const child=spawn(process.execPath,[program,config],{stdio:['ignore','pipe','pipe'],env:{...process.env,SUPERCODE_ORCHESTRATOR_HOME:values.root,HERMES_HOME:values.root}});
32
37
  children.set(grant.name,child);let buffer='';
33
38
  child.stdout.on('data',chunk=>{
34
39
  process.stdout.write(chunk);if(stopped)return;buffer+=chunk.toString();let at;
@@ -101,7 +106,8 @@ async function renew(){
101
106
  let changed=false;
102
107
  if(Date.parse(grant.rh2.expiresAt)-Date.now()<=7*86400000){grant.rh2={...grant.rh2,...(await connectionCall(record,'rh2','POST',`/api/v3/automation/installations/${grant.rh2.installationId}/renew`,{},grant.rh2.accessToken)).data};changed=true;save();}
103
108
  if(grant.teams)changed=await renewTeams(grant.teams)||changed;
104
- if(changed&&existsSync(join(values.root,'apps',`${grant.name}.${record.id}.json`))){await stopChild(grant.name);start(grant);}
109
+ // A renewed credential reaches its program by a restart with a regenerated config (the service's, for its registrations).
110
+ if(changed){const host=record.grants.find(g=>g.name===childOf(grant));if(host&&existsSync(join(values.root,'apps',`${host.name}.${record.id}.json`))){await stopChild(host.name);start(host);}}
105
111
  if(!grant.teams?.pendingRenewal)recover(`expiry.${grant.name}`);
106
112
  }catch(error){issue(`expiry.${grant.name}`,error.code??'renewal-unavailable');}
107
113
  }
@@ -109,12 +115,8 @@ async function renew(){
109
115
  }
110
116
  for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>void stop());
111
117
  for(const grant of record.grants)start(grant);
112
- let lastRenew=0,lastStanding=0,lastKeys=0;
113
- async function publishIssuerKeys(results=[]){
114
- const keys=(await connectionCall(record,'rh2','GET','/.well-known/jwks.json')).keys;
115
- await connectionCall(record,'teams','POST',`${teamPath}/app-installations/${record.issuerCredential.installation.id}/issuer-keyring`,{keys,results},record.issuerCredential.credential.token);
116
- lastKeys=Date.now();
117
- }
118
+ let lastRenew=0,lastStanding=0;
119
+ // Teams verifies the Workplace's tokens against its own JWKS (ADR 0070): nothing is relayed for them here.
118
120
  while(!stopped){
119
121
  try{
120
122
  const installed=record.grants.filter(grant=>existsSync(join(values.root,'apps',`${grant.name}.${record.id}.json`)));
@@ -123,19 +125,8 @@ while(!stopped){
123
125
  const pendingRenewalDue=[record.issuerCredential,...installed.map(grant=>grant.teams)].some(value=>value?.pendingRenewal&&!(value.renewalRetryAt>Date.now()));
124
126
  if(pendingRenewalDue||Date.now()-lastRenew>3600000){await renew();lastRenew=Date.now();}
125
127
  if(Date.now()-lastStanding>15000){standing();lastStanding=Date.now();}
126
- // A restart may have outlived the key lease; publish before a twenty-second idle poll can delay admission.
127
- if(Date.now()-lastKeys>=30000)await publishIssuerKeys();
128
- const pending=(await connectionCall(record,'teams','GET',`${teamPath}/app-installations/${record.issuerCredential.installation.id}/issuer-checks`,undefined,record.issuerCredential.credential.token)).data.checks;
129
- const results=await Promise.all(pending.map(async check=>{
130
- // Every generation starts its own status read AFTER this poll received it. Never share a token decision,
131
- // even if two generations of the same token occur in one batch; only this generation may use this answer.
132
- const generation=check.id;
133
- const status=(await connectionCall(record,'rh2','POST','/api/v3/delegated/status',{token:check.token})).data;
134
- return {id:generation,live:status?.live===true};
135
- }));
136
- if(results.length||Date.now()-lastKeys>=30000)await publishIssuerKeys(results);
137
128
  recover('issuer');await syncAlerts();
138
- }catch(error){issue('issuer',error.code??error.status??'outbound-connection-unavailable');await syncAlerts();}
129
+ }catch(error){issue('issuer',error.code??error.status??'connection-unavailable');await syncAlerts();}
139
130
  await new Promise(done=>setTimeout(done,2000));
140
131
  }
141
132
  async function stop(){if(stopped)return;stopped=true;await Promise.all([...children.keys()].map(stopChild));record.stoppedAt=new Date().toISOString();save();unlinkSync(lock);process.exit(0);}
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ // A reader such as `head` may close its pipe before the final flush. Finish the command and its document work;
3
+ // a closed output pipe is not an unhandled runtime error. Other stream failures retain their original error.
4
+ for (const stream of [process.stdout, process.stderr]) stream.on('error', error => { if (error.code !== 'EPIPE') throw error; });
2
5
  // before anything loads node:sqlite: Node's notice that it is experimental is not this command's to print
3
6
  try { await import('@volter/supercode-harness-sdk/runtime-warnings'); } catch { /* a harness SDK without it: the notice is printed */ }
4
7
  // Check before handing off or importing board/runtime dependencies.
@@ -36,10 +39,20 @@ if (process.argv[2] === 'workflow') {
36
39
  } else if (process.argv[2] === 'connect') {
37
40
  const {parseArgs}=await import('node:util');
38
41
  const {connectWorkplace}=await import('../connection.mjs');
39
- const options=Object.fromEntries(['root','ticket-file','modules-root','source','context','teams-token-file','teams-url','team','floor','standing-file','alert-role'].map(key=>[key,{type:'string'}]));
40
- options['no-start']={type:'boolean'};
41
- try{const {values}=parseArgs({args:process.argv.slice(3),options});process.stdout.write(JSON.stringify(await connectWorkplace(values),null,2)+'\n');}
42
+ const {SERVICE_REGISTRATIONS}=await import('../connection.mjs');
43
+ const options=Object.fromEntries(['root','ticket-file','modules-root','source','context','teams-token-file','teams-url','team','floor','standing-file','alert-role','webhook-url','webhook-listen'].map(key=>[key,{type:'string'}]));
44
+ options['no-start']={type:'boolean'};options.registrations={type:'boolean'};
45
+ try{
46
+ const {values}=parseArgs({args:process.argv.slice(3),options});
47
+ if(values.registrations)process.stdout.write(`${SERVICE_REGISTRATIONS}\n`);
48
+ else process.stdout.write(JSON.stringify(await connectWorkplace(values),null,2)+'\n');
49
+ }
42
50
  catch(error){process.stderr.write(`connect: ${error.message}\n`);process.exitCode=1;}
51
+ } else if (process.argv[2] === 'disconnect') {
52
+ const {parseArgs}=await import('node:util');
53
+ const {disconnectWorkplace}=await import('../connection.mjs');
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
+ catch(error){process.stderr.write(`disconnect: ${error.message}\n`);process.exitCode=1;}
43
56
  } else if (process.argv[2] === 'agent-channel') {
44
57
  await import('./agent-channel.mjs');
45
58
  } else {
@@ -2,7 +2,7 @@
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
4
  import { agentScope, localCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
5
- import { event, json, tx, view } from './store.mjs';
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';
8
8
 
@@ -13,7 +13,7 @@ function admitsScope(board,target,org){
13
13
  }
14
14
 
15
15
  export async function declareCardAgentEffect(board,effect) {
16
- const task=view(board,db=>db.prepare('SELECT t.*,c.subtask_of,c.archived,c.sponsor FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(effect.id));
16
+ const task=view(board,db=>db.prepare('SELECT t.*,c.subtask_of,c.archived FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(effect.id));
17
17
  if(!task||task.subtask_of)return true;
18
18
  const entry=workflowFor(board.root),profile=task.assignee??effect.profile??entry.params.default_assignee;
19
19
  // The reasons already told since the card's last declaration: each is recorded once, so a retry that alternates
@@ -28,7 +28,7 @@ export async function declareCardAgentEffect(board,effect) {
28
28
  org=scope.org;
29
29
  if(!admitsScope(board,effect.id,org))return true;
30
30
  const options={root:board.root,org:scope.org,server:scope.server,profiles:entry.profiles,
31
- profile,card:effect.id,role:effect.role,sponsor:task.sponsor??null,context,starterSession:effect.starter_session};
31
+ profile,card:effect.id,handle:cardHandleOf(board,effect.id),readableId:readableIdOf(board,effect.id),role:effect.role,context,starterSession:effect.starter_session};
32
32
  const agent=await localCardAgent(options);
33
33
  if(task.archived||['done','cancelled','archived'].includes(task.status))finishAgentAssignment(board.root,scope.org,agent.assignment,`card ${effect.id} ${task.status}`);
34
34
  // A daemon not running keeps the declaration here; every delivery pass hands it on again.
package/board/args.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // A workflow verb's arguments, as the CLI and the board's owner both read them.
2
2
  import { resolve } from 'node:path';
3
3
 
4
- export const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
5
- export const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
4
+ export const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding', 'source']);
5
+ export const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'subtasks', 'reclaim', 'dry-run',
6
6
  'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', 'side']);
7
7
  // flags that take every following word up to the next flag (Hermes's nargs='+')
8
8
  export const GREEDY = new Set(['ids', 'rm']);
@@ -0,0 +1,29 @@
1
+ // Native workflows declare orthogonal holds; Hermes declares lifecycle blocks.
2
+ // Readers must use the declaring workflow, including for legacy stored rows.
3
+ const parse = (value, fallback) => {
4
+ if (typeof value !== 'string') return value ?? fallback;
5
+ try { return JSON.parse(value) ?? fallback; } catch { return fallback; }
6
+ };
7
+
8
+ // The aliases belong to the board's task listings. Use the same hold precedence
9
+ // in SQL filters as in row projection, including a legacy blocked storage row.
10
+ export const cardStateSql = ({ nativeHolds = false } = {}) => nativeHolds ? `CASE
11
+ WHEN COALESCE(c.stage,t.status) IN ('done','cancelled','archived') OR t.status = 'archived' THEN t.status
12
+ WHEN c.paused IS NOT NULL AND c.paused != 'null' THEN 'paused'
13
+ WHEN CASE WHEN json_valid(c.blocked_by) THEN json_array_length(c.blocked_by) ELSE 0 END > 0 THEN 'blocked'
14
+ WHEN t.status IN ('paused','blocked') THEN CASE WHEN COALESCE(c.stage,t.status) IN ('paused','blocked') THEN 'todo' ELSE COALESCE(c.stage,t.status) END
15
+ ELSE t.status END` : 't.status';
16
+
17
+ export function cardState(task, { nativeHolds = false } = {}) {
18
+ const paused = parse(task.paused, null);
19
+ const blocked_by = parse(task.blocked_by, []);
20
+ const lane = task.stage ?? task.workflow_status ?? task.status;
21
+ const ended = ['done', 'cancelled', 'archived'].includes(lane) || task.status === 'archived';
22
+ const status = !nativeHolds ? task.status : ended ? task.status : paused ? 'paused' : blocked_by.length ? 'blocked'
23
+ : task.status === 'blocked' || task.status === 'paused' ? (['blocked', 'paused'].includes(lane) ? 'todo' : lane) : task.status;
24
+ return { ...task, status, paused, blocked_by };
25
+ }
26
+
27
+ export const holdDescription = (task) => task.status === 'paused' && task.paused
28
+ ? `paused by ${task.paused.by ?? 'unknown'}: ${task.paused.reason ?? 'reason not recorded'}`
29
+ : task.status === 'blocked' && task.blocked_by?.length ? `blocked by ${task.blocked_by.map(b => `${b.id}${b.reason ? `: ${b.reason}` : ''}`).join(', ')}` : null;
package/board/ci.mjs CHANGED
@@ -8,6 +8,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
10
  import { githubFetch } from './github-rest.mjs';
11
+ import { cardHandleOf } from './store.mjs';
11
12
 
12
13
  // GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
13
14
  // waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
@@ -285,8 +286,10 @@ const originOf = async (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/
285
286
  async function repositoryWorkspace(board, id) {
286
287
  const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
287
288
  const parent = task?.subtask_of && board.db.prepare('SELECT t.*,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(task.subtask_of);
288
- const workspace = task?.workspace_kind === 'scratch' ? parent : task;
289
- if (!workspace || workspace.workspace_kind === 'scratch') return null;
289
+ const owner = task?.workspace_kind === 'scratch' ? parent : task;
290
+ if (!owner || owner.workspace_kind === 'scratch') return null;
291
+ // the worktree's folder, when it is still to be made, is named for its card (workspace.mjs worktreeFolder)
292
+ const workspace = { ...owner, handle: cardHandleOf(board, owner.id) };
290
293
  const here = isLocalWorkspace(workspace);
291
294
  if (workspace.workspace_anchor) {
292
295
  // A portable remote declaration supplies the source identity. Reviewing
@@ -371,17 +374,31 @@ function registeredBranches(board, id) {
371
374
  if (!row.workspace_anchor || /^(\/|~|[A-Za-z]:[\\/])/.test(row.workspace_anchor)) continue;
372
375
  out.push({ repository: row.workspace_anchor.replace(/^github\.com\//i, '').replace(/\.git$/, ''), branch: row.branch_name });
373
376
  }
377
+ // and each repository and branch the arc declared as its own (arcSources)
378
+ for (const declared of arcSources(board.db, id)) if (!out.some((r) => r.repository.toLowerCase() === declared.repository.toLowerCase() && r.branch === declared.branch)) out.push(declared);
374
379
  return out;
375
380
  }
376
381
 
377
- /** Whether `head` is a branch named for card `id`: `wt/<id>` (or `wt/t-…`), alone or with a `-…`/`/…` suffix. */
382
+ /** The repositories and branches the arc itself declares it changes: its latest `comment --source owner/repo:branch`
383
+ * declaration (engine.comment), the arc's complete set. A multi-repository arc carries them on the arc card, and its
384
+ * review discovers and accepts PRs from them with no subtask registered in that repository (VO-2858, VO-2860). */
385
+ 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);
387
+ let declared = [];
388
+ try { declared = row ? JSON.parse(row.payload).branches : []; } catch { /* an unreadable declaration names none */ }
389
+ return declared.filter((b) => typeof b?.repository === 'string' && typeof b?.branch === 'string').map((b) => ({ repository: b.repository, branch: b.branch }));
390
+ }
391
+
392
+ /** Legacy branches carry the globally unique native id. Keyed handles are local to a board, so their branches
393
+ * count only by exact recorded repository/branch identity (openReviewPullRequests), never by a handle prefix. */
378
394
  export const namedWorkBranch = (id, head) => [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
379
395
 
380
- /** The branch names an arc declares as its work: its own and its subtasks' `--branch`, and each branch its latest
381
- * review request covered. With `namedWorkBranch`, these are the branches its PRs are opened from. */
396
+ /** The branch names an arc declares as its work: its own and its subtasks' `--branch`, the branches it declares as its
397
+ * sources (arcSources), and each branch its latest review request covered. With `namedWorkBranch`, these are the branches its PRs are opened from. */
382
398
  export function declaredWorkBranches(db, id) {
383
399
  const declared = db.prepare(`SELECT t.branch_name FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
384
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);
401
+ declared.push(...arcSources(db, id).map((b) => b.branch));
385
402
  const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
386
403
  let reviewed = [];
387
404
  try { reviewed = candidatePullRequests(row && JSON.parse(row.payload)?.ci).map((p) => p.branch); } catch { /* an unreadable payload names none */ }
@@ -405,6 +422,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
405
422
  const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
406
423
  || registered.some((r) => r.repository === repository && r.branch === head);
407
424
  const repositoriesToRead = [...new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))];
425
+ const answered = answeredChanges(board, id);
408
426
  const groups = await readTogether(repositoriesToRead, async repository => {
409
427
  const prs = [];
410
428
  const hasWorkBranches = repository === ownRepository || registered.some(r => r.repository === repository);
@@ -441,11 +459,12 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
441
459
  ...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
442
460
  ...registered.filter(r => r.repository === repository).map(r => r.branch),
443
461
  ])];
462
+ // A judged merged head is passed over, except while an answered changes-requested verdict makes it due again.
444
463
  const histories = await readTogether(historical, async headBranch => {
445
464
  if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
446
465
  return (await mergedBranchPullRequests(repository, headBranch, { fresh })).filter(pr =>
447
466
  !pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
448
- && pr.baseRefName === pr.defaultBranch && !judgedHead(board, id, pr.headRefOid));
467
+ && pr.baseRefName === pr.defaultBranch && (answered || !judgedHead(board, id, pr.headRefOid)));
449
468
  });
450
469
  for (const history of histories) for (const pr of history) {
451
470
  if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
@@ -454,8 +473,8 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
454
473
  });
455
474
  const prs = groups.flat();
456
475
  // A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
457
- // discovery cannot see; it never hides what discovery found. Only repositories the card's workspace or registered
458
- // branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
476
+ // discovery cannot see; it never hides what discovery found. Only repositories the card's workspace, its declared
477
+ // sources or its tasks' registered branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
459
478
  // declared one, so a push after the declaration is reviewed instead of failing every tick.
460
479
  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);
461
480
  const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
@@ -475,6 +494,8 @@ export async function noCodeChange(board, id) {
475
494
  const taskRow = board.db.prepare('SELECT workspace_kind FROM tasks WHERE id=?').get(id);
476
495
  if (taskRow?.workspace_kind === 'scratch') {
477
496
  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;
497
+ // a scratch arc that declares repositories it changes carries code, never a disposition
498
+ if (arcSources(board.db, id).length) return null;
478
499
  // Scratch declares an operational disposition rather than repository code.
479
500
  // Closed tasks/results and the retained card thread are the review source;
480
501
  // this does not invent a verdict or assert that an external act succeeded.
@@ -509,14 +530,24 @@ async function knownRepositories({ fresh = false } = {}) {
509
530
  catch (error) { if (fresh) throw error; return []; }
510
531
  }
511
532
 
512
- /** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
513
- * Before any verdict every unjudged head is. After a verdict, a PR it judged with a new head is, and so is a head
514
- * committed after that verdict (a new PR carrying the fixes, or any PR after a verdict that named none). After changes
515
- * are requested, a PR that joins with a head committed before the verdict is not: the next run is the implementer's
516
- * until it pushes. After a pass nothing is owed by the implementer, so a PR that verdict's request did not name is due
517
- * at whatever head it has: one that was a draft or stacked on another PR's branch when the request was made, and
518
- * became the arc's once that PR merged (t_bbbd2086's #1355, committed 2 h before the pass that named only #1353). */
533
+ /** Whether the arc's open PRs (`[{ url, head }]`) call for its review: its last verdict's findings are answered, or
534
+ * some head no verdict judged is new work.
535
+ * After changes are requested, the verdict is answered once every task it owns has been closed (`answeredChanges`):
536
+ * its review is due again at whatever heads the PRs have, the same ones included, since a finding about evidence (a
537
+ * recording, a card comment) is answered without a commit (VO-2858 t_cc590655: its one finding, t_95c21edb, was closed
538
+ * at 22:08Z on 2026-10-17 with all three heads unchanged, and the arc waited on "push a commit" until the manager's
539
+ * reopen_review).
540
+ * Before any verdict every unjudged head is new work. After a verdict, a PR it judged with a new head is, and so is a
541
+ * head committed after that verdict (a new PR carrying the fixes, or any PR after a verdict that named none). After
542
+ * changes are requested, a judged PR's new head whose source tree is the one the verdict judged is not new work (an
543
+ * empty commit answers nothing: VO-2858's 59f38683 carried db7d19a0's tree and was requested at once), and a PR that
544
+ * joins with a head committed before the verdict is not: the next run is the implementer's until it answers. After a
545
+ * pass nothing is owed by the implementer, so any new head of a PR it judged is due (acceptance needs the verdict on
546
+ * the current heads), and a PR that verdict's request did not name is due at whatever head it has: one that was a draft
547
+ * or stacked on another PR's branch when the request was made, and became the arc's once that PR merged (t_bbbd2086's
548
+ * #1355, committed 2 h before the pass that named only #1353). */
519
549
  export async function reviewDue(board, id, prs) {
550
+ if (prs.length && answeredChanges(board, id)) return true;
520
551
  const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
521
552
  if (!unjudged.length) return false;
522
553
  const voided = board.db.prepare("SELECT r.payload FROM task_events v JOIN task_events r ON r.id=json_extract(v.payload,'$.review_request') AND r.task_id=v.task_id WHERE v.task_id=? AND v.kind='review_voided' ORDER BY v.id DESC LIMIT 1").get(id);
@@ -530,23 +561,50 @@ export async function reviewDue(board, id, prs) {
530
561
  ORDER BY r.id DESC LIMIT 1`).get(id);
531
562
  if (!last) return true;
532
563
  const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
533
- if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
534
- if (last.verdict === 'review_pass' && unjudged.some((p) => !judged.some((j) => j.pr.url === p.url))) return true;
535
- for (const p of unjudged) if ((await commitTime(p)) > Number(last.verdict_at)) return true;
564
+ const judgedOf = (p) => judged.find((j) => j.pr.url === p.url) ?? null;
565
+ for (const p of prs) {
566
+ const j = judgedOf(p);
567
+ if (!j || p.head === j.commit) continue;
568
+ if (last.verdict === 'review_pass' || !await sameTree(p, j.commit)) return true;
569
+ }
570
+ if (last.verdict === 'review_pass' && unjudged.some((p) => !judgedOf(p))) return true;
571
+ // a PR the verdict judged was answered above, by its tree; any other is new work when committed after the verdict
572
+ for (const p of unjudged) if (!judgedOf(p) && (await commitTime(p)) > Number(last.verdict_at)) return true;
536
573
  return false;
537
574
  }
538
575
 
539
- const commitTimes = new Map();
540
- /** Cache successful immutable commit dates only. An unread date is unknown, never evidence of old work. */
541
- async function commitTime({ url, head }) {
542
- const key = `${githubBase()}\0${repositoryOf(url)}\0${head}`;
543
- if (commitTimes.has(key)) return commitTimes.get(key);
544
- const at = Math.floor(Date.parse((await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit?.committer?.date) / 1000);
545
- if (!Number.isFinite(at)) throw new Error(`GitHub commit date for ${url} at ${head} is unreadable; review eligibility is unknown`);
546
- commitTimes.set(key, at);
547
- return at;
576
+ /** Whether the arc's last review event is changes requested and every task that verdict owns is closed now. A verdict
577
+ * owns the tasks filed or reopened between the request it answered and itself (`request-changes --finding` files each
578
+ * finding as a task just before the verdict; a reviewer may reopen one instead). A verdict that owns no task is never
579
+ * answered this way: only a new head is. Task state is read as every board reader reads it (statusOf: the card's
580
+ * workflow stage over its stored status). */
581
+ export function answeredChanges(board, id) {
582
+ const db = board.db;
583
+ const verdict = db.prepare("SELECT id, kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided') ORDER BY id DESC LIMIT 1").get(id);
584
+ if (verdict?.kind !== 'changes_requested') return false;
585
+ const asked = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_requested' AND id < ?").get(id, verdict.id)?.id ?? 0;
586
+ const owned = db.prepare(`SELECT COALESCE(c.stage, t.status) AS status FROM supercode_cards c JOIN tasks t ON t.id = c.task_id
587
+ WHERE c.subtask_of = ? AND EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = c.task_id AND e.kind IN ('created', 'reopened') AND e.id > ? AND e.id < ?)`).all(id, asked, verdict.id);
588
+ return owned.length > 0 && owned.every((t) => ['done', 'cancelled', 'archived'].includes(t.status));
548
589
  }
549
590
 
591
+ const commitReads = new Map();
592
+ /** A commit's date and source tree, read once: both are immutable. Only a successful read is cached; an unread commit
593
+ * is unknown, never evidence of old work or of a changed tree. */
594
+ async function commitRead({ url, head }) {
595
+ const key = `${githubBase()}\0${repositoryOf(url)}\0${head}`;
596
+ if (commitReads.has(key)) return commitReads.get(key);
597
+ const commit = (await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit;
598
+ const at = Math.floor(Date.parse(commit?.committer?.date) / 1000), tree = commit?.tree?.sha;
599
+ if (!Number.isFinite(at) || typeof tree !== 'string' || !tree) throw new Error(`GitHub commit ${head} of ${url} is unreadable; review eligibility is unknown`);
600
+ const read = { at, tree };
601
+ commitReads.set(key, read);
602
+ return read;
603
+ }
604
+ const commitTime = async (p) => (await commitRead(p)).at;
605
+ /** Whether the PR's head carries the same source tree as `commit` of the same PR. */
606
+ const sameTree = async (p, commit) => (await commitRead(p)).tree === (await commitRead({ url: p.url, head: commit })).tree;
607
+
550
608
  /** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
551
609
  async function requireCurrentReviewSet(board, id, urls) {
552
610
  const current = await openReviewPullRequests(board, id, { fresh: true });
@@ -620,7 +678,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
620
678
  const registered = registeredBranches(board, id);
621
679
  const declared = (pr) => namedWorkBranch(id, pr.headRefName) || registered.some((r) => r.branch === pr.headRefName && r.repository.toLowerCase() === repositoryOf(pr.url).toLowerCase());
622
680
  const stray = read.find((pr) => pr.state !== 'MERGED' && !declared(pr));
623
- if (stray) throw new Error(`An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; register it with create "<what>" --subtask-of ${id} --workspace worktree:${repositoryOf(stray.url)} --branch ${stray.headRefName}`);
681
+ if (stray) throw new Error(`An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; declare it on the arc: comment ${id} "<why>" --source ${repositoryOf(stray.url)}:${stray.headRefName} (with every other source the arc changes)`);
624
682
  }
625
683
  // A verdict with no requested PR (a scratch arc) keeps its own contract. An arc's review
626
684
  // always names every opened PR it changed, independent of deployment.
@@ -635,7 +693,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
635
693
  const prs = await mapGithubSources(urls, (url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage, read[index]));
636
694
  if (stage === 'request_review') {
637
695
  // A review is asked for only while some PR has a head no verdict has judged.
638
- if (!await reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('The PRs the last verdict judged have no new head; push a new head before requesting review.');
696
+ if (!await reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('Every PR head has a verdict, carries the source tree its verdict judged, or predates it: push a commit that changes the source, or close the tasks that own the last verdict\'s findings, before requesting review.');
639
697
  } else if (asked) {
640
698
  const requested = candidatePullRequests(asked);
641
699
  if (requested.length !== prs.length || requested.some((r) => !prs.some((p) => p.pr.url === r.pr.url && p.commit === r.commit))) throw new Error('The verdict covers the requested PRs at their requested heads; a PR whose head moved needs a new review request.');