@volter/supercode-orchestrator 0.5.206 → 0.5.208

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/tools.mjs CHANGED
@@ -43,7 +43,9 @@ export async function boardTool(root, name, args = {}, env = {}) {
43
43
  const slug = args.board || env.HERMES_KANBAN_BOARD || DEFAULT_BOARD;
44
44
  // the board's workflow decides what each tool's event does; the worker is its run's role on its own task, and
45
45
  // nobody on another (Hermes's worker scoping)
46
- try { await loadWorkflow(root); } catch (e) { return error(e.message); }
46
+ let entry;
47
+ try { entry = await loadWorkflow(root); } catch (e) { return error(e.message); }
48
+ const stateOptions = { nativeHolds: Boolean(entry.workflow.flags?.paused) };
47
49
  engine.setCaller((db, cardId) => {
48
50
  if (!own || !runId) return own ? [] : ['manager'];
49
51
  const meta = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode ?? {};
@@ -78,13 +80,13 @@ export async function boardTool(root, name, args = {}, env = {}) {
78
80
  if (name !== 'kanban_show' && name !== 'kanban_list' && !(name === 'kanban_comment' && isLatestReviewer(board.db, tid, actorOf()))) { const stale = ownRun(); if (stale) return error(stale); }
79
81
  switch (name) {
80
82
  case 'kanban_show': {
81
- const card = tx(board, (db) => { const c = engine.showCard(db, tid); c.context = engine.cardContext(db, tid); return c; });
83
+ const card = tx(board, (db) => { const c = engine.showCard(db, tid, stateOptions); c.context = engine.cardContext(db, tid, stateOptions); return c; });
82
84
  const t = card.task;
83
85
  return JSON.stringify({
84
- 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,
86
+ task: { id: t.id, readable_id: t.readable_id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, tenant: t.tenant, priority: t.priority, workspace_kind: t.workspace_kind,
85
87
  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,
86
88
  current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override },
87
- parents: card.parents.map((p) => p.id), children: card.children,
89
+ parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks,
88
90
  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 })),
89
91
  worker_context: card.context,
90
92
  });
@@ -92,7 +94,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
92
94
  case 'kanban_list': {
93
95
  const limit = args.limit === undefined ? 100 : Number(args.limit);
94
96
  if (!Number.isInteger(limit) || limit < 1) return error('limit must be an integer >= 1');
95
- const cards = tx(board, (db) => engine.listCards(db, { status: args.status ?? null, assignee: args.assignee ?? null, includeArchived: Boolean(args.include_archived) }))
97
+ const cards = tx(board, (db) => engine.listCards(db, { ...stateOptions, status: args.status ?? null, assignee: args.assignee ?? null, includeArchived: Boolean(args.include_archived), includeSubtasks: Boolean(args.include_subtasks) }))
96
98
  .filter((t) => !args.tenant || t.tenant === args.tenant).slice(0, limit);
97
99
  return ok({ tasks: cards.map((t) => ({ id: t.id, title: t.title, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant, machine: t.machine })), count: cards.length });
98
100
  }
@@ -120,7 +122,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
120
122
  }
121
123
  case 'kanban_block': {
122
124
  const stale = ownRun(); if (stale) return error(stale);
123
- const { to: status } = await engine.block(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, on: args.on ?? null, waitingOn: args.waiting_on ?? null });
125
+ const { to: status } = await engine.block(board, tid, { kind: args.kind ?? 'needs_input', reason: args.reason ? redact(String(args.reason)) : null, on: args.on ?? null, waitingOn: args.waiting_on ?? null });
124
126
  return ok({ task_id: tid, status });
125
127
  }
126
128
  case 'kanban_request_state': {
@@ -135,7 +137,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
135
137
  }
136
138
  case 'kanban_request_changes': {
137
139
  const stale = ownRun(); if (stale) return error(stale);
138
- // each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
140
+ // each finding is filed as a task on the arc first: the arc's review is due again once every one is closed (ci.mjs answeredChanges)
139
141
  const findings = [].concat(args.findings ?? []).map((f) => redact(String(f)).trim()).filter(Boolean);
140
142
  const filed = findings.map((finding) => engine.createCard(board, { title: finding, subtaskOf: tid }).id);
141
143
  await engine.requestChanges(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, reviewer: author });
@@ -183,7 +185,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
183
185
  case 'kanban_create': {
184
186
  const workspace = args.workspace_kind ? `${args.workspace_kind}${args.workspace_path ? `:${args.workspace_path}` : ''}` : undefined;
185
187
  const made = engine.createCard(board, await engine.prepareCard(board, {
186
- title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, reviewer: args.reviewer ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
188
+ title: args.title, readableId: args.readable_id, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, reviewer: args.reviewer ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
187
189
  priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
188
190
  maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
189
191
  goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
@@ -0,0 +1,11 @@
1
+ // Shared by the bounded CLI reader and the engine's record listing. t and c are
2
+ // the task and its supercode state; the two parameters opt into subtasks/history.
3
+ export const CARD_VISIBILITY_SQL = `(? OR c.subtask_of IS NULL) AND (? OR (
4
+ t.status NOT IN ('cancelled', 'archived') AND COALESCE(c.stage, '') NOT IN ('cancelled', 'archived')
5
+ AND COALESCE(c.archived, 0) = 0
6
+ AND NOT EXISTS (
7
+ SELECT 1 FROM tasks arc LEFT JOIN supercode_cards state ON state.task_id = arc.id
8
+ WHERE arc.id = c.subtask_of AND (arc.status IN ('cancelled', 'archived')
9
+ OR state.stage IN ('cancelled', 'archived') OR state.archived = 1)
10
+ )
11
+ ))`;
@@ -1,3 +1,4 @@
1
+ import { allocateReadableId } from './readable-id.mjs';
1
2
  // The board's workflow, interpreted (docs/architecture/orchestrator.md §2.9). The IR's `Orchestration.workflow`
2
3
  // (crates/interchange/src/orchestration/workflow.rs; Hermes's dispatcher, hermes.workflow.yaml, when a home declares
3
4
  // none) says every status a card can be in, what runs there, which events move a card and who may send them, what
@@ -13,12 +14,12 @@ import { promisify } from 'node:util';
13
14
  import { existsSync, readdirSync, realpathSync, watch, readFileSync } from 'node:fs';
14
15
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
15
16
  import { randomBytes } from 'node:crypto';
16
- import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
17
+ import { actorOf, appendRunObservation, boardKey, cardHandleOf, event as record, json, now } from './store.mjs';
17
18
  import { releaseAllSideSessions } from './side-sessions.mjs';
18
19
  import { evaluate, holds, render, needsSources } from './expr.mjs';
19
20
  import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
20
21
  import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
21
- import { identifyNotice } from './notices.mjs';
22
+ import { cardNumber, identifyNotice, numbered } from './notices.mjs';
22
23
 
23
24
  const loaded = new Map();
24
25
  // the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
@@ -150,9 +151,9 @@ export function workflowEntry(orchestration) {
150
151
  }
151
152
 
152
153
  /** The workflow loaded for `root` (loadWorkflow first): the running call's basis, else the latest read. */
153
- export function workflowFor(root) {
154
+ export function workflowFor(root, { optional = false } = {}) {
154
155
  const entry = current().workflows.get(root) ?? loaded.get(root);
155
- if (!entry) throw new Error(`the board workflow of ${root} is not loaded`);
156
+ if (!entry && !optional) throw new Error(`the board workflow of ${root} is not loaded`);
156
157
  return entry;
157
158
  }
158
159
 
@@ -235,7 +236,8 @@ function pattern(p) {
235
236
  // ran: volter-org t_9a9c2a9c). A folder that is not there, a git that did not answer, a machine that could not be asked:
236
237
  // unknown, with why, and never "no commits".
237
238
  const commitTimes = new Map(); // `${path}:${id}` → { at, known, value, error }
238
- async function readCardCommit(path, id, branches) {
239
+ // A commit names the card by its id or by its keyed number (`VO-856`); a branch is the card's by its handle too.
240
+ async function readCardCommit(path, id, branches, handle = null) {
239
241
  if (!existsSync(path)) return { known: false, value: null, error: `no folder ${path} on this machine` };
240
242
  // awaited, never a wait that holds the event loop: `status` 0 or the exit code, as a synchronous run answered
241
243
  const git = (repo, args) => execFileAsync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout: 10_000 }).then(({ stdout }) => ({ status: 0, stdout }), (error) => ({ status: error.code ?? 1, stdout: error.stdout ?? '', timedOut: Boolean(error.killed) }));
@@ -251,7 +253,13 @@ async function readCardCommit(path, id, branches) {
251
253
  if (!refs.length) return { repo: true, known: true, value: null };
252
254
  const r = await git(repo, ['log', ...refs, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${id}`, '--']);
253
255
  if (r.status !== 0) return { repo: true, known: false, value: null, error: `git log ${r.timedOut ? 'did not answer within 10s' : 'failed'} in ${repo}` };
254
- const value = Number(r.stdout?.trim());
256
+ let value = Number(r.stdout?.trim());
257
+ const recorded = refs.filter(ref => branches.includes(short(ref)) && !['main', 'master', fallback].includes(short(ref)));
258
+ if (handle && recorded.length) {
259
+ const keyed = await git(repo, ['log', ...recorded, '--no-merges', '-1', '--format=%ct', '--fixed-strings', `--grep=${handle.toUpperCase()}`, '--']);
260
+ if (keyed.status !== 0) return { repo: true, known: false, value: null, error: `git log failed in ${repo}` };
261
+ value = Math.max(value || 0, Number(keyed.stdout?.trim()) || 0);
262
+ }
255
263
  return { repo: true, known: true, value: Number.isFinite(value) && value > 0 ? value : null };
256
264
  };
257
265
  const own = await readCommit(path);
@@ -271,15 +279,15 @@ async function readCardCommit(path, id, branches) {
271
279
  // a commit that was read stands; with none read, a child that could not be read leaves the whole reading unknown
272
280
  return value == null && unread ? { known: false, value: null, error: unread } : { known: true, value };
273
281
  }
274
- async function observeLastCommit(path, id, branches = [], machine = null) {
282
+ async function observeLastCommit(path, id, branches = [], machine = null, handle = null) {
275
283
  if (!path) return null;
276
284
  const key = `${path}:${id}`;
277
285
  const cached = commitTimes.get(key);
278
286
  if (cached && now() - cached.at < 15) return cached.value;
279
287
  let reading;
280
288
  try {
281
- if (isLocalWorkspace({ machine })) reading = await readCardCommit(path, id, branches);
282
- else reading = { known: true, value: (await cardCommitsOn(machine, path, id, branches)).at };
289
+ if (isLocalWorkspace({ machine })) reading = await readCardCommit(path, id, branches, handle);
290
+ else reading = { known: true, value: (await cardCommitsOn(machine, path, id, branches, handle?.toUpperCase() ?? null)).at };
283
291
  } catch (error) { reading = { known: false, value: null, error: String(error?.message ?? error).slice(0, 300) }; }
284
292
  commitTimes.set(key, { at: now(), ...reading });
285
293
  return reading.value;
@@ -313,7 +321,7 @@ export async function refreshCardInputs(board, id, { commits = false } = {}) {
313
321
  if (commits && !task.subtask_of) {
314
322
  const { path, machine } = commitSource(board.db, task);
315
323
  if(path)commitTimes.delete(`${path}:${id}`);
316
- await observeLastCommit(path,id,declaredWorkBranches(board.db,id),machine);
324
+ await observeLastCommit(path,id,declaredWorkBranches(board.db,id),machine,cardHandleOf(board,id));
317
325
  }
318
326
  }
319
327
 
@@ -333,6 +341,12 @@ export async function refreshRelationAnswers(board) {
333
341
  for (const row of board.db.prepare("SELECT e.payload FROM task_events e JOIN (SELECT task_id, MAX(id) id FROM task_events WHERE kind IN ('blocked','unblocked') GROUP BY task_id) last ON e.id=last.id JOIN tasks t ON t.id=e.task_id WHERE e.kind='blocked' AND t.status='blocked'").all()) {
334
342
  const id = json(row.payload, {})?.waiting_on; if (id) ids.add(id);
335
343
  }
344
+ // State requests stay live while the card runs, independently of a blocker.
345
+ // Read their exact question ids through the same mailbox receipt door.
346
+ for (const row of board.db.prepare("SELECT id FROM tasks WHERE current_run_id IS NOT NULL AND status NOT IN ('done','cancelled','archived')").all()) {
347
+ const request = stateRequestOf(board.db, row.id);
348
+ if (request?.message_id) ids.add(request.message_id);
349
+ }
336
350
  const list = [...ids];
337
351
  if (!list.length) return;
338
352
  // one read of this machine's daemon for every id (its header index answers, no process per message)
@@ -341,7 +355,9 @@ export async function refreshRelationAnswers(board) {
341
355
  if (!Array.isArray(rows)) throw new Error('the mail door returned no message list');
342
356
  for (const id of list) {
343
357
  const row = rows.find((item) => item?.id === id);
344
- mailboxAnswers.set(id, { answer: row?.replies?.[0]?.id ?? null, askedOf: row?.session ?? null });
358
+ mailboxAnswers.set(id, row?.found === true
359
+ ? { answer: row.replies?.[0]?.id ?? null, askedOf: row.session ?? null }
360
+ : { error: `the mailbox did not find question ${id}; its answer is unknown` });
345
361
  }
346
362
  } catch (error) { for (const id of list) mailboxAnswers.set(id, { error: `the answer to ${id} could not be read: ${String(error.message).slice(0, 200)}` }); }
347
363
  }
@@ -351,6 +367,18 @@ function answerTo(id) {
351
367
  return observed?.answer ?? null;
352
368
  }
353
369
 
370
+ // A manager's state act settles the request; a mailbox reply answers it without
371
+ // changing the card. Unknown mailbox reads never assert an unanswered question.
372
+ function stateRequestOf(db, id) {
373
+ const row = one(db, "SELECT id,payload,created_at FROM task_events WHERE task_id=? AND kind='state_requested' ORDER BY id DESC LIMIT 1", id);
374
+ if (!row || one(db, "SELECT 1 FROM task_events WHERE task_id=? AND id>? AND kind IN ('paused','resumed','blocked','unblocked','completed','cancelled','review_pass','reopened') LIMIT 1", id, row.id)) return null;
375
+ const payload = json(row.payload, {}) ?? {};
376
+ const question = payload.message_id ?? json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND id<? AND kind='board_message' AND json_extract(payload,'$.batch') IS NOT NULL ORDER BY id DESC LIMIT 1", id, row.id)?.payload, {})?.message_id;
377
+ const receipt = question ? mailboxAnswers.get(question) : null;
378
+ return { id: row.id, created_at: row.created_at, state: payload.state, reason: payload.reason, message_id: question ?? null,
379
+ answer_known: Boolean(receipt && !receipt.error), answer: receipt?.answer ?? null, error: receipt?.error ?? null };
380
+ }
381
+
354
382
  const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
355
383
  const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
356
384
  /** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
@@ -484,6 +512,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
484
512
  task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null; },
485
513
  // the message whose answer the standing block waits for (`block --waiting-on`), and that answer's id once it has landed
486
514
  get waiting_on() { return waitingOn(); },
515
+ get state_request() { return stateRequestOf(db, task.id); },
487
516
  answer: () => answerTo(waitingOn()),
488
517
  // the seconds its runs spent, by the role each run was for (implementer: coding, reviewer: reviewing, …), an open
489
518
  // run counted to now; `waiting` is the rest of its life since it was filed
@@ -593,7 +622,7 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
593
622
  const defaultNotice = noticeSession
594
623
  ? '{{card.id}}: verified adoption started run {{run.id}} ({{event.from}} → {{event.to}}). Follow the board’s current state before continuing.'
595
624
  : '{{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 ?? ""}}';
596
- const body = render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope);
625
+ const body = numbered(boardKey(board.root, board.slug), cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope));
597
626
  const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
598
627
  const text = identifyNotice({ id: source, kind: name, run_id: targetRun.id, created_at: now() }, body);
599
628
  const effect = { kind: 'session_notice', id: task.id, runId: targetRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
@@ -825,12 +854,14 @@ function dueAfter(db, workflow, task, status, scope) {
825
854
 
826
855
  /**
827
856
  * Where a new card starts: the first `initial` transition that holds (`event` is the create's own words), its status
828
- * set and its actions run (a goal card by the workflow's default, for one). Answers the status.
857
+ * set and its actions run (a goal card by the workflow's default, for one). Answers the status. A validator that fails
858
+ * refuses the create, as it refuses any other event (a home that keeps its root unassigned says so here).
829
859
  */
830
860
  export function startNew(db, board, entry, task, event) {
831
861
  const scope = scopeOf(db, board, task, { params: entry.params, event, extra: { lanes: lanesIn(entry) } });
832
862
  const t = entry.workflow.initial.find((x) => holds(x.guard ?? null, scope));
833
863
  if (!t?.target) throw new Error('the workflow says nowhere for a new card to start');
864
+ for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw new Error(render(v.message, scope));
834
865
  const status = String(templateValue(t.target, scope));
835
866
  if (!entry.workflow.statuses[status]) throw new Error(`the workflow starts a new card in ${status}, which it does not declare`);
836
867
  setStatus(db, entry.workflow, task.id, status);
@@ -887,7 +918,7 @@ const FORMAT = {
887
918
  model_override_set: ({ e }) => ({ model: (e.field === 'model' ? e.value : e.model) || null, provider: e.provider || null }),
888
919
  goal_set: ({ after }) => ({ goal_mode: Boolean(after.goal_mode), goal_max_turns: after.goal_mode ? after.goal_max_turns ?? null : null }),
889
920
  heartbeat: ({ e }) => (e.note || e.until ? { ...(e.note ? { note: e.note } : {}), ...(e.until ? { until: Number(e.until) } : {}) } : null),
890
- commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length, ...(e.sources ? { sources: e.sources } : {}) }),
921
+ commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length, ...(e.sources ? { sources: e.sources } : {}), ...(e.branches ? { branches: e.branches } : {}) }),
891
922
  archived: () => null,
892
923
  status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
893
924
  machine_set: ({ e, before }) => ({ machine: e.field === 'machine' ? e.value ?? null : e.machine ?? null, was: before.machine }),
@@ -1105,6 +1136,11 @@ const ACTIONS = {
1105
1136
  cardField(ctx, 'workspace_root', null);
1106
1137
  if (moves) cardField(ctx, 'machine', ws.machine ?? null);
1107
1138
  }
1139
+ if (said?.readable_id != null) {
1140
+ const task = cardOf(ctx.db, ctx.id);
1141
+ const readable = allocateReadableId(ctx.db, task.title, said.readable_id, ctx.id);
1142
+ run(ctx.db, 'UPDATE tasks SET readable_id=? WHERE id=?', readable, ctx.id);
1143
+ }
1108
1144
  if (said?.title && String(said.title).trim()) run(ctx.db, 'UPDATE tasks SET title = ? WHERE id = ?', String(said.title).trim(), ctx.id);
1109
1145
  if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
1110
1146
  },
@@ -1186,7 +1222,7 @@ const ACTIONS = {
1186
1222
  send_to_actor(ctx, text) {
1187
1223
  const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
1188
1224
  const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
1189
- effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, text);
1225
+ effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(boardKey(ctx.board.root, ctx.board.slug), cardNumber(ctx.db, ctx.id), text));
1190
1226
  run(ctx.db, 'UPDATE supercode_effects SET effect=? WHERE id=?', JSON.stringify(effect), Number(inserted.lastInsertRowid));
1191
1227
  ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
1192
1228
  },
@@ -1369,14 +1405,22 @@ function settleRelations(db, board, entry, id, effects) {
1369
1405
  export function storedStatus(db, board, entry, id) {
1370
1406
  const task = cardOf(db, id);
1371
1407
  const status = statusOf(db, task);
1372
- if (!entry.workflow.stored_as?.length) return { status, stored: task.status };
1408
+ // Native holds are stored honestly. Hermes lowering is an export concern;
1409
+ // older homes may still declare a lossy paused -> blocked storage rule.
1410
+ const nativeHolds = Boolean(entry.workflow.flags?.paused);
1411
+ if (nativeHolds && !task.archived) {
1412
+ if (task.paused) return { status, stored: 'paused' };
1413
+ if (task.blocked_by.length) return { status, stored: 'blocked' };
1414
+ }
1415
+ if (!entry.workflow.stored_as?.length) return { status, stored: nativeHolds ? storedAs(entry.workflow, status) : task.status };
1373
1416
  const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
1374
1417
  const rule = entry.workflow.stored_as.find((r) => holds(r.guard, scope));
1375
- return { status, stored: rule?.status ?? storedAs(entry.workflow, status) };
1418
+ const stored = rule?.status ?? storedAs(entry.workflow, status);
1419
+ return { status, stored: nativeHolds && ['blocked', 'paused'].includes(stored) ? storedAs(entry.workflow, status) : stored };
1376
1420
  }
1377
1421
 
1378
1422
  export function projectStatus(db, board, entry, id) {
1379
- if (!entry.workflow.stored_as?.length) return;
1423
+ if (!entry.workflow.stored_as?.length && !entry.workflow.flags?.paused) return;
1380
1424
  const { status, stored } = storedStatus(db, board, entry, id);
1381
1425
  // Only a change is written: an unchanged write still commits, and a commit wakes the dispatcher that called this.
1382
1426
  run(db, 'UPDATE tasks SET status = ? WHERE id = ? AND status IS NOT ?', stored, id, stored);
@@ -4,6 +4,7 @@ import { basename, dirname } from 'node:path';
4
4
  import { runtimeDoor } from './runtime-door.mjs';
5
5
  import { inspectCheckout } from '@volter/teams/checkout';
6
6
  import { localMachine, normalMachine, commandEnv } from './dispatch.mjs';
7
+ import { cardHandleOf } from './store.mjs';
7
8
 
8
9
  // Repository identities are portable input values; the daemon owns their checkout resolution.
9
10
  function repositoryId(value) {
@@ -19,7 +20,7 @@ export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '
19
20
  let presence = null;
20
21
  export const notePresence = (reading) => { presence = reading; };
21
22
  async function workspaceDoor(machine, verb, value, options = {}) {
22
- if (machine && presence?.known === true && normalMachine(machine) !== localMachine() && !presence.online.has(normalMachine(machine))) {
23
+ if (machine && presence?.known === true && normalMachine(machine) !== localMachine() && presence.offline?.has(normalMachine(machine))) {
23
24
  throw new Error(`workspace on ${machine}: machine ${normalMachine(machine)} is offline`);
24
25
  }
25
26
  const local = !machine || normalMachine(machine) === localMachine();
@@ -42,12 +43,12 @@ const commitDoorHeld = new Map(); // machine → { until (ms), why }
42
43
  * seconds here): `{ at }`, seconds or null for none. Throws when it could not be read (the machine offline or silent,
43
44
  * its daemon without the door, no such folder there): an unread checkout is unknown, never a card with no commits.
44
45
  */
45
- export async function cardCommitsOn(machine, path, card, branches = []) {
46
+ export async function cardCommitsOn(machine, path, card, branches = [], ref = null) {
46
47
  const name = normalMachine(machine);
47
- if (presence?.known === true && !presence.online.has(name)) throw new Error(`machine ${name} is offline`);
48
+ if (presence?.known === true && presence.offline?.has(name)) throw new Error(`machine ${name} is offline`);
48
49
  const held = commitDoorHeld.get(name);
49
50
  if (held && Date.now() < held.until) throw new Error(held.why);
50
- const r = await runtimeDoor(machine, 'harness.v1.workspaces.commits', { path, card, branches }, { timeout: 10_000 });
51
+ const r = await runtimeDoor(machine, 'harness.v1.workspaces.commits', { path, card, branches, ...(ref ? { ref } : {}) }, { timeout: 10_000 });
51
52
  if (r.code !== 0) {
52
53
  let refusal = null; try { refusal = JSON.parse(String(r.stderr).trim().split('\n').pop()); } catch { /* said in its own words */ }
53
54
  const missing = refusal?.code === -32601 || /unknown method/.test(refusal?.message ?? '');
@@ -92,6 +93,15 @@ export async function branchCheckouts(machine, branch) {
92
93
  export async function checkoutOf(machine, repository, branch) {
93
94
  return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
94
95
  }
96
+ // A card's solo worktree folder under the repository's `.worktrees`: the one it was made in, while its path is recorded
97
+ // (a card running keeps its folder), else its handle (`vo-856`, store.mjs cardHandle; `task.handle`), as the board names
98
+ // a card's worktree, else its id. Its branch is its own, else `wt/<id>`, as the folder named for its id had.
99
+ const worktreeFolder = (task) => {
100
+ if (task.workspace_path && basename(dirname(task.workspace_path)) === '.worktrees') return basename(task.workspace_path);
101
+ const branch = task.branch_name?.split('/').at(-1);
102
+ return task.handle && (branch === task.handle || branch?.startsWith(`${task.handle}-`)) ? task.handle : task.id;
103
+ };
104
+ const worktreeBranch = (task) => task.branch_name ?? `wt/${task.id}`;
95
105
  export async function resolveWorkspace(task, create = false) {
96
106
  const declared = task.workspace_anchor ?? task.workspace_path;
97
107
  if (isWorkspacePath(declared)) {
@@ -104,19 +114,21 @@ export async function resolveWorkspace(task, create = false) {
104
114
  // not move an already allocated card worktree. Machine moves and workspace
105
115
  // retargets clear this runtime receipt; never borrow an arbitrary checkout.
106
116
  if (task.workspace_kind === 'worktree' && task.workspace_path &&
107
- basename(task.workspace_path) === task.id && basename(dirname(task.workspace_path)) === '.worktrees' &&
117
+ basename(dirname(task.workspace_path)) === '.worktrees' &&
108
118
  (!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
109
119
  const cached = await workspaceDoor(task.machine, 'inspect', task.workspace_path);
110
- if (cached.linked && basename(cached.path) === task.id && basename(dirname(cached.path)) === '.worktrees' &&
111
- cached.repository === repositoryId(declared) && cached.branch === (task.branch_name ?? `wt/${task.id}`)) {
120
+ if (cached.linked && basename(cached.path) === worktreeFolder(task) && basename(dirname(cached.path)) === '.worktrees' &&
121
+ cached.repository === repositoryId(declared) && cached.branch === worktreeBranch(task)) {
112
122
  return { ...cached, root: dirname(dirname(cached.path)) };
113
123
  }
114
- throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${task.branch_name ?? `wt/${task.id}`}; retarget its workspace explicitly`);
124
+ throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${worktreeBranch(task)}; retarget its workspace explicitly`);
115
125
  }
116
- return workspaceDoor(task.machine, 'resolve', declared, { branch: task.branch_name, solo: task.workspace_kind === 'worktree', card: task.id, create });
126
+ const solo = task.workspace_kind === 'worktree';
127
+ return workspaceDoor(task.machine, 'resolve', declared, { branch: solo ? worktreeBranch(task) : task.branch_name, solo, card: solo ? worktreeFolder(task) : task.id, create });
117
128
  }
118
129
  export async function checkWorkspace(board, task) {
119
130
  if (task.workspace_kind === 'scratch') return null;
131
+ task = { ...task, handle: task.handle ?? cardHandleOf(board, task.id) };
120
132
  const facts = await resolveWorkspace(task), peers = [];
121
133
  if (task.workspace_kind === 'dir' && !facts.linked) {
122
134
  for (const row of board.db.prepare(`SELECT t.id,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind='dir'`).all()) {