@volter/supercode-orchestrator 0.4.148 → 0.4.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/board/engine.mjs CHANGED
@@ -37,7 +37,7 @@ export function getTask(db, id) {
37
37
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
38
38
  if (!row) return null;
39
39
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
40
- return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, review_only: Boolean(ours?.review_only), blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
40
+ return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
41
41
  }
42
42
 
43
43
  function must(db, id) {
@@ -152,10 +152,36 @@ function projectOf(root, idOrSlug) {
152
152
  } catch { return null; } finally { db.close(); }
153
153
  }
154
154
 
155
+ /**
156
+ * A card's Tasks are its subtasks (the document renders them as its Tasks section). Task lines written in an arc's
157
+ * body (`## Tasks` then `- [ ] step`, its indented lines the step's own text) are answered as the steps to file as
158
+ * its subtasks, and leave the body. A Tasks section with no task line is the body's own text.
159
+ */
160
+ export function bodyTasks(body) {
161
+ const lines = String(body ?? '').split('\n');
162
+ const at = lines.findIndex((line) => /^#{2,3}\s+Tasks\s*$/i.test(line));
163
+ if (at < 0) return { body, tasks: [] };
164
+ let end = lines.findIndex((line, i) => i > at && /^#{1,3}\s/.test(line));
165
+ if (end < 0) end = lines.length;
166
+ const section = lines.slice(at + 1, end);
167
+ if (!section.some((line) => /^[-*] \[[ xX]\]/.test(line))) return { body, tasks: [] };
168
+ const tasks = [];
169
+ for (const line of section) {
170
+ if (!line.trim()) continue;
171
+ const step = /^[-*] \[ \]\s+(\S.*)$/.exec(line);
172
+ if (step) tasks.push({ title: step[1].trim(), body: [] });
173
+ else if (/^\s+\S/.test(line) && tasks.length) tasks.at(-1).body.push(line.replace(/^\s{1,4}/, ''));
174
+ else throw new Error("A card's Tasks section holds open task lines ('- [ ] <step>', each filed as a subtask, with its own indented lines); record finished work as a comment and move other text out of the section");
175
+ }
176
+ const rest = [...lines.slice(0, at), ...lines.slice(end)].join('\n').replace(/\n{3,}/g, '\n\n').trim();
177
+ return { body: rest || null, tasks: tasks.map((t) => ({ title: t.title, body: t.body.join('\n') || null })) };
178
+ }
179
+
155
180
  export function createCard(board, spec) {
156
- if (spec.reviewOnly !== undefined && typeof spec.reviewOnly !== 'boolean') throw new Error('reviewOnly must be a boolean');
157
181
  const title = String(spec.title ?? '').trim();
158
182
  if (!title) throw new Error('a card has a title');
183
+ const steps = spec.subtaskOf ? [] : bodyTasks(spec.body).tasks;
184
+ if (steps.length) spec = { ...spec, body: bodyTasks(spec.body).body };
159
185
  const ws = parseWorkspace(spec.workspace ?? 'scratch');
160
186
  if (spec.branch && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
161
187
  const parents = [...new Set((spec.parents ?? []).filter(Boolean))];
@@ -184,10 +210,6 @@ export function createCard(board, spec) {
184
210
  if (missing.length) throw new Error(`unknown parent card(s): ${missing.join(', ')}`);
185
211
  const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
186
212
  const entry = workflowFor(board.root);
187
- if (spec.reviewOnly) {
188
- const permitted = entry.params.review_only_by;
189
- if (arc || spec.every || initial || !Array.isArray(permitted) || !callerRoles(db, arc?.id ?? '').some(role => permitted.includes(role))) throw new Error('Only a permitted manager creates a review-only arc, without recurrence, subtasks or an initial-status override.');
190
- }
191
213
  if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
192
214
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
193
215
  let id = newTaskId();
@@ -216,13 +238,13 @@ export function createCard(board, spec) {
216
238
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
217
239
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
218
240
  const machine = spec.machine ?? arc?.machine ?? null;
219
- run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,review_only) VALUES (?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null, spec.reviewOnly ? 1 : 0);
241
+ run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root) VALUES (?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null);
220
242
  for (const p of parents) addDependency(db, entry, id, p);
221
243
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
222
244
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
223
245
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
224
- review_only: Boolean(spec.reviewOnly), every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
225
- event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, review_only: Boolean(spec.reviewOnly), parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
246
+ every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
247
+ event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
226
248
  if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
227
249
  if (parents.length) inheritSubscriptions(db, id, parents);
228
250
  if (arc) {
@@ -232,7 +254,9 @@ export function createCard(board, spec) {
232
254
  }
233
255
  if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
234
256
  else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
235
- return { id, existing: false };
257
+ // the body's task lines, as subtasks the creator filed, in this same write
258
+ const subtasks = steps.map((step) => createCard(board, { ...step, subtaskOf: id, assignee: spec.assignee || null }).id);
259
+ return { id, existing: false, ...(subtasks.length ? { subtasks } : {}) };
236
260
  });
237
261
  }
238
262
 
@@ -345,8 +369,9 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
345
369
  const entry = await loadWorkflow(board.root);
346
370
  if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
347
371
  refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
348
- if (['complete', 'request_review', 'request_state'].includes(name)) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id))) };
349
- if (payload.ci?.required && !payload.ci.ready) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
372
+ const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
373
+ if (evidencePhase) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
374
+ if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
350
375
  const result = tx(board, (db) => {
351
376
  roles ??= callerRoles(db, id);
352
377
  const r = fire(db, board, entry, id, name, { roles, payload, extra });
@@ -368,9 +393,14 @@ export async function requestStateMany(board, ids, payload = {}) {
368
393
  const entry = await loadWorkflow(board.root);
369
394
  const prepared = new Map(targets.map(id => {
370
395
  const roles = callerRoles(board.db, id);
371
- return [id, inspectCompletionEvidence(board, id, entry, payload, completionPhase(board, id, entry, 'request_state', payload, roles))];
396
+ const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
397
+ const evidence = inspectCompletionEvidence(board, id, entry, payload, phase);
398
+ // An ordinary request mails a decision to the manager, not a completion.
399
+ // Keep a failed source observation in that mail. A reviewer recording an
400
+ // actual verdict still has to verify the requested PR head.
401
+ if (phase === 'review_verdict' && evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
402
+ return [id, evidence];
372
403
  }));
373
- for (const evidence of prepared.values()) if (evidence.required && !evidence.ready) throw new Error(evidence.reason ?? 'The reviewed PR head could not be verified.');
374
404
  const result = tx(board, db => {
375
405
  const effects = [];
376
406
  const results = targets.map(id => withCompletionEvidence(board, id, prepared.get(id), () => {
@@ -494,7 +524,7 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
494
524
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
495
525
  export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
496
526
  export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
497
- export function specify(board, id, { title = null, body = null, workspace = null, branch = null, reviewOnly = null, author = 'user', roles } = {}) {
527
+ export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, author = 'user', roles } = {}) {
498
528
  let declaration = null;
499
529
  if (workspace != null || branch != null) {
500
530
  const task = getTask(board.db, id);
@@ -507,8 +537,11 @@ export function specify(board, id, { title = null, body = null, workspace = null
507
537
  if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
508
538
  if (declaration.kind === 'dir') declaration.path = checkWorkspace(board, { ...task, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch }).path;
509
539
  }
510
- if (reviewOnly != null && (typeof reviewOnly !== 'boolean' || !Array.isArray(workflowFor(board.root).params.review_only_by))) throw new Error('This workflow must declare review_only_by before a boolean review-only declaration is changed.');
511
- return act(board, id, 'specify', { roles, payload: { title, body, author, ...(reviewOnly != null ? { review_only: reviewOnly } : {}), ...(declaration ? { workspace: declaration } : {}) } });
540
+ // an arc's new task lines are filed as its subtasks once the specify itself is accepted
541
+ const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
542
+ const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, author, ...(declaration ? { workspace: declaration } : {}) } });
543
+ for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
544
+ return done;
512
545
  }
513
546
  export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
514
547
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
@@ -665,7 +698,6 @@ export function cardContext(db, id) {
665
698
  const task = must(db, id);
666
699
  const out = [`# Card ${task.id}: ${task.title}`, ''];
667
700
  out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_anchor ? ` ${task.workspace_anchor}` : task.workspace_path ? ` ${task.workspace_path}` : ''}${task.branch_name ? ` (branch ${task.branch_name})` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
668
- if (task.review_only) out.push('Purpose: review-only. Record the independent verdict with request-state --state done --reason <verdict> --evidence <artifact>; its creator or manager accepts this review job.');
669
701
  if (task.body) out.push('', cap(task.body, 8 * 1024));
670
702
  const parents = all(db, 'SELECT t.id, t.title, t.result FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?', id);
671
703
  if (parents.length) {
package/board/store.mjs CHANGED
@@ -73,7 +73,7 @@ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY K
73
73
  CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
74
74
  // `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
75
75
  // `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
76
- const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', review_only: 'INTEGER DEFAULT 0', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER' };
76
+ const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER' };
77
77
 
78
78
  // ------------------------------------------------------------------ backings
79
79
  //
@@ -172,18 +172,21 @@ export const tx = (board, fn) => {
172
172
  export const view = (board, fn) => fn(board.db);
173
173
 
174
174
  // A deferred native transaction, with the whole operation retried on an optimistic conflict.
175
- // There is no separate application writer lock or contention timeout.
175
+ // There is no separate application writer lock or contention timeout. A write inside one is part of it.
176
+ const inTx = new WeakSet();
176
177
  function sqliteTx(db, fn) {
178
+ if (inTx.has(db)) return fn(db);
177
179
  for (;;) {
178
180
  try {
179
181
  db.exec('BEGIN');
182
+ inTx.add(db);
180
183
  const out=fn(db);
181
184
  db.exec('COMMIT');
182
185
  return out;
183
186
  } catch(error) {
184
187
  try {db.exec('ROLLBACK');} catch {}
185
188
  if (!/locked|busy/i.test(error.message))throw error;
186
- }
189
+ } finally { inTx.delete(db); }
187
190
  }
188
191
  }
189
192
 
package/board/tools.mjs CHANGED
@@ -80,7 +80,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
80
80
  return JSON.stringify({
81
81
  task: { id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, tenant: t.tenant, priority: t.priority, workspace_kind: t.workspace_kind,
82
82
  workspace_path: t.workspace_path, created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
83
- current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override, review_only: Boolean(t.review_only) },
83
+ current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override },
84
84
  parents: card.parents.map((p) => p.id), children: card.children,
85
85
  comments: card.comments, events: card.events.slice(-50), runs: card.runs.map((r) => ({ id: r.id, profile: r.profile, status: r.status, outcome: r.outcome, summary: r.summary, error: r.error, metadata: r.metadata, started_at: r.started_at, ended_at: r.ended_at })),
86
86
  worker_context: card.context,
@@ -176,7 +176,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
176
176
  title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
177
177
  priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
178
178
  maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
179
- goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author, reviewOnly: args.review_only ?? false,
179
+ goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
180
180
  });
181
181
  return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
182
182
  }
@@ -104,7 +104,8 @@ function pattern(p) {
104
104
  }
105
105
 
106
106
  // the time of the latest commit that names `id` in its message, on any ref of the repository at `path` (a worktree's
107
- // pushes land on the shared remote-tracking refs), or null (asked only when an expression does)
107
+ // pushes land on the shared remote-tracking refs), or null (asked only when an expression does). Merge commits are
108
+ // not the card's work: merging its reviewed PR, or main into its branch, is not a newer change to review.
108
109
  const commitTimes = new Map();
109
110
  function observeLastCommit(path, id) {
110
111
  if (!path) return null;
@@ -112,7 +113,7 @@ function observeLastCommit(path, id) {
112
113
  const cached = commitTimes.get(key);
113
114
  if (cached && now() - cached.at < 15) return cached.value;
114
115
  const readCommit = (repo) => {
115
- const r = spawnSync('git', ['-C', repo, 'log', '--all', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
116
+ const r = spawnSync('git', ['-C', repo, 'log', '--all', '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`], { encoding: 'utf8', timeout: 10_000 });
116
117
  const value = Number(r.stdout?.trim());
117
118
  return { repo: r.status === 0, value: Number.isFinite(value) && value > 0 ? value : null };
118
119
  };
@@ -201,11 +202,8 @@ function onlyTicks(was, body) {
201
202
 
202
203
  /** Seconds a card's runs spent by role, and the rest of its life as `waiting`. */
203
204
  export function runWorkingSeconds(row, at = now()) {
204
- const metadata = typeof row.metadata === 'string' ? json(row.metadata, {}) : row.metadata;
205
- const session = metadata?.supercode ?? {};
206
205
  const end = Number(row.ended_at ?? at);
207
- const waiting = Number(session.wait_seconds ?? 0) + (session.wait_started_at != null ? Math.max(0, end - Number(session.wait_started_at)) : 0);
208
- return Math.max(0, end - Number(row.started_at ?? end) - waiting);
206
+ return Math.max(0, end - Number(row.started_at ?? end));
209
207
  }
210
208
 
211
209
  function timeOf(db, task) {
@@ -250,7 +248,6 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
250
248
  ...task,
251
249
  creator_address: creatorOf(db, task.id),
252
250
  completion_owner_address: completionOwnerOf(db, task.id),
253
- review_only: Boolean(state.review_only),
254
251
  latest_review: latestReview(db, task.id) ?? {},
255
252
  review_candidate: reviewCandidate(board, task.id) ?? {},
256
253
  ci: completionEvidence(board, task.id),
@@ -363,6 +360,11 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
363
360
  cancellation?.outcome === 'not_issued' && cancellation?.live === false && cancellation?.pane === null) return;
364
361
  const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
365
362
  if (!address && !session?.launch_key && !session?.pane) return;
363
+ // The session that made this change learns it from its own command's answer; a notice about
364
+ // its own act would only hold its close until the notice is confirmed. A session whose close is
365
+ // confirmed is not told either: its next run resumes it with the card's state in its prompt.
366
+ if (address && address === actorOf()) return;
367
+ if (session?.close_confirmed_at) return;
366
368
  const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
367
369
  const body = render(entry.workflow.notify?.worker?.run_changed ?? '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}', scope);
368
370
  const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
@@ -642,17 +644,6 @@ const ACTIONS = {
642
644
  const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
643
645
  ctx.effects.push(...result.effects);
644
646
  },
645
- wait_run(ctx, waiting) {
646
- if (typeof waiting !== 'boolean') throw new Error('wait_run requires a boolean');
647
- const at = now();
648
- runSession(ctx, session => {
649
- if (waiting) return { ...session, wait_started_at: session.wait_started_at ?? at };
650
- if (session.wait_started_at == null) return session;
651
- return { ...session, wait_started_at: null,
652
- wait_seconds: Number(session.wait_seconds ?? 0) + Math.max(0, at - Number(session.wait_started_at)),
653
- goal_nudged_at: at };
654
- });
655
- },
656
647
  record(ctx, args, scope) {
657
648
  const kind = typeof args === 'string' ? args : args?.kind;
658
649
  const given = typeof args === 'object' && args?.payload ? args.payload : null;
@@ -772,12 +763,6 @@ const ACTIONS = {
772
763
  run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
773
764
  },
774
765
  specify(ctx, said) {
775
- if (said?.review_only != null) {
776
- const task = cardOf(ctx.db, ctx.id);
777
- if (typeof said.review_only !== 'boolean' || task.current_run_id || task.subtask_of || ctx.scope.card.every) throw new Error('A review-only declaration changes only an arc with no current run or recurrence.');
778
- cardField(ctx, 'review_only', said.review_only ? 1 : 0);
779
- record(ctx.db, ctx.id, 'review_mode_changed', { review_only: said.review_only }, null);
780
- }
781
766
  if (said?.workspace) {
782
767
  const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
783
768
  if (task.current_run_id) throw new Error('end the current run before changing its workspace');
@@ -867,6 +852,8 @@ const ACTIONS = {
867
852
  requester: actorOf() ?? ctx.scope.requester,
868
853
  window_seconds: Math.max(1, Math.min(60, Number(ctx.entry.params.request_batch_seconds) || 10)),
869
854
  } : null;
855
+ const observation = ctx.scope.event?.ci;
856
+ if (batch && observation?.required && !observation.ready) text = `${String(text ?? '')}\nSource evidence could not be verified: ${observation.reason ?? 'unavailable'}. The decision request is retained.`;
870
857
  record(ctx.db, ctx.id, 'board_message', { to: String(to), text: batch ? String(text ?? '') : `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id, ...(batch ? { batch } : {}) }, null);
871
858
  ctx.effects.push({ kind: 'board_message', id: ctx.id });
872
859
  },
@@ -1041,13 +1028,19 @@ function settleRelations(db, board, entry, id, effects) {
1041
1028
  }
1042
1029
 
1043
1030
 
1044
- export function projectStatus(db, board, entry, id) {
1045
- if (!entry.workflow.stored_as?.length) return;
1031
+ /** A card's workflow status and the status Hermes's `tasks.status` stores it as, read without writing. */
1032
+ export function storedStatus(db, board, entry, id) {
1046
1033
  const task = cardOf(db, id);
1047
1034
  const status = statusOf(db, task);
1035
+ if (!entry.workflow.stored_as?.length) return { status, stored: task.status };
1048
1036
  const scope = scopeOf(db, board, task, { params: entry.params });
1049
1037
  const rule = entry.workflow.stored_as.find((r) => holds(r.guard, scope));
1050
- const stored = rule?.status ?? storedAs(entry.workflow, status);
1038
+ return { status, stored: rule?.status ?? storedAs(entry.workflow, status) };
1039
+ }
1040
+
1041
+ export function projectStatus(db, board, entry, id) {
1042
+ if (!entry.workflow.stored_as?.length) return;
1043
+ const { status, stored } = storedStatus(db, board, entry, id);
1051
1044
  run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
1052
1045
  run(db, 'INSERT INTO supercode_cards (task_id, stage) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage', id, status);
1053
1046
  }
@@ -1,5 +1,7 @@
1
1
  // Filesystem/RPC work happens before a board transaction; the ownership check is pure.
2
2
  import { spawnSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { basename, dirname } from 'node:path';
3
5
  import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
4
6
  import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
5
7
  export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '');
@@ -28,6 +30,17 @@ export function declareWorkspace(kind, value, machine, branch = null) {
28
30
  const row = workspaceDoor(machine, 'register', value);
29
31
  return { repository: row.repository, branch: branch ?? (kind === 'dir' ? row.branch : null), path: kind === 'dir' ? row.path : null };
30
32
  }
33
+ /** The checkouts this machine keeps of any repository on `branch`: where an arc changing several repositories
34
+ * keeps its work in each of them (its own worktree aside). Another machine's map is not read from here. */
35
+ export function branchCheckouts(machine, branch) {
36
+ if (!branch || (machine && normalMachine(machine) !== localMachine())) return [];
37
+ return new Workspaces(teamsHome()).list().filter((row) => (branch === '*' || row.branch === branch) && existsSync(row.path));
38
+ }
39
+ /** The registered checkout of `repository` on `branch` on `machine`: where a card changing several repositories
40
+ * keeps its work in each of them. */
41
+ export function checkoutOf(machine, repository, branch) {
42
+ return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
43
+ }
31
44
  export function resolveWorkspace(task, create = false) {
32
45
  const declared = task.workspace_anchor ?? task.workspace_path;
33
46
  if (isWorkspacePath(declared)) {
@@ -36,6 +49,19 @@ export function resolveWorkspace(task, create = false) {
36
49
  if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
37
50
  throw new Error('legacy worktree path needs registration and a repository declaration before launch');
38
51
  }
52
+ // A primary checkout can change when another clone is registered. That does
53
+ // not move an already allocated card worktree. Machine moves and workspace
54
+ // retargets clear this runtime receipt; never borrow an arbitrary checkout.
55
+ if (task.workspace_kind === 'worktree' && task.workspace_path &&
56
+ basename(task.workspace_path) === task.id && basename(dirname(task.workspace_path)) === '.worktrees' &&
57
+ (!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
58
+ const cached = workspaceDoor(task.machine, 'inspect', task.workspace_path);
59
+ if (cached.linked && basename(cached.path) === task.id && basename(dirname(cached.path)) === '.worktrees' &&
60
+ cached.repository === repositoryId(declared) && cached.branch === (task.branch_name ?? `wt/${task.id}`)) {
61
+ return { ...cached, root: dirname(dirname(cached.path)) };
62
+ }
63
+ throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${task.branch_name ?? `wt/${task.id}`}; retarget its workspace explicitly`);
64
+ }
39
65
  return workspaceDoor(task.machine, 'resolve', declared, { branch: task.branch_name, solo: task.workspace_kind === 'worktree', card: task.id, create });
40
66
  }
41
67
  export function checkWorkspace(board, task) {